网络游戏——强联网游戏

时间:2022-06-01 21:34:14

转自:http://note.youdao.com/share/?id=7aa4edb9034c543d0db12c32425fd649&type=note


 在强联网这几章中,我们要介绍一些非常有意思的内容,把强联网这几章的知识掌握好,你可以变得更加强大,因为内容过于丰富,所以我们需要分为四章来介绍。


    第一章我们介绍TCP的基础知识,包括socket接口,跨平台处理,心跳检测,半包粘包,非阻塞,select详解,最后以一个简单的TCP服务器和客户端来总结。

    第二章我们介绍一个横版RPG的单机版本的实现,这不是一个纯粹的单机游戏,它可以很优雅地变身为网络版,所以,好好吸收这一章的一些设计思路。

    第三章我们介绍前后端通讯流程的设计,以及服务端的实现,我们使用一个简易的开源服务器框架KxServer来实现服务器。并轻松将部分前端的逻辑代码移植到后端。

    第四章我们通过少量的改动,并使用KxClient将单机版本变身为网络版,讨论并解决游戏的实时同步问题。

 

    本章要介绍的内容如下:


  1. Socket接口与TCP
  2. 简单的TCP服务器端与客户端
  3. 心跳与超时
  4. 半包粘包
  5. 非阻塞Socket与Select


一、Socket接口与TCP


    什么是Socket?什么是TCP?Socket和TCP有什么联系?socket也称为套接字,描述了IP和端口,用于网络通讯,应用程序需要通过套接字来发起网络请求,或接收网络消息。TCP是一种面向连接的,可靠的,基于字节流的传输通讯协议。TCP在两个主机中间提供了一条可靠的连接来进行数据传输。我们可以创建一个TCP socket,来创建这样的一条连接。


    每一条TCP连接,都是由一端的 IP+端口 连接到另一端的 IP+端口,每台主机都有一个IP,用于标识唯一的一台主机,每台主机最多有65535个端口,一个端口可以对应多条连接,连接数的上限取决于操作系统,例如服务器开一个端口,可能有几万条连接连到服务器的这个端口,这是没有问题的。


    在我们编写强联网的实时网游的时候,用到最多的就是TCP了(http协议也是基于TCP协议实现的),TCP提供可靠的连接,它可以确保数据有序地到达目标地址,在连接正常的情况下,你不需要担心你发出去的数据会丢失(TCP底层实现了丢包重发的机制,通过ACK确认来判断发出的包对方是否收到)错乱(TCP的数据包是有序的)或者错误(TCP使用一个校验和函数来校验数据是否有错误)。在使用TCP之前,先简单介绍一下接下来我们要使用到的几个socket API,这些看似简单的socket API实际上隐藏着大量细节,对其内部运行流程及细节和原理的了解在一定程度上决定了你的网络编程水平,本章试图细述这些内部细节,但这并非区区数万字所能概括,只能提醒初学者,网络编程并非只是调用几个函数那么简单。


    PS.本章的内容对于初学者而言,可能有些地方较为深入,第一次看这一章不必纠结于其中的一些概念,了解大概流程即可。可以看完网络游戏通篇之后,再回过头来细读本章


TCP服务器与客户端交互流程


    在介绍Socket API之前,我们先了解一下TCP客户端与服务端是如何通讯的。我们可以将TCP服务器与客户端交互的流程分为3个阶段,建立连接阶段,数据通讯阶段以及关闭连接阶段。下面我们来简单了解一下,这三个阶段中我们的应用层需要做什么?以及TCP底层做了什么?


    首先是建立连接阶段,连接是由客户端主动发起的,那么在客户端连接的时候,服务端必须是处于监听状态才能连接成功(例如我要去你家里串门,那么你必须在家,我才能进去)

    所以服务端必须先启动,并进入监听状态。这时候服务端需要依次调用3个函数,socket()创建套接字,bind()绑定网卡和端口,listen()进入监听状态。网卡和端口是整台主机的公有资源,所有的进程都可以访问,当某网卡的某端口已经被绑定,我们就不能重复绑定它(除非所有绑定它的socket都设置了SO_REUSEADDR选项)

    客户端需要依次调用两个函数来连接服务器,socket()创建套接字,connect()连接服务器。对于客户端套接字而言,bind函数并不是必须的,在调用connect时操作系统会为你自动选取网卡和端口,connect函数将向服务端发送一个SYN报文,服务端socket处于监听状态时,会回复一个SYN+ACK报文,在客户端socket接收到这个报文后,会再次回复一个ACK报文,这三次报文的传输称为3次握手,完成3次握手后TCP连接就建立完成了。3次握手由客户端的connect发起,由两端的TCP底层完成,在握手完成后,阻塞的connect调用会返回连接成功。

    当服务器的socket处于监听状态时,接收到客户端发起的SYN报文,会回复SYN+ACK报文进行三次握手,在服务端socket的TCP底层,存在2个队列,一个是正在执行3次握手的队列(正在连接队列),一个是已完成连接的队列,队列的大小由listen函数的第二个参数指定,但这个参数对应的队列大小是实现相关的。在已监听的服务端socket调用accept函数可以从TCP底层的已完成连接队列中取出一个连接的socket对象,然后进行通讯,如果底层的已完成连接队列为空,那么accept会阻塞,一直等到有客户端连接成功为止。

网络游戏——强联网游戏

    建立连接的流程如上图所示,但客户端的连接并不会改变服务器的监听socket的状态,syn_recv和established状态对应客户端连接的socket对象,而不是监听socket对象。


    在数据通讯阶段中,服务端和客户端都可以调用send来发送数据,以及调用recv来接收数据。客户端socket必须是已连接的socket才可以发送和接收消息,而服务端需要使用accept返回的socket来发送和接收消息。在每次数据传输到对端之后,对端的应用层就可以调用recv进行接收了,同时对端的TCP底层会回复一个ACK报文给发送端,告诉发送端,我已经收到你的消息了。

    send函数会将数据从应用层拷贝到TCP底层的发送缓区中,然后由TCP进行发送,send完成时,发送端并不能保证对端已经接收到。当发送缓区已满,send函数会被阻塞,直到send的所有内容被拷贝进发送缓冲区。recv函数会阻塞直到对端发送数据到本端的接收缓存区中,recv会将接收缓存区的内容拷贝到应用层。当接收缓存区已满,对端发送过来的数据会被丢弃,但TCP的流量控制会尽量避免这一情况发生。

网络游戏——强联网游戏


    最后是关闭连接阶段,任何一端都可以调用close函数来关闭TCP连接,关闭需要经过四次握手,一般是由客户端来发起关闭。当调用close关闭后,对端调用recv时会返回0,这时对端需要调用close来完成连接的关闭。

    

网络游戏——强联网游戏


Socket API 详解


    在大概了解TCP客户端与服务端的通讯流程之后,我们来看一下相关的Socket API。


socket函数:

    在使用socket进行通讯之前,我们需要先创建一个socket对象,通过调用socket函数传入一个协议域,一个socket类型,以及指定的协议,来创建一个socket套接字并返回套接字的描述符。在linux下的socket是一个文件,所以socket函数返回了一个文件描述符。socket描述符我们可以用来绑定,连接以及发送和接收数据。


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

    domain:协议域,又称协议族。常用的协议族有AF_INET、AF_INET6、AF_UNIX、AF_ROUTE等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位)与端口号(16位)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。

    type:指定Socket类型。常用的socket类型有SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等。流式Socket(SOCK_STREAM)是一种面向连接的Socket,针对于面向连接的TCP服务应用。数据报式Socket(SOCK_DGRAM)是一种无连接的Socket,对应于无连接的UDP服务应用。

    protocol:指定协议。常用协议有IPPROTO_TCP、IPPROTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。type和protocol不可以随意组合,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当第三个参数为0时,会自动选择第二个参数类型对应的默认协议。 

    返回值:成功返回所创建的socket的文件描述符,失败返回-1


    如果调用成功就返回新创建的套接字的描述符,如果失败就返回INVALID_SOCKET(Linux下失败返回-1)。套接字描述符是一个整数类型的值。每个进程的进程空间里都有一个套接字描述符表,该表中存放着套接字描述符和套接字数据结构的对应关系。每个进程在自己的进程空间里都有一个套接字描述符表但是套接字数据结构都是在操作系统的内核缓冲里。 


bind函数:

    在进行网络通讯的时候,必须把套接字绑定到一个地址上,我们可以使用bind函数进行绑定。套接字的协议族决定了要绑定的地址类型,我们常用的TCP和UDP协议都是需要绑定到一个32位的IP地址+16位的端口号上。当我们并不要求绑定到一个明确的地址和端口时,例如作为客户端连接服务器时,可以不调用bind函数进行绑定,而是在连接时让操作系统自动将套接字绑定到一个可用的地址上。


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

    sockfd:套接字描述符,要绑定的套接字

    address:sockaddr结构指针,该结构中包含了要结合的地址和端口号。 这个结构的定义在不同的平台有区别。实际上我们需要填充一个sockaddr_in结构体,在调用时将这个结构体指针转换为sockaddr指针传入

    address_len:address的长度,传入实际结构的长度即可

    返回值:成功返回客户端的文件描述符,失败返回-1


    address结构的填充是这样的,我们需要绑定ip和端口,并设置协议族,在设置端口和IP时,我们需要将IP和端口从主机字节序转换成网络字节序。htons和htonl函数可以完成这个功能。另外设置IP时,Win下我们需要将IP设置到 sockaddr_in.sin_addr.S_un.S_addr中,而在Linux下,我们需要设置到sockaddr_in.sin_addr.s_addr中。


    sockaddr_in addr;

    addr.sin_family = AF_INET;

    addr.sin_port = htons(port);


    // Win下

    addr.sin_addr.S_un.S_addr = inet_addr("192.168.0.29");

    // Linux下

    addr.sin_addr.s_addr = inet_addr("192.168.0.29");

    bind(socketfd, (struct sockaddr*)addr, sizeof(  addr ));

    作为服务器的套接字需要绑定IP和端口,相当于通知操作系统,在这个网卡设备上,所有发往我所监听的端口的消息,让我来处理。那么一台主机可能拥有多个网卡,甚至动态地增减网卡(未尝试过),通过绑定 INADDR_ANY,也就是"0.0.0.0"这个任意地址类型,可以绑定所有网卡。这相当于通知操作系统,只要是发往我监听的端口的消息,都交给我处理,不管是哪个网卡的。


    // 将inet_addr改为htonl(INADDR_ANY)

    addr.sin_addr.s_addr = htonl(INADDR_ANY);


listen函数:

    listen函数可以将一个已经完成绑定的套接字设置为监听状态,并使其可以接受连接请求。当我们调用了listen之后,这时候客户端可以connect成功,完成三次握手。连接成功的socket会被放到一个队列中,调用accept函数可以从这个队列中取出这个socket套接字并进行通讯。

    由于三次握手需要一段时间,所以一个连接有可能处于正在执行三次握手的半连接状态下,操作系统会为它另外维护一个队列。这两个队列的大小是有限制的,第二个参数backlog被定义为这两个队列的大小总和,这个参数所能接受的取值范围与操作系统的实现相关。一般我将其设置为100。当同大量进程在同一个瞬间连接服务器时,如果队列已满,那么新的连接的SYN请求会被丢弃,这时候TCP的超时机制会让客户端自动重发这个SYN请求。


    int listen(int sockfd, int backlog);

    sockfd:套接字描述符

    backlog:排队等待应用程序accept的最大连接数

    返回值:成功返回客户端的文件描述符,失败返回-1


accept函数:

    当有客户端连接上服务器,完成三次握手时,会被放到一个队列中,调用accept可以从队列中取出一个socket套接字进行通讯。一个队列中可能有多个已连接的套接字等待accept。这是一个阻塞操作!非阻塞的accept在没有新的客户端需要accept时,会立即返回失败。


    int accept( int sockfd, struct socketaddr* addr, socklen_t* len);

    sockfd:一个已经成功调用了listen函数的套接字描述符

    addr:这是一个输出参数,返回客户端连接的地址信息,通过这个结构我们可以知道客户端的IP和端口,这个信息可以作为日志记录,也可以作黑名单或白名单使用。如果你不关心客户端的地址信息,可以设置为NULL

    len:接收返回地址的缓冲区长度,如果你不关心客户端的地址信息,可以设置为NULL

    返回值:成功返回客户端的文件描述符,失败返回-1


connect函数:

    当我们希望连接TCP服务器时,需要调用connect函数,传入一个未连接的socket套接字,以及要连接的服务器IP和端口,来连接服务器。connect是一个阻塞的函数,它会向服务器发起三次握手,当三次握手成功之后或者连接失败才返回。当套接字是一个非阻塞套接字时,connect会立即返回。当连接成功或失败时,socket会变成可写,这时我们需要判断socket是否连接成功。


    int connect(int sockfd, const struct sockaddr* server_addr, socklen_t addrlen)

    sockfd:一个未连接的套接字描述符

    server_addr:要连接的TCP服务器地址信息,参考bind函数

    addrlen:server_addr结构体的大小

    返回值:成功返回0,否则返回-1


recv函数:

    对一个已连接的套接字调用recv可以接收数据,这个函数是一个阻塞函数,它会一直阻塞住,直到有数据可读,才会返回。recv会从TCP缓存区中读取数据到我们传入的缓存区中,如果TCP缓存区的内容大于接收缓存区的容量,那么我们需要调用多次recv来接收。非阻塞的recv会将数据读出,如果没有数据则立即返回。


    int recv(int sockfd, char* buf, int len, int flags); 

    socket:一个已连接的 套接字描述符

    buf:用于接收数据的缓冲区

    len:缓冲区长度

    flags:指定调用方式,0表示读取数据到缓存区,并从输入队列中删除。MSG_PEEK 表示查看当前数据,数据将被复制到缓冲区中,但并不从输入队列中删除,MSG_OOB 表示处理带外数据

    返回值: 若无错误发生,recv()返回读入的字节数,如果连接已中止,返回0,否则的话,返回-1


send函数:

    对一个已连接的套接字调用send可以发送数据,这个函数是一个阻塞函数,正常情况下它会将要发送的数据拷贝到TCP底层的发送缓存区,并发送到连接的另一端,当另一端接收到时(并不需要程序调用recv),会回复一个ACK报文来告诉我已经接收到了(如果没有收到这个确认,那么TCP会重发这个包)

    send并不一定能将你要发送的数据全部发送出去,当TCP缓冲区快满的时候,例如你要发送100个字节的数据,而TCP发送缓存区只能放下10个字节的数据,非阻塞的send会一直等到将所有要发送的内容写到发送缓冲区之后函数才返回。而非阻塞的send会返回10,只有前面10个字节的数据会被发送出去,剩下的内容需要再次send。


    int send( int sockfd, const char* buf, int size, int flags);

    sockfd:套接字

    buf:待发送数据的缓冲区

    size:待发送缓冲区长度

    flags:调用方式标志位,一般为0

    返回值:如果成功,则返回发送的字节数,失败则返回-1


close函数:

    close函数可以关闭一个socket套接字,当我们要关闭一条连接的时候,可以调用close来关闭。close并不会立即关闭连接,而是发送一个FIN报文给对方,进入四次握手流程,当四次握手流程走完,socket会进入TIME_WAIT状态,不论是客户端还是服务端,谁主动关闭,谁就会进入TIME_WAIT状态,这个状态会占用一定的资源,在一段时间后才会完全关闭(大约是2-3分钟)


    int close(int sockfd);

    sockfd:要关闭的socket套接字

    返回值:成功返回0,失败返回-1


Windows Socket API


    在Windows中使用Socket,需要做一些调整,首先socket套接字在Windows并不是一个int类型,而是SOCKET类型。socket函数如果失败,会返回INVALID_SOCKET,而不是-1。将socket API的套接字类型由int变为SOCKET即可。


    在Windows下需要包含WinSocket的头文件 WinSock2.h。在Windows下需要引用WinSocket的库文件,可以加上这行代码:

    #pragma comment(lib, "ws2_32.lib")


    另外,要在Windows下使用Socket,我们还需要先调用WSAStartup来初始化WinSocket,当不需要使用Socket时,再调用WSACleanup函数进行清理。下面是使用WSAStartup初始化WinSocket的代码,当初始化失败时,我们会调用WSACleanup进行清理。


    WSADATA wsaData; 

    WORD wVersionRequested = MAKEWORD( 2, 2 );

    int err = WSAStartup( wVersionRequested, &wsaData );

    if ( err != 0 )

    {

        return;

    }

    if ( LOBYTE( wsaData.wVersion ) != 2 ||  HIBYTE( wsaData.wVersion ) != 2 )

    {

        WSACleanup();

        return;

    }


二、简单的TCP服务器端与客户端


    在了解完TCP服务器与客户端的工作流程之后,我们来实现一个最简单的TCP通讯Demo,相当于是网络编程的HelloWrold —— 回显服务器。我们的服务器可以在Windows运行,也可以在Linux下运行。

    首先是服务器的实现,服务器会在8888端口进行监听,当一个客户端连接上时,等待客户端发送消息,接收到客户端发送的消息再原样发送给客户端,然后关闭客户端。

    如果是在Windows下,我们需要创建一个Win32控制台项目  (WIN32预处理是Win32工程属性中定义的),首先我们要包含一些头文件,以及做一些预处理,来方便代码的跨平台。

#include<stdio.h>

//消除平台相关的时间,Socket差异
#ifdef WIN32

#include <WinSock2.h>

typedef SOCKET sock;
typedef int sockLen;
#define badSock (INVALID_SOCKET)

#pragma comment(lib, "ws2_32.lib")

#else

#include<sys/types.h>
#include<errno.h>
#include<fcntl.h>
#include<unistd.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>

typedef int sock;
typedef socklen_t sockLen;
#define badSock -1

#endif

#define check(ret) if(ret) return -1;

    在main函数中,实现我们的服务器

    int main()
    {
        int err = 0;
        char buf[512];
        initSock();

        // 创建一个TCP Socket
        sock server = socket(AF_INET, SOCK_STREAM, 0);
        check(server != badSock);

        // 绑定到端口
        sockaddr_in addr = sockAddr(8888);
        err = bind(server, (sockaddr*)&addr, sizeof(addr));
        check(err != -1);

        // 开始监听
        err = listen(server, 10);
        check(err != -1);

        do
        {
            // 取出一个客户端连接
            sock client = accept(server, NULL, NULL);

            // 接收数据并打印
            int n = recv(client, buf, sizeof(buf), 0);
            printf("server recv %d byte: %s", n, buf);

            // 发送回给客户端并关闭客户端
            n = send(client, buf, n, 0);

    #ifdef WIN32
            closesocket(client);
    #else
            close(client);
    #endif

        } while(true);
       
        cleanSock();
        return 0;
    }
    
    服务器用到了自定义的  initSock,cleanSock  sockAddr函数,用来初始化socket,清理socket,以及获取socket地址结构体。具体实现如下:

    void initSock()
    {
    #ifdef WIN32
        WSADATA wsaData;
        WORD wVersionRequested = MAKEWORD( 2, 2 );
        int err = WSAStartup( wVersionRequested, &wsaData );
        if ( err != 0 )
        {
            return;
        }

        if ( LOBYTE( wsaData.wVersion ) != 2 ||HIBYTE( wsaData.wVersion ) != 2 )
        {
            WSACleanup();
            return;
        }
    #endif
    }

    void cleanSock()
    {
    #ifdef WIN32
        WSACleanup();
    #endif
    }

    sockaddr_in sockAddr(int port)
    {
        sockaddr_in addr;
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);

    #ifdef WIN32
        addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
    #else
        addr.sin_addr.s_addr = htonl(INADDR_ANY);
    #endif

        return addr;
    }

    sockaddr_in sockAddr(int port, const char* ip)
    {
        sockaddr_in addr;
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);

    #ifdef WIN32
        addr.sin_addr.S_un.S_addr = inet_addr(ip);
    #else
        addr.sin_addr.s_addr = inet_addr(ip);
    #endif

        return addr;
    }

    最后是客户端的实现,我们需要创建一个新的程序,客户端实现了连接服务器,发送数据,接收数据后打印并关闭的工作,代码如下:

    int main()
    {
        int err = 0;
        char buf[512];
        initSock();

        // 创建一个TCP Socket
        sock client = socket(AF_INET, SOCK_STREAM, 0);
        check(client == badSock);

        // 连接服务器
        sockaddr_in addr = sockAddr(8888, "127.0.0.1");
        err = connect(client, (sockaddr*)&addr, sizeof(addr));
        check(err == -1);

        // 发送数据到服务器
        sprintf(buf, "hello world!");
        send(client, buf, strlen(buf) + 1, 0);

        // 接收数据并打印
        int n = recv(client, buf, sizeof(buf), 0);
        printf("client recv %d byte: %s\n", n, buf);

    #ifdef WIN32
            closesocket(client);
    #else
            close(client);
    #endif
        // 暂停
        getchar();
        cleanSock(); 
        return 0;
    }

    运行服务器,然后再运行客户端,我们可以看到服务器打印了一句server recv 13 byte : hello world!,而客户端也打印了一句client   recv 13 byte : hello world!。因为我们将hello world!后面的\0结尾也发送过去了,所以接收到的是13个字节。

三、非阻塞Socket与Select 

非阻塞socket

    在上面的例子中,我们使用了阻塞的socket进行通讯,accept,connect,recv和send这几个函数的调用会导致程序阻塞在那里,当处于阻塞状态下,程序就做不了其他事情了。例如客户端在调用connect连接服务器的时候,我们希望场景的Loading动画正常播放,而当connect阻塞时,Loading动画会卡住。在游戏中我们调用recv函数接收数据时,整个游戏都会被阻塞。将socket设置为非阻塞可以很好地解决这个问题,所有的函数都会立即返回。

    使用多线程也可以解决这个问题,但多线程用起来相对比较危险,特别是在访问公共资源的时候,需要为这些公共资源上锁,锁的粒度太大,容易影响效率,锁的粒度太小,没有锁好可能导致程序出现各种BUG,并且多线程的BUG比较难定位,除非你要做的事情足够的简单,独立,完全在你的掌控之中,或者你对多线程有着丰富的经验,可以让一切都在掌控之中,否则,我们还是使用非阻塞的socket吧。

    在windows下是通过ioctlsocket函数来设置套接字的非阻塞,而在ios和android下是通过fcntl函数,我们可以用预处理来封装一下阻塞设置的代码。

void  setNonBlock(sock s, bool noblock)
{
#ifdef WIN32
    // when  noblock is true, nonblock is 1
    // when  noblock is false, nonblock is 0
    ULONG nonblock =  noblock;
    ioctlsocket(  s, FIONBIO, &nonblock);
#else
    int flags = fcntl(s, F_GETFL, 0);
     noblock ? flags |= O_NONBLOCK : flags -= O_NONBLOCK;
    fcntl(  s, F_SETFL, flags);
#endif
}

Select的使用

    当我们将Socket设置为非阻塞之后,可以使阻塞的socket函数立即返回,例如recv函数,我们需要实时地知道对方有没有发送消息过来,可以通过在游戏主循环里面,不停地recv,当接受到数据的时候,recv会返回接收到的字节数,没有数据则返回-1  (错误码一般为EAGAIN或者EWOULDBLOCK)    非阻塞操作中,EAGAIN或者EWOULDBLOCK错误是可以接受的  。在主循环里面不断地recv,但这个方法效率较低  (假设是服务器这么做,只要有那么几百个连接,对性能的影响就已经非常大了)  。另外非阻塞的connect,我们需要知道什么时候连接成功,可以使用socket来发送或接收数据,也需要在主循环中不断地检测是否连接成功。

    靠谱一点的做法是使用  IO复用  配合非阻塞Socket,什么是IO复用?IO复用是一种高效管理连接的IO模型,它可以很高效地管理成百上千的连接,当Socket有数据来到,或者要发送,IO复用会通知应用程序,然后让应用程序去调用recv或send。而不是让所有的Socket不断地去recv。使用select可以实现简单的IO复用,在弱联网一章中,我们已经使用了select。Epoll和IOCP分别是linux和windows下(目前)最强大的IO复用实现,他们用于高效地管理成千上万的连接,但对于一般的cocos2d-x客户端而言,select就足够了,并且select在linux,mac和win下面都有实现,很容易就可以编写出跨平台的select。我们可以在游戏的主循环update中,调用select来检测套接字触发的事件,根据事件进行相应的处理。

    select的函数原型如下:

    int select(int maxfd, fd_set* readfds, fd_set* writefds, fd_set* errorfds, struct timeval* timeout); 

    maxfd  在linux下是所有待检测的socket套接字描述符中,最大的描述符的  值 + 1,而windows下该值可以忽略。
    fd_set结构体是socket套接字的集合,  readfds,writefds和errorfds对应监听套接字是否可读,可写以及异常。这几个参数即是输入参数,又是输出参数。当socket套接字有数据可读时,套接字会被放到到readfds中,当socket套接字有数据可写时,以及调用connect的套接字连接成功时,会被放到writefds中,当调用connect的套接字连接失败时,会被放到errorfds中。
    timeout可以指定select的超时时间,timeval结构体包含两个变量,tv_sec为秒,tv_usec为微秒(百万分之一秒),select会按照指定的时间进行等待,如果在指定的时间内触发了事件,则返回,否则会在时间结束后返回。当timeout传入NULL时,select会一直阻塞直到监听到事件才返回。指定的时间为0时,select不阻塞。
    select的返回值等于0时,表示超时,无事件触发。大于0时,返回值表示准备就绪的socket套接字数量。小于0时表示出错。

    在调用select之前我们需要设置好fd_set,而在调用完select之后,需要根据返回值来判断fd_set中是否有套接字准备就绪。通过下面几个宏可以操作fd_set:

  • FD_CLR(s, *set) 将套接字从set中移除 
  • FD_ISSET(s, *set) 判断套接字在set中是否处于就绪状态 
  • FD_SET(s, *set) 将套接字添加到set中 
  • FD_ZERO(*set) 将set清空

    windows下select函数详细文档:  https://msdn.microsoft.com/en-us/library/ms740141(VS.85).aspx

    Select函数可以用来批量检测套接字是否可读,可写或异常,在使用select的时候,是这样的几个步骤:http://blog.csdn.net/piaojun_pj/article/details/5991968

  1. 将需要检测的套接字放到对应的套接字集合中(底层是一个数组,有监听可读,可写和异常三个集合)
  2. 在主循环中,调用select函数,将几个集合作为参数传入,同时传入最大套接字ID以及等待的时间参数
  3. 在调用完select函数之后,根据返回的结果进行处理,处理完之后,重新执行步骤1

    下面我们将前面例子的TCP服务器调整为select + 非阻塞socket来实现。这意味着我们可以同时处理多个连接,所以这里我们会用一个stl的set容器来管理连接。首先我们将监听的socket设置为非阻塞,这样对它做accept操作就不会阻塞住了,accept返回的socket也需要设置非阻塞,这样与客户端的通讯也不会阻塞了。
    接下来使用select来检测这些套接字是否可读,select返回结果之后,我们需要遍历所有的socket,来检测这些socket是否触发了事件,然后进行处理。   示例代码如下:

    // 添加两个全局变量方便处理
    fd_set g_inset;
    set<sock> g_clients;

    int main()
    {
        int err = 0;
        int maxfd = 0;

        initSock();

        // 创建一个TCP Socket
        sock server = socket(AF_INET, SOCK_STREAM, 0);
        check(server == badSock);
        setNonBlock(server, true);

        // 绑定到端口
        sockaddr_in addr = sockAddr(8888);
        err = bind(server, (sockaddr*)&addr, sizeof(addr));
        check(err == -1);

        // 开始监听
        err = listen(server, 10);
        check(err == -1);

        // 设置select的等待时间
        timeval t;
        t.tv_sec = 0;
        t.tv_usec = 1000;

        // 清空全局的fd可读集合,将服务器的监听socket添加进去
        FD_ZERO(&g_inset);
        FD_SET(server, &g_inset);

        do
        {
            // 每次都重置inset,这样只需要维护好g_inset即可
            fd_set inset = g_inset;
            int ret = select(maxfd, &inset, NULL, NULL, &t);

            if(ret > 0)
            {
                // 如果是服务器就绪,说明有客户端连接
                if(FD_ISSET(server, &inset))
                {
                    processAccept(server, maxfd);
                    --ret;
                }
               
                // 判断是否有客户端套接字就绪,有则处理
                for(set<sock>::iterator iter = g_clients.begin();
                    iter != g_clients.end() && ret > 0; )
                {
                    sock client = *iter;
                    if(FD_ISSET(client, &inset))
                    {
                        --ret;
                        // 如果客户端关闭从g_inset和g_clients中清除该客户端
                        if(!processClient(client))
                        {
                            FD_CLR(client, &g_inset);
                            g_clients.erase(iter++);
                            continue;
                        }
                    }
                    ++iter;
                }
                // 当select触发的事件处理完,会提前结束遍历
            }
        } while(true);
       
        return 0;
    }

    上面调用了两个函数,processAccept以及  processClient,分别用来处理客户端连接以及客户端发送消息。

    void processAccept(sock &server, int &maxfd)
    {
        sock client = accept(server, NULL, NULL);
        if(client != badSock)
        {
            // 添加到g_clients进行管理
            // 并设置到g_inset中,在下次循环时监听该套接字
            g_clients.insert(client);
            FD_SET(client, &g_inset);
            // 将客户端socket设置为非阻塞
            setNonBlock(client, true);

            // 在linux下,我们需要为select传入一个正确的maxfd
    #ifndef WIN32
            if(maxfd < client)
            {
                maxfd = client;
            }
    #endif
        }
    }
    
    bool processClient(sock &client)
    {
        char buf[512];
        // 接收数据并打印
        int n = recv(client, buf, sizeof(buf), 0);
        if(n > 0)
        {
            printf("server recv %d byte: %s\n", n, buf);
            // 发送回给客户端并关闭客户端
            n = send(client, buf, n, 0);
        }
        else if(n == 0)
        {
            // 关闭socket,返回false
            printf("client close");
    #ifdef WIN32
            closesocket(client);
    #else
            close(client);
    #endif
            return false;
        }

        return true;
    }

    通过调整之后,我们的服务端就可以同时处理多个客户端了,这也称作并发处理。select函数适合处理1000以内的连接数,并且很多系统限制了select能处理的最大连接数为1024,强行修改这个值,可以使select能处理更多的连接数,但同时效率也会随着下降。如果你希望处理更多的连接,epoll和iocp拥有更强大的并发处理能力。对于一般的客户端程序,大概了解非阻塞操作就够用了,在游戏每一帧的update去调用一次非阻塞recv的消耗并不算大,但你需要知道得更多,变得更强!

四、半包粘包

    通过对非阻塞和select的了解,接下来我们来看看处理消息,在初学阶段,基本上我们的消息处理都很轻松,但随着传输频率的提高以及传输数据的复杂,我们就会开始接触到半包粘包的问题。  半包粘包是使用TCP的时候,经常会碰到的问题,这个问题是我们必须了解并解决的一个问题!这是由于TCP的特性导致,但这本身不是TCP的缺陷,而是应用层需要处理的内容,TCP本来就是流式套接字,只管把数据有序地发送到对端。这里的包是应用层的概念。TCP的更底层会有IP包,但跟我们应用层的包不一样,应用层的包是程序逻辑上的概念。

    半包指的是你一次性send100个字节的内容,但是对方recv只recv到了50个字节。而连包是指你第一次发送了50个字节,然后再发送50个字节,对方一次recv到了100个字节。  举个例子,开学之际,学校的食堂服务器要向全校的师生连续发3条信息,热烈欢迎,新老师生,前来用餐,那么在半包的情况下,可能第一次收到热烈,第二次收到欢迎,然后是剩下的内容,而连包的情况,可能第一次收到的是,热烈欢迎新老师生前,第二次收到,来用餐。

    半包在什么情况下容易出现呢?一般是一次性发送的数据量太大,TCP无法一次性发送完,例如你需要将一个视频文件,几十MB的文件发送给对方,那么TCP底层会对其进行分片,数据发送到对方的Socket中,无法一次性发送完,会出现半包。

    相对于半包,  包的情况非常常见,因为TCP有一个延迟发送的规则,我们调用send会将数据写入到TCP的发送缓冲区中,但不会立即发送,因为在TCP中会有很多细小的分节(ACK报文),TCP在发送的时候捎上这些小分节可以很好地节约带宽,而且一般一个send会对应一次recv,就是收到数据之后,处理完成,返回,TCP的这个规则可以很好地适应这种情况,将recv的ACK报文和send的数据一起发出,而且  当你快速的send两次的时候,对方recv到的一般也会是这两次send的所有数据

    除了将数据一次性发送的规则之外,TCP的接收是底层先将数据拷贝到系统的TCP接收缓冲区  (每个连接都有一个),连续发送N次的数据都会先放到这个接收缓冲区里面,recv的调用是从这个系统的缓冲区中读取数据,所以当你recv调用的时候,可能之前发送的几个包都在这里了。一次recv就可以全部recv出来。

    对于半包,处理的策略是,不能丢弃,因为一旦将这个半包丢弃,后面的数据全部都错乱了,你需要把它缓存起来,你需要等待剩余的数据,然后拼成一个完整的包,再处理。对于粘包的处理策略是,将数据包,一个个从一连串的内存中区分开,然后单独处理。

    处理半包  包的重点在于,如何判断这个包是一个半包还是粘包,亦或是正常的包,常用的有两种判断方法:

  • 第一种是为每个数据包定义一个包头,在包头中填上这个数据包的大小,根据这个字段,和接收到的实际数据大小来判断包是否完整。
  • 第二种是在每个包的最后加上一个结束标识,当判断到这个结束标识的时候,认为数据包已经完整,否则认为数据包不完整,连包的情况也可以使用这个结束标识来区分。

    一般情况下,我们使用第一种,因为它的效率和适用性会更好一些,第二种方法的话,需要校验接收到的所有数据,来查找结束标识,并且,在数据内容中,不得出现与改标识相同的内容,否则数据包会解析错误,而第一种方法就没有这些问题了,下面的代码将介绍半包粘包是如何处理的。

    下面是一个TcpClient接收数据的回调,  TcpClient封装了一个socket,当这个socket可读时,外部会调用TcpClient的onRecv函数进行接收。  里面用到了  m_ProcessModule的RequestLen和Process函数,这两个函数的功能分别是根据数据包结合协议计算出该数据包完整长度是多少,以及处理一个完整的数据包。

    在接收到数据之后,先判断是否存在半包,如果是则先将数据拼接到半包之后。然后根据接收到的数据是否完整来进行处理,优先处理半包拼接成的数据包,再将剩余的粘包进行遍历处理,  直到处理完所有的包或者碰到半包无法处理,或者数据解析异常。

    int CTcpClient::onRecv()
    {
        char buf[512];
        int requestLen = 0;

        int ret = recv(m_socket, buf, sizeof(buf), 0);
        if(ret <= 0) return -1;
       
        char* processBuf = buffer;
        char* stickBuf = NULL;

        //m_RecvBuffer缓存了上一次没有接完的半包
        //如果存在半包,需要把新的内容追加到半包后面
        //这时有两种情况,接收到的数据长度大于半包所剩余的内容长度,或者小于等于半包所剩余的内容长度
        if (NULL != m_RecvBuffer)
        {
            unsigned int newsize = ret;
            if ((m_RecvBufferLen - m_RecvBufferOffset) < (unsigned int)ret)
            {
                newsize = m_RecvBufferLen - m_RecvBufferOffset;
                stickBuf = processBuf + newsize;
            }

            //拷贝到接收缓冲区中
            memcpy(m_RecvBuffer + m_RecvBufferOffset, processBuf, newsize);
            m_RecvBufferOffset += newsize;
            ret += m_RecvBufferLen;
            processBuf = m_RecvBuffer;
        }

        //对包进行处理,m_ProcessModule是一个处理对象
        //这里用来查询包的长度
        requestLen = m_ProcessModule->RequestLen(processBuf, ret);
        if (requestLen <= 0 || requestLen > MAX_PKGLEN)
        {
            //解析出来的包数据错误
            return requestLen;
        }

        //如果未到达预期的包长度,说明是半包
        //将半包缓存到m_RecvBuffer中
        if (ret < requestLen)
        {
            if (NULL == m_RecvBuffer)
            {
                m_RecvBuffer = new char[requestLen];
                m_RecvBufferLen = requestLen;
                m_RecvBufferOffset = ret;
                memcpy(m_RecvBuffer, processBuf, ret);
            }
            return ret;
        }

        //如果等于或超过了预期的长度,可以逐个处理
        //直到处理完所有的包或者碰到半包无法处理
        while (ret >= requestLen)
        {
            m_ProcessModule->Process(processBuf, requestLen, this);
            processBuf += requestLen;
           
            //m_RecvBuffer中只会有一个半包,并且最先处理的就是半包
            //处理完这个半包后就可以释放缓存半包的内存了
            if (NULL != m_RecvBuffer)
            {
                processBuf = stickBuf;
                delete [] m_RecvBuffer;
                m_RecvBuffer = NULL;
                m_RecvBufferOffset = m_RecvBufferLen = 0;
            }

            //如果存在粘包,继续处理后面的包
            //直到处理完所有的包,或碰到半包,或数据异常才返回
            ret -= requestLen;
            if (ret > 0
                && NULL != processBuf)
            {
                // 取出下一个包所需的长度
                requestLen = m_ProcessModule->RequestLen(processBuf, ret);
                if (requestLen <= 0 || requestLen > MAX_PKGLEN)
                {
                    return requestLen;
                }
                //半包缓存
                else if (ret < requestLen)
                {
                    //在这里m_RecvBuffer一定是NULL,
                    m_RecvBuffer = new char[requestLen];
                    m_RecvBufferLen = requestLen;
                    m_RecvBufferOffset = ret;
                    memcpy(m_RecvBuffer, processBuf, ret);
                    return ret;
                }
            }
        }
    }

五、心跳与超时

    现在我们已经可以正确地处理数据以及正常的连接关闭了,但这还不够,我们还需要掌握一些网络异常处理的能力。说到网络异常,最常见的网络异常就是断网了。网络的正常关闭,是由一方调用close发起的,通过发送FIN报文给对方来进行四次握手关闭。当我们的程序崩溃时,操作系统会帮我们关闭套接字,完成四次握手。  所以程序的正常退出,以及异常退出,操作系统都会帮我们回收,关闭套接字资源的。当然,自己创建的套接字自己close是一个良好的习惯。

    但当我们将网线拔掉,或者WIFI信号突然中断的时候,或者主机突然断电,是没有任何消息通知到TCP的,我们的socket仍然是可以发送,接收数据的,只不过数据无法传输到对端,但数据是否传输到了对端,TCP是通过对端回复的ACK报文来确定的。如果没有回复,那视为丢包,丢包的情况下,TCP的处理是超时未收到确认的ACK报文,则进行重发。由于没有任何消息通知到TCP,所以从TCP层的角度来看,当前的连接是正常的,但是实际上它已经无法工作了。这种情况下,我们知道网络断了,但是TCP不知道。

    当我们很快速地断网再重连,这时候socket的连接并不一定会产生异常。也就是说,物理网线的断开跟TCP连接的断开不是一回事。  是一条虚拟的连接,由TCP协议维护的虚拟连接,不要把这条连接跟你的网线混为一谈,你想,你的数据是通过网线发出去,这肯定没错,所以这条网线用来描述你的TCP连接,感觉就非常地恰当。但是,我们的UDP,它是无连接的,它的数据传输,就不用经过网线了吗?所以,TCP上面说的这条连接,跟大部分初学者理解的,物理网线连接,它就不是一回事,TCP连接更像是一条想象中的连接。

网络游戏——强联网游戏

    上图演示了物理连接中断时,TCP的数据传输情况。实际上拔网线,同本机到对端主机的物理线路中任何一个环节中断了,在TCP的角度来看,都是一样的。如果我们在这种状态下,将主机断电,或者将应用程序关闭,那么这个TCP连接的对端,就永远收不到任何消息了。

TCP的死连接

    这种在TCP层处于连接状态,而通讯两边的数据又无法传输的TCP连接,我们称之为死连接。  对于这个问题,客户端和服务端所处的情况不同,解决问题的策略也不同。死连接会占用系统资源  (占着茅坑不拉屎),当服务器存在大量的死连接而没有及时清理,会降低服务器的运行效率。甚至耗光服务器的socket资源,导致服务器无法连接,非常的恶心  (现在的硬件设备不太容易出现这种情况)。所以对于服务器而言,只需要能在一段时间内检测出死连接,并关闭清理即可。这段时间可以是30分钟,2个小时。一般并不需要太及时,不需要过于频繁地检查。

    对于客户端而言,  在大多数情况下,我们希望程序能够立即检测到网络断开的消息。及时的反馈是客户端的需求,我们可以在客户端发起请求的同时,添加一个计时器,当我们收到服务端的响应时,移除这个计时器,当计时器的时间到了之后,还没有接收到服务端的响应,那么视为连接已中断。当然,我们也可以使用和服务端一样的检测方法,在没有用户触发请求的情况下,来检测连接是否断开。

检测死连接

    由于死连接的socket在逻辑上并没有任何异常,所以我们无法从socket本身检测到任何错误。那么如何判断一个连接是否已死呢?死连接的特点就是,不会有任何消息发过来,你的消息也无法到达对端。在实际中用的最多的就是心跳包。

    什么是心跳包?一般是一个非常小的包,在里面你可以按照你的协议发送任何数据,它的作用就是让你接收到数据,当一段时间没有接收到数据时,关闭这个连接,所以,心跳包还需要借助定时器来实现,每次收到心跳包,都更新一下定时器的超时时间。心跳包这个说法很形象!人死了,就没有了心跳,而连接死了,也不会有心跳。心跳包一般是用来检测死链接用的,但不仅仅局限于这个用途,心跳包还可以用于各种检测,例如看服务端是否有新的公告,或者时间之类的校验,不过最多的还是检测死链接。
    
    用心跳来检测死连接,因为死链接不会有心跳,玩家的网线断了,他客户端的心跳肯定是发不到服务端的,这种情况下,服务端就可以根据客户端的心跳频率,还判断它是不是死链接,假设客户端在30分钟内,都没有心跳,那就可以断定,客户端挂了。服务端这时候可以主动关闭这条连接,心跳脉搏的频率可以自己定,5分钟到1个小时都是正常的范围。  根据业务需求来定一个合理的超时时间。如果不想这么麻烦,也可以用TCP自带的Socket选项SO_KEEPALIVE,来设置TCP的保活定时器,系统默认是两个小时监测一次。

    服务端也可以主动send一个数据到客户端,但一般不这么做,像这种既可以客户端主动发送,又可以服务端主动发送的事情,肯定是让客户端来做,每个客户端做一次,放到服务端这边,可能就是数万次了。往死链接上发送数据的话,如果对方主机在一时间内没有收到响应,会返回一个RST报文,意思是这条连接已经失效了,重置这条连接。