介绍
都是自描述性的且项目无关的, 可以直接放心使用.
. . .
这篇文章网上找不到翻译, 我也没时间详细翻译, 大概总结一下吧.
本篇主要讲了把本系列之前五篇文章的技术应用到实战中处理客户端与服务器的的连接.
请看总结, 不明之处再看文中具体讲解.
First up we have the client state machine.
The client is in one of three states:
The goal is to create an abstraction on top of a UDP socket where our server presents a number of virtual slots for clients to connect to.
When a client requests a connection, it gets assigned to one of these slots.
If a client requests connection, but no slots are available, the server is full and the connection request is denied.
On the server, we have the following data structure:
const int MaxClients = 64; |
这些问题需要用授权系统和加密系统来解决.
we no longer accept client connections immediately on connection request, instead we send back a challenge packet, and only complete connection when a client replies with information that can only be obtained by receiving the challenge packet.
为了防止攻击者利用我们的服务器当做DDos放大攻击的工具, 我们让客户端发的包比服务器发的包要大些.
We’ll add some unique random identifiers, or ‘salts’, to make each client connection unique from previous ones coming from the same IP address and port.
一旦彼此连接上之后, 就用 client salt 和 server salt 的异或值来标识彼此.
以上的防御措施让我们的服务器做到了 no longer able to be used as port of DDoS amplification attacks, and with a trivial xor based authentication, 但对于一个经验丰富的会抓包分析的攻击者来说, 还存在以下问题 :
To solve this, we need to get serious with cryptography to encrypt and sign packets so they can’t be read or modified by a third party.
原文标题 : Client Server Connection (How to create a client/server connection over UDP)
Hi, I’m Glenn Fiedler and welcome to Building a Game Network Protocol.
So far in this article series we’ve discussed how games read and write packets, how to unify packet read and write into a single function, how to fragment and re-assemble packets, and how to send large blocks of data over UDP.
Now in this article we’re going to bring everything together and build a client/server connection on top of UDP.
Developers from a web background often wonder why games go to such effort to build a client/server connection on top of UDP, when for many applications, TCP is good enough.
These days even web servers are transitioning to UDP via Google’s QUIC. If you still think TCP is good enough for time critical data in 2016, I encourage you to put that in your pipe and smoke it :)
The reason is that games send time critical data.
Why don’t games use TCP for time critical data? The answer is that TCP delivers data reliably and in-order, and to do this on top of IP (which is unreliable, unordered) it holds more recent packets hostage in a queue while older packets are resent over the network.
This is known as head of line blocking and it’s a huuuuuge problem for games. To understand why, consider a game server broadcasting the state of the world to clients 10 times per-second. Each client advances time forward and wants to display the most recent state it receives from the server.
But if the packet containing state for time t = 10.0 is lost, under TCP we must wait for it to be resent before we can access t = 10.1 and 10.2, even though those packets have already arrived and contain the state the client wants to display.
Worse still, by the time the resent packet arrives, it’s far too late for the client to actually do anything useful with it. The client has already advanced past 10.0 and wants to display something around 10.3 or 10.4!
So why resend dropped packets at all? BINGO! What we’d really like is an option to tell TCP: “Hey, I don’t care about old packets being resent, by they time they arrive I can’t use them anyway, so just let me skip over them and access the most recent data”.
Unfortunately, TCP simply does not give us this option :(
All data must be delivered reliably and in-order.
This creates terrible problems for time critical data where packet loss and latency exist. Situations like, you know, The Internet, where people play FPS games.
Large hitches corresponding to multiples of round trip time are added to the stream of data as TCP waits for dropped packets to be resent, which means additional buffering to smooth out these hitches, or long pauses where the game freezes and is non-responsive.
Neither option is acceptable for first person shooters, which is why virtually all first person shooters are networked using UDP. UDP doesn’t provide any reliability or ordering, so protocols built on top it can access the most recent data without waiting for lost packets to be resent, implementing whatever reliability they need in radically different ways to TCP.
But, using UDP comes at a cost:
UDP doesn’t provide any concept of connection.
We have to build that ourselves. This is a lot of work! So strap in, get ready, because we’re going to build it all up from scratch using the same basic techniques first person shooters use when creating their protocols over UDP. You can use this client/server protocol for games or non-gaming applications and, provided the data you send is time critical, I promise you, it’s well worth the effort.
The goal is to create an abstraction on top of a UDP socket where our server presents a number of virtual slots for clients to connect to:
When a client requests a connection, it gets assigned to one of these slots:
If a client requests connection, but no slots are available, the server is full and the connection request is denied:
Once a client is connected, packets are exchanged in both directions. These packets form the basis for the custom protocol between the client and server which is game specific.
In a first person shooter, packets are sent continuously in both directions. Clients send input to the server as quickly as possible, often 30 or 60 times per-second, and the server broadcasts the state of the world to clients 10, 20 or even 60 times per-second.
Because of this steady flow of packets in both directions there is no need for keep-alive packets. If at any point packets stop being received from the other side, the connection simply times out. No packets for 5 seconds is a good timeout value in my opinion, but you can be more aggressive if you want.
When a client slot times out on the server, it becomes available for other clients to connect. When the client times out, it transitions to an error state.
Let’s get started with the implementation of a simple protocol. It’s a bit basic and more than a bit naive, but it’s a good starting point and we’ll build on it during the rest of this article, and the next few articles in this series.
First up we have the client state machine.
The client is in one of three states:
Initially the client starts in disconnected.
When a client connects to a server, it transitions to the connecting state and sends connection request packets to the server:
The CRC32 and implicit protocol id in the packet header allow the server to trivially reject UDP packets not belonging to this protocol or from a different version of it.
Since connection request packets are sent over UDP, they may be lost, received out of order or in duplicate.
Because of this we do two things: 1) we keep sending packets for the client state until we get a response from the server or the client times out, and 2) on both client and server we ignore any packets that don’t correspond to what we are expecting, since a lot of redundant packets are flying over the network.
On the server, we have the following data structure:
const int MaxClients = 64;
class Server
{
int m_maxClients;
int m_numConnectedClients;
bool m_clientConnected[MaxClients];
Address m_clientAddress[MaxClients];
};
Which lets the server lookup a free slot for a client to join (if any are free):
int Server::FindFreeClientIndex() const
{
for ( int i = 0; i < m_maxClients; ++i )
{
if ( !m_clientConnected[i] )
return i;
}
return -1;
}
Find the client index corresponding to an IP address and port:
int Server::FindExistingClientIndex( const Address & address ) const
{
for ( int i = 0; i < m_maxClients; ++i )
{
if ( m_clientConnected[i] && m_clientAddress[i] == address )
return i;
}
return -1;
}
Check if a client is connected to a given slot:
bool Server::IsClientConnected( int clientIndex ) const
{
return m_clientConnected[clientIndex];
}
… and retrieve a client’s IP address and port by client index:
const Address & Server::GetClientAddress( int clientIndex ) const
{
return m_clientAddress[clientIndex];
}
Using these queries we implement the following logic when the server processes a connection request packet:
If the server is full, reply with connection denied.
If the connection request is from a new client and we have a slot free, assign the client to a free slot and respond with connection accepted.
If the sender corresponds to the address of a client that is already connected, also reply with connection accepted. This is necessary because the first response packet may not have gotten through due to packet loss. If we don’t resend this response, the client gets stuck in the connecting state until it times out.
The connection accepted packet tells the client which client index it was assigned, which the client needs to know which player it is in the game:
Once the server sends a connection accepted packet, from its point of view it considers that client connected. As the server ticks forward, it watches connected client slots, and if no packets have been received from a client for 5 seconds, the slot times out and is reset, ready for another client to connect.
Back to the client. While the client is in the connecting state the client listens for connection denied and connection accepted packets from the server. Any other packets are ignored.
If the client receives connection accepted, it transitions to connected. If it receives connection denied, or after 5 seconds hasn’t received any response from the server, it transitions to disconnected.
Once the client hits connected it starts sending connection payload packets to the server. If no packets are received from the server in 5 seconds, the client times out and transitions to disconnected.
While this protocol is easy to implement, we can’t use a protocol like this in production. It’s way too naive. It simply has too many weaknesses to be taken seriously:
Spoofed packet source addresses can be used to redirect connection accepted responses to a target (victim) address. If the connection accepted packet is larger than the connection request packet, attackers can use this protocol as part of a DDoS amplification attack.
Spoofed packet source addresses can be used to trivially fill all client slots on a server by sending connection request packets from n different IP addresses, where n is the number of clients allowed per-server. This is a real problem for dedicated servers. Obviously you want to make sure that only real clients are filling slots on servers you are paying for.
An attacker can trivially fill all slots on a server by varying the client UDP port number on each client connection. This is because clients are considered unique on an address + port basis. This isn’t easy to fix because due to NAT (network address translation), different players behind the same router collapse to the same IP address with only the port being different, so we can’t just consider clients to be unique at the IP address level sans port.
Traffic between the client and server can be read and modified in transit by a third party. While the CRC32 protects against packet corruption, an attacker would simply recalculate the CRC32 to match the modified packet.
If an attacker knows the client and server IP addresses and ports, they can impersonate the client or server. This gives an attacker the power to completely a hijack a client’s connection and perform actions on their behalf.
Once a client is connected to a server there is no way for them to disconnect cleanly, they can only time out. This creates a delay before the server realizes a client has disconnected, or before a client realizes the server has shut down. It would be nice if both the client and server could indicate a clean disconnect, so the other side doesn’t need to wait for timeout in the common case.
Clean disconnection is usually implemented with a disconnect packet, however because an attacker can impersonate the client and server with spoofed packets, doing so would give the attacker the ability to disconnect a client from the server whenever they like, provided they know the client and server IP addresses and the structure of the disconnect packet.
If a client disconnects dirty and attempts to reconnect before their slot times out on the server, the server still thinks that client is connected and replies with connection accepted to handle packet loss. The client processes this response and thinks it’s connected to the server, but it’s actually in an undefined state.
While some of these problems require authentication and encryption before they can be fully solved, we can make some small steps forward to improve the protocol before we get to that. These changes are instructive.
The first thing we want to do is only allow clients to connect if they can prove they are actually at the IP address and port they say they are.
To do this, we no longer accept client connections immediately on connection request, instead we send back a challenge packet, and only complete connection when a client replies with information that can only be obtained by receiving the challenge packet.
The sequence of operations in a typical connect now looks like this:
To implement this we need an additional data structure on the server. Somewhere to store the challenge data for pending connections, so when a challenge response comes in from a client we can check against the corresponding entry in the data structure and make sure it’s a valid response to the challenge sent to that address.
While the pending connect data structure can be made larger than the maximum number of connected clients, it’s still ultimately finite and is therefore subject to attack. We’ll cover some defenses against this in the next article. But for the moment, be happy at least that attackers can’t progress to the connected state with spoofed packet source addresses.
Next, to guard against our protocol being used in a DDoS amplification attack, we’ll inflate client to server packets so they’re large relative to the response packet sent from the server. This means we add padding to both connection request and challenge response packets and enforce this padding on the server, ignoring any packets without it. Now our protocol effectively has DDoS minification for requests -> responses, making it highly unattractive for anyone thinking of launching this kind of attack.
Finally, we’ll do one last small thing to improve the robustness and security of the protocol. It’s not perfect, we need authentication and encryption for that, but it at least it ups the ante, requiring attackers to actually sniff traffic in order to impersonate the client or server. We’ll add some unique random identifiers, or ‘salts’, to make each client connection unique from previous ones coming from the same IP address and port.
The connection request packet now looks like this:
The client salt in the packet is a random 64 bit integer rolled each time the client starts a new connect. Connection requests are now uniquely identified by the IP address and port combined with this client salt value. This distinguishes packets from the current connection from any packets belonging to a previous connection, which makes connection and reconnection to the server much more robust.
Now when a connection request arrives and a pending connection entry can’t be found in the data structure (according to IP, port and client salt) the server rolls a server salt and stores it with the rest of the data for the pending connection before sending a challange packet back to the client. If a pending connection is found, the salt value stored in the data structure is used for the challenge. This way there is always a consistent pair of client and server salt values corresponding to each client session.
The client state machine has been expanded so connecting is replaced with two new states: sending connection request and sending challenge response, but it’s the same idea as before. Client states repeatedly send the packet corresponding to that state to the server while listening for the response that moves it forward to the next state, or back to an error state. If no response is received, the client times out and transitions to disconnected.
The challenge response sent from the client to the server looks like this:
The utility of this being that once the client and server have established connection, we prefix all payload packets with the xor of the client and server salt values and discard any packets with the incorrect salt values. This neatly filters out packets from previous sessions and requires an attacker to sniff packets in order to impersonate a client or server.
Now that we have at least a basic level of security, it’s not much, but at least it’s something, we can implement a disconnect packet:
And when the client or server want to disconnect clean, they simply fire 10 of these over the network to the other side, in the hope that some of them get through, and the other side disconnects cleanly instead of waiting for timeout.
We now have a much more robust protocol. It’s secure against spoofed IP packet headers. It’s no longer able to be used as port of DDoS amplification attacks, and with a trivial xor based authentication, we are protected against casual attackers while client reconnects are much more robust.
But it’s still vulnerable to a sophisticated actors who can sniff packets:
This attacker can read and modify packets in flight.
This breaks the trivial identification based around salt values…
… giving an attacker the power to disconnect any client at will.
To solve this, we need to get serious with cryptography to encrypt and sign packets so they can’t be read or modified by a third party.
本篇主要讲了数据包的分包和重组问题, 到底数据包多大才好呢?是不是越大越好呢?包太大了怎么办呢?
请看总结, 不明之处再看文中具体讲解.
网络协议在动作游戏类型(FPS)中的典型特征就是一个持续发送的数据包,在两个方向上以稳定的速度如20或30包每秒发送。这些数据包都包含有不可靠的无序数据例如t时间内的世界状态;所以,当一个数据包丢失,重新发送它并不是特别有用。当重新发送的数据包到达时,时间t已经过去了。
所以这就是我们将要实现可靠性的现状。对于我们90%的数据包,仅丢弃并不再重新发送它会更好。对于10%或更少(误差允许范围内)的情况,我们确实需要可靠性,但这样的数据是非常罕见的,很少被发送而且比不可靠的数据的平均大小要小得多。这个使用案例适用于所有过去十五年来发布的AAA级的FPS游戏。
为实现数据包层级的应答,在每个包的前面添加如下的报头:
struct Header |
这些报头元素组合起来以创建应答系统:
ack_bits 不仅是一个节省带宽的巧妙的编码,它同样也增加了信息冗余来抵御包的丢失。每个应答码要被发送32次。如果有一个包丢失了,仍然有其他31个包有着相同的应答码。从统计上来说,应答码还是非常有可能送达的。
但突发的传送数据包的丢失还是有可能发生的,所以重要的是要注意:
以我的经验,没有必要设计完善的应答机制。在一个极少丢应答码的系统上构建一个可靠性系统并不会增加什么大问题。
为实现这个应答系统,我们在发送方还需要一个数据结构来追踪一个数据包是否已经被应答,这样我们就可以忽略冗余的应答(每个包会通过 ack_bits
多次应答)。我们同样在接收方也还需要一个数据结构来追踪那些已经收到的包,这样我们就可以在数据包的报头填写ack_bits
的值。
const int BufferSize = 1024; |
你在这可以看到的窍门是这个滚动的缓冲区是以序列号来作为索引的:
const int index =sequence % BufferSize;
当条目被顺序添加,就像一个被发送的队列,对插入所需要做的就是把这个序列缓冲区的值更新为新的序列号并且在该索引处重写这个数据:
PacketData & InsertPacketData( uint16_t sequence ) |
原文标题 : Reliable Ordered Messages (How to implement reliable-ordered messages on top of UDP)
Hi, I’m Glenn Fiedler and welcome to Building a Game Network Protocol.
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.
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.
To implement packet level acks, we add the following packet header:
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.
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.
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:
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:
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.
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.
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.
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.
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.
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:
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.
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:
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.
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.
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.
嗨,我是格伦费德勒,欢迎来到创建一个游戏网络协议第五篇文章。
从上一篇文章到现在已经有很长一段时间了,上次我已经率先而且实现了余下的这一系列文章所需的源码并创建了开源库libyojimbo,是本系列文章所要描述的网络协议的一个质量有保证的的和经过单元测试的版本。
如果你想要有一个开源库来为自己在UDP之上实现可靠消息或是为了其他更多,看看libyojimbo。但是,如果你像我这样是想理解它具体是怎么工作的并且可能自己去实现它,阅读下去,因为我们将要从头到脚地去建立一个在UDP之上用来发送可靠有序消息的完整的系统!
很多人也许会跟你说,要在UDP之上实现你自己的可靠消息系统是愚蠢的。为什么要撰写你特有的简化版本的TCP?这些人深信,任何可靠性的实现不可避免地 最终会成为一个(简化的)TCP的重实现。
但也有很多不同的方法来在UDP之上实现可靠消息,各有不同的优势和劣势。TCP的方法并不是唯一的选择。事实上,我所了解到的大多数可靠有序信息的选择的原理和TCP并不相同。所以让我们为我们的目标发挥创造力并弄懂我们该如何充分利用我们的现状来实现一个比TCP更好 的可靠性系统。
网络协议在动作游戏类型(FPS)中的典型特征就是一个持续发送的数据包,在两个方向上以稳定的速度如20或30包每秒发送。这些数据包都包含有不可靠的无序数据例如t时间内的世界状态;所以,当一个数据包丢失,重新发送它并不是特别有用。当重新发送的数据包到达时,时间t已经过去了。
所以这就是我们将要实现可靠性的现状。对于我们90%的数据包,仅丢弃并不再重新发送它会更好。对于10%或更少(误差允许范围内)的情况,我们确实需要可靠性,但这样的数据是非常罕见的,很少被发送而且比不可靠的数据的平均大小要小得多。这个使用案例适用于所有过去十五年来发布的AAA级的FPS游戏。
可靠性的一个常用的方法是使用两种包类型:可靠有序的和不可靠的。你在众多网络库中都会看到这个方法。它基本的想法是,这个库不断重新发送可靠的数据包直到它的另一方接收到为止。这是一个最终看起来会有一点像TCP方式传输的可靠包的选择。这并没有很糟糕,但你也可以做得更好。
我更愿意去考虑的方法就是消息其实是更小的位包装元素,它知道如何使它们自己序列化。这就显得非常有意义了,因为按位打包的消息中,用于描述下个字节的前缀或者后缀的字节开销在大部分的情况下是不必需的(例如每个包中包含的许多小的消息)。被发送的消息会被放在一个队列并且每次一个包被发送时,发送队列中的一些消息就会被包含在外发的包中。这样一来,就没有可靠的数据包需要被重新发送了。可靠消息也只会包含在数据包里直到它们被接收。
要做到这样最简单的方法就是,把所有未应答的消息包含到每个被发送的包中。它是这样的:每个被发送的消息都有一个随每当一个消息被发送时递增的id。每个输出数据包包含起始消息id ,紧跟着的是n 个消息的数据。接收方不断发回最新收到消息的id给发送方作为一个应答信号,并且消息要当且仅当比最新的应答消息id要更新,才会被包含在数据包中。
这很简单也易于实现,但当你正在发送消息时如果突发一个很大的包丢失情况,你会遇到一个数据包大小的峰值,因为有很多未应答的消息。。正如在数据包分割和重组中讨论的需要按照MTU分割包的方式来发送大的数据包会增加丢包的情况。在高丢包率下你最不想做的就是增大包的规格并引起更多的包的丢失。这是一个潜在的无底洞。
你可以通过扩展系统来给每个包的消息数量n设置一个上限,来避免这种情况。但现在如果你有一个高数据包发送率(如每秒60包)你就要多次发送同样的消息直到你得到该消息的应答信号。如果的往返时间是100ms,每条消息在被应答之前将要平均被多余发送六次。也许你真的需要这些多余的发送数量因为你的消息是对时间极其敏感的,但在大多数情况下,你应该给队列里的其他消息分配合理的带宽。
我比较喜欢的方法是用一个优先次序系统整合每包的应答信号,这个系统检出n条最重要的消息并包含在每个包中。在散布的消息穿过所有在发送队列中的消息发送时,这样就把对时间敏感的递送与每包仅发送n条消息的能力联合起来了。
让我们行动起来实现它。 这种可靠性系统的基础是每个包的应答。
但为什么应答是在数据包层级而不是在消息层级呢?简要截说原因就是包的数量会远远少于消息的数量。假设每个包中有32或64条消息,显然让一个包含32或64条消息的包来应答会比让每个消息都分别应答要高效得多。
这样同样也增加了灵活性,因为你可以在数据包层级应答上构建其他可靠性系统,不仅仅是为了可靠有序的消息。例如,使用了数据包层级应答,你就知道哪一个时间先决的不可靠状态更新已结束,所以你可以轻易地构建一个系统,在一旦一个数据包所包含的最后一个状态更新已经应答时,停止发送不再改变的对象状态。
为实现数据包层级的应答,在每个包的前面添加如下的报头:
1 2 3 4 5 6 | struct Header { uint16_t sequence; uint16_t ack; uint32_t ack_bits; }; |
这些报头元素组合起来以创建应答系统:
ack_bits 不仅是一个节省带宽的巧妙的编码,它同样也增加了信息冗余来抵御包的丢失。每个应答码要被发送32次。如果有一个包丢失了,仍然有其他31个包有着相同的应答码。从统计上来说,应答码还是非常有可能送达的。
但突发的传送数据包的丢失还是有可能发生的,所以重要的是要注意:
以我的经验,没有必要设计完善的应答机制。在一个极少丢应答码的系统上构建一个可靠性系统并不会增加什么大问题。但对于在所有情况下来测试这个系统的工作将会成为很大的挑战,因为还要考虑应答码丢失的边界情况。
所以如果你自己实现这个系统的话,请设置一个浸泡测试来覆盖糟糕的网络情况,用来确保你的应答系统是在正确的工作,相关地,你的消息系统的执行实际上是在这些网络情况下可靠地而且有序的交付可靠有序消息。以我之见(并且我已经写了许多这样的系统的变式至少有十次了),这是确保正确行为的一个必要步骤。
你在这篇文章的示例源代码中会找到这样一个浸泡测试,它对patreon支持是有效的,并且也在开源网络库libyojimbo中。
为实现这个应答系统,我们在发送方还需要一个数据结构来追踪一个数据包是否已经被应答,这样我们就可以忽略冗余的应答(每个包会通过 ack_bits多次应答)。我们同样在接收方也还需要一个数据结构来追踪那些已经收到的包,这样我们就可以在数据包的报头填写ack_bits的值。
这个数据结构应该具有以下属性:
你可能会想。哦,当然,哈希表。但还有一个更简单的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | const int BufferSize = 1024; uint32_t sequence_buffer[BufferSize]; struct PacketData { bool acked; }; PacketData packet_data[BufferSize];
{ const int index = sequence % BufferSize; if ( sequence_buffer[index] == sequence ) return &packet_data[index]; else return NULL; } |
你在这可以看到的窍门是这个滚动的缓冲区是以序列号来作为索引的:
const int index =sequence % BufferSize;
这是可行的,因为我们并不在意旧条目破坏。随着序列号的递增,旧的条目也自然而然地随着我们插入了新条目而被重写。sequence_buffer[index]的值是用来测试该索引的条目是否实际上与你所搜寻的序列号相符。一个缓冲序列的值是0xFFFFFFFF 就表示一个空的条目并自然地对任何序列号查询返回NULL,没有任何其他(代码)分支。
当条目被顺序添加,就像一个被发送的队列,对插入所需要做的就是把这个序列缓冲区的值更新为新的序列号并且在该索引处重写这个数据:
1 2 3 4 5 6 | PacketData & InsertPacketData( uint16_t sequence ) { const int index = sequence % BufferSize; sequence_buffer[index] = sequence; return packet_data[index]; } |
但在接收端数据包以乱序到达并且有一部分丢失。在高得离谱的丢包率下(99%),我就会看到旧的序列缓冲区条目还存在,但是新条目的序列号已经超过了65535并且循环到达了旧条目之前,并且打破了我的应答逻辑(导致错误应答并打破了可靠性,这时发送方会真的认为对方已经接收到了一些东西但其实并不是…)
解决这个问题的办法是遍历上一个最高的插入序列与最新收到的插入序列之间的条目(如果它是更加新的话)并在缓冲区清除这些条目即都置为0xFFFFFFFF。现在,在一般情况下,插入操作是非常接近 时间常量的,但最糟的情况是,在先前最高的序列号和当前插入的序列号之间线性遍历的次数n等于缓冲区的长度。
在我们继续之前,我想指出,你可以用这个数据结构做更多事情而不仅是对于应答码。例如,你可以加入发送时间,来扩展每个包的数据:
1 2 3 4 5 | struct PacketData { bool acked; double send_time; }; |
有了这些信息你可以对往返时间通过做指数级的平滑取平均数做修正,最终得到合理的预期往返时间。你甚至可以看到在发送数据包的序列缓存区的数据包会比你RTT预计的(你现在应该已经收到了它们的应答码…)要旧,通过这个往返时间对还没有应答的包做判断,来决定创建你的数据包丢失预计。
现在我们来把注意力集中在数据包层级应答的实际算法上。该算法如下:
在数据包发送端:
在数据包接收端:
重要的一点是这个算法是在两端都可以执行的,所以如果你有一个客户端和一个服务端,然后每一方的连接运行着同样的逻辑,维护自己的序列号发送的数据包,跟踪最新从另一方收到的数据包序列#还有从一个序列缓冲区里接收到的数据包中生成sequence, ack 和ack_bits 来发送到另一方。
并且这真的就是和它有关的全部了。现在当一个数据包被另一方接收到时,你有一个回调:OnPacketAcked 。这个可靠性系统的关键就在于你得知道哪个数据包被接收,你可以在你想的媒介之上创建任何 可靠性系统。它不仅限于可靠有序的消息。例如,你可以用它确认哪个不可靠的状态更新已经完成了,用以实现基于每个物体的增量编码。
消息是小型的对象(比数据包大小要小,所以很多消息装配在一个典型的数据包中)并且知道如何将它们自己序列化。在我的系统里,它们使用一个统一的序列化函数来执行序列化。
这个序列化的函数是模板化的,所以你只要写它一次它就会处理读、写以及测量 。
是的。测量。我喜欢的一个技巧就是有一个虚拟流类叫做MeasureStream,如果你调用了序列化函数,它不参与任何的序列化,而只是测量可能被写入的比特数。这对于解决哪个消息要装载到你的数据包里,特别是当消息可以有任意复杂的序列化函数的情况时是特别有用的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | 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 ); } };
|
这里的技巧是桥接统一模板的序列化函数(所以你只需要写一次)与虚拟序列化方法,这通过从虚函数每个流类型中调入它。我通常用一个宏来打包这个引用,但它在上文的代码中这个宏已经被展开,所以你可以看到发生了什么。
现在,假设你有一个基于消息的指针可以让你做到这样并且它只是通过重载来工作:
1 2 | Message message = CreateSomeMessage(); message->SerializeInternal( stream ); |
另外一个就是如果你在编译时间知道了消息的完整组合,就可以为每个类型在被调入序列化函数之前实现一个关于消息类型转换为确切消息类型的大的switch语句。我在过去已经在控制台平台实现的这个消息系统这么做了(如PS3 SPUs),但对于现在(2016)的应用程序,虚函数的总开销是忽略不计的。
消息从一个基类派生,这个基类提供一个通用的接口例如序列化、消息的查询类型还有引用计数。引用计数是必要的,因为消息是通过指针传递的并且在应答之前不只是存储在消息发送队列,而且也存储在外发的数据包中,包本身是C++结构体。
这是一个策略,就是避免通过指针传递消息和数据包来复制数据。别的一些场景(理想的情况是在一个单独的线程)它们里面的数据包和消息会序列化到一个缓冲区。最终,当不再有对存在消息发送队列的消息的引用时(消息已经被应答)并且没有数据包包含保留在数据包发送队列里的消息,消息即是被销毁的。
我们也需要一种方式来创建消息。我用一个消息的工厂类来做这件事情,它有一个被复写的虚函数来根据类型创建一个消息。如果这个数据包工厂还知道消息类型的总数量就好了,那样我们就可以在网络上序列化一个消息类型,因为有严格的界限和在有效范围之外的消息类型值的包的恶意丢弃:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | 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; } }; |
再次重申,这是一个引用并且通常是被包裹在宏里面的,但下面要说明的就是它具体是怎么回事了。
现在让我们来着手于如何在应答系统中实现可靠有序消息的细节。
发送可靠有序消息的算法如下:
对于消息发送:
对于数据包发送:
对于数据包接收:
对于数据包应答:
对于消息接收:
总之,消息要保持被包含在数据包中直到这个数据包包含的消息得到应答。我们在发送者方使用一个数据结构来给消息标识的组合映射数据包序列号以便应答。当消息被应答时,要从发送队列中移除。对于接收方,以乱序到达的消息会被存储在一个序列缓冲区,并以消息id为索引,这个id会让我们以它们被发送的顺序接收它们。
在发送方,这为用户提供了一个像这样的接口:
1 2 3 4 5 6 7 8 | TestMessage message = (TestMessage) factory.Create( TEST_MESSAGE ); if ( message ) { message->a = 1; message->b = 2; message->c = 3; connection.SendMessage( message ); } |
还有在接收方:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | while ( true ) { Message message = connection.ReceiveMessage(); if ( !message ) break ; if ( message->GetType() == TEST_MESSAGE ) { TestMessage testMessage = (TestMessage*) message; // process test message } factory.Release( message ); } |
正如你所看到的,它已经是简单得不能再简单了。
如果这几个接口有引起你的兴趣,请看看我的新开源库 libyojimbo。
我希望你到现在为止对这个系列的文章是享受的请在 patreon上支持我的写作,并且我将更快写新的文章,再者你会在加州大学伯克利分校软件的开源许可证下获得这篇文章的示例源代码。谢谢你的支持!
即将到来:客户端与服务器的连接
在“创建一个游戏网络协议”的下一篇文章会展示你如何在UDP之上创建你自己的客户端/服务器连接层,它会实现挑战/响应,会在服务器上分配客户端插槽,当服务器爆满或检测超时就拒绝客户端的连接。
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权;
写到一半发现论坛的热门帖子里官方写了个u3d的demo源码解析, 内容几乎重复, u3d跟ue4的demo框架流程几乎都是差不多的, 直接给出官方帖子的链接好了, 尴尬:
http://bbs.kbengine.org/forum.php?mod=viewthread&tid=166
有了本系列上篇文章中的分包和重组系统为何还要这个发送大块数据系统?是否是多余的?是雷同的吗?
请看总结概要理清思路, 再细看文章.
第一眼看上去,这种替代性的技术似乎非常类似于数据包的分包和重组,但是它的实现是完全不同的。这种实现上的差异的目的是为了解决数据包分包和重组的一个关键弱点 : 一个片段的丢失就会导致整个数据包都要被丢弃掉然后重新分包重发。
你可能需要这样做的一些常见的例子包括:客户端在首次加入的时候,服务器需要下发一个大的数据块给客户端(可能是世界的初始状态)、一开始用来做增量编码的基线或者是在一个多人在线网络游戏里面客户端在加载界面所等待的大块数据。
在这些情况下非常重要的是不仅要优雅地处理数据包的丢失,还要尽可能的利用可用的带宽并尽可能快的发送大块数据。
这个发送大块数据系统大致可以理解为是一个在原来分包和重组系统的基础上增加了分包确认功能, 也就是说增加了可靠性的部分.
In this new system blocks of data are called chunks. Chunks are split up into slices. This name change keeps the chunk system terminology (chunks/slices) distinct from packet fragmentation and reassembly (packets/fragments).
这个系统在网络上发送的数据包类型一共有两种类型:
Slice packet片段数据包 : 这包括了一个块的片段,最多大小为1k。
const int SliceSize = 1024; |
Ack packet确认数据包 : 一个位域bitfield指示哪些片段已经收到, we just send the entire state of all acked slices in each ack packet. When the ack packet is received (including the slice that was just received).
struct AckPacket : public protocol2::Packet |
与之前文章介绍的数据包的分包和重组系统不同,块系统在同一时间只能由一个块正在传输。
发送方的策略是:
对于发送方而言有一点比较微妙,实现一个片段数据包重新发送的最小延迟是一个很棒的主意,如果不这么做的话,就可能会出现这种一样情况,对于很小的块数据或者一个块的最后几个片段数据包,很容易不停的发送它们把整个网络都塞满。正是因为这一原因,我们使用了一个数组来记录每个片段数据包的上一次发送时间。重新发送延迟的一个选择是使用一个估计的网络往返时延,或者只有在超过上一次发送时间网络往返时延*1.25还没有收到确认数据包的情况才会重新发送。或者,你可以说“这根本就无所谓”,只要超过上一次发送时间100毫秒了就重新发送。我只是列举适合我自己的方案!
我们使用以下的数据结构来描述发送方:class ChunkSender
{
bool sending;
uint16_t chunkId;
int chunkSize;
int numSlices;
int numAckedSlices;
int currentSliceId;
bool acked[MaxSlicesPerChunk];
uint8_t chunkData[MaxChunkSize];
double timeLastSent[MaxSlicesPerChunk];
};
首先,接收方的设置会从块0开始。当一个片段数据包从网络上传递过来,并且能够匹配这个块id的话,“receiving”状态会从false翻转为true,第一个片段数据包的数据会插入” chunkData“变量的合适位置,片段数据包的数量会根据第一个片段数据包里面的数据进行正确的设置,已经接收到的片段数据包的数量会加一,也就是从0到1,针对每个片段数据包的接收标记里面对应这个片段数据包的项会变为true。
随着这个块数据的其他片段数据包的到来,会对每一个片段数据包进行检测,判断它们的id是否与当前块的id相同,如果不相同的话就会被丢弃。如果这个片段数据包已经收到过的话,那么这个包也会被丢弃。否则,这个片段数据包的数据会插入” chunkData“变量的合适位置、已经接收到的片段数据包的数量会加一、针对每个片段数据包的接收标记里面对应这个片段数据包的项会变为true。
这一过程会持续进行,直到接收到所有的片段数据包。一旦接收到所有的片段数据包(也就是已经接收到的片段数据包的数量等于片段数据包的数量的时候),接收方会把“receiving “状态改为false,而把”readyToRead“状态改为true。当”readyToRead”状态为true的时候,所有收到的片段数据包都会被丢弃。在这一点上,这个处理过程通常非常的短,会在收到片段数据包的同一帧进行处理,调用者会检查”我有一块数据要读取么?“并处理块数据。然后会重置数据块接收器的所有数据为默认值,除了块数据的id从0增加到1,这样我们就准备好接收下一个块了。class ChunkReceiver
{
bool receiving;
bool readyToRead;
uint16_t chunkId;
int chunkSize;
int numSlices;
int numReceivedSlices;
bool received[MaxSlicesPerChunk];
uint8_t chunkData[MaxChunkSize];
};
如果你对每个收到的片段数据包都会回复一个确认数据包的话,那么发送方能够构造一个很小的片段数据包发送给你,而你会回复一个比发送给你的片段数据包还大的确认数据包,这样你的服务器就变成了一个可以被人利用来进行DDos放大攻击的工具。
永远不要设计一个包含对接收到的数据包进行一对一的映射响应的协议。让我们举个简单例子来说明一下这个问题。如果有人给你发送1000个片段数据包,永远不要给他回复1000个确认数据包。相反只发一个确认数据包,而且最多每50毫秒或者100毫秒才发送一个确认数据包。如果你是这样设计的话,那么DDos攻击完全不可能的。
原文标题 : Sending Large Blocks of Data (How to send blocks quickly and reliably over UDP)
Hi, I’m Glenn Fiedler and welcome to Building a Game Network Protocol.
In the previous article we implemented packet fragmentation and reassembly so we can send packets larger than MTU.
This approach works great when the data block you’re sending is time critical and can be dropped, but in other cases you need to send large blocks of quickly and reliably over packet loss, and you need the data to get through.
In this situation, a different technique gives much better results.
It’s common for servers to send large block of data to the client on connect, for example, the initial state of the game world for late join.
Let’s assume this data is 256k in size and the client needs to receive it before they can join the game. The client is stuck behind a load screen waiting for the data, so obviously we want it to be transmitted as quickly as possible.
If we send the data with the technique from the previous article, we get packet loss amplification because a single dropped fragment results in the whole packet being lost. The effect of this is actually quite severe. Our example block split into 256 fragments and sent over 1% packet loss now has a whopping 92.4% chance of being dropped!
Since we just need the data to get across, we have no choice but to keep sending it until it gets through. On average, we have to send the block 10 times before it’s received. You may laugh but this actually happened on a AAA game I worked on!
To fix this, I implemented a new system for sending large blocks, one that handles packet loss by resends fragments until they are acked. Then I took the problematic large blocks and piped them through this system, fixing a bunch of players stalling out on connect, while continuing to send time critical data (snapshots) via packet fragmentation and reassembly.
In this new system blocks of data are called chunks. Chunks are split up into slices. This name change keeps the chunk system terminology (chunks/slices) distinct from packet fragmentation and reassembly (packets/fragments).
The basic idea is that slices are sent over the network repeatedly until they all get through. Since we are implementing this over UDP, simple in concept becomes a little more complicated in implementation because have to build in our own basic reliability system so the sender knows which slices have been received.
This reliability gets quite tricky if we have a bunch of different chunks in flight, so we’re going to make a simplifying assumption up front: we’re only going to send one chunk over the network at a time. This doesn’t mean the sender can’t have a local send queue for chunks, just that in terms of network traffic there’s only ever one chunk in flight at any time.
This makes intuitive sense because the whole point of the chunk system is to send chunks reliably and in-order. If you are for some reason sending chunk 0 and chunk 1 at the same time, what’s the point? You can’t process chunk 1 until chunk 0 comes through, because otherwise it wouldn’t be reliable-ordered.
That said, if you dig a bit deeper you’ll see that sending one chunk at a time does introduce a small trade-off, and that is that it adds a delay of RTT between chunk n being received and the send starting for chunk n+1 from the receiver’s point of view.
This trade-off is totally acceptable for the occasional sending of large chunks like data sent once on client connect, but it’s definitely not acceptable for data sent 10 or 20 times per-second like snapshots. So remember, this system is useful for large, infrequently sent blocks of data, not for time critical data.
There are two sides to the chunk system, the sender and the receiver.
The sender is the side that queues up the chunk and sends slices over the network. The receiver is what reads those slice packets and reassembles the chunk on the other side. The receiver is also responsible for communicating back to the sender which slices have been received via acks.
The netcode I work on is usually client/server, and in this case I usually want to be able to send blocks of data from the server to the client and from the client to the server. In that case, there are two senders and two receivers, a sender on the client corresponding to a receiver on the server and vice-versa.
Think of the sender and receiver as end points for this chunk transmission protocol that define the direction of flow. If you want to send chunks in a different direction, or even extend the chunk sender to support peer-to-peer, just add sender and receiver end points for each direction you need to send chunks.
Traffic over the network for this system is sent via two packet types:
The slice packet is sent from the sender to the receiver. It is the payload packet that gets the chunk data across the network and is designed so each packet fits neatly under a conservative MTU of 1200 bytes. Each slice is a maximum of 1k and there is a maximum of 256 slices per-chunk, therefore the largest data you can send over the network with this system is 256k.
const int SliceSize = 1024;
const int MaxSlicesPerChunk = 256;
const int MaxChunkSize = SliceSize MaxSlicesPerChunk;
struct SlicePacket : public protocol2::Packet
{
uint16_t chunkId;
int sliceId;
int numSlices;
int sliceBytes;
uint8_t data[SliceSize];
template <typename Stream> bool Serialize( Stream & stream )
{
serialize_bits( stream, chunkId, 16 );
serialize_int( stream, sliceId, 0, MaxSlicesPerChunk - 1 );
serialize_int( stream, numSlices, 1, MaxSlicesPerChunk );
if ( sliceId == numSlices - 1 )
{
serialize_int( stream, sliceBytes, 1, SliceSize );
}
else if ( Stream::IsReading )
{
sliceBytes = SliceSize;
}
serialize_bytes( stream, data, sliceBytes );
return true;
}
};
There are two points I’d like to make about the slice packet. The first is that even though there is only ever one chunk in flight over the network, it’s still necessary to include the chunk id (0,1,2,3, etc…) because packets sent over UDP can be received out of order.
Second point. Due to the way chunks are sliced up we know that all slices except the last one must be SliceSize (1024 bytes). We take advantage of this to save a small bit of bandwidth sending the slice size only in the last slice, but there is a trade-off: the receiver doesn’t know the exact size of a chunk until it receives the last slice.
The other packet sent by this system is the ack packet. This packet is sent in the opposite direction, from the receiver back to the sender. This is the reliability part of the chunk network protocol. Its purpose is to lets the sender know which slices have been received.
struct AckPacket : public protocol2::Packet
{
uint16_t chunkId;
int numSlices;
bool acked[MaxSlicesPerChunk];
bool Serialize( Stream & stream )
{
serialize_bits( stream, chunkId, 16 );
serialize_int( stream, numSlices, 1, MaxSlicesPerChunk );
for ( int i = 0; i < numSlices; ++i )
{
serialize_bool( stream, acked[i] ); return true; } };
}
}
};
Acks are short for ‘acknowledgments’. So an ack for slice 100 means the receiver is acknowledging that it has received slice 100. This is critical information for the sender because not only does it let the sender determine when all slices have been received so it knows when to stop, it also allows the sender to use bandwidth more efficiently by only sending slices that haven’t been acked.
Looking a bit deeper into the ack packet, at first glance it seems a bit redundant. Why are we sending acks for all slices in every packet? Well, ack packets are sent over UDP so there is no guarantee that all ack packets are going to get through. You certainly don’t want a desync between the sender and the receiver regarding which slices are acked.
So we need some reliability for acks, but we don’t want to implement an ack system for acks because that would be a huge pain in the ass. Since the worst case ack bitfield is just 256 bits or 32 bytes, we just send the entire state of all acked slices in each ack packet. When the ack packet is received, we consider a slice to be acked the instant an ack packet comes in with that slice marked as acked and locally that slice is not seen as acked yet.
This last step, biasing in the direction of non-acked to ack, like a fuse getting blown, means we can handle out of order delivery of ack packets.
Let’s get started with the implementation of the sender.
The strategy for the sender is:
We use the following data structure for the sender:
class ChunkSender
{
bool sending;
uint16_t chunkId;
int chunkSize;
int numSlices;
int numAckedSlices;
int currentSliceId;
bool acked[MaxSlicesPerChunk];
uint8_t chunkData[MaxChunkSize];
double timeLastSent[MaxSlicesPerChunk];
};
As mentioned before, only one chunk is sent at a time, so there is a ‘sending’ state which is true if we are currently sending a chunk, false if we are in an idle state ready for the user to send a chunk. In this implementation, you can’t send another chunk while the current chunk is still being sent over the network. If you don’t like this, stick a queue in front of the sender.
Next, we have the id of the chunk we are currently sending, or, if we are not sending a chunk, the id of the next chunk to be sent, followed by the size of the chunk and the number of slices it has been split into. We also track, per-slice, whether that slice has been acked, which lets us count the number of slices that have been acked so far while ignoring redundant acks. A chunk is considered fully received from the sender’s point of view when numAckedSlices == numSlices.
We also keep track of the current slice id for the algorithm that determines which slices to send, which works like this. At the start of a chunk send, start at slice id 0 and work from left to right and wrap back around to 0 again when you go past the last slice. Eventually, you stop iterating across because you’ve run out of bandwidth to send slices. At this point, remember our current slice index via current slice id so you can pick up from where you left off next time. This last part is important because it distributes sends across all slices, not just the first few.
Now let’s discuss bandwidth limiting. Obviously you don’t just blast slices out continuously as you’d flood the connection in no time, so how do we limit the sender bandwidth? My implementation works something like this: as you walk across slices and consider each slice you want to send, estimate roughly how many bytes the slice packet will take eg: roughly slice bytes + some overhead for your protocol and UDP/IP header. Then compare the amount of bytes required vs. the available bytes you have to send in your bandwidth budget. If you don’t have enough bytes accumulated, stop. Otherwise, subtract the bytes required to send the slice and repeat the process for the next slice.
Where does the available bytes in the send budget come from? Each frame before you update the chunk sender, take your target bandwidth (eg. 256kbps), convert it to bytes per-second, and add it multiplied by delta time (dt) to an accumulator.
A conservative send rate of 256kbps means you can send 32000 bytes per-second, so add 32000 dt to the accumulator. A middle ground of 512kbit/sec is 64000 bytes per-second. A more aggressive 1mbit is 125000 bytes per-second. This way each update you accumulate a number of bytes you are allowed to send, and when you’ve sent all the slices you can given that budget, any bytes left over stick around for the next time you try to send a slice.
One subtle point with the chunk sender and is that it’s a good idea to implement some minimum resend delay per-slice, otherwise you get situations where for small chunks, or the last few slices of a chunk that the same few slices get spammed over the network.
For this reason we maintain an array of last send time per-slice. One option for this resend delay is to maintain an estimate of RTT and to only resend a slice if it hasn’t been acked within RTT * 1.25 of its last send time. Or, you could just resend the slice it if it hasn’t been sent in the last 100ms. Works for me!
Do the math you’ll notice it still takes a long time for a 256k chunk to get across:
Which kinda sucks. The whole point here is quickly and reliably. Emphasis on quickly. Wouldn’t it be nice to be able to get the chunk across faster? The typical use case of the chunk system supports this. For example, a large block of data sent down to the client immediately on connect or a block of data that has to get through before the client exits a load screen and starts to play. You want this to be over as quickly as possible and in both cases the user really doesn’t have anything better to do with their bandwidth, so why not use as much of it as possible?
One thing I’ve tried in the past with excellent results is an initial burst. Assuming your chunk size isn’t so large, and your chunk sends are infrequent, I can see no reason why you can’t just fire across the entire chunk, all slices of it, in separate packets in one glorious burst of bandwidth, wait 100ms, and then resume the regular bandwidth limited slice sending strategy.
Why does this work? In the case where the user has a good internet connection (some multiple of 10mbps or greater…), the slices get through very quickly indeed. In the situation where the connection is not so great, the burst gets buffered up and most slices will be delivered as quickly as possible limited only by the amount bandwidth available. After this point switching to the regular strategy at a lower rate picks up any slices that didn’t get through the first time.
This seems a bit risky so let me explain. In the case where the user can’t quite support this bandwidth what you’re relying on here is that routers on the Internet strongly prefer to buffer packets rather than discard them at almost any cost. It’s a TCP thing. Normally, I hate this because it induces latency in packet delivery and messes up your game packets which you want delivered as quickly as possible, but in this case it’s good behavior because the player really has nothing else to do but wait for your chunk to get through.
Just don’t go too overboard with the spam or the congestion will persist after your chunk send completes and it will affect your game for the first few seconds. Also, make sure you increase the size of your OS socket buffers on both ends so they are larger than your maximum chunk size (I recommend at least double), otherwise you’ll be dropping slices packets before they even hit the wire.
Finally, I want to be a responsible network citizen here so although I recommend sending all slices once in an initial burst, it’s important for me to mention that I think this really is only appropriate, and only really borderline appropriate behavior for small chunks in the few 100s of k range in 2016, and only when your game isn’t sending anything else that is time-critical.
Please don’t use this burst strategy if your chunk is really large, eg: megabytes of data, because that’s way too big to be relying on the kindness of strangers, AKA. the buffers in the routers between you and your packet’s destination. For this it’s necessary to implement something much smarter. Something adaptive that tries to send data as quickly as it can, but backs off when it detects too much latency and/or packet loss as a result of flooding the connection. Such a system is outside of the scope of this article.
Now that we have the sender all sorted out let’s move on to the reciever.
As mentioned previously, unlike the packet fragmentation and reassembly system from the previous article, the chunk system only ever has one chunk in flight.
This makes the reciever side of the chunk system much simpler:
class ChunkReceiver
{
bool receiving;
bool readyToRead;
uint16_t chunkId;
int chunkSize;
int numSlices;
int numReceivedSlices;
bool received[MaxSlicesPerChunk];
uint8_t chunkData[MaxChunkSize];
};
We have a state whether we are currently ‘receiving’ a chunk over the network, plus a ’readyToRead’ state which indicates that a chunk has received all slices and is ready to be popped off by the user. This is effectively a minimal receive queue of length 1. If you don’t like this, of course you are free to add a queue.
In this data structure we also keep track of chunk size (although it is not known with complete accuracy until the last slice arrives), num slices and num received slices, as well as a received flag per-slice. This per-slice received flag lets us discard packets containing slices we have already received, and count the number of slices received so far (since we may receive the slice multiple times, we only increase this count the first time we receive a particular slice). It’s also used when generating ack packets. The chunk receive is completed from the receiver’s point of view when numReceivedSlices == numSlices.
So what does it look like end-to-end receiving a chunk?
First, the receiver sets up set to start at chunk 0. When the a slice packet comes in over the network matching the chunk id 0, ‘receiving’ flips from false to true, data for that first slice is inserted into ‘chunkData’ at the correct position, numSlices is set to the value in that packet, numReceivedSlices is incremented from 0 -> 1, and the received flag in the array entry corresponding to that slice is set to true.
As the remaining slice packets for the chunk come in, each of them are checked that they match the current chunk id and numSlices that are being received and are ignored if they don’t match. Packets are also ignored if they contain a slice that has already been received. Otherwise, the slice data is copied into the correct place in the chunkData array, numReceivedSlices is incremented and received flag for that slice is set to true.
This process continues until all slices of the chunk are received, at which point the receiver sets receiving to ‘false’ and ‘readyToRead’ to true. While ‘readyToRead’ is true, incoming slice packets are discarded. At this point, the chunk receive packet processing is performed, typically on the same frame. The caller checks ‘do I have a chunk to read?’ and processes the chunk data. All chunk receive data is cleared back to defaults, except chunk id which is incremented from 0 -> 1, and we are ready to receive the next chunk.
The chunk system is simple in concept, but the implementation is certainly not. I encourage you to take a close look at the source code for this article for further details.
翻译:张华栋 (wcby) 审校:王磊(未来的未来)
大家好,我是格伦·菲德勒。欢迎大家阅读系列教程《构建游戏网络协议》的第四篇文章。在之前的文章中,我们讨论了如何在游戏协议这一层实现对数据包的分包和重组。
现在在这篇文章里面,我们将继续通过探索在UDP协议上发送大块数据的替代方案来继续我们构建一个专业级别的游戏网络协议的征程。
第一眼看上去,这种替代性的技术似乎非常类似于数据包的分包和重组,但是它的实现是完全不同的。这种实现上的差异的目的是为了解决数据包分包和重组的一个关键弱点-一个片段的丢失就会导致整个数据包都要被丢弃掉。这种行为是非常不好的,因为它会随着分包数量的增加而放大数据包丢失的概率。当你遇到大块数据包的时候,这种放大是如此的明显,加入256 k大小的分包丢失率是1%的话,那么原始数据包就有92.4%的概率被丢弃。平均来说,你需要发送原始数据包10次,它才能顺利的到达网络的另外一端!
如果你需要在一个可能会有数据包丢失的网络上比如说互联网,快速和可靠地发送大量的数据,很明显,这样的方法是完全不可接受的。你可能需要这样做的一些常见的例子包括:客户端在首次加入的时候,服务器需要下发一个大的数据块给客户端(可能是世界的初始状态)、一开始用来做增量编码的基线或者是在一个多人在线网络游戏里面客户端在加载界面所等待的大块数据。
在这些情况下非常重要的是不仅要优雅地处理数据包的丢失,还要尽可能的利用可用的带宽并尽可能快的发送大块数据。这正是我要在这篇文章里面告诉你该如何做的内容。
块和片段
让我们开始使用基本术语。在这个新系统中,大块的数据被称为”块“,而它们被分成的分包被称为”片段”。 这个名字上的改变使的块系统的术语(块和片段)不同于数据包分包和重组的术语(数据包和分包)。这是我认为很重要的一个事情,因为这些系统是在解决不同的问题,没有理由你不能在相同的网络协议中同时这两个系统。事实上,我经常把这两个结合起来,在时间比较关键的增量数据包里面使用数据包的分包和重组,当客户端加入游戏的时候,使用块系统来下发整个游戏世界的初始状态下(非常大的数据包)。
块系统的基本思想,真是一点都不复杂,是把块分成很多片段,然后通过网络多次发送片段,直到他们都顺利的到达网络的另外一端。当然,因为我们正在UDP协议上实现这个功能,同时还有可能数据包会丢失、数据包乱序到达以及数据包重复到达的情况,简单的概念在实现中也会变得非常复杂,因为我们必须在UDP协议上建立我们自己的具有基本可靠性的系统,这样发送方才能知道这个片段已经被网络的另外一端成功收到。
如果我们有一组不同的块正在传输过程中(就像我们在数据包的分包和重组中所做的那样),那么可靠性的问题就会变得非常棘手,所以我们要做一个简化的假设。我们一次只会通过网络发送一个块的数据。这并不意味着发送者不能在本地有一个块的发送队列,这只是意味着在实际的网络传输中只有一个块的数据会正在传递。
这么做之所以有意义,是因为有了这一点假设以后就能保证块系统能够可靠有序的发送块。如果你因为某些原因在同一时间发送块0和块1,这会发生什么?你不能在块0到来之前处理块1,否则这个传输过程就不是有序可靠了。也就是说,如果你挖得深一些的话,你会发现一次只能发送一个块确实引入了一个小的权衡,它给正在接收的块N增加了一个网络往返延迟,以及从接收方的角度看块N+1的发送开始时间也被延迟了一个网络往返延迟。
这个代价是完全可以接受的,因为发送大块数据是一个非常偶然的事情(举些简单的例子来说,当客户端连接上来的时候会发送大块数据,当新的关卡需要进行加载的时候才会发送大块数据。。。),但是如果1秒钟内10次或者20次发送块数据的话这就是绝对不能被接受的了。所以我希望你能看到这个系统是专为什么目的设计的以及不是为什么目的设计的。
数据包的结构
在块系统中有两方会参与,分别是发送方和接收方。
发送方是负责将块压入队列并通过网络发送片段。接收方是负责在网络的另外一端读取这些片段并进行重组。接收方还负责通过发送“确认”数据包给发送方来与发送方交流表明这个片段已经收到。
我工作过的网络模式通常是客户端与服务端之间的通信,在这种情况下,我通常希望能够从服务器往客户端发送大块数据,以及从客户端到服务器发送大块数据。所以在这种情况下,有两个发送方和两个接收方,一个发送方在客户端对应着在服务器那边有一个接收方,反过来也是如此。可以把发送方和接收方认为是块传输协议的终点,这样也就定义了网络流的方向。如果你想在不同的方向发送块,甚至是扩展块的发送方来支持点对点的发送,只需要在你需要发送块的每个方向添加一个发送方和一个接收方作为终点。
这个系统在网络上发送的数据包类型一共有两种类型:
1)片段数据包-这包括了一个块的片段,最多大小为1k。
2)确认数据包-一个位域指示哪些片段已经收到。
片段数据包是从发送方发送到接收器的。这是通过网络对块数据进行传递的有效载荷数据包,在设计的时候每个片段数据包的大小都贴近一个保守的最大传输单元的大小,也就是 1200字节。每个片段数据包最大是1 k,每个块最多有256个片段数据包,所以通过这个系统可以通过网络发送的最大的数据是256k(如果你愿意的话,你可以增加这个片段的最大数目)。我建议保持片段的大小为1k,这主要是基于最大传输单元方面的考虑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | const int SliceSize = 1024; const int MaxSlicesPerChunk = 256; const int MaxChunkSize = SliceSize MaxSlicesPerChunk; struct SlicePacket : public protocol2::Packet { uint16_t chunkId; int sliceId; int numSlices; int sliceBytes; uint8_t data[SliceSize]; template < typename stream
> bool Serialize( Stream & stream ) { serialize_bits( stream, chunkId, 16 ); serialize_int( stream, sliceId, 0, MaxSlicesPerChunk - 1 ); serialize_int( stream, numSlices, 1, MaxSlicesPerChunk ); if ( sliceId == numSlices - 1 ) { serialize_int( stream, sliceBytes, 1, SliceSize ); } else if ( Stream::IsReading ) { sliceBytes = SliceSize; } serialize_bytes( stream, data, sliceBytes ); return true ; } };
|
在这里我想对片段数据包进行两点说明。第一点是即使只有一个块在网络上进行传输,仍然是有必要在数据包里面包含一个块的id(比如说,0、1、2、3、等等等),这是,因为通过UDP协议发送的数据包可以是乱序到达的。通过这种方式的话,如果一个片段数据包到达的时候对应着一个已经接受过的块,举个简单的例子来说明,你正在接受块2的数据,但是块1的一个片段数据包现在到达了,你可以直接拒绝这个数据包,而不是接受它的数据包并把它的数据插入到块2从而把块2的数据给弄混了。
第二点。由于我们知道块分成片段的方法会把所有的片段除了最后一个以外都弄成必须SliceSize的大小(也就是1024字节)。我们利用这一点来节省一点带宽,我们只在最后一个片段里面发送片段的大小,但这是一种权衡:接收方不知道块的确切大小到底是多少字节,直到它接收到最后一个片段才能知道。
可以让这个系统继续往后发送新的数据包的机制是确认数据包。这个数据包是沿着另外一个方向进行发送的,也就是从接收方发回给发送方,这也是块网络协议中负责可靠性的部分。它存在的目的是让发送方知道这个片段已经被发送方收到。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | struct AckPacket : public protocol2::Packet { uint16_t chunkId; int numSlices; bool acked[MaxSlicesPerChunk]; bool Serialize( Stream & stream ) { serialize_bits( stream, chunkId, 16 ); serialize_int( stream, numSlices, 1, MaxSlicesPerChunk ); for ( int i = 0; i < numSlices; ++i ) serialize_bool( stream, acked[i] ); return true ; } }; } }; |
ack是“确认”的缩写。所以一个对片段100的确认数据包意味着接收方确认它已经接收到了片段100。这对于发送方来说是一条关键信息,因为它不仅让发送方知道什么时候所有的片段都已经被成功接收,这样发送方就可以停止发送了,它还允许发送方只重发那些还没有被确认的片段,这样就能让发送方更有效率的利用带宽。
让我们对确认数据包再深入一点思考,似乎在一开始看上去对于每个数据包的所有分片都发送确认包似乎有点多余。我们为什么要这么做?是的,这是因为确认数据包是通过UDP协议发送的,所以没有办法保证所有的确认数据包都会成功的到达网络的另外一端,你当然不会希望发送方和接收方之间对于目前确认到那个片段的信息都是不同步的。
所以我们需要一些确认数据包传输的可靠性,但是我们不希望实现一个确认数据包的确认系统,因为这将会是一个非常痛苦和麻烦的过程。因为在最坏的情况下,确认数据包的大小是256位或32字节,最简单的方法是也是最好的。we just send the entire state of all acked slices in each ack packet. When the ack packet is received, we consider a slice to be acked the instant an ack packet comes in with that slice marked as acked and locally that slice is not seen as acked yet.
基本的发送方实现
现在我们已经了解了这个系统背后的基本概念,让我们从发送方的实现开始实现整个系统。
发送方的策略是:
1)持续的发送片段数据包,直到所有的片段数据包都被确认。
2)不再对已经确认过的片段数据包进行发送。
我们使用以下的数据结构来描述发送方:
1 2 3 4 5 6 7 8 9 10 11 12 | class ChunkSender { bool sending; uint16_t chunkId; int chunkSize; int numSlices; int numAckedSlices; int currentSliceId; bool acked[MaxSlicesPerChunk]; uint8_t chunkData[MaxChunkSize]; double timeLastSent[MaxSlicesPerChunk]; }; |
正如之前提到的那样,一次只会发送一个块的数据,如果我们正在发送一个块的数据的时候,那么关于“发送”的状态是true,假如我们处于闲置状态、正在准备发送一个块的数据的时候,那么关于“发送”的状态是false。在这个实现中,如果当前有一个块的数据仍在通过网络进行发送的话,你不能发送另外一个块的数据。你必须等待当前块的数据发送完毕之后才可以发送另外一个块的数据。如果你不喜欢的话,在块的发送器的前端按照你的意愿可以放置一个发送队列。
接下来,我们有我们正在发送的块数据的id,或者如果我们没有在发送块数据的话,那么我们有要发送的下一个块数据的id、以及这个块所分成的片段数据包的数量。我们也会跟踪每个片段数据包,来记录这个片段数据包是否已经被确认,这可以让我们避免重发那些已经收到的片段数据包,并且我们还会记录迄今为止已经确认收到的片段数据包的数量,这个数量会去掉冗余的确认,也就是每个片段数据包的确认只算一次。从发送方的观点来看,只有当确认的片段数据包的数量等于这个块所分成的片段数据包的数量的时候才会这个块数据已经被完全收到了。
我们还需要为这个算法记录当前发送的片段数据包的id,因为这将决定了哪些片段数据包将被发送。它的工作机制大致是这样:一个块数据开始发送的时候,是从id为0的片段数据包开始发送的,然后依次从左到右开始发送直到经过最后一个片段数据包(也就是id为分包数量的大小-1)的时候会回头从id为0的片段数据包继续发送。最终,你会停止这个迭代因为发送的片段数据包已经耗尽了带宽。在这一点上,我们通过记录当前发送的片段数据包的id就能记住我们当前遍历的片段数据包的索引,这样在下一次开始遍历的时候你就可以继续从这个位置开始发送片段数据包。最后一部分是非常重要的,这是因为它可以把发送一个块数据所有的片段数据包这个事情是分散开,而不是在一起就全部发出去。
现在让我们讨论下带宽限制。显然你不能把所有的片段数据包一次全部发完,因为如果这么做的话,会把整个链接堵住,那么,我们该如何限制发送方所使用的带宽?我的实现机制大概是这样的:当你对全部的片段数据包进行遍历并且考虑你想要发送的每个片段数据包的时候,大概估计下这个片段数据包会需要占据多少字节,比如可以用这种估计算法:大概这个片段的字节数+一些协议的开销和UDP / IP的报头。然后用所需的字节数和你带宽预算里面可用来进行发送的字节数进行比较。如果带宽预算里面没有足够可用的字节数,那么就停止发送。否则的话,从带宽预算里面减去发送这个片段数据包所需的字节数,然后对于下个片段数据包重复整个过程。
带宽预算里面可用的字节发送预算是从哪里计算得来的?在每一帧更新块的发送方之前,把你的目标带宽(比如说每秒256KB)转换成每秒可以发送的字节数,然后用它乘以更新时间来把记过放到一个累加器里面。每秒256KB是一个比较保守的发送速率,这意味你可以每秒发送32000个字节,所以把32000 dt这个值添加到累加器里面。每秒512KB是一个比较适中的估计,意味你可以每秒发送64000个字节。每秒1MB是一个比较激进的估计,意味你可以每秒发送125000个字节。通过这种方法,在每次更新的时候你就可以累加你被允许发送的字节数了,这样当你可以按照预算来发送最大数量的片段数据包,如果还有数据没有发完的话,会等到下一帧的时候再尝试发送。
对于发送方而言有一点比较微妙,实现一个片段数据包重新发送的最小延迟是一个很棒的主意,如果不这么做的话,就可能会出现这种一样情况,对于很小的块数据或者一个块的最后几个片段数据包,很容易不停的发送它们把整个网络都塞满。正是因为这一原因,我们使用了一个数组来记录每个片段数据包的上一次发送时间。重新发送延迟的一个选择是使用一个估计的网络往返时延,或者只有在超过上一次发送时间网络往返时延*1.25还没有收到确认数据包的情况才会重新发送。或者,你可以说“这根本就无所谓”,只要超过上一次发送时间100毫秒了就重新发送。我只是列举适合我自己的方案!
把发送方实现的更完美一点
如果你仔细用数学计算一下的话,你会注意到对于一个256K 的数据块而言,它要在网络上发送完毕仍然需要发送很长的时间:
这可有点糟糕。我们实现系统的重点是快速和可靠性。再次强调下需要能够快速传递。如果块系统的传输不能做到快速的话,这是不是会不太好?块系统的一些典型用例会支持这一点。举个简单的例子来说明,当客户端第一次连接上服务器的时候,一大块数据需要立刻发送给客户端,或者在客户端退出加载界面开始游戏的时候需要能够大量数据快速下发给客户端。你想要尽快的传递完需要的数据,而且在这两种情况下,用户对于自己的带宽并没有什么太多其他的用途,那么为什么不使用尽可能多的带宽?
在过去我曾经尝试过一个方法,就是在一开始的时候尽量传递,这取得了很好的效果。假设你的块大小并不是那么大,而且你的块发送频率并不那么频繁,我没找到什么理由为什么你不能在一开始就把所有的片段数据包都发送出去,填充满贷款,然后等待100毫秒,在恢复成正常的带宽受限的片段数据包发送策略。
为什么这样会取得良好的效果?如果用户有一个良好的网络连接(可以每秒发送超过10MB的数据甚至更多。。。),事实上,片段数据包在网络上的传输非常的快速。如果是连接的情况并不是那么好的情况下,大部分的片段数据包会得到缓冲,大部分的片段数据包受限于带宽但是会尽可能快的发送出去。处理完这些数据包之后,就会切换到常规的策略,从那些第一次没有发送出去的片段数据包选择合适的进行发送。
这似乎有点冒险,所以让我来解释一下。如果出现大量数据需要传输但是已经超过带宽限制的情况,互联网上的路由器会倾向于缓冲这些数据包,而不是不惜代价的抛弃它们。这就是TCP协议会做的事情。通常情况下,我讨厌这个机制因为它会诱发延迟而且会弄乱那些你想要尽快交付的游戏数据包,但在这种情况下它是一个非常好的行为,这是因为玩家真的没有其他事情可以做,智能等待你的块数据赶紧传输完毕。只是在你的块数据传输完毕以后,会有一些垃圾数据或者交通拥堵,它会影响你的游戏开始的几秒钟。另外,请确保你增加了网络两端的加操作系统的套接字缓冲区的大小,这样它们才可以比你最大的块数据的大小要大(我建议至少增加一倍),否则在超过网络带宽的限制之前你就会出现丢弃段数据包的情况。
最后,我想成为一个负责任的网络公民,虽然在这里我推荐在最开始连接的时候一次发送所有的片段数据包,所以对我来说介绍下我认为这真的是适当的是非常非常重要的,在2016年的网络环境下,发送几百个KB量级的数据包是没什么大不了的行为,而且只会发生在没有其他关键数据同时发送的情况下。让我们举个简单的例子来说明,如果用户正在玩你的游戏,那么当你发送大块数据的时候,使用保守的策略。如果不这么做的话,就会冒影响用户游戏体验的风险,这是因为你的发送行为可能会诱导额外的网络延迟或者出现数据包丢失的情况。
同样,如果你的块数据非常大的情况下,比如说是十几MB的情况,那么请不要使用这种野蛮发送的策略,这是因为这种方法太过于依赖陌生人的仁慈,也就是在你和你的数据包目的地之间的路由器缓冲区。如果要持续发送非常大的数据块保持一个高吞吐量有必要实施一些更聪明的方法。这是某种自适应的方法,它会试图尽快发送数据,但是一旦检测到因为连接上有太多的数据在传输导致太多的延迟或者数据包的丢失,就能切换回一个低速的方式。这样一个系统超出了本文的范围。
接收方的实现
现在我们已经解决了发送方实现的所有细节和小问题,那么让我们开始实现接收方。正如之前提到的那样,与之前文章介绍的数据包的分包和重组系统不同,块系统在同一时间只能由一个块正在传输。
这使得块系统的接收方可以实现的更加简单,你可以看下面的实现:
1 2 3 4 5 6 7 8 9 10 11 | class ChunkReceiver { bool receiving; bool readyToRead; uint16_t chunkId; int chunkSize; int numSlices; int numReceivedSlices; bool received[MaxSlicesPerChunk]; uint8_t chunkData[MaxChunkSize]; }; |
我们有一个状态来记录我们是否正在网络上“接收”一个块数据,加上“readyToRead’”状态来表明是否已经有一个块的所有片段数据包都已经收到、已经准备好被用户弹出进行读取处理了。接收队列的最小长度是1,这是非常有效的。如果你不喜欢这个的话,你当然可以立即从块数据接收器里面将这个数据弹出并把它插入实际的接收队列。
在这个数据结构中我们还记录了块数据的大小(尽管不是完全准确,直到收到最后一个片段数据包才能准确的计算块数据的大小)、片段数据包的数量、已经接收到的片段数据包的数量还有针对每个片段数据包的一个接收标记。针对每个片段数据包的接收标记可以让我们丢弃那些我们已经收到的片段数据包,并计算到目前为止我们已经收到的片段数据包的数量(因为我们可能会多次收到同一个片段数据包,但是我们只会在第一次收到这个片段数据包的才会增加计数器的值)。它也被用在生成确认数据包上。当已经接收到的片段数据包的数量等于片段数据包的数量的时候,从接收方的角度看这个块数据的接收才算完成。
首先,接收方的设置会从块0开始。当一个片段数据包从网络上传递过来,并且能够匹配这个块id的话,“receiving”状态会从false翻转为true,第一个片段数据包的数据会插入” chunkData“变量的合适位置,片段数据包的数量会根据第一个片段数据包里面的数据进行正确的设置,已经接收到的片段数据包的数量会加一,也就是从0到1,针对每个片段数据包的接收标记里面对应这个片段数据包的项会变为true。
随着这个块数据的其他片段数据包的到来,会对每一个片段数据包进行检测,判断它们的id是否与当前块的id相同,如果不相同的话就会被丢弃。如果这个片段数据包已经收到过的话,那么这个包也会被丢弃。否则,这个片段数据包的数据会插入” chunkData“变量的合适位置、已经接收到的片段数据包的数量会加一、针对每个片段数据包的接收标记里面对应这个片段数据包的项会变为true。
这一过程会持续进行,直到接收到所有的片段数据包。一旦接收到所有的片段数据包(也就是已经接收到的片段数据包的数量等于片段数据包的数量的时候),接收方会把“receiving “状态改为false,而把”readyToRead“状态改为true。当”readyToRead”状态为true的时候,所有收到的片段数据包都会被丢弃。在这一点上,这个处理过程通常非常的短,会在收到片段数据包的同一帧进行处理,调用者会检查”我有一块数据要读取么?“并处理块数据。然后会重置数据块接收器的所有数据为默认值,除了块数据的id从0增加到1,这样我们就准备好接收下一个块了。
浸泡测试的重要性和确认数据包
第一眼看上去,确认数据包这个系统似乎很简单:
1)记录已经接收到的片段数据包。
2)当一个片段数据包收到以后,回复一个包含所有确认收到的片段数据包信息的确认数据包。
这看上去实现起来似乎相当的简单,但是像大多数发生在UDP协议的事情一样,当涉及到数据包丢失的时候,就有一些微妙的点让它的处理有点棘手。
一个对于确认数据包比较天真的实现可能是这样子的。每次收到片段数据包,就回复一个包含所有确认收到的片段数据包信息的确认数据包(也会包括刚收到的片段数据包的信息)。这看上去非常符合逻辑,但是这使得块协议给恶意发送者一个漏洞使得它们可以块协议作为一个DDos的工具。如何作为一个DDos的工具?如果你对每个收到的片段数据包都会回复一个确认数据包的话,那么发送方能够构造一个很小的片段数据包发送给你,而你会回复一个比发送给你的片段数据包还大的确认数据包,这样你的服务器就变成了一个可以被人利用来进行DDos放大攻击的工具。
现在也许是因为我对DDos这个事情有一点偏执(我确实是有一点),但是一般来说你可以防止对DDos的放大,永远不要设计一个包含对接收到的数据包进行一对一的映射响应的协议。让我们举个简单例子来说明一下这个问题。如果有人给你发送1000个片段数据包,永远不要给他回复1000个确认数据包。相反只发一个确认数据包,而且最多每50毫秒或者100毫秒才发送一个确认数据包。如果你是这样设计的话,那么滥用你的UDP协议对DDos进行放大就是完全不可能的。
还有其他的方法让这个确认系统容易出错,而这些都往往表现为”发送挂起“。换句话说,接收方已经知道这个块已经发送完毕了,但是由于程序员的错误,发送方错过了一个确认数据包(可能是针对最后一个片段数据包的确认数据包)并且卡入到一个状态,会不停的反复重发这个片段数据包而没有得到一个确认数据包的响应。
在过去10年里,我可能至少5次从头开始实现这个块系统,每次我都找到新的和令人兴奋的方式来让发送方挂起。我开发和测试块系统的策略是首先进行编码确认它能够跑起来,然后设置一个测试工具在有大量的数据包丢失、数据包乱序和重复的情况下随机发送随机大小的块。这往往会清除任何挂起。我曾经实现过的块系统都至少有一个挂起存在,通常会有2到3个挂起。所以如果你是打算从头开始实现这个块系统的话,请不要轻敌。请设置一个浸泡测试。你会感谢我在这里的提醒的。
我通常遇到的第一次挂起是由于对同一个片段数据包的多次收到不会回复一个确认数据包。它有点像这样:” 哦,这个片段数据包已经收到过了么?已经收到过了就丢弃它”,然后忘记在确认数据包里面设置标记。这对于发送方来说是一个困扰,因为这样的话就不会有一个确认数据包,那么如果出现这种情况的话,又恰好遇到第一次收到这个片段数据包的时候发送的确认数据包出现丢包的情况,发送方根本就不知道这个他在反复发送的片段数据包其实已经被收到了。如果你就是这么不巧,遇上了第一次收到这个片段数据包的时候发送的确认数据包出现丢包的情况,那么就遇上了挂起的情况。如果你想在你的代码里面重现这个情况的话,可以在收到最后一个片段数据包的时候不发送确认数据包,那么出现的情况就是这种挂起了。
下一个挂起会发生在接收方在发送方知道之前就已经知道块发送完毕并切换它的状态变量“readyToRead”来丢弃后续传入的片段数据包。在这种状态下,即使接收方认为块已经完全接收完毕,但是发送方还不知道这一点,所以有必要设置确认数据包对应的标志位,即使块已经完全接收完毕,这样发送方才能一直接收到提示块已经全部发送完毕的确认数据包。
通常遇到的最后一个挂起情况是在读取完块数据以后的状态切换里面,那个时候状态变量“readyToRead”已经切回false而块的id也加一了。让我们举个简单例子来说明一下这个问题,块0已经完成接收,用户已经完成对块0的读取并且块id已经递增到1了,所以我们已经准备好接收块1的片段数据包了(我们会丢弃任何与我们当前正在接收块ID不同的片段数据包)。
再一次出现这种情况,就是这里的发送方因为确认数据包的丢失导致信息有一点滞后,可能是因为没有收到第一个确认数据包。在这种情况下,有必要关注片段数据包,如果我们正处于这么一个状态:我们尚未收到第n个片段数据包,但是前面n – 1个片段数据包都已经收到了,我们必须设置一个特殊的标记位然后我们会发送一个包含所有前面n – 1个片段数据包都已经收到信息的确认数据包,否则发送方不会意识到块数据已经收到并且发送方已经准备挂起了。
正如你所看到的那样,确认数据包的实现是有一点微妙的,这是一个有点奇怪的过程因为当片段数据包在网络的一端收到的时候,需要设置一个标记位来发送确认数据包直到发送方知道都有哪些发送的片段数据包被成功接收为止。如果你打破了片段数据包->确认数据包这个链接的话,那么整个系统就将挂起。我鼓励你仔细看看这篇文章的源代码搞清楚进一步的细节。
总结
块系统在概念上是很简单的,但是它的具体实现肯定不是微不足道的。在我看来,实现发送者设计这一块是一个很好的学习经验,当你从头开始实现这样的系统的时候一定有很多东西需要学习。
我希望你喜欢这个系统的设计,并试着自己动手从头开始实现它。这是一个很好的学习经历。此外,我鼓励你在patreon上支持我,作为回报,你会得到本文的示例源代码(以及本系列的其他文章的示例源代码),还包括我在GDC 2015上关于网络物理的演讲的源代码。
如果你觉得这篇文章有价值的话,请在patreon上支持我的写作,这样我会写的更快。你可以在BSD 3.0许可下访问到这篇文章里面的代码。非常感谢你的支持!
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。