常见的游戏编程框架(多人在线游戏架构实战)
常见的游戏编程框架(多人在线游戏架构实战)https://edu.uwa4d.com/course-intro/0/382课程完整版可前往UWA学堂观看《多人在线游戏架构实战:基于C 的分布式游戏编程》1.1节介绍了单机游戏与网络游戏的区别。1.2和1.3小节带你理解IP地址和TCP/IP。1.4小节介绍了阻塞网络编程。
课程简介本书主要讲述大型多人在线游戏开发的框架与编程实践,以实际例子来介绍从无到有地制作网络游戏框架的完整过程,让读者了解网络游戏制作中的所有细节。全书共12章,从网络游戏的底层网络编程开始,逐步引导读者学习网络游戏开发的各个步骤。
本书通过近50个真实示例、近80个流程图,以直观的方式阐述和还原游戏制作的全过程,涵盖了网络游戏设计的核心概念和实现,包括游戏主循环、线程、Actor模式、定时器、对象池、组件编码、架构层的解耦等。
本书既可以作为网络游戏行业从业人员的编程指南,也可以作为大学计算机相关专业网络游戏开发课程的参考书。
关键词:网络游戏,游戏程序,程序设计
1.1节介绍了单机游戏与网络游戏的区别。
1.2和1.3小节带你理解IP地址和TCP/IP。
1.4小节介绍了阻塞网络编程。
课程完整版可前往UWA学堂观看《多人在线游戏架构实战:基于C 的分布式游戏编程》
https://edu.uwa4d.com/course-intro/0/382
1.5 非阻塞网络编程
本节提供一个非阻塞功能的例子,要达到的目的如下:
(1)客户端与服务端建立网络通信。
(2)建立通信之后,客户端发起3个线程,分别向服务端发送不同数据。这些数据是ping_0、ping_1和ping_2。
(3)服务端收到数据,将相同数据转发给客户端。
(4)客户端收到数据,验证并打印结果。
本例在基础收发数据的功能上增加了非阻塞的设置,虽然看似只有细微改变,但其逻辑会变得非常不一样。
1.5.1 工程源代码工程的源代码在本书源代码库的01_02_network_nonblock目录下,使用make-all脚本进行编译之后,在01_02_network_nonblock/bin目录下会生成clientd和serverd两个文件,先启动serverd,再启动clientd。先来看一下这个非阻塞工程的执行结果,见表1-7。
表1-7 非阻塞式网络通信运行结果
在Linux下,按Ctrl C组合键可退出服务端进程。执行本例时,每一次产生的结果都不一样。因为是线程操作,客户端发送数据的前后关系不确定,所以服务端收到数据的顺序也不确定。为了便于区分,将数据按到达服务端的时间分成了1组到3组,每一组数据在同一个socket通道上。虽然这里只有几行简单的数据打印,但有几个关键点需要理解。
关键点1:Socket值的重用
从表1-7中可以看到,客户端首先发送了两条数据,在Socket值为3和4的通道中分别发送了ping_2和ping_1。
服务端首先收到了ping_2的数据。在打印信息时显示了一个Socket值,在Linux上,Socket的值是线性增加的,因为程序在启动时会用到前面的几个描述符,所以当前可用描述符是从4开始的。这意味着服务端建立第一个连接时,它的Socket值为4,数据是ping_2,服务端收到了该数据并发送了相同的数据给客户端。接着服务器收到了ping_0的数据,Socket值依然是4。
这里读者一定有疑问,不是说不同的通道值不一样吗?为什么两个Socket却用了一样的值?这是因为Socket是可以重用的,如果关闭了描述符为4的Socket,当有新的连接到服务端时会将描述符4重新分配给它。
从上面的数据中可以看出,当服务端收到ping_2并将它发送出去之后,会马上关闭Socket 4,这时又收到ping_0,Socket 4被再次分配。
关键点2:Socket值是进程级数据
认真观察会发现,客户端发送ping_2的时候,在客户端用的Socket值为3,为什么服务端接收到的Socket值却是4?客户端在发送ping_1的时候也使用了Socket 4,服务端和客户端使用了相同的Socket 4值,为什么没有出错?
这里需要区分一个概念,Socket值是在进程中独立的,不是通用的。它不像端口,就某个端口而言,一台物理机就只有一个,同一时间不可重用。但同一时间不同的进程可能存在相同的Socket值的通道。在客户端的Socket 3和在服务端的Socket 3不是同一个通道,只是值相同而已。
关键点3:网络数据的无序与有序
在客户端,从打印结果可以看出,先发送了ping_1,再发送了ping_0。但是在服务端,收到数据的时候却正好相反,先收到了ping_0,后收到了ping_1,这和发送数据的顺序不一致。网络数据的收发是无序的,两个连接即使同时发送数据,也有可能这次你先到,下次我先到。但同一个Socket连接,如果先发了Msg0,再发送Msg1,在TCP下,服务端收到的数据一定是有序的,必定是先收到Msg0,再收到Msg1。
在网络不稳定的时候,在TCP机制下,包丢失会重发。如果Msg0发送失败引起了重发,Msg1很有可能比Msg0先到达目标机器,这种情况是有可能存在的,但完全没必要担心,因为在TCP底层,有一套可靠的机制对收到的消息进行重新排序。如果出现错误,Socket连接就会抛出异常,也就是常说的玩家断线。
1.5.2 服务端代码分析
下面分析这个非阻塞的源代码,看看它是如何实现的,与阻塞的代码又有什么不同。还是先从服务端代码开始。
关键点1:Socket初始化
与前一个例子相比,服务器创建Socket、绑定IP地址、监听Socket的流程并没有发生变化,只看重点代码:
_sock_init();
SOCKET socket = ::socket(AF_INET SOCK_STREAM IPPROTO_TCP);
...
_sock_nonblock(socket);
在服务端,Socket创建成功之后调用了一个新宏:_sock_nonblock。该宏设置Socket的属性,把阻塞模式变为非阻塞模式,在Windows下和Linux下的调用函数各不相同。宏对比如下(该宏定义在network.h文件中):
#ifndef WIN32
#define _sock_nonblock( sockfd ) { int flags = fcntl(sockfd F_GETFL 0); fcntl(sockfd F_SETFL flags | O_NONBLOCK); }
#else
#define _sock_nonblock( sockfd )
{ unsigned long param = 1; ioctlsocket(sockfd FIONBIO (unsigned long *)¶m); }
#endif
在Windows下,函数ioctlsocket的目的是对Socket执行命令操作。在本例中,将Socket设置为非阻塞。函数ioctlsocket的说明可以在MSDN网上查到。原型如下:
int ioctlsocket(SOCKET sock long cmd u_long *argp);
在Linux下,调用fcntl,其原型如下:
int fcntl(int sock int cmd ...);
在Linux下,Socket被认为是一个文件描述符,可以用read、write、open等IO操作来操作它。而fcntl是对文件描述符进行属性操作的函数。在上面的宏定义中,首先传入参数F_GETFL,取得文件描述符的属性并保存到flags变量中,再在取得的属性之上增加一个O_NONBLOCK属性,即非阻塞模式。
关键点2:如何接收连接请求与收发数据
在上一个阻塞例子中,如果使用断点调试,就会发现在调用::accept、::recv和::send函数时会一直卡住,等待数据。现在,在非阻塞模式下,不论有没有数据,这些函数都会马上返回。在主线程中,不知道什么时候能收到::accept、::recv数据,因此做了个死循环。先浏览一下代码:
while (true) {
SOCKET newSocket = ::accept(socket &socketclient &socketLength);
if (newSocket != INVALID_SOCKET) {
_sock_nonblock(newSocket); // 接收到一个新的连接请求,设为非阻塞
sockets.push_back(newSocket);
}
// 遍历所有连接,调用::recv函数,查看是否有数据到来
// 如果有,就调用::send函数将接收到的数据发送回去
auto iter = sockets.begin();
while (iter != sockets.end()) {
SOCKET one = *iter;
auto size = ::recv(one buf 1024 0);
if (size > 0) {
::send(one buf size 0);
iter = sockets.erase(iter);
...
}
...
}
}
在循环中,收到新的连接便把新的Socket值保存起来。每一帧不停地主动询问在这个Socket上是否有数据,如果接收到数据,就发送一段相同的数据到客户端。
修改一下代码,把while循环去掉,再执行一下,会发现程序开始执行就退出了。因为所有的底层函数都不会再等待数据,没有数据就直接返回了。
如果在while循环中加一个计数并打印出来,就会发现一秒可以执行无数次的检查。非阻塞模式保证了每个Socket的收发都在同时进行,互不影响。
1.5.3 客户端代码分析
在客户端,为了保证多组Socket互不影响地发送数据,用到了线程。将每个连接包装成一个ClientSocket类,其定义在client_socket.h文件中。
每一个ClientSocket类的功能类似于1.4节中阻塞的例子,发送数据,阻塞等待数据到来,接收到数据,然后退出。图1-9展示了客户端与服务端通信的整体流程。
图1-9 非阻塞式网络通信流程
关键点1:ClientSocket类
为了让客户端互不影响,在client.cpp文件的main函数里创建了多个ClientSocket实例,每个实例都是在线程中执行的,即使在本线程中是阻塞的,也不会影响其他线程。ClientSocket类一共有4个函数,其中1个是构造函数。定义如下:
class ClientSocket {
public:
ClientSocket(int index); // 开启了一个线程
void MsgHandler(); // 阻塞式的Socket收发数据流程
bool IsRun() const; // 收发数据是否已完成
void Stop(); // 结束线程
private:
bool _isRun{ true };
...
};
ClientSocket类的构造函数实现如下:
ClientSocket::ClientSocket(int index) : _curIndex(index) {
_thread = std::thread([index this]() {
_isRun = true;
this->MsgHandler();
_isRun = false;
});
}
在创建ClientSocket类时创建了一个线程,在线程中调用了MsgHandler函数。
函数MsgHandler的思路和前一个例子中的一样,即创建Socket,发送一条数据,再等待接收一条数据,使用的还是阻塞模式,这里不再重复。处理完数据之后,线程退出。
总之,ClientSocket类使用了线程的方式发送数据。
关键点2:主线程逻辑
在主线程中创建了3个ClientSocket实例,使用while不断循环查看每个ClientSocket实例是否还处于IsRun运行模式下,如果已经完成,就调用ClientSocket的Stop函数,直到全部ClientSocket类都退出,程序结束。
int main(int argc char *argv[]) {
std::list<std::shared_ptr<ClientSocket>> clients;
// 启动3个线程
for (int index = 0; index < 3; index ) {
clients.push_back(std::make_shared<ClientSocket>(index));
}
// 遍历当前所有ClientSocket类,如果已完成数据收发,就关闭线程,剔除数组
while (!clients.empty()) {
auto iter = clients.begin();
while (iter != clients.end()) {
auto pClient = (*iter);
if ((*pClient).IsRun()) {
iter;
continue;
}
pClient->Stop();
iter = clients.erase(iter);
}
}
return 0;
}
主线程并不关心ClientSocket类中做了些什么,只是关心它的生命周期,每个线程结束了,整个程序就认为结束了。
特别说明:std::thread是C 标准线程库,在创建线程时使用了一个Lambda匿名函数,std::thread执行匿名函数。如果读者没有接触过Lambda匿名函数,就要学习一下Lambda匿名函数与常规代码的对比。下面的代码中,TestThread1使用了匿名函数,TestThread2则使用的是常规代码。
void TestThread1() {
bool isrun = true;
auto thread = std::thread([index &isrun]() {
printf("call test1.\n");
isrun = false;
});
while (isrun) {
while (thread.joinable()) {
thread.join();
}
}
}
void TestThread2() {
bool isrun = true;
auto thread = std::thread(&CallFunc &isrun);
while (isrun) {
while (thread.joinable()) {
thread.join();
}
}
}
void CallFunc(bool* pIsRun) {
printf("call test2.\n");
*pIsRun = false;
}
两个函数中代码的目的完全一样,一个使用Lambda匿名函数给线程注册了一个调用函数,而另一个则编写了一个常规函数注册到线程中。Lambda匿名函数使我们的代码显得更为简洁。
1.5.4 小结
在本例中,服务端使用了非阻塞模式接收数据。简单来说,就是你接收你的数据,我发送我的数据,互不影响。有数据到来就处理,至于对方是何时发过来的,接收数据方并不关心。
本例采用了主动询问数据的方式在服务端判定有没有数据直接调用::recv函数,每一个循环中都要对每一个Socket调用::recv函数,当返回值大于0时,认为有数据到来。
课程完整版可前往UWA学堂观看《多人在线游戏架构实战:基于C 的分布式游戏编程》
https://edu.uwa4d.com/course-intro/0/382
本书特色1、从网络游戏的底层编码开始,深入讲解游戏开发的详细步骤、游戏主循环、线程的使用、Actor模式的应用等。
2、以直观的方式阐述和还原游戏制作的全过程,全面介绍游戏编码过程中众多的核心概念和具体实现,如定时器、对象池、组件编码、架构层的解耦等。
3、使用C 来实现游戏的架构,读者也可以举一反三,使用其他的编程语言轻松实现游戏开发目标。
你将获得1、充分了解业务逻辑和底层框架的设计意图
2、立足实践的服务端学习思路,深入浅出
3、用实际案例贯穿各知识点,在实践中学习
4、了解商业游戏的设计思路和实现方法