(翻译 gafferongames)Reliable Ordered Messages 可靠有序消息
https://gafferongames.com/post/reliable_ordered_messages/
Many people will tell you that implementing your own reliable message system on top of UDP is foolish. After all, why reimplement TCP?
But why limit ourselves to how TCP works? But there are so many different ways to implement reliable-messages and most of them work nothing like TCP!
So let’s get creative and work out how we can implement a reliable message system that’s better and more flexible than TCP for real-time games.
很多人会告诉你,在 UDP 之上实现你自己的可靠消息系统是愚蠢的——毕竟,为什么要重新实现 TCP 呢?
但为什么我们要局限于 TCP 的工作方式呢?其实,实现可靠消息的方法有很多,而且大多数都跟 TCP 完全不一样!
所以,让我们发挥创造力,来设计一个比 TCP 更适合实时游戏、更灵活的可靠消息系统吧。
Different Approaches
A common approach to reliability in games is to have two packet types: reliable-ordered and unreliable. You’ll see this approach in many network libraries.
The basic idea is that the library resends reliable packets until they are received by the other side. This is the option that usually ends up looking a bit like TCP-lite for the reliable-packets. It’s not that bad, but you can do much better.
The way I prefer to think of it is that messages are smaller bitpacked elements that know how to serialize themselves. This makes the most sense when the overhead of length prefixing and padding bitpacked messages up to the next byte is undesirable (eg. lots of small messages included in each packet). Sent messages are placed in a queue and each time a packet is sent some of the messages in the send queue are included in the outgoing packet. This way there are no reliable packets that need to be resent. Reliable messages are simply included in outgoing packets until they are received.
The easiest way to do this is to include all unacked messages in each packet sent. It goes something like this: each message sent has an id that increments each time a message is sent. Each outgoing packet includes the start message id followed by the data for n messages. The receiver continually sends back the most recent received message id to the sender as an ack and only messages newer than the most recent acked message id are included in packets.
This is simple and easy to implement but if a large burst of packet loss occurs while you are sending messages you get a spike in packet size due to unacked messages.
You can avoid this by extending the system to have an upper bound on the number of messages included per-packet n. But now if you have a high packet send rate (like 60 packets per-second) you are sending the same message multiple times until you get an ack for that message.
If your round trip time is 100ms each message will be sent 6 times redundantly before being acked on average. Maybe you really need this amount of redundancy because your messages are extremely time critical, but in most cases, your bandwidth would be better spent on other things.
The approach I prefer combines packet level acks with a prioritization system that picks the n most important messages to include in each packet. This combines time critical delivery and the ability to send only n messages per-packet, while distributing sends across all messages in the send queue.
在游戏中实现可靠性的一种常见方式是使用两种数据包类型:可靠有序和不可靠。你会在许多网络库中看到这种方法。
基本思想是,库会不断重发可靠数据包,直到它们被对方接收。这种方法通常会让可靠数据包看起来有点像简化版的 TCP。虽然这并不差,但其实你可以做得更好。
我更喜欢的思路是,把消息看作是较小的位打包元素,它们知道如何自我序列化。当不希望使用长度前缀和将打包消息填充到下一个字节(例如,每个数据包包含大量小消息)时,这种方式最有意义。
发送的消息被放入一个队列中,每次发送数据包时,从发送队列中挑选一些消息加入到即将发出的数据包中。这样就不需要重新发送整个可靠数据包,可靠消息只要在未收到确认前不断地包含在发送的数据包中即可。
最简单的实现方式是:每个数据包都包含所有未确认的消息。大致过程是这样的:每条发送的消息都有一个 id,每次发送都会递增。每个发送的数据包都包含起始消息 id,随后是 n 条消息的数据。接收端不断将最近收到的消息 id 作为 ack 发回给发送端,而发送端只会在数据包中包含比这个 ack id 更新的消息。
这种方式简单易实现,但如果在发送消息时出现大量数据包丢失,就会因为未确认的消息堆积而导致数据包大小激增。
你可以通过为每个数据包设置最大消息数 n 来避免这个问题。但如果你有很高的包发送率(比如每秒 60 个包),那你就会在每条消息被确认前多次发送相同的消息。
如果往返时间是 100ms,那么平均每条消息会被重复发送 6 次,直到收到确认。也许你的消息确实极度时间敏感,需要这种冗余,但在大多数情况下,你的带宽更应该用于其他方面。
我更喜欢的方法是将数据包级别的确认机制与一个优先级系统结合起来,每个数据包挑选 n 条最重要的消息发送。这样既能保证关键消息的及时传递,又能限制每个数据包的消息数量,并在整个发送队列中均衡地分发发送机会。
Packet Level Acks
To implement packet level acks, we add the following packet header:
数据包级别的确认(Packet Level Acks)
为了实现数据包级别的确认机制,我们需要在数据包头部添加以下字段:
struct Header { uint16_t sequence; uint16_t ack; uint32_t ack_bits; };
These header elements combine to create the ack system: sequence is a number that increases with each packet sent, ack is the most recent packet sequence number received, and ack_bits is a bitfield encoding the set of acked packets.
If bit n is set in ack_bits, then ack - n is acked. Not only is ack_bits a smart encoding that saves bandwidth, it also adds redundancy to combat packet loss. Each ack is sent 32 times. If one packet is lost, there’s 31 other packets with the same ack. Statistically speaking, acks are very likely to get through.
这些包头字段共同组成了确认系统:sequence 是一个随着每个发送的数据包递增的编号,ack 是最近接收到的数据包序列号,ack_bits 是一个用于编码已确认数据包集合的位字段。
如果 ack_bits 中的第 n 位被设置,则 ack - n 被确认。ack_bits 不仅是一种节省带宽的巧妙编码方式,它还增加了冗余以应对数据包丢失。每个 ack 会被发送 32 次。如果一个数据包丢失了,还有另外 31 个数据包带有相同的 ack。从统计角度来看,ack 很可能会成功传达。
But bursts of packet loss do happen, so it’s important to note that:
-
If you receive an ack for packet n then that packet was definitely received.
-
If you don’t receive an ack, the packet was most likely not received. But, it might have been, and the ack just didn’t get through. This is extremely rare.
In my experience it’s not necessary to send perfect acks. Building a reliability system on top of a system that very rarely drops acks adds no significant problems. But it does create a challenge for testing this system works under all situations because of the edge cases when acks are dropped.
So please if you do implement this system yourself, setup a soak test with terrible network conditions to make sure your ack system is working correctly. You’ll find such a soak test in the example source code for this article, and the open source network libraries reliable.io and yojimbo which also implement this technique.
但数据包丢失的突发情况确实会发生,所以需要注意以下几点:
1.如果你收到了对第 n 个数据包的确认(ack),那么这个数据包肯定已经被接收了。
2.如果你没有收到确认,该数据包很可能没有被接收。但也有可能已经接收了,只是确认没能传达过来。这种情况极为罕见。
根据我的经验,没必要发送“完美”的确认。在一个极少丢失确认的系统之上构建可靠性机制并不会造成严重问题。但这确实为测试系统在所有场景下是否正常工作带来了挑战,特别是在某些边缘情况中 ack 丢失时。
所以,如果你打算自己实现这个系统,请务必在极差的网络条件下进行 soak 测试,确保你的确认机制能够正常工作。你可以在本文的示例源码中找到这样的 soak 测试,以及在同样实现了这种技术的开源网络库 reliable.io 和 yojimbo 中找到。
Sequence Buffers
To implement this ack system we need a data structure on the sender side to track whether a packet has been acked so we can ignore redundant acks (each packet is acked multiple times via ack_bits. We also need a data structure on the receiver side to keep track of which packets have been received so we can fill in the ack_bits value in the packet header.
The data structure should have the following properties:
- Constant time insertion (inserts may be random, for example out of order packets…)
- Constant time query if an entry exists given a packet sequence number
- Constant time access for the data stored for a given packet sequence number
- Constant time removal of entries
为了实现这个 ack 系统,我们需要在发送端使用一种数据结构来追踪哪些数据包已经被确认,以便忽略多余的 ack(因为每个数据包会通过 ack_bits 被多次确认)。同时,在接收端我们也需要一个数据结构,用来追踪哪些数据包已经被接收,以便填写包头中的 ack_bits 字段。
这个数据结构应当具备以下特性:
-
常数时间的插入(插入可能是随机的,例如乱序接收的数据包)
-
常数时间的存在性查询(给定一个数据包序列号,查询是否存在对应条目)
-
常数时间的访问(根据数据包序列号访问存储的数据)
-
常数时间的删除(移除条目)
You might be thinking. Oh of course, hash table. But there’s a much simpler way:
你可能会想:“哦,当然,用哈希表。”但其实,有一种更简单的方法:
const int BufferSize = 1024; uint32_t sequence_buffer[BufferSize]; struct PacketData { bool acked; }; PacketData packet_data[BufferSize]; PacketData * GetPacketData( uint16_t sequence ) { const int index = sequence % BufferSize; if ( sequence_buffer[index] == sequence ) return &packet_data[index]; else return NULL; }
As you can see the trick here is a rolling buffer indexed by sequence number: 正如你所看到的,这里的技巧是使用一个按序列号索引的滚动缓冲区:
const int index = sequence % BufferSize;
This works because we don’t care about being destructive to old entries. As the sequence number increases older entries are naturally overwritten as we insert new ones. The sequence_buffer[index] value is used to test if the entry at that index actually corresponds to the sequence number you’re looking for. A sequence buffer value of 0xFFFFFFFF indicates an empty entry and naturally returns NULL for any sequence number query without an extra branch.
When entries are added in order like a send queue, all that needs to be done on insert is to update the sequence buffer value to the new sequence number and overwrite the data at that index:
之所以这样有效,是因为我们不关心破坏旧的条目。随着序列号的增加,旧的条目会随着新条目的插入自然被覆盖。sequence_buffer[index] 的值用于测试该索引处的条目是否确实对应你正在查找的序列号。一个值为 0xFFFFFFFF 的序列缓冲区条目表示该位置为空,并且对于任何序列号查询,它会自然地返回 NULL,无需额外的分支。
当条目按顺序添加(像发送队列一样)时,插入时只需执行以下操作:更新序列缓冲区的值为新的序列号,并覆盖该索引处的数据:
PacketData & InsertPacketData( uint16_t sequence ) { const int index = sequence % BufferSize; sequence_buffer[index] = sequence; return packet_data[index]; }
Unfortunately, on the receive side packets arrive out of order and some are lost. Under ridiculously high packet loss (99%) I’ve seen old sequence buffer entries stick around from before the previous sequence number wrap at 65535 and break my ack logic (leading to false acks and broken reliability where the sender thinks the other side has received something they haven’t…).
The solution to this problem is to walk between the previous highest insert sequence and the new insert sequence (if it is more recent) and clear those entries in the sequence buffer to 0xFFFFFFFF. Now in the common case, insert is very close to constant time, but worst case is linear where n is the number of sequence entries between the previous highest insert sequence and the current insert sequence.
不幸的是,在接收端,数据包是乱序到达的,并且有些数据包会丢失。在极高的数据包丢失率(99%)下,我曾见过旧的序列缓冲区条目依然存在,这些条目来自之前序列号溢出(65535)之前的状态,导致我的确认逻辑出现问题(导致错误的确认和破坏的可靠性,发送端错误地认为对方已经接收到某些数据包,而实际上他们并没有收到……)。
解决这个问题的方法是,在前一个最高插入序列和当前插入序列之间(如果当前插入序列更大)遍历,并将这些序列缓冲区条目清除为 0xFFFFFFFF。这样,在常见情况下,插入操作接近常数时间,但最坏情况是线性时间,其中 n 是前一个最高插入序列与当前插入序列之间的序列条目数。
Before we move on I would like to note that you can do much more with this data structure than just acks. For example, you could extend the per-packet data to include time sent:
在继续之前,我想指出,你可以使用这个数据结构做更多的事情,而不仅仅是处理确认。例如,你可以扩展每个数据包的数据,加入发送时间:
struct PacketData { bool acked; double send_time; };
With this information you can create your own estimate of round trip time by comparing send time to current time when packets are acked and taking an exponentially smoothed moving average. You can even look at packets in the sent packet sequence buffer older than your RTT estimate (you should have received an ack for them by now…) to create your own packet loss estimate.
有了这些信息,你可以通过将发送时间与数据包被确认时的当前时间进行比较,并采用指数平滑的移动平均方法,来创建你自己的往返时间(RTT)估算。你甚至可以查看发送数据包序列缓冲区中比你的 RTT 估算时间更早的包(你现在应该已经收到了它们的确认……),从而创建你自己的数据包丢失估算。
Ack Algorithm
Now that we have the data structures and packet header, here is the algorithm for implementing packet level acks:
On packet send:
-
Insert an entry for for the current send packet sequence number in the sent packet sequence buffer with data indicating that it hasn’t been acked yet
-
Generate ack and ack_bits from the contents of the local received packet sequence buffer and the most recent received packet sequence number
-
Fill the packet header with sequence, ack and ack_bits
-
Send the packet and increment the send packet sequence number
On packet receive:
-
Read in sequence from the packet header
-
If sequence is more recent than the previous most recent received packet sequence number, update the most recent received packet sequence number
-
Insert an entry for this packet in the received packet sequence buffer
-
Decode the set of acked packet sequence numbers from ack and ack_bits in the packet header.
-
Iterate across all acked packet sequence numbers and for any packet that is not already acked call OnPacketAcked( uint16_t sequence ) and mark that packet as acked in the sent packet sequence buffer.
现在我们已经有了数据结构和数据包头部,以下是实现数据包级别确认的算法:
发送数据包时:
-
在已发送数据包序列缓冲区中插入当前发送数据包的序列号条目,数据表明该数据包尚未被确认。
-
从本地接收到的数据包序列缓冲区以及最近接收到的数据包序列号中生成
ack和ack_bits。 -
将数据包头部填充为
sequence、ack和ack_bits。 -
发送数据包并增加发送数据包的序列号。
接收数据包时:
-
从数据包头部读取
sequence。 -
如果该序列号比先前接收到的最新序列号更大,则更新最新接收到的序列号。
-
在接收到的数据包序列缓冲区中插入该数据包的条目。
-
解码数据包头部中的
ack和ack_bits,获取已确认的数据包序列号集合。 -
遍历所有已确认的数据包序列号,对于任何尚未被确认的包,调用
OnPacketAcked(uint16_t sequence),并在已发送数据包序列缓冲区中将该数据包标记为已确认。
Importantly this algorithm is done on both sides so if you have a client and a server then each side of the connection runs the same logic, maintaining its own sequence number for sent packets, tracking most recent received packet sequence # from the other side and a sequence buffer of received packets from which it generates sequence, ack and ack_bits to send to the other side.
And that’s really all there is to it. Now you have a callback when a packet is received by the other side: OnPacketAcked. The main benefit of this ack system is now that you know which packets were received, you can build any reliability system you want on top. It’s not limited to just reliable-ordered messages. For example, you could use it to implement delta encoding on a per-object basis.
重要的是,这个算法是在连接的双方都执行的,所以如果你有一个客户端和一个服务器,那么连接的每一侧都会运行相同的逻辑:
-
维护自己发送数据包的序列号;
-
跟踪来自对方的最近接收数据包的序列号;
-
拥有一个接收数据包的序列缓冲区,并根据该缓冲区生成
sequence、ack和ack_bits,然后发送给对方。
这就是整个流程的全部内容了。现在你拥有了一个回调函数 OnPacketAcked,当某个数据包被对方接收时就会触发它。
这个 ack 系统的主要优势在于:你明确知道哪些数据包被成功接收了,因此可以在此基础上构建任何你想要的可靠性系统。它不仅限于可靠有序消息(reliable-ordered messages),例如你可以用它来为每个对象实现差异编码(delta encoding)。
Message Objects
Messages are small objects (smaller than packet size, so that many will fit in a typical packet) that know how to serialize themselves. In my system they perform serialization using a unified serialize functionunified serialize function.
The serialize function is templated so you write it once and it handles read, write and measure.
Yes. Measure. One of my favorite tricks is to have a dummy stream class called MeasureStream that doesn’t do any actual serialization but just measures the number of bits that would be written if you called the serialize function. This is particularly useful for working out which messages are going to fit into your packet, especially when messages themselves can have arbitrarily complex serialize functions.
消息对象
消息是一些小型对象(小于数据包的大小,因此在一个典型的数据包中可以容纳多个),它们知道如何自行进行序列化。在我的系统中,它们通过一个统一的 serialize 函数来执行序列化操作。
这个 serialize 函数是模板化的,因此你只需要写一次,它就能同时处理读取(read)、写入(write)和测量(measure)。
是的,测量。
我最喜欢的技巧之一是使用一个名为 MeasureStream 的虚拟流类,它不会执行任何实际的序列化操作,而只是测量如果你调用了 serialize 函数将会写入多少位(bit)。
当消息本身的 serialize 函数可能任意复杂时,这个技巧在判断哪些消息可以装入你的数据包中时尤其有用。
struct TestMessage : public Message { uint32_t a,b,c; TestMessage() { a = 0; b = 0; c = 0; } template <typename Stream> bool Serialize( Stream & stream ) { serialize_bits( stream, a, 32 ); serialize_bits( stream, b, 32 ); serialize_bits( stream, c, 32 ); return true; } virtual SerializeInternal( WriteStream & stream ) { return Serialize( stream ); } virtual SerializeInternal( ReadStream & stream ) { return Serialize( stream ); } virtual SerializeInternal( MeasureStream & stream ) { return Serialize( stream ); } };
The trick here is to bridge the unified templated serialize function (so you only have to write it once) to virtual serialize methods by calling into it from virtual functions per-stream type. I usually wrap this boilerplate with a macro, but it’s expanded in the code above so you can see what’s going on.
Now when you have a base message pointer you can do this and it just works:
这里的技巧在于:通过在每种流类型的虚函数中调用统一的模板化 serialize 函数,将这个统一函数与虚函数机制衔接起来(这样你只需要写一次 serialize 函数)。
我通常会用一个宏把这些样板代码包装起来,但在上面的代码中是直接展开的,这样你可以清楚地看到它是怎么工作的。
现在,当你拥有一个指向基类的消息指针时,你就可以像下面这样使用它,并且一切都能正常运作:
Message * message = CreateSomeMessage();
message->SerializeInternal( stream );
An alternative if you know the full set of messages at compile time is to implement a big switch statement on message type casting to the correct message type before calling into the serialize function for each type. I’ve done this in the past on console platform implementations of this message system (eg. PS3 SPUs) but for applications today (2016) the overhead of virtual functions is neglible.
Messages derive from a base class that provides a common interface such as serialization, querying the type of a message and reference counting. Reference counting is necessary because messages are passed around by pointer and stored not only in the message send queue until acked, but also in outgoing packets which are themselves C++ structs.
This is a strategy to avoid copying data by passing both messages and packets around by pointer. Somewhere else (ideally on a separate thread) packets and the messages inside them are serialized to a buffer. Eventually, when no references to a message exist in the message send queue (the message is acked) and no packets including that message remain in the packet send queue, the message is destroyed.
如果你在编译时就知道所有的消息类型,另一种做法是使用一个 大 switch 语句,根据消息类型将其转换为正确的消息类型,然后再调用各自的 serialize 函数。我过去在一些游戏主机平台(例如 PS3 的 SPU)上实现这个消息系统时就是这么做的,但在如今的应用中(2016 年),虚函数的开销可以忽略不计。
所有消息都派生自一个基类,该基类提供一个统一的接口,比如序列化、查询消息类型,以及引用计数。
引用计数是必须的,因为消息是通过指针传递的,而且不仅在消息发送队列中会被保存直到被确认(acked),还会被保存在数据包中,而数据包本身是 C++ 结构体。
这是一种避免复制数据的策略,通过指针传递消息和数据包。
在系统的其它部分(理想情况下是在另一个线程中),这些数据包和它们包含的消息会被序列化成缓冲区。
最终,当消息在发送队列中不再被引用(表示已经收到 ack),并且没有任何包含该消息的数据包仍保留在发送队列中时,该消息就会被销毁。
We also need a way to create messages. I do this with a message factory class with a virtual function overriden to create a message by type. It’s good if the packet factory also knows the total number of message types, so we can serialize a message type over the network with tight bounds and discard malicious packets with message type values outside of the valid range:
我们还需要一种方式来创建消息。我是通过一个消息工厂类(message factory class)来实现的,其中包含一个虚函数,通过覆盖该函数可以根据类型创建不同的消息。
如果这个工厂类还能知道消息类型的总数,那就更好了。这样我们就可以在网络上传输消息类型时使用紧凑的范围进行序列化,并且丢弃那些包含非法消息类型值的恶意数据包。
enum TestMessageTypes { TEST_MESSAGE_A, TEST_MESSAGE_B, TEST_MESSAGE_C, TEST_MESSAGE_NUM_TYPES }; // message definitions omitted class TestMessageFactory : public MessageFactory { public: Message * Create( int type ) { switch ( type ) { case TEST_MESSAGE_A: return new TestMessageA(); case TEST_MESSAGE_B: return new TestMessageB(); case TEST_MESSAGE_C: return new TestMessageC(); } } virtual int GetNumTypes() const { return TEST_MESSAGE_NUM_TYPES; } };
Again, this is boilerplate and is usually wrapped by macros, but underneath this is what’s going on.
同样地,这些代码本质上是样板代码(boilerplate),通常会被宏(macros)包装起来,但底层实际执行的逻辑就是这些。
Reliable Ordered Message Algorithm
The algorithm for sending reliable-ordered messages is as follows:
On message send:
-
Measure how many bits the message serializes to using the measure stream
-
Insert the message pointer and the # of bits it serializes to into a sequence buffer indexed by message id. Set the time that message has last been sent to -1
-
Increment the send message id
On packet send:
-
Walk across the set of messages in the send message sequence buffer between the oldest unacked message id and the most recent inserted message id from left -> right (increasing message id order).
-
Never send a message id that the receiver can’t buffer or you’ll break message acks (since that message won’t be buffered, but the packet containing it will be acked, the sender thinks the message has been received, and will not resend it). This means you must never send a message id equal to or more recent than the oldest unacked message id plus the size of the message receive buffer.
-
For any message that hasn’t been sent in the last 0.1 seconds and fits in the available space we have left in the packet, add it to the list of messages to send. Messages on the left (older messages) naturally have priority due to the iteration order.
-
Include the messages in the outgoing packet and add a reference to each message. Make sure the packet destructor decrements the ref count for each message.
-
Store the number of messages in the packet n and the array of message ids included in the packet in a sequence buffer indexed by the outgoing packet sequence number so they can be used to map packet level acks to the set of messages included in that packet.
-
Add the packet to the packet send queue.
On packet receive:
-
Walk across the set of messages included in the packet and insert them in the receive message sequence buffer indexed by their message id.
-
The ack system automatically acks the packet sequence number we just received.
On packet ack:
-
Look up the set of messages ids included in the packet by sequence number.
-
Remove those messages from the message send queue if they exist and decrease their ref count.
-
Update the last unacked message id by walking forward from the previous unacked message id in the send message sequence buffer until a valid message entry is found, or you reach the current send message id. Whichever comes first.
On message receive:
-
Check the receive message sequence buffer to see if a message exists for the current receive message id.
-
If the message exists, remove it from the receive message sequence buffer, increment the receive message id and return a pointer to the message.
-
Otherwise, no message is available to receive. Return NULL.
In short, messages keep getting included in packets until a packet containing that message is acked.
We use a data structure on the sender side to map packet sequence numbers to the set of message ids to ack.
Messages are removed from the send queue when they are acked. On the receive side, messages arriving out of order are stored in a sequence buffer indexed by message id, which lets us receive them in the order they were sent.
可靠有序消息算法(Reliable Ordered Message Algorithm)
发送可靠有序消息的算法如下:
当发送消息时:
-
使用测量流(measure stream)来测量该消息序列化后占用的比特数。
-
将消息指针和其序列化所占用的比特数插入一个以消息 ID 为索引的序列缓冲区中,并将该消息的“上次发送时间”设为 -1。
-
递增发送消息的 ID。
当发送数据包时:
-
遍历发送消息序列缓冲区中,从最旧的未确认消息 ID 到最新插入消息 ID 的消息,顺序为从左到右(即消息 ID 递增顺序)。
-
切勿发送接收方无法缓冲的消息 ID,否则将破坏消息确认机制:该消息不会被缓冲,但包含它的数据包可能会被确认,于是发送方会误以为该消息已被接收,从而不再重发它。
-
这意味着你**绝不能发送消息 ID 大于或等于“最旧未确认消息 ID + 消息接收缓冲区大小”**的消息。
-
-
对于任何“在过去 0.1 秒内未发送过”且“能装进当前数据包剩余空间”的消息,将其加入待发送消息列表。由于遍历顺序是从旧到新,旧消息天然拥有优先级。
-
将这些消息包含在即将发送的数据包中,并为每条消息添加一个引用计数。确保数据包的析构函数会减少每条消息的引用计数。
-
将数据包中消息数量
n和包含的消息 ID 数组存入一个以该数据包序列号为索引的序列缓冲区中,以便后续可以通过包级别的确认(ack)来映射出这些消息。 -
将数据包加入发送队列。
当接收数据包时:
-
遍历该数据包中包含的所有消息,并根据它们的消息 ID 将其插入到接收消息序列缓冲区中。
-
Ack系统会自动确认刚刚接收到的数据包的序列号。
当收到数据包确认(ack)时:
-
根据数据包的序列号,查找该包中包含的消息 ID 集合。
-
如果这些消息仍存在于消息发送队列中,则将它们移除,并减少它们的引用计数。
-
通过从之前的最旧未确认消息 ID 开始向前推进,直到找到一个有效的消息条目,或达到当前的发送消息 ID(以先到者为准),来更新最新的未确认消息 ID。
当接收消息时:
-
查看接收消息序列缓冲区中是否存在当前的接收消息 ID 对应的消息。
-
如果存在该消息:
-
从接收缓冲区中移除该消息;
-
增加接收消息 ID;
-
返回该消息的指针。
-
-
如果不存在该消息,则当前没有可接收的消息,返回 NULL。
下面我通过一个具体的例子来说明「可靠有序消息算法(Reliable Ordered Message Algorithm)」是如何工作的:
🎮 假设场景:
你正在开发一个多人射击游戏,客户端需要将以下玩家操作消息可靠且有序地发送给服务器:
-
M0: 移动(向前) -
M1: 开火 -
M2: 切换武器
1. 发送消息(On message send):
每条消息先通过 MeasureStream 估算序列化后占多少比特。
-
插入
M0到发送消息缓冲区,消息ID为 0,序列化大小为 12 bits,标记上次发送时间为-1。 -
插入
M1(ID=1) 和M2(ID=2) 同理。 -
发送消息ID计数器依次变为:1 → 2 → 3...
2. 发送数据包(On packet send):
假设当前最大接收端能缓存 256 条消息,且最早未确认消息 ID 是 0。
从发送消息缓冲区中按顺序扫描:
-
如果消息还没在 0.1 秒内发送过,并且可以装进当前剩余的包空间,则打包进去。
-
此时:
M0和M1都符合条件,被打包进Packet #100。 -
Packet header 记录了:消息个数 = 2,消息 ID = [0, 1]。
-
把这个消息ID数组记录在一个
packet sequence buffer中(按包序号记录消息ID们)。
3. 接收数据包(On packet receive):
服务器收到 Packet #100:
-
解析出包含
M0,M1,将它们按 message id 插入接收消息缓冲区。 -
系统自动确认接收到
Packet #100。
4. 确认包(On packet ack):
客户端收到 ack(确认)表示 Packet #100 被成功接收。
-
查表得知该包包含
[0, 1]这两个消息。 -
从
发送消息缓冲区中移除M0,M1,减少引用计数,消息可被释放。 -
更新最旧未确认消息ID为 2(即
M2)。
5. 接收消息(On message receive):
服务器应用层想要处理消息:
-
检查
接收消息缓冲区是否有消息 ID=0。 -
有 → 取出
M0,递增接收消息ID为 1。 -
再取出
M1,接收ID 变为 2。 -
如果下一个包还未到(如
M2没收到),返回 NULL 等待。
总结:
-
每个消息被持续打包发送,直到确认(ack)它被成功收到。
-
可靠性:只会在收到确认后才从队列移除消息。
-
有序性:接收端按消息ID排好序缓冲,只有按顺序才能取出。
你可以把它理解为一种封装在数据包发送系统上的“可靠消息传输层”,但又比 TCP 更灵活 —— 因为你可以基于它实现:按对象 delta 更新、语义自定义重传等。
The End Result
This provides the user with an interface that looks something like this on send:
这为用户在发送时提供了一个类似如下的接口:
TestMessage * message = (TestMessage*) factory.Create( TEST_MESSAGE ); if ( message ) { message->a = 1; message->b = 2; message->c = 3; connection.SendMessage( message ); }
And on the receive side:
while ( true ) { Message * message = connection.ReceiveMessage(); if ( !message ) break; if ( message->GetType() == TEST_MESSAGE ) { TestMessage * testMessage = (TestMessage*) message; // process test message } factory.Release( message ); }
Which is flexible enough to implement whatever you like on top of it.

浙公网安备 33010602011771号