多人在线游戏架构实战——阻塞式网络编程

295 阅读18分钟

课程简介

本书主要讲述大型多人在线游戏开发的框架与编程实践,以实际例子来介绍从无到有地制作网络游戏框架的完整过程,让读者了解网络游戏制作中的所有细节。全书共12章,从网络游戏的底层网络编程开始,逐步引导读者学习网络游戏开发的各个步骤。

本书通过近50个真实示例、近80个流程图,以直观的方式阐述和还原游戏制作的全过程,涵盖了网络游戏设计的核心概念和实现,包括游戏主循环、线程、Actor模式、定时器、对象池、组件编码、架构层的解耦等。

本书既可以作为网络游戏行业从业人员的编程指南,也可以作为大学计算机相关专业网络游戏开发课程的参考书。

关键词:网络游戏,游戏程序,程序设计

1.1节介绍了 单机游戏与网络游戏的区别,1.2和1.3小节带你理解IP地址和TCP/IP。

课程完整版可前往UWA学堂观看《多人在线游戏架构实战:基于C++的分布式游戏编程》

1.4 阻塞式网络编程

下面就从简单的网络模型入手来实现一个简单的网络程序。要达到的目的如下:

(1)客户端与服务端建立网络通信。

(2)完成通信之后,客户端向服务端发起一条协议。

(3)服务端收到协议,并转发给客户端。

(4)客户端收到协议,打印出来。

在这个例子中,将展示网络编程的基本功能——接收和发送,处理服务端与客户端对于Socket的不同表现,实现服务端与客户端的收发协议流程。

1.4.1 工程源代码

该工程的源代码在本书源代码库的01_01_network_first目录下。先来执行工程,看看结果如何(注:在不少中文版的集成开发环境中把英文版中的“Project”翻译成“项目”,因此工程和项目在这种语境下指同一个概念,例如工程文件就是指项目文件)。工程中提供了两种打开方式,一种是在Windows系统下的Visual Studio工程文件,另一种是在Linux系统下的CMake文件。如果读者还不了解CMakeLists.txt文件的定义,那么建议先阅读附录中的CMake部分。本书提供的所有源代码均有这两种打开方式。

如果在使用Windows编译源代码时出现SDK版本不一致的问题,那么右击“解决方案”,选择“重定解决方案目标”。产生这个问题的原因是工程原来指定的SDK与本地环境中的SDK版本不一致。再次提醒,编译目标为“debug,x86”。

现在看看在Linux上如何执行本例。进入工程目录,执行脚本make-all.sh,编译的步骤已经在这个脚本中写好了,本书所有的工程都采用该方式编译。

[root@localhost 01_01_network_first]# ./make-all.sh

执行make-all脚本时,它会将该工程上的所有可执行程序都进行编译,本例中生成了两个文件:clientd和serverd。为了便于调试,所有的库文件源代码都是直接编译到执行文件中的,不再生成中间的静态库文件。

这是我们第一次使用make-all.sh脚本,每个工程都会有该脚本,用于批量编译。读者可使用“vim./make-all.sh”命令查看该脚本。

这里做一个简短的说明,在make-all.sh脚本中提供了两个参数,默认情况下,采用Debug模式编译代码,如果执行命令“./make-all.sh release”,就编译Release版本。除此之外,还可给定clean参数,即执行“./make-all.sh clean”,目的是清除CMake生成的临时文件,重新生成Makefile文件。

在脚本中提供了一个build函数,该函数的目的是对给定目录下的所有工程进行编译。以src/libs目录为例,函数build对libs目录下的src/libs/network/目录进行了编译。这个目录是网络库工程,其下有一个已经写好的CMakeLists.txt文件。该文件与附录中讨论的文件格式大同小异,有3个地方值得注意。

(1)编译文件名

set(MyProjectName networkd)

指定一个编译文件名。当属性CMAKE_BUILD_TYPE为Debug时,输出文件加了d字符,以方便区分Debug和Release版本。CMake提供的STREQUAL函数用于字符串比较。

(2)输出目录

set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "../../../libs")

设置属性
CMAKE_ARCHIVE_OUTPUT_DIRECTORY。该属性指定了静态库生成的目录。

工程生成的可执行文件放在工程目录下的bin目录中,库文件放在工程目录下的lib目录中。不论是Windows系统还是Linux系统都遵从该规则。

(3)生成文件

在CMakeLists.txt文件的最后使用了add_library指令,而不是add_executable指令。

add_library(${MyProjectName} STATIC ${SRCS})

add_executable生成可执行文件,add_library则生成一个库。关键字STATIC表示要生成一个静态库,需要生成动态库时,关键字改为SHARED即可。

下面看看第一个例子的执行结果,执行make-all.sh编译完成之后,进入bin目录。

  [root@localhost 01_01_network_first]# cd bin/
  [root@localhost bin]# ls
  clientd  serverd

在bin目录下生成了serverd和clientd两个可执行文件。先运行serverd,再运行clientd。

在表1-2中展示了服务端和客户端的打印数据。进程serverd开始运行之后,就会进入网络监听状态,当clientd启动后,serverd收到一个连接请求,打印“accept one connection”,双方连接成功之后,clientd首先发出数据“ping”,serverd收到之后返回一条相同的数据,最后clientd收到serverd发出的数据。这个简单的例子完成了一个来回的数据发送与处理。看看流程图可能会更容易厘清思路,如图1-8所示,图中标注了数据流转的4个步骤。

表1-2 阻塞式网络通信运行结果

①客户端发送ping数据。

②服务端收到ping数据。

③服务端收到ping数据的同时返回一个ping给客户端。

④客户端收到ping数据。

图1-8 阻塞式网络通信流程

网络监听和连接是如何实现的呢?无论是服务端还是客户端,首先要做的事情都是创建一个Socket(套接字)。那么Socket是什么呢?下面通过代码分析来简要说明。


1.4.2 服务端代码分析

服务端的主要代码在server.cpp文件中。对比源代码进行查看,需要掌握以下几个关键点。

关键点1:Socket初始化

代码的第一行为创建Socket做初始化准备:

_sock_init( );

首先要了解什么是Socket。简单来说,Socket定义了IP地址上的一个通信连接。例如,同一台计算机向同一个服务端发起两个网络连接,这就是两个通信连接,不论在客户端还是服务端都会产生两个不同的Socket。Socket实际上就是一个ID,也可以用通道来理解这种通信。打个比方,你有一个手机号码,当你打电话给别人的时候,你与对方建立了一个通信通道。Socket也是类似的,每次通信开始,通信双方建立了Socket,这个Socket被分配了一个ID,一个固定的ID固定了通道,避免收到错误消息。

_sock_init()定义在network工程的network.h文件中。在Windows系统下的宏定义为:

#define _sock_init( ) { WSADATA wsaData; WSAStartup( MAKEWORD(2, 2), &wsaData ); }

在Windows系统下,需要初始化执行WSAStartup函数。在Linux系统下,调用::socket函数之前不需要执行任何操作,所以定义了一个空宏。

关键点2:创建Socket

初始化操作完成之后需要创建一个Socket。创建失败会调用宏_sock_err来显示其错误。创建代码如下:

SOCKET socket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (socket == INVALID_SOCKET) {
        std::cout << "::socket failed. err:" << _sock_err() << std::endl;
        return 1;
    }

函数::socket是底层函数,这个函数调用的细节放在后面来讲解。它在两个系统下略有不同。在Linux系统下,位于/usr/include/sys/socket.h文件中,其定义如下:

extern int socket (int __domain, int __type, int __protocol) __THROW;

从函数返回值可以看出,生成的Socket为int类型。

而在Windows系统下,返回值为SOCKET类型。socket()函数定义如下:

SOCKET WSAAPI socket(_In_ int af, _In_ int type, _In_ int protocol);
typedef UINT_PTR SOCKET;

在Windows系统下,SOCKET类型被重定义为一个UINT类型,也就是说,如果编译的版本为32位,SOCKET类型就为unsigned int,64位则为unsigned__int64,总之,SOCKET本身也是一个数值类型。

为了兼容Linux和Windows这两个系统,工程中在Linux下定义了两个宏:SOCKET和INVALID_SOCKET。

#define SOCKET int
#define INVALID_SOCKET -1

虽然两个系统中的定义略有不同,但::socket函数的表现却是相同的。如果调用::socket函数失败,在Linux系统下的返回值为-1,在Windows系统下返回一个宏定义INVALID_SOCKET。

除了定义不同之外,两个系统显示出错信息的函数也有差别,工程中定义了_sock_err宏来处理。关于这个宏,在Windows系统和Linux系统下的定义不同,其定义如下:

#ifndef WIN32
#define _sock_err( ) WSAGetLastError()
#else
#define _sock_err( ) errno
#endif

关键点3:绑定IP与端口

创建Socket之后需要指定IP地址和端口实现绑定操作,本例中指向了本机127.0.0.1的2233端口。

sockaddr_in addr;
memset(&addr, 0, sizeof(sockaddr_in));
addr.sin_family = AF_INET;
addr.sin_port = htons(2233);
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
if (::bind(socket, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) < 0) {
    std::cout << "::bind failed. err:" << _sock_err() << std::endl;
    return 1;
}

在绑定过程中使用了sockaddr_in结构,该结构中指定了协议族、IP地址和端口。

之前讨论过IP地址可以唯一标识一台计算机,但一台计算机可能有多个IP地址,至少可以有一个对外的地址和一个对内的地址。日常中,设置IP地址为192.168.0.120,这是一个内网地址,127.0.0.1是一个特定的描述,指向本机,也是一个内网地址。在本例中,打开127.0.0.1的2233端口,开放的范围只是本机,也就是说这个服务端只能由本机上执行的clientd对它进行连接。如果这里填写的IP地址是192.168.0.120,那么开放的范围是整个局域网,离开了局域网就不能访问了。如果计算机还有一个公网IP地址,调用::bind函数绑定的是一个公网IP地址,那么在任何地方、任何计算机上都可以访问这个IP地址开放的端口。

特别说明:如果在Linux下反复测试时遇到了错误“::bind failed.Err:98”,则是因为之前绑定的端口没有被释放,系统有一定的回收时间。为了快速释放,我们可以输入“ss-lnpt”命令找到端口对应的PID,使用kill指令杀掉进程。

[root@localhost bin]# ss -lnpt
State    Recv-Q Send-Q Local Address:Port     Peer Address:Port
LISTEN   0      10     127.0.0.1:2233    :*users:(("logind",pid=6367,fd=3))
[root@localhost bin]# kill -9 6367

关键点4:监听网络

绑定好IP地址和端口之后,还需要打开对Socket的监听,服务端的工作才算完成。其代码如下:

int backlog = GetListenBacklog();
if (::listen(socket, backlog) < 0) {
    std::cout << "::listen failed." << _sock_err() << std::endl;
    return 1;
}

对于服务器来说,这是必不可少的一步,调用了底层函数::listen。只有打开了监听,我们才可以敏锐地察觉是否有客户端对该端口发起了通信请求。参数backlog指定了请求缓存列表可以有多长。

关键点5:等待连接

有了监听就可以接收连接了,接收连接的代码如下:

struct sockaddr socketClient;
socklen_t socketLength = sizeof(socketClient);
int newSocket = ::accept(socket, &socketClient, &socketLength);

在本例中,程序会在::accept函数阻塞住。如果在::accept后面一行打一个断点,会发现断点不会被触发。::accept函数的功能是接收一个请求,如果没有就会一直等待。所以,在运行了serverd还没有运行clientd之前,服务端处于阻塞状态,它在等待一个连接请求。

在一些网络编程图书中会讨论TCP的3次握手,在本程序中,我们并没有看到3次握手,而是通过::accept函数就收到了连接。这并不是说3次握手不存在或者没有完成,而是这3次握手过程已经在底层完成了。

关键点6:接收数据

当::accept函数接收到有新的连接到来时,双方通信建立完成,可以开始发送数据。代码中调用了底层::recv函数接收数据,再调用::send函数发送接收到的数据。这部分的代码如下:

char buf[1024];
memset(&buf, 0, sizeof(buf));
auto size = ::recv(newSocket, buf, sizeof(buf), 0);
if (size > 0) {
    std::cout << "::recv." << buf << std::endl;
    ::send(newSocket, buf, size, 0);
    std::cout << "::send." << buf << std::endl;
}

在上面的代码中,接收数据放在一个长度为1024字节的缓存中,接收到什么数据就发送什么数据回去。

关键点7:关闭Socket

关闭Socket调用了两个宏:

_sock_close(socket);
_sock_exit();

在Windows和Linux系统下做了不同的处理。在Windows系统下初始化时调用了WSAStartup函数,结束时则需要调用WSACleanup函数。宏定义如下:

#ifndef WIN32
#define _sock_exit( )
#define _sock_close( sockfd ) ::close( sockfd )
#else
#define _sock_exit( )     { WSACleanup(); }
#define _sock_close( sockfd ) ::closesocket( sockfd )
#endif

特别说明:在Linux下关闭Socket时,有时使用::close函数,有时使用::shutdown函数。这两者有什么区别呢?

可以做一个实验,将::close函数替换为::shutdown函数。在生成和关闭Socket处进行打印,从打印信息中可以看出,使用::shutdown函数,关闭过的Socket即使关闭了,也不会重用。每次有新的连接到来时,就需要用到新的Socket,其值会在之前的值上加1。这是因为::shutdown函数会关闭TCP连接,但不释放Socket。而::close函数会将套接字计数减1,当计数==0时,会自动调用::shutdown函数。看上去在关闭连接时使用::close才是正确的,但为什么还是有人直接使用::shutdown呢?因为使用::close并不能真正断开连接,它只是计数减1,在某些情况下,可能需要直接断开连接,所以调用::shutdown函数关闭网络。

而使用::close函数,在某些情况下,Socket的状态会变为CLOSE_WAIT状态,CLOSE_WAIT实际上就是等待关闭状态。

出现这种情况的原因比较复杂,有一种情况是客户端想关闭,但服务端可能还在读或写,就产生了等待。在网络编程中,这是我们需要特别注意的一个问题。


1.4.3 客户端代码分析

客户端的源代码与服务端略有不同,相对来说步骤简单一些。客户端的主要逻辑在client.cpp文件中,需要掌握以下几个关键点。

关键点1:Socket初始化

在客户端开始时,同样初始化和创建了Socket,与服务端原理一致。

_sock_init();
SOCKET socket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (socket == INVALID_SOCKET) {
    std::cout << "::socket failed. err:" << _sock_err() << std::endl;
    return 1;
}

关键点2:网络连接

对于客户端来说,并不需要执行::bind绑定函数,也不需要监听端口。客户端需要执行的是调用::connect函数,它向一个指定的IP和端口发起连接操作。函数::connect会用到sockaddr_in结构。调用代码如下:

sockaddr_in addr;
memset(&addr, 0, sizeof(sockaddr_in));
addr.sin_family = AF_INET;
addr.sin_port = htons(2233);
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
if (::connect(socket, (struct sockaddr *)&addr, sizeof(sockaddr)) < 0) {
    std::cout << "::connect failed. err:" << _sock_err() << std::endl;
    return 1;
}

在客户端,使用sockaddr_in结构的初始化工作与服务端一样。客户端调用了服务端没有涉及的底层函数::connect,向一个指定的地址(也就是在服务端开放的地址)发起了一个连接。

关键点3:发送数据

客户端与服务端一致,都是调用底层函数::send发送数据。使用下面的代码发送一个"ping"字符串:

std::string msg = "ping";
::send(socket, msg.c_str(), msg.length(), 0);

关键点4:接收数据

客户端发送数据之后,陷入等待操作中,函数::recv等待接收数据。

char buffer[1024];
memset(&buffer, 0, sizeof(buffer));
::recv(socket, buffer, sizeof(buffer), 0);
std::cout << "::recv." << buffer << std::endl;

1.4.4 系统差异

在前两小节中,对Linux和Windows两个系统进行了有区别的编码。除了Socket的定义之外,还有两个大的区别:

(1)虽然网络API是底层函数,但在Windows系统下创建Socket之前需要调用WSAStartup函数,而退出的时候需要调用WSACleanup函数。两个函数的定义如下:

int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
int WSACleanup();

(2)获取错误的方式也略有不同。在Linux系统下使用errno变量,在Windows下使用WSAGetLastError函数,如果执行网络API时出错,该函数就会返回一个错误码,定义如下:

int WSAGetLastError();

1.4.5 网络底层函数说明

前面举了一个简单的例子,用到的底层函数是网络编程的基础,本书之后的所有示例都是基于这些网络底层函数来完成的。为了加深理解,下面对这些底层函数逐一进行详细的说明。

1. 函数::socket

客户端与服务端在初始化时,均使用了::socket函数。每个网络通信必有一个Socket。函数的参数说明见表1-3,原型如下:

int socket(int family, int type, int protocol);

表1-3 socket函数参数

在本节的例子中,不论是在服务端还是客户端,生成Socket时均采用了AF_INET、SOCK_STREAM、IPPROTO_TCP这3个参数,即采用IPv4协议,以SOCK_STREAM数据流发送时采用可靠的TCP。

正常情况下,调用该函数会返回一个大于零的正数,即Socket值。这个值是唯一的,由系统分配。如果A与B建立了连接,那么对于A来说,在连接没有中断的情况下,它一定是一个定值,且不与其他Socket值相同。Socket这个单词在英文里的翻译是插槽、插座,在网络术语中一般翻译成套接字,可以将其理解为如果占用了一个插槽,其他人是不可能再使用的。

在Windows下,生成的Socket值是随机大于1000的值,在Linux下,它是从个位数开始累计的。在Linux下,Socket值也被称为描述符。这和Linux的内核结构有关联,这里不需要深究,只需要知道Socket也称为描述符即可。一旦Socket创建成功,返回的Socket值就会成为网络通信的重要依据。需要注意的是,Socket值是可以被复用的,也就是说,如果最开始分配了123给A,随后A断线,C上线,C也有可能分配到123。

2. 函数::bind

::bind是服务端必不可少的一个函数,其作用是指定IP和端口开放给客户端连接,客户端则没必要调用该函数。函数的参数说明见表1-4,函数的原型如下:

int bind(int sockfd, const sockaddr *address, int address_len);

表1-4 bind函数参数

参数sockfd即调用::socket函数之后得到的Socket值。

sockaddr是通用的套接字地址结构,在代码中还使用了sockaddr_in结构,这两者在这里没有什么差别,长度一样,可以相互转换,sockaddr_in是Internet环境下套接字的地址形式。参数address指定了Socket需要绑定的地址信息,这些信息中包括IP和端口。

该函数返回负数就表示出错了。如果试图绑定一个已经在使用的端口,调用::bind就会失败。

3. 函数::listen

::listen是服务端调用的函数,用于对IP地址和端口的监听。函数的参数说明见表1-5,函数的原型如下:

int listen(int sockfd, int backlog);

表1-5 listen函数的参数说明

关于连接队列的最大长度,可以使用系统的宏SOMAXCONN。在Linux下,它的定义在
/usr/include/bits/socket.h文件中,默认为128。值backlog的意义在于,当一个连接请求到来时,另一个连接请求可能同时到来,系统需要缓存其中之一,backlog是系统处理连接的缓冲队列的长度。虽然TCP有3次握手,但是目前来看这个过程还是相当快的,所以5~10个缓存已经足够使用。

该函数返回负数就表示出错了。

4. 函数::connect

::connect是对一个已知地址进行网络连接的函数。一旦客户端调用::connect函数,就会触发TCP的3次握手协议,3次握手完成之后,在服务端会调用::accept函数。

在调用::connect函数时,如果失败,就不能对当前Socket再次调用::connect函数,正确的做法是关闭Socket再次调用::connect函数。该函数的参数与::bind函数是一致的。函数的原型如下:

int connect( int sockfd, const sockaddr *address, int address_len);

该函数返回负数就表示出错了。

5. 函数::accept

该函数用于监听端口,若::accept收到数据,则一定有一个新的连接被发起。函数的参数与::bind函数是一致的。函数的原型如下:

int accept(int sockfd, sockaddr* address, int* address_len);

函数::accept返回的值是一个新的Socket值。调用::accept时,我们传入了一个Socket值,这个值可以称为监听Socket,而返回的这个新值就是客户端连接服务端的连接Socket。这两个值是有区别的。服务端有且仅有一个监听Socket,却可以有无数个连接Socket。

6. 函数::send和::recv

函数::send和::recv是一对用于发送和接收数据的函数,除了这两个函数之外,网络底层还提供了其他发送和接收数据的函数,适用于不同的场合,这里只介绍我们使用的这一对函数。函数的参数说明见表1-6,函数的原型如下:

int send(int sockfd, const char *buf, int len, int flags)
int recv(int sockfd, char *buf, int len, int flags)

表1-6 发送、接收函数参数

在4个参数中,需要着重说明的是buf参数。对于::send函数而言,发送的缓冲buf是const指针,而len则是发送数据的长度。

对于::recv函数而言,buf是接收数据的缓存,len是该缓存的长度。假设服务端向客户端发送了2024字节的数据,但客户端接收buf的长度只有1024,len的长度也只能为1024,即::recv函数一次只会读取系统底层网络缓冲中的1024字节,放入buf缓冲中。这个概念非常关键,会引发粘包、拆包的问题。网络数据并不是我们想象中一条一条规整地发送过来的,有可能接收的1024字节里面有3个协议数据,也有可能接收的1024字节只是某个协议的一部分,需要多次读取。在后面的例子中,我们会详细讲解这些情况该如何处理。

发送和接收函数调用失败返回非正数,若成功,则返回发送、接收的字节长度。对于::recv函数来说,若返回0,则表示在另一端发送了一个FIN结束包,网络已中断。但::recv函数在返回负数时,也并不都意味着网络出错而需要断开网络,这在后面用到的时候再讲解。


1.4.6 小结

我们已经对网络通信有了一定的了解,客户端和服务端在初始化时略有不同,但收发数据的流程是相同的,本例采用了阻塞式的收发数据方式,所有的代码都是在阻塞模式下进行的。函数::accept、::send和::recv都处于阻塞模式下。

所谓阻塞,就是一定要收到数据之后,后面的操作才会继续。客户端调用::connect函数连接到服务端,发送数据之后一直阻塞在::recv函数上,直到收到服务端传来的数据才退出。服务端同样是阻塞的,在::accept函数处等待连接进来,如果没有就一直等待,接收到一个连接之后,再次阻塞,等待::recv函数返回数据。

作为服务端,采用阻塞模式显然不够高效。一般来说,服务端需要同时处理成千上万个通信,不能因为一个连接而阻塞另一个连接的收发数据进程。在实际情况下,更常用的是非阻塞模式。接下来以一个实例来说明非阻塞模式是如何工作的。

课程完整版可前往UWA学堂观看《多人在线游戏架构实战:基于C++的分布式游戏编程》

本书特色

1、从网络游戏的底层编码开始,深入讲解游戏开发的详细步骤、游戏主循环、线程的使用、Actor模式的应用等。

2、以直观的方式阐述和还原游戏制作的全过程,全面介绍游戏编码过程中众多的核心概念和具体实现,如定时器、对象池、组件编码、架构层的解耦等。

3、使用C++来实现游戏的架构,读者也可以举一反三,使用其他的编程语言轻松实现游戏开发目标。

你将获得

1、充分了解业务逻辑和底层框架的设计意图

2、立足实践的服务端学习思路,深入浅出

3、用实际案例贯穿各知识点,在实践中学习

4、了解商业游戏的设计思路和实现方法