网络基础
计算机网络是指多台计算机连接起来,以实现资源共享和信息传递。
TCP和UDP
TCP(Transmission Control Protocol,传输控制协议)是面向连接、面向字节流的协议,也就是说在收发数据前,必须和对方建立可靠的连接。(3握4挥)
UDP(User Data Protocol,用户数据报协议)是非连接、面向数据报的协议,不能保证可靠传输。
- 如何选择?
对系统资源开销,TCP开销较多,UDP开销少。
UDP只使用一个socket进行通信,不像TCP需要为每一个客户端建立一个socket连接,但UDP不能保证数据包的先后次序,以及传输时的可靠性。
容易造成巨大的延迟问题是TCP的性质决定的,在发生丢包的时候,会产生巨大的延迟,因为TCP首先会去检测哪些包发生了丢失,然后重发所有丢失的包,直到他们都被接收到。虽然UDP也是有延迟的,但是由于它是在UDP的基础之上建立的通信协议,所以可以通过多种方式来减少延迟,不像TCP,所有的东西都要依赖于TCP协议本身而无法被更改。
总结:
如果是由客户端间歇性的发起无状态的查询,并且偶尔发生延迟是可以容忍,那么使用HTTP/HTTPS吧。
如果客户端和服务器都可以独立发包,但是偶尔发生延迟可以容忍(比如:在线的纸牌游戏,许多MMO类的游戏),那么使用TCP长连接吧。
如果客户端和服务器都可以独立发包,而且无法忍受延迟(比如:大多数的多人动作类游戏,以及少部分MMO类游戏),那么使用UDP吧。
实现TCP长连接
TCP需要考虑的问题
- 建立连接
- 断线检测
- 网络协议
- 发送和接受队列缓冲
- 发送数据合并
- 线程死锁策略
相关API
C#的.net库提供了TCP的Socket连接API,大部分情况都会使用异步操作的方法,不至于让游戏卡住(堵塞)去等待连接。
- Socket:创建套接字。
- Bind:为套接字绑定IP和端口。
- Listen:开启监听,参数表示最多可连接的个数(0表示不限制)。
- BeginConnect,开始连接 (异步)
- BeginReceive,开始接收信息 (异步)
- BeginSend,开始发送数据 (异步)
- BeginDisconnect,开始断开 (异步)
- Disconnect(Boolean),立刻断开连接
异步的方法在调用后会开启一个线程来工作,最后一个为同步阻塞方式的断开连接接口。最后一个阻塞式大都在游戏退出时调用,但问题是APP没有退出事件,因此一般Disconnect都会用在Unity3D Eitor下或者windows版本上调用,以保证在开发时强制退出后编辑器不会奔溃。
线程锁
实际项目中网络模块中所有的操作都会以线程级的形式对待,而Unity3D的渲染和逻辑都是在主线程上运作的,这里就涉及到了主线程和子线程对资源抢占冲突导致我们需要做线程锁的问题。
当主线程与子线程一起工作到某时间点都需要某个内存块或者资源时(临界资源),就会同时去读取或者写入资源,这就会造成资源读写混乱的情况。
解决方法,加锁 (Lock)。
缓冲队列
为了防止程序来不及处理源源不断发送过来的数据,实现流量控制,就需要用一个队列来进行存储和缓冲,它就被称为缓冲队列。
一般我们会让负责接收的子线程把接收好的网络数据包放入接收缓冲队列,再由主线程通过Update轮训去检查接收队列里是否有数据,有的话则一个个取出来处理,没有的话继续轮训等待。
其中接收数据时会有些细节在里面,因为数据包并不会按照我们希望的大小发送,所以它或多或少的都会拆分一些数据或者粘粘了其他的数据(拆包和粘包),导致我们需要识别是否是一个完整的数据包,然后再从中解析出正确的数据包。(需要数据包格式定义)
双队列结构
缓冲队列是在多线程编程中常用的手段之一,不过它的效率还不够高,因为多个线程的锁的效率影响会被锁点卡住导致其他线程无法继续工作。双队列数据结构就能很好解决这个问题,它能增强多线程中队列的读写效率。
双队列是一种高效的内存数据结构,在多线程编程中能保证生产者线程的写入和消费者的读出尽量做到最低的影响,避免了共享队列的锁开销。
大多数多线程工作时都需要对缓冲队列读写,其中接收数据的网络线程会将数据写入队列,而处理数据的主线程则会读取队列头部并删除(即我们所说的弹出),两者都会读写队列导致资源争夺,因此通常会增加锁机制来规范它们的行为。但是锁机制导致线程会常常处于等待状态,大大降低了线程的效率。
双队列与普通的缓冲队列在接收数据包部分的逻辑操作都是一样的,即接收数据线程接收到数据时直接推入接收数据的队列,不一样的地方在当处理数据的线程轮询时,先将接收数据的队列拷贝到处理数据的队列中并清空接收数据的队列,然后主线再对拷贝后的数据队列进行处理,这时子线程无需等待主线程的逻辑处理时间就能够顺利的继续接收数据。这样就解放了两个线程各自工作的冲突时间,即两个队列分别理解为了接收数据队列和处理数据队列,当主线程需要处理数据时,先把接收到的数据队列中的数据置换为处理数据队列上,最后各自继续处理自己的工作因为队列已经分开。
发送数据
发送数据时也需要队列来做缓冲。当发送的数据包会很多时,也有可能很短时间内会积累过多数据包导致发送池溢出。如果发送时大多数的数据包都是很小很小的数据包,如果每个数据包都发送一次等待接受后再发送就会导致发送效率过低,发送太慢导致延迟过大。而如果一下子把全部数据都发送的话,发送的数据可能会太大,导致发送效率很差,因为数据包越大越容易发送失败或丢包,TCP就会全盘否定这次发送的内容,并将整个包都重新发送一次,效率极其糟糕。
建立发送缓冲的步骤:
- 每次当你调用发送接口时先把数据包推入发送队列,发送程序就开始轮训是否有需要发送的信息在队列里,有的话就发送,没有的话就继续轮训等待。
- 发送时合并队列里的一部分数据包,这样可以一次性发送多个数据包以提高效率。
- 对这种合并操作做个限制,如果因为合并而导致数据包太大,也会导致效率差。发送过程中,只要丢失一个数据就要全盘重新发送,数据包很大的话,发送本来就很缓慢的情况下,又重新整体重新发送,就会使得发送效率大大降低,合并的数据的大小限制在窗口大小的范围内(2的16次字节内)。
协议数据定义标准
在网络数据传输中协议是比较重要的一个关键点,它是客户端与服务器交流的语言。
制定协议过程中的几个关键点:
- 选择客户端和服务器都能接受的格式。
- 数据包体大小最小化。
- 要有一定的校验能力。
加密
为了保证网络数据包不被篡改和查看,导致外挂破坏整个游戏平衡,我们需要对发送的网络数据包中的主体部分进行加密。加密算法很多,包括RSA,公钥私钥,以及非对称加密等,其中最简单也是最快的加密方式就是对数据做异或处理,由于数据两次异或处理就能使得数据回到原形,所以算法中常使用异或的操作来做加密。通常做法是发送时对数据做异或处理一次,收到时再做一次异或处理,这样就能简单快速加密解密数据。
如果加密的性能损耗过大那就得不偿失了,所以我们仍然希望加密的过程是快速的,在不损耗大量CPU前提下,不影响项目性能的情况下对协议数据做最大化的加密工作。
断线检测
TCP本身就是强连接,所以自身就有断线的检测机制,但是它本身的检测机制还不够好,时常会因为网络问题导致断线的判断不够及时,所以我们在编写TCP长连接的程序时需要加强断线检测机制,让断线判断变的更加准确及时。
为了能有效检测TCP连接是否正常,我们需要服务器和客户端共同达成一个协议来检测连接,我们把这个共同达成的协议取名叫心跳包协议。在心跳包协议中,每几秒服务器向客户端发送一个心跳包,包内包含了服务器时间、服务器状态等少量信息,然后由接收到这个心跳协议的客户端做反馈,发送给服务器一个心跳回应包,包内也包含客户端的少量信息例如客户端状态、用户信息等。两边的终端上的逻辑可以就此达成共识,认为当收到心跳信息时认为连接时存在的,当服务器30秒没有收到任何反馈心跳包的信息则认为客户端已经断线,这时主动断开客户端的连接。客户端这边也是同样的协定,当客户端30秒内没有接收到任何数据包时则认为网络已经断开,客户端最好主动退出游戏重新登陆重新连接服务器。
通常当网络异常时,客户端和服务器都很难断定连接是否依然存在,因此需要用这种机制来加以判定,例如在ios中APP可以随时切出屏幕并不关闭游戏,或者直接关闭应用不给服务器任何解释,服务器自然收不到断开连接的请求。面对各种异常的情况,我们制定的心跳包和心跳回应来判定是否仍处于连接的状态,倘若没有收到心跳包和心跳回应包,就表示连接存在问题了,有可能已经断开。为了规避一些时候网络的波动,我们可以设置一个有效判断断开连接的时间间隔,比如,10秒内没有收到心跳包和心跳回应包,就表示连接已经断开,这时服务器和客户端主动断开连接,客户端可以根据游戏的逻辑先退出游戏再重新登陆寻求再次与服务器连接。
心跳协议在TCP之上,加强了断线检测的准确性,能更有效快速的检测到断线问题。
实现UDP
(待补充)
网络编程实战
(基于TCP连接)
第一步 建立基础服务端和客户端程序
- 服务端创建Socket,绑定好IP地址和端口后,异步等待客户端连接
- 客户端通过Connect连接到服务器
- 服务端为每个客户端维护Socket及缓冲区(定义一个Conn类,放在连接池中)
- 调试程序,现在可以简单实现收发消息了
第二步 序列号与定时器
- 将需要传输的信息类(如Player的信息)序列化,保存到数据库
- 实现定时器Timer,为之后心跳检测做准备
- 解决线程互斥产生的问题,为临界区资源加锁Lock
第三步 通信协议
- 定义通信协议,及对应的包装和解析函数
- 构建消息列表,让U3D主线程可以提取消息进行执行(非主线程无法设置U3D组件)
第四步 构建服务端框架
- 服务端框架分为:逻辑层、中间层、底层
- 实现数据管理类DateMgr,功能是验证和读取数据库信息,同时要防止Sql注入
- 实现网络管理类ServNet,解决粘包和分包的问题
- TCP协议会将小的网络数据包进行合并/接收缓冲区的数据没有及时被取走==>产生粘包。
- TCP协议会将大的网络数据包进行拆分==>产生分包
- 解决方法:可以每个数据包前面加上长度字节,接收时进行判断即可
- 完善Conn连接类,增加心跳时间(记录时间利用时间戳),同时因为 异步回调、定时器、主线程会同时处理Conn对象,所以要加锁
第五步 定义协议
- 定义协议基类,实现解码、编码等功能
- 此协议可以理解为,发出的不同的请求,比如位置移动Pos,登录Login 下线Logout
- 实现消息处理类,对不同的协议消息进行分发处理
- 可以提取协议名,再通过反射的方法分发调用
第六步 客户端网络模块
- 客户端实现监听表,对收到的不同协议的消息进行分发处理
- 实现Connection连接,定义缓冲区,协议,及定时向服务端发送心跳信号
第七步 数据同步
- 帧同步和状态同步
网络同步
网络同步 = 实时的多端数据同步+实时的多端表现
帧同步(LockStep)
基本原理:
- 帧同步的战斗逻辑在客户端,服务端只转发操作,不做任何逻辑处理。
- 客户端按照一定的帧速率(理解为逻辑帧,而不是客户端的渲染帧)去上传当前的操作指令,服务端将操作指令广播给所有客户端。
- 当客户端收到指令后执行本地代码。
缺陷:
- 外挂。
- 网络条件较差的客户端会影响其他玩家的游戏体验。
- 不同机器浮点数精度问题、容器排序不确定性、RPC时序、随机数值计算不统一。
解决网速不匹配问题–乐观帧锁定:
- “定时不等待”的乐观方式:每次Interval时钟发生时固定将操作广播给所有用户,不依赖具体每个玩家是否有操作更新
- 服务端每秒钟20-50次向所有客户端发送更新消息(包含所有客户端的操作和递增的帧号)
- 客户端就像播放游戏录像一样不停的播放这些包含每帧所有玩家操作的 update消息
- 客户端如果没有update数据了,就必须等待,直到有新的数据到来
- 客户端如果一下子收到很多连续的update,则快进播放
状态同步
基本原理:
- 通过开发服务端程序,把用户的操作作为输入实时上传到服务端,服务端通过计算返回结果给各个客户端,这样的过程就是状态同步。
- 状态同步的战斗逻辑在服务端
- 在状态同步下,客户端更像是一个服务端数据的表现层
- 客户端上传操作到服务器,服务器收到后计算游戏行为的结果,然后以广播的方式下发游戏中各种状态,客户端收到状态后再根据状态显示内容
同步手段:
- 增量同步
- RPC(远程过程调用)
缺陷:
- 难以实现回放系统。
- 延迟过大、客户端性能浪费、服务端压力大。
- 对带宽的浪费。对于对象少的游戏,可以用快照保存整个游戏的状态发送,但一旦数量多起来,数量的占用就会直线上升。
网络同步优化技术
表现优化(弱化玩家对延迟的感受):
- 插值优化
- 客户端预测+回滚
延迟对抗(弱化玩家对延迟的感受):
- 延迟补偿(Lag Compensation)
- 命令缓冲区
- 假表现
丢包对抗(弱化玩家对延迟的感受):
- 使用TCP
- 冗余UDP数据包
带宽优化(减小客户端及服务器的同步压力):
- 同步对象裁剪
- 分区、分房间
- 数据压缩和裁剪
- 减少遍历和更细粒度的优化
帧率优化:
- 提升帧率
- 保持帧率稳定和匹配
- 分摊计算压力
Reference
[1] Unity3D网络游戏实战 罗培羽
[2] Unity3D高级编程: 主程手记 陆泽西