这是我自己做的一个多人联机游戏中网络部分的总结。全部为自己全新做的,没用开源软件(有一个网络游戏开源软件Raknet)。目的是写一个属于自己的可靠网络模块,修改、扩展后在很多地方都能用得着。也想
自己从上至下完全写一遍,对网络编程有更深的理解。代码因为某些限制不方便公布。这里把设计和遇到的问题跟大家分享。
1
设计、实现
1.1
设计总原则
先保证可靠性,保证各种特殊情况下所有主从机逻辑的一致性。这是必须保证的,否则各机逻辑任何不一致游戏就完全失效了。
可靠性得到完全保障后,再考虑速度、流畅问题,逐步优化到高速。
1.2
架构设计
分层架构:主循环层,
Game
层和
Net
层。
Net
层封装了游戏协议和
socket
,提供简单接口给应用层。
Game
层逻辑中,只把
Net
层当做一种普通的输入输出。
Game
的
Main Loop
仿照
HGE
游戏引擎的方式,主循环只做事件处理和时间间隔计算,具体的每帧逻辑由
Game
的
FrameProc
成员函数完成。
由于公司网络无法上传图片,这里不能给出架构图了。
1.3
发送操作还是状态
如果发送的是操作,会出现如下的问题:
A
、
B
、
C
三个机器,主机收到某个机器的操作后发送给所有机器,因为网络差别每个机器收到的时间早晚不一样,会造成逻辑不一致。例如某角色在惯性下向前走,然后玩家发送了一个停止操作,
A
机器在角色走了
10
米后收到,
B
机器在角色走了
8
米后收到,
C
机器在角色走了
12
米后收到,三个机器中,这个角色停止的位置就不一致了,逻辑失效。
所以不能发送纯操作数据,而应该发送状态或状态变化量这样的数据。发送状态,例如某机中操作角色向左走到了
(x,y)
处,并处于出拳状态,把这个坐标和状态发送到所有机器,各机逻辑才能保证一致。发送状态变化量,例如(角色向左移动
1
米,向上移动
2
米)这样的数据。
1.4
多线程
1.4.1
开多少线程
总原则:
由于接收操作是一个阻塞操作,所以每一个接收都要开一个线程。
Client
端:
比较简单,就是一个线程用来创建连接和发送,一个线程用来接收。
Server
端:
一个线程用来
侦听和接受
新客户端的连接,每接受一个
Client
端就创建一个线程接收来自该
Client
的数据。
Server
给
Client
发送消息,最好创建线程专门用于发送,而不能直接在主线程中调用
send
发送。
因为
send
函数会阻塞线程运行,因为如果协议正在发送缓冲区中的数据,
send
函数会等待协议将数据发送完毕才把数据加入缓冲区。
Server
端发送线程的三种做法:
1.
为每个客户端创建一个发送线程。
——目前采用的这个做法
好处:入参一次性在线程入口带入,方便。并且可以利用多核的处理性能。
坏处:需要为每个发送线程创建一个信号量。
2.
对所有客户端共用一个发送线程。
好处:不用为每个发送线程创建一个信号量
坏处:入参没法儿一次性带入了,因为要确定消息发送给哪个
Client
需要传递进去。另外由于单线程发送多请求,可能需要建立一个发送链表,并且可能需要有锁来保护该跨线程变量。
3.开辟线程池,所有客户端发送公用线程池
好处:充分发挥CPU效率并减少资源消耗。
坏处:实现的复杂度高,出现问题后不容易调试。
1.4.2
实现方式
用
POSIX
的
pthread
实现,以实现跨平台(
Windows
、
Linux
、
Tizen
)可移植。
1.4.3
多线程交互问题
多线程交互,复杂性极高且因为锁和信号量极大影响了处理效率。
例如:
ClientsList
的锁,导致了很多死锁问题,主要集中在
client
或
server
连接或断开连接时。
解决方法:只让一个线程操作
ClientsList
,就可以简单清晰地避免死锁问题。最好的解决方法就是用“生产者
–
消费者模式”把
CNetServer
做成一个事件处理线程,让
CNetServer
只接收事件来处理自己的数据,这样就安全了,不用再考虑多线程访问
CNetServer
数据的问题。
学习了“网络多线程编程”和“同步异步
IO
”的理论知识,从理论上解决该问题。根据“生产者
–
消费者”模式,做了“事件处理线程类”和“普通线程类”,让发送线程和
CNetServer
从“事件处理线程类”派生出来,侦听和接收线程从“普通线程类”派生。
1.4.4
关闭线程
1.4.5
线程共享数据的锁
总原则:
所有线程间共享数据都需要加锁。在有效率问题并且确认多线程不会同时操作此共享数据时,才可以不加锁访问。
经过“生产者
–
消费者模式”的改造,目前线程间共享数据已经很少了。
1.5
Socket
1.5.1
主动优雅地关闭
Socket
连接
我们的程序里,需要在
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
协议的连接原理清访问
第
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
信号(如果没忽略的话)
1.5.2
TCP
心跳机制(断网检测)
所谓的心跳包就是客户端定时发送简单的信息给服务器端告诉它我还在而已。代码就是每隔几分钟发送一个固定信息给服务端,服务端收到后回复一个固定信息如果服务端几分钟内没有收到客户端信息则视客户端断开。
在
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
,则为掉线。
1.5.3
网络延迟
注意网络延迟
!=
网速慢,而是指网元相隔太远,传输节点太多导致收报时间
–
发报时间的时间差。
魔兽对战的网络流量,是十几
K
每秒,发送的数据并不大。
如果发送状态数据的话,游戏中有上百个对象,按每秒
20
帧速发送,每对象
20byte
数据,则每秒需要发送
80K
。
还有职业玩家专门分析在延迟情况下怎么打。
对于关键动作,为了逻辑的一致性,都是采用服务器统一分发命令的方式来严格保证逻辑,例如击中处理。
只有在普通的移动时,才会用“航迹推算算法”这样的
p2p+
推算
+
纠正的方式
1.5.4
网络多线程设计模式
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
1.5.5
Socket
选项(属性)设置
在
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));
1.6
游戏通信协议
游戏通信协议主要是为了解决“
TCP
粘包问题”,也为了解决各种控制流程的问题。问题详见“极端、例外情况及处理”章节。
协议报文结构如下:
|
|
|
|
|
|
|
|
|
|
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.7
发送队列
发送队列是为了解决操作速率、发送速率、接收速率的不匹配问题。问题详见“极端、例外情况及处理”章节。目前发送线程已经通过“生产者
–
消费者模式”拥有了队列机制。
实现:
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
端保持一致
——测试通过,现在能保证逻辑一致了。
1.8
极端、例外情况及处理
1.8.1
TCP
粘包问题
TCP
是流式协议,当发送较快时,会出现多个包并成一个包发送的情况。例如先发送“
luojiao
”,再发送“
lishanshan
”,有可能会合并成“
luojiaolishanshan
”一起发送。这时接收方直接取数据作为数据结构就不对了,例如接收方以
12byte
为单位
recv
数据,就会取出“
luojiaolisha
”和“
nshan
”两个错误的数据,而不是预期的先取出“
luojiao
”后取出“
lishanshan
”。
在服务端和客户端都在本地时,不容易粘包,因为实际上没走网络。而服务端和客户端不在一个机器时,就很容易出现这个问题,就普通的
30
帧率就会出现。
解决方法:
通过应用层协议解决。核心目的是要确定包长,好从
TCP
流中解析出各个应用层报文来。
——已解决
1.8.2
断网和离线处理:
目前用的
TCP
协议,本身是面向连接的会有心跳保活机制,通过
send
或
recv
的返回值即可判断是否断网。
断网或离线后,要做一系列相应处理:
1.
Game
删除此角色对象
2.
NetServer
去掉该
client
对象,回收
playerID
。清空对该
client
的发送队列。
3.
正常关闭对该
client
的发送和接收线程。
如果用
UDP
协议,那就要自己实现心跳机制和超时来判断断网了。
——正在进行中,遇到困难。
1.8.3
发送速率低于操作速率问题
当网络较慢或瞬时拥塞时,虽然发送缓存
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
,外界用起来更方便、放心些。
1.8.4
接收速率高于处理速率问题
如果机器处理能力不足,出现接收速率高于处理速率问题,例如每秒接收了
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
端,从而使各机逻辑一致。
1.9
速度优化
现在有
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
最大核心数
+
某常量”。