先保证可靠性,保证各种特殊情况下所有主从机逻辑的一致性。这是必须保证的,否则各机逻辑任何不一致游戏就完全失效了。
可靠性得到完全保障后,再考虑速度、流畅问题,逐步优化到高速。
分层架构:主循环层,Game层和Net层。Net层封装了游戏协议和socket,提供简单接口给应用层。Game层逻辑中,只把Net层当做一种普通的输入输出。
Game的Main Loop仿照HGE游戏引擎的方式,主循环只做事件处理和时间间隔计算,具体的每帧逻辑由Game的FrameProc成员函数完成。
由于公司网络无法上传图片,这里不能给出架构图了。
如果发送的是操作,会出现如下的问题:A、B、C三个机器,主机收到某个机器的操作后发送给所有机器,因为网络差别每个机器收到的时间早晚不一样,会造成逻辑不一致。例如某角色在惯性下向前走,然后玩家发送了一个停止操作,A机器在角色走了10米后收到,B机器在角色走了8米后收到,C机器在角色走了12米后收到,三个机器中,这个角色停止的位置就不一致了,逻辑失效。
所以不能发送纯操作数据,而应该发送状态或状态变化量这样的数据。发送状态,例如某机中操作角色向左走到了(x,y)处,并处于出拳状态,把这个坐标和状态发送到所有机器,各机逻辑才能保证一致。发送状态变化量,例如(角色向左移动1米,向上移动2米)这样的数据。
总原则:
由于接收操作是一个阻塞操作,所以每一个接收都要开一个线程。
Client端:
比较简单,就是一个线程用来创建连接和发送,一个线程用来接收。
Server端:
一个线程用来侦听和接受新客户端的连接,每接受一个Client端就创建一个线程接收来自该Client的数据。
Server给Client发送消息,最好创建线程专门用于发送,而不能直接在主线程中调用send发送。因为send函数会阻塞线程运行,因为如果协议正在发送缓冲区中的数据,send函数会等待协议将数据发送完毕才把数据加入缓冲区。
Server端发送线程的三种做法:
1.为每个客户端创建一个发送线程。——目前采用的这个做法
好处:入参一次性在线程入口带入,方便。并且可以利用多核的处理性能。
坏处:需要为每个发送线程创建一个信号量。
2.对所有客户端共用一个发送线程。
好处:不用为每个发送线程创建一个信号量
坏处:入参没法儿一次性带入了,因为要确定消息发送给哪个Client需要传递进去。另外由于单线程发送多请求,可能需要建立一个发送链表,并且可能需要有锁来保护该跨线程变量。
3.开辟线程池,所有客户端发送公用线程池
好处:充分发挥CPU效率并减少资源消耗。
坏处:实现的复杂度高,出现问题后不容易调试。
用POSIX的pthread实现,以实现跨平台(Windows、Linux、Tizen)可移植。
多线程交互,复杂性极高且因为锁和信号量极大影响了处理效率。例如:ClientsList的锁,导致了很多死锁问题,主要集中在client或server连接或断开连接时。
解决方法:只让一个线程操作ClientsList,就可以简单清晰地避免死锁问题。最好的解决方法就是用“生产者-消费者模式”把CNetServer做成一个事件处理线程,让CNetServer只接收事件来处理自己的数据,这样就安全了,不用再考虑多线程访问CNetServer数据的问题。
学习了“网络多线程编程”和“同步异步IO”的理论知识,从理论上解决该问题。根据“生产者-消费者”模式,做了“事件处理线程类”和“普通线程类”,让发送线程和CNetServer从“事件处理线程类”派生出来,侦听和接收线程从“普通线程类”派生。
总原则:
所有线程间共享数据都需要加锁。在有效率问题并且确认多线程不会同时操作此共享数据时,才可以不加锁访问。
经过“生产者-消费者模式”的改造,目前线程间共享数据已经很少了。
我们的程序里,需要在server或client主动关闭时能有效去除相应资源,目的是为了在player进入或离开的操作后,不给系统留下残余。因为目前使用的recv是阻塞式的,所以在主动退出时,需要先调用shutdown(socket, SD_BOTH)关闭连接,recv才能退出,否则会一直阻塞在那里,接收线程无法结束。同样,accept也是阻塞的,主动退出时要shutdown(serverSocket)和closeSocket(serverSocket)之后accpet才能退出。
下面是主动优雅关闭Socket连接的方法:
如果要求待未发送完的数据发送出去后再关闭socket,一种方法是(MSDN上推荐):
关闭Socket时先shutdown(socket, SD_BOTH),再closesocket或close。
但也有说Linux下这样还是会丢数据的,可能Linux跟Windows不同?
所以也有另一种方法是进行如下Socket设置:
struct linger {
u_short l_onoff;
u_short l_linger;
}m_sLinger;
m_sLinger.l_onoff = 1;//在调用closesocket()时还有数据未发送完,允许等待。若m_sLinger.l_onoff=0;则调用closesocket()后强制关闭
m_sLinger.l_linger = 5; //设置等待时间为5秒
setsockopt(s, SOL_SOCKET, SO_LINGER, (const char*)&m_sLinger,sizeof(linger));
然后在需要关闭socket的时候closesocket(Windows)或close(linux)
下面是原理解释:
从函数调用上来分析(msdn):一旦完成了套接字的连接,应当将套接字关闭,并且释放其套接字句柄所占用的所有资源。真正释放一个已经打开的套接字句柄的资源直接调用closesocket即可,但要明白closesocket的调用可能会带来负面影响,具体的影响和如何调用有关,最明显的影响是数据丢失,因此一般都要在closesocket之前调用shutdown来关闭套接字。 shutdown:为了保证通信双方都能够收到应用程序发出的所有数据,一个合格的应用程序的做法是通知接受双发都不在发送数据!这就是所谓的“正常关闭”套接字的方法,而这个方法就是由shutdown函数,传递给它的参数有SD_RECEIVE,SD_SEND,SD_BOTH三种,如果是 SD_RECEIVE就表示不允许再对此套接字调用接受函数。这对于协议层没有影响,另外对于tcp套接字来说,无论数据是在等候接受还是即将抵达,都要重置连接(注意对于udp协议来说,仍然接受并排列传入的数据,因此udp套接字而言shutdown毫无意义)。如果选择SE_SEND,则表示不允许再调用发送函数。对于tcp套接字来说,这意味着会在所有数据发送出并得到接受端确认后产生一个FIN包。如果指定SD_BOTH,答案不言而喻。 closesocket:对此函数的调用会释放套接字的描述,这个道理众所周知(凡是经常翻阅msdn的程序员),因此,调用此函数后,再是用此套接字就会发生调用失败,通常返回的错误是WSAENOTSOCK。此时与被closesocket的套接字描述符相关联的资源都会被释放,包括丢弃传输队列中的数据!!!!对于当前进程中的线程来讲,所有被关起的操作,或者是被挂起的重叠操作以及与其关联的任何事件,完成例程或完成端口的执行都将调用失败!另外 SO_LINGER标志还影响着closesocket的行为,但对于传统的socket程序,这里不加解释 因此可以可以看出shutdown对切断连接有着合理的完整性。 下面从tcp协议上来分析shutdown和closesocket的行为(behavior):closesocket或shutdown(使用 SD_SEND当作参数时),会向通信对方发出一个fin包,而此时套接字的状态会由ESTABLISHED变成FIN_WAIT_1,然后对方发送一个 ACK包作为回应,套接字又变成FIN_WAIT_2,如果对方也关闭了连接则对方会发出FIN,我方会回应一个ACK并将套接字置为 TIME_WAIT。因此可以看出closesocket,shutdown所进行的TCP行为是一样的,所不同的是函数部分,shutdown会确保 windows建立的数据传输队列中的数据不被丢失,而closesocket会冒然的抛弃所有的数据,因此如果你愿意closesocket完全可以取代shutdown,然而在数据交互十分复杂的网络协议程序中,最好还是shutdown稳妥一些!?有关TCP协议的连接原理清访问 http://www.rfc-editor.org第RFC793号文件
对于"关闭连接"
socket连接的关闭分为:"优雅关闭"和"强制关闭";
MSDN上有说明:closesocket的关闭动作依赖于socket选项SO_LINGER和SO_DONTLINGER;(SO_DONTLINGER为缺省值),其含义如下:
选项 阻塞时间 关闭方式 等待关闭与否
SO_DONTLINGER 不关心 优雅 否
SO_LINGER 零 强制 否
SO_LINGER 非零 优雅 是
MSDN上,还有说明:为了确保数据能被对方接收,应用程序应当在调用closesocket之前调用shutdown。
closesocket应最后被调用,以便系统释放socket句柄及相关资源。
windows下,socket的客户端执行closesocket(clientSocket)时,就是关闭了socket连接,此时客户端会给服务端发送一个recvLen为0的包来通知服务端。TCP建立连接以后,双方就是对等的。不论是哪一方,只要正常close(socket_handle),那么TCP底层软件都会向对端发送一个FIN包。FIN包到达对方机器之后,对方机器的TCP软件会向应用层程序传递一个EOF字符,同时自动进入断开连接流程(要来回协商几次,但这些都是自动的、不可控的)。什么是EOF字符?它其实什么也不是,只是一个标记,上层应用程序如果这时读socket句柄的话,就会读到EOF,也就是说,此时socket句柄看起来里面有数据,但是读不出来,因此select返回可读(非阻塞模式下)read不会阻塞(阻塞模式下)但是read的返回值却是0。
如果此时不是读操作而是写操作,并且此时socket已经断开连接,那么write函数会返回-1且置errno为EPIPE(如果忽略了SIGPIPE信号的话)或者引发SIGPIPE信号(如果没忽略的话)
所谓的心跳包就是客户端定时发送简单的信息给服务器端告诉它我还在而已。代码就是每隔几分钟发送一个固定信息给服务端,服务端收到后回复一个固定信息如果服务端几分钟内没有收到客户端信息则视客户端断开。
在TCP的机制里面,本身是存在有心跳包的机制的,也就是TCP的选项:SO_KEEPALIVE。系统默认是设置的2小时的心跳频率。TCP的KeepAlive默认是不打开的。
IM软件一般自己实现心跳机制,因为自己实现的心跳机制通用,可以无视底层的UDP或TCP协议。
参考文章: http://tldp.org/HOWTO/html_single/TCP-Keepalive-HOWTO
http://blog.sina.com.cn/s/blog_7a3510120101775w.html
我们自己实现心跳很简单:只需要send或者recv一下,如果结果为零或SOCKET_ERROR,则为掉线。
注意网络延迟 != 网速慢,而是指网元相隔太远,传输节点太多导致收报时间-发报时间的时间差。
魔兽对战的网络流量,是十几K每秒,发送的数据并不大。
如果发送状态数据的话,游戏中有上百个对象,按每秒20帧速发送,每对象20byte数据,则每秒需要发送80K。
还有职业玩家专门分析在延迟情况下怎么打。
对于关键动作,为了逻辑的一致性,都是采用服务器统一分发命令的方式来严格保证逻辑,例如击中处理。
只有在普通的移动时,才会用“航迹推算算法”这样的 p2p+推算+纠正的方式
lf leader/followers是分布式系统底层常用的快速分离网络请求的设计模式。
poco c++是一套风格清爽,易读易学的开源基础库。目前比较遗憾的是网络
核心中没有实现lf leader/followers设计模式。本人对poco c++的reactor模式
加以适应多线程的改造,实现了lf leader/followers设计模式,希望对想要开发
高效多线程网络应用的同学有所帮助。代码下载地址分别是:
google code: http://code.google.com/p/lfreactor/downloads/list
sourceforge: http://sourceforge.net/projects/lfreactor/?source=directory
在Socket连接之前(Server端就是accept之前,client端就是connect之前),调用setsockopt设置Socket一些属性。
设置send和recv的超时
int nNetTimeout = 1000; //1秒
//发送时限
setsockopt(socket,SOL_SOCKET,SO_SNDTIMEO,(char *)&nNetTimeout,sizeof(int));
//接收时限
setsockopt(socket,SOL_SOCKET,SO_RCVTIMEO,(char *)&nNetTimeout,sizeof(int));
设置send和recv缓冲区大小
// 接收缓冲区
int nRecvBuf = 32 * 1024; //设置为32K
setsockopt(s,SOL_SOCKET,SO_RCVBUF,(const char*)&nRecvBuf,sizeof(int));
//发送缓冲区
int nSendBuf = 32*1024; //设置为32K
setsockopt(s,SOL_SOCKET,SO_SNDBUF,(const char*)&nSendBuf,sizeof(int));
//在发送数据的时,不执行由系统缓冲区到socket缓冲区的拷贝,以提高程序的性能:
int nZero = 0;
setsockopt(socket,SOL_SOCKET,SO_SNDBUF,(char *)&nZero,sizeof(nZero));
//在接收数据时,不执行将socket缓冲区的内容拷贝到系统缓冲区:
int nZero = 0;
setsockopt(s,SOL_SOCKET,SO_RCVBUF,(char *)&nZero,sizeof(int));
游戏通信协议主要是为了解决“TCP粘包问题”,也为了解决各种控制流程的问题。问题详见“极端、例外情况及处理”章节。
协议报文结构如下:
Packet Length |
Controller |
PlayerID |
Action |
Data |
16bit |
8bit |
8bit |
8bit |
不定长 |
Packet Length代表总报文长度,包括报文头和数据的长度。
Controller代表该报文要做什么,目前有如下可取值:
typedef enum
{
LOG_IN = 1,
LOG_IN_ACK,
LOG_OUT,
PLAYER_JOIN, //新玩家加入
PLAYER_LEAVE, //玩家离开
SERVER_DISCONNECT,//断开与服务器连接
MOVE, //角色动作
}GAME_PROTOCOL_CONTROLLER;
PlayerID代表玩家的角色ID。
Action代表子动作,目前尚未启用
Data是报文携带的数据。
发送队列是为了解决操作速率、发送速率、接收速率的不匹配问题。问题详见“极端、例外情况及处理”章节。目前发送线程已经通过“生产者-消费者模式”拥有了队列机制。
实现:
1. 发送的数据先插入队列,发送线程从队列取数据进行发送。——测试完成
2. 发送队列满时,让发送线程停止接收和发送。目前实现方式是用::Send()函数的返回值返回false来表示队列满,处理方式如下:
while ( false == CEventProcThread::Send(event) )
{
sleep_msec(50);
}
测试结果:
模拟Server操作比发送快的场景(在Server端发送线程中,每发送完一包进行延时0.5秒,人为造成发送速度比操作速度慢),看Client还能否保证逻辑一致——测试通过,现在能保证逻辑一致了。
模拟Client操作比发送快的场景(在Client端发送线程中,每发送完一包延时0.5秒),看client能否阻塞主线程操作——测试通过,能阻塞。
模拟Server处理能力低下的场景(Server端接收线程每接收一包延时100ms,再把发送速度每发送一包延时更大,500ms,让发送速度低于接收速度)。看client端逻辑还能否和server端保持一致——测试通过,现在能保证逻辑一致了。
TCP是流式协议,当发送较快时,会出现多个包并成一个包发送的情况。例如先发送“luojiao”,再发送“lishanshan”,有可能会合并成“luojiaolishanshan”一起发送。这时接收方直接取数据作为数据结构就不对了,例如接收方以12byte为单位recv数据,就会取出“luojiaolisha”和“nshan”两个错误的数据,而不是预期的先取出“luojiao”后取出“lishanshan”。
在服务端和客户端都在本地时,不容易粘包,因为实际上没走网络。而服务端和客户端不在一个机器时,就很容易出现这个问题,就普通的30帧率就会出现。
解决方法:
通过应用层协议解决。核心目的是要确定包长,好从TCP流中解析出各个应用层报文来。——已解决
目前用的TCP协议,本身是面向连接的会有心跳保活机制,通过send或recv的返回值即可判断是否断网。
断网或离线后,要做一系列相应处理:
1. Game删除此角色对象
2. NetServer去掉该client对象,回收playerID。清空对该client的发送队列。
3. 正常关闭对该client的发送和接收线程。
如果用UDP协议,那就要自己实现心跳机制和超时来判断断网了。
——正在进行中,遇到困难。
当网络较慢或瞬时拥塞时,虽然发送缓存buf(这个buf指我们自己的主线程和发送线程传递数据的buf,不是Socket本身的发送缓存)做了互斥锁处理不会导致发送出错误的数据,但是会出现某些“包丢失”的现象,发送出0长度空包。其实不是包丢失了,而是由于主线程填写buf比发送线程发送buf要快(也就是发送速率低于帧率),第一次填写buf后,在发送线程还没来得及发送出这个buf,主线程又写了多次buf并给了多个信号量,此时发送线程获取多个信号量循环多次,就只发出了最后一个buf和memset之后的多个空buf。
经过分析有如下几种场景会出现问题:
场景1:Server有10个用户操作,Server本机处理了这10个操作。发给Client 10个包,有5个包因为发送线程太慢而被缓冲区清空没有发出去,就造成Client端只收到和处理了5个操作,而Server端处理了10个操作,两者逻辑不一致了。
模拟方法:在Server端发送线程中,每发送完一包进行延时1秒,人为造成发送速度比操作速度慢,即可模拟出此场景。下图是模拟出的场景,可以看出Client端红圈的位置已经与Server端不一致了。
场景2:Server端收到Client端的操作,分发给所有Client端时,有的Client端发送的正常,有的Client端因网络慢发送有空包,导致各机逻辑不一致。其实该问题跟场景1本质上是一样的,都是有的机器收到并处理正常,有的收到并处理的包少,导致各机逻辑不一致。
场景3:Client端把自己操作发送到Server的发送慢倒不会产生问题,因为Client端是收到Server端回发的操作才处理,不是直接本机处理操作。这样只要Server的发送不出问题,各Client端仍然收到一致的操作(一致的比实际操作少),逻辑仍然一致,只是会发送慢的该Client端会感觉不好因为自己的很多操作没有生效。
模拟方法:client端发送线程中,每发送完一包进行延时1秒,人为造成发送速度比操作速度慢,即可模拟出此场景。下图是模拟出的场景,经过长期操作,发送慢的Client1,和正常的Server、client2的逻辑不会出现不一致的情况,只是client1的很多操作没有生效。
解决方法:
1. Server端发送的数据,对每个client建立一个发送队列,保证每个被处理的操作都发送到所有Client端,从而使各机逻辑一致。如果网络一直慢使某个发送队列满了,就通知主线程让其停止处理本机操作和发送,通知接收线程停止接收所有client操作和发送,此时所有用户操作无效。等发送队列中所有包都发送出去后,再通知主线程和接收线程恢复正常。
这样解决了场景1中操作被Server处理却没被发送的问题,因为每个被Server处理的操作,都有队列机制保证被发送到Client端。解决了场景2中各Client收到操作不一致的问题,因为Server端为每个Client建一个发送队列,Server发给有任何client的发送队列满无法再发送,就通知Server不再处理所有client端的操作。
2.为了让游戏体验更友好,发送队列满导致Server停止处理时,Server可以界面提示“等待谁谁中”,并发送一个“那个谁谁卡了,都别操作了等着吧”的消息给所有client端,client端界面提示“等待谁谁中”。这样会更友好,不然所有用户会发现自己的操作一直不生效也不知道为什么,体验不好。
该解决方法会引发另一个疑问:Server停止处理所有Client操作,但Client端仍然在发送,那Server接收各Client端的接收Socket缓存会不会溢出?该问题用TCP的Socket时不会出现,因为TCP本身的拥塞控制会在接收方Socket缓存满时自动控制发送方不再发送。此时发送方即client的send()会阻塞,使得发送线程阻塞,client主线程因为共享数据的锁也会阻塞,缓存不会溢出,client也不会再响应操作和发送。等Server恢复正常后,会再读取处理接收缓存,client端也恢复正常发送。通过Server接收线程一段时间不recv(),很容易模拟出该场景,经测试确实是上面所说的结果。所以不用担心这个问题。
3.client端发送给Server最好也建立发送队列。虽然经过场景3的分析,Client端把自己操作发送到Server的发送慢不会产生逻辑不一致问题,只是client会很多操作没有生效。但client端有些关键操作不能失效,例如加入游戏等。增加这个队列也让游戏更友好一些,在发送队列满时,提示用户网络阻塞,并发送一个包插到队列头尽早通知Server让其暂停游戏。其实就是异步可靠IO的概念,加入了队列后是异步可靠的,不会丢失的IO,外界用起来更方便、放心些。
如果机器处理能力不足,出现接收速率高于处理速率问题,例如每秒接收了50个包,却只处理得了20个包,会出现什么问题?
场景1:Client端处理能力不足,Server端发送来的包,来不及处理。Client在Socket缓冲区满之前,表现出卡顿就是响应自己和别人的动作都不及时。直到client端的Socket缓存满时,Server端会send()函数被阻塞,并导致Server主线程因为锁也被阻塞。Server做了发送队列也一样,Server会因为发送队列满而停止处理。最终表现就是因为Server的阻塞或停止处理,其他client端也阻塞不响应用户操作,并不会出现逻辑不一致问题。
从本质上来说,这种场景的接收速率高于处理速率,与发送方发送速率低于操作速率是一样的。
模拟方法:让某一个客户端接收处理包时进行延时,就可以模拟出此场景。下图是模拟出的结果,反应慢client缓冲区满之前,Server和Client处理和操作都正常,反应慢client上表现出自己和别人的操作都卡顿,滞后,但最终仍能保证一致。反应慢client缓冲区满后,Server和所有Client都会被阻塞(因为Server阻塞了,不再响应client的操作请求)。下面是模拟的结果,上图是client表现出卡顿滞后,下图是最终所有端仍然保证了逻辑一致。
反应慢client表现出卡顿、滞后:
最终仍能保证一致:
解决方法:
可以不用解决,因为不会导致逻辑不一致。
如果想做得更友好一些,可以将Socket的接收缓存设置得小一些,让处理速度慢的client能尽早缓冲区满从而阻塞Server。否则别人都很流畅操作High得很,自己却由于操作响应慢而被整惨。
场景2:Server端处理能力不足,各client端发来的包,来不及处理和发出。这样会使所有client端延迟收到操作,所有client会觉得所有角色都“行动缓慢”并且最终由于server的接收缓存满而被阻塞。如果Server发送速度高于接收速度和自身操作速度,那只是会卡,但不会造成逻辑不一致问题。而如果Server发送速度不够,就可能会丢失给client的包(因为覆写发送区),并且各对client丢的包不一致,从而导致各机逻辑不一致。如果有发送队列那么可以避免此逻辑不一致问题。
可以看出该场景下,接收速率高于处理速率本身不会造成逻辑不一致问题。只有牵扯到发送速率时,就会跟“Server端发送速率低于操作速率问题”一样的原因造成各机逻辑不一致。
模拟方法:Server端接收线程每帧延时(如100ms),就可以模拟出来。为了模拟Server发送速度低于接收速度,那就再把发送速度每帧延时更大(如1s),下图是模拟结果,可以明显看出client端逻辑已经和server端不一致了。
解决办法:
跟“Server端发送速率低于操作速率问题”一样,Server端发送的数据,对每个client建立一个发送队列,保证每个被接收的操作都发送到所有Client端,从而使各机逻辑一致。
现在有4个及以上player时,连续操作时会有某些客户端延迟的现象。
可能原因1:打印导致的处理速度慢。
因为每帧都打印,printf系统调用是比较耗时的。
可能原因2:由于加太多锁导致线程间相互等待,CPU利用率不高。从理论上来说就是“计算操作和IO操作的并行化程度低”。尤其是Server端Game在收到client消息操作m_role时,对所有角色数据加锁可能是较大的等待消耗。
解决方法:使用双缓冲区技术。M_role对每个角色分别加锁,而不是每次全锁住。
可能原因3:队列缓冲区的频繁堆内存申请和释放。
解决办法:用环形队列,使用固定的一块内存,避免频繁申请释放内存。
可能原因4:开了太多的线程,线程调度开销太大。目前Server是2+clientNum*2个线程,当client数到6个时,Server就要开14个线程。
解决方法:用异步IO,这样Server端发送和接收可以各只用一个线程,Server就只用开4个线程,但这样又可能没有充分发挥CPU效率。另一种方法是用线程池,线程数量保持在“CPU最大核心数+某常量”。