《Exploring in UE4》网络同步原理深入(下):原理分析

原创 作者:Jerish 2019-05-15 2.3k
接上篇:《Exploring in UE4》网络同步原理深入[原理分析]

七.可靠数据传输

UE4默认收发包都在主线程处理,收包可以通过控制CVarNetIpNetDriverUseReceiveThread来开启线程单独处理。

发包堆栈

收包堆栈

1.数据包格式

这里再次拿出来一般网络数据包的格式,可以看到虚幻里面的网络包是精确到bit的,这些信息都可以通过FBitWriter与FBitReader去读与写。

网络包分为Ack与Bunch两种

对于ActorChannel,Bunch分为属性Bunch与RPC Bunch

平时看函数堆栈的时候我们可能看到Bunch、RawBunch、Packet、RawPacket等。所谓的Bunch就是上面图所展示的(ActorChannel发送数据的Bunch分为属性Bunch与RPC Bunch),Bunch如果太大就会被拆分成很多个小的Bunch,一旦拆分成小的bunch那么这个bunch就不是一个完整的bunch(就可以叫做一个Rawbunch,具体逻辑在UChannel::SendBunchInner里面),这些bunch可以都被塞到一个Sendbuffer里面,如果这样直接发出去,就是一个Packet。每一个Sendbuffer发出前还可能会被PacketHandler处理,处理之后就是RawPacket。按照这样的理解,你就能看懂下面的堆栈了。

客户端与服务器通过ControlChannel建立连接的某次通信堆栈

另外,由于平时我们的发包都是按照最小单位Byte来发送的,UE4里面又精确到bit。所以会在Sendbuffer最后面添加1bit的结束标志位,另一端在收到包的时候就可以先找到最后一个为1的bit,把后面的0删除,前面剩下的就是原始的网络包。

2.PacketHandler

PacketHandler是用来对原始数据包(Packet)进行处理的一个“工具”,里面可以自由的添加组件来对原始数据包进行多层处理,目前引擎内置的组件有握手组件StatelessConnectHandlerComponent、各种加密组件FEncryptionComponent、可靠数据传输组件ReliabilityHandlerComponent等。由于组件的不确定性,所以网络的消息包头也是不确定的。比如加密组件可能会对一个Packet进行加密,然后在前面添加一个2bit的头部以及1bit的结束标志位,也因此各个组件应该固定的顺序处理packet。默认情况下回一直存在一个StatelessConnectHandlerComponent组件。

由于PacketHandle组件可能对原有的packet进行加密从而导致位发生变化,所以PacketHandle组件本身也会对处理过的数据后添加一个bit的结束标志位。


3.Bunch的发送时机

每次只要执行sendrawbunch(可能在netdrivertick里worldtick里面的代理tick,也可能在worldtick里tickgroup里面)就会设置TimeSensitive为true,就会触发flushnet,所以说只要每帧有数据就会发送。只要里面有sendbuffer或者到时间了就会触发lowlevelsend,调用socket的发送


4.可靠数据传输的实现

可靠数据传输的基本原理就是接收方对每一个包都要做Ack回应,如果接收方没收到Ack,那么就要进行重传。

UE4底层默认是主动重传,只要没有按顺序收到bunch就会重传。每个包有一个OutPacketId(记录在Connection里面),一个packet可能包括N个Bunch,每个bunch也会记录当前所在的OutPacketId。

简单来说,发送端会记录一个已经传送成功的序号(已经收到的Ack.OutAckPacketId)假如发送端发了10个包(1-10),接收端收到了1那么会回复一个ack,里面是OutAckPacketId 1。然后发生丢包,接收端收到了序号5,那么就会回复一个ack5,这时候发送端会更新当前的OutAckPacketId并重传序号2-4所有的packet(保存在connection的缓存里面)。所以,可以保证所有的包到上层都是严格有序的。

  1. if( AckPacketId>OutAckPacketId )
  2. {
  3.         for (int32 NakPacketId = OutAckPacketId + 1; NakPacketId<AckPacketId; NakPacketId++, OutPacketsLost++, OutTotalPacketsLost++, Driver->OutTotalPacketsLost++)
  4.         {
  5.                 UE_LOG(LogNetTraffic, Verbose, TEXT("   Received virtual nak %i (%.1f)"), NakPacketId, (Reader.GetPosBits()-StartPos)/8.f );
  6.                 ReceivedNak( NakPacketId );
  7.         }
  8.         OutAckPacketId = AckPacketId;
  9. }
复制代码

除了Bunch里面的OutPacketId外,每个channel里面的还有一套ChSequenceID,记录了当前通道内可靠数据包的序号,每次发送加1。每个Connection里面会有N个Channel,每个Channel发出去的可靠数据包的数量会以Connection->OutReliable数组的形式存储,而真正发出去与接收到的数据包会缓存在OutRec链表与InRec链表链表里面,每次发送一个数据包就会添加到OutRec里面并设置其Ack状态为0,收到一个Ack的时候就会遍历当前Channel的OutRec链表,将对应Ack设为1,调用Channel::ReceivedAcks()并清空OutRec中被确认过的前面的所有缓存。OutRec并没有限制大小,所以理论上这里会出现内存溢出的情况,不过在逻辑上层还有一些自己的处理机制,比如Channel可以设置阈值,超过阈值就退化成停等协议,具体内容请参考UChannel::SendBunchInner。

每个通过有一个ChIndex,connection在接收Bunch的时候可以通过这个Index找到对应的Channel再下发消息。

5.属性的可靠传输

首先要确认一点,属性同步本身并不是可靠的,也就是他的属性bunch所在的packet如果丢失并不会将这个packet重新发送。只有Actor在第一次同步的时候才会设置合格属性bunch为Reliable

  1. // Send initial stuff.
  2. //UActorChannel::ReplicateActor
  3. if( OpenPacketId.First != INDEX_NONE && !Connection->bResendAllDataSinceOpen )
  4. {       //第一次收到spawn的ack会把后面不可靠的属性也重新同步一遍
  5.         if( !SpawnAcked && OpenAcked )
  6.         {
  7.                 // After receiving ack to the spawn, force refresh of all subsequent unreliable packets, which could
  8.                 // have been lost due to ordering problems. Note: We could avoid this by doing it in FActorChannel::ReceivedAck,
  9.                 // and avoid dirtying properties whose acks were received *after* the spawn-ack (tricky ordering issues though).
  10.                 SpawnAck
  11. ed = 1;
  12.                 for (auto RepComp = ReplicationMap.CreateIterator(); RepComp; ++RepComp)
  13.                 {
  14.                         RepComp.Value()->ForceRefreshUnreliableProperties();
  15.                 }
  16.         }
  17. }
  18. else
  19. {       //第一次同步是可靠的
  20.         Bunch.bClose = Actor->bNetTemporary;
  21.         Bunch.bReliable = true; // Net temporary sends need to be reliable as well to force them to retry
  22. }
复制代码

那么属性是怎样做到可靠的呢?我发现即使接收方即使接收到的Packet里面的bunch不是reliable的,在通道不关闭、不是拆分的Bunch等情况下还是会回复一个Ack的,所以发送端可以接收到一个Ack从而知道当前的属性是否被另一端接收到。

当发生丢包或者乱序的时候,RepState就会记录当前Nak的数量,并对当前的同步发送历史信息进行标记

  1. void FRepLayout::ReceivedNak( FRepState * RepState, int32 NakPacketId ) const
  2. {
  3.         if ( RepState == NULL )
  4.         {
  5.                 return;                // I'm not 100% certain why this happens, the only think I can think of is this is a bNetTemporary?
  6.         }

  7.         for ( int32 i = RepState->HistoryStart; i < RepState->HistoryEnd; i++ )
  8.         {
  9.                 const int32 HistoryIndex = i % FRepState::MAX_CHANGE_HISTORY;

  10.                 FRepChangedHistory & HistoryItem = RepState->ChangeHistory[ HistoryIndex ];

  11.                 if ( !HistoryItem.Resend && HistoryItem.OutPacketIdRange.InRange( NakPacketId ) )
  12.                 {
  13.                         check( HistoryItem.Changed.Num() > 0 );
  14.                         HistoryItem.Resend = true;
  15.                         RepState->NumNaks++;
  16.                 }
  17.         }
  18. }
复制代码


当下一帧要进行属性同步的时候,就会把之前的历史记录合并到最新的历史记录里面,然后一起发出去,这样达到了不用重发丢失的bunch还能保证属性可靠的效果了。这一块的逻辑主要在FRepLayout::ReplicateProperties里面,关于属性变化的历史记录可以参考上面第五章第4小节。

八.ReplicationGraph

ReplicationGraph是Epci官方针对堡垒之夜网络同步优化而加入的新的插件系统,可以大大减少Actor的同步与遍历,比较适合对大世界场景进行网络同步优化。这一块已经有文章写的比较清晰了,所以我只是简单的列举其优化点与基本原理。

通常服务器在同步Actor到各个连接的时候,会遍历场景中所有标记Replicated的Actor,但是实际上与玩家距离比较远的根本就不需要遍历,更不用说同步,所以ReplicationGraph加入了GridSpatialization2D节点系统,把N*N的格子,并把Actor放到当前所有与他有关的格子里,这样一个玩家靠近他的时候就从当前子集所在的格子里面找一下有没有那个Actor就可以了(只遍历所有在这个格子里的Actor即可)


当然,作为一个系统不仅仅是提供这样一种功能和优化,其里面还内置了很多节点用于不同的同步需求(比如可以对不同的Connection进行某一个Actor的特定属性进行共享序列化),你也可以自定义一个节点专门处理某些需要特殊处理的Actor,严格控制它的同步时机。这一块可以参考官方的Shootergame项目。

注:ReplicationGraph是一个纯C++插件系统,使用的话需要修改配置文件DefaultEngine.ini里面的内容。

作者:Jerish
专栏地址:https://zhuanlan.zhihu.com/p/55596030

相关推荐