Dealing with high packet loss or long pauses in transmissions in custom UDP protocol

Started by
9 comments, last by fjholmstrom 11 years, 7 months ago
I've been working on my own UDP networking library for a while, mostly because I wanna learn and know more about how one works. I've managed to implement the basic NACK/ACK:ing of packets and two types of delivery unreliable-drop-late and reliable-in-order. But I digress, I have a question about a very specific edge case:

When sending a package, which might be the last package to go between the two peers for a little while (say loading a new map at the end of a round or something) - how does one deal with detecting that this package has been dropped? Assuming a message such as "Load Map", which will most likely be sent reliable-in-order and is the last message to originate from the server to a client for a few seconds as after this package is sent the server starts loading the map.

Now, if everything doesn't go as planned there are two things that can happen:


  1. The packet gets dropped on the way to the client
  2. The packet gets to the client, but the ACK back to the server is dropped


If this happens, the server is now waiting for an ACK that will not come, no matter which packet was dropped. During normal operation this is not a problem as packets are flying back and forth constantly and the ACK/NACK from the client will show that the package was dropped. There are two possible solutions to this the way I see it:


  1. Run a timer on the server, if the package has not been ACK/NACK:ed within some limit (roundTripTime * 2 or something), re-send it
  2. The client usually works at some fixed send-rate, make sure to always send the latest acks at these intervals even if there are not data to send - if the server detects that the package is not getting through it can re-send it.


I'm not sure if there are more ways to solve this, or if I'm even on the right track - but that's why I'm asking, how is this *usually* solved in popular networking libraries? I know that the Lidgren library for .NET uses the first method, I have been peeking at the OpenTNL library and they *seem* to be using method 2 (as I can only ever see that re-sends being queued when they get NACK:s from the other peer).

The first solution to me, feels a bit dirty - and it also feels like it could make the situation worse if you already have high packet loss and the server keeps spouting data over and over even if the packet might have arrived at the client but the ACKs are getting dropped. The second solution feels a lot cleaner, but maybe there are other ways?
Advertisement
TCP always waits for a return packet that contains an ACK, even if it may contain no data. In UDP, this would be the same: waiting for a return packet with no data. If you don't see that return packet in a while (say, 2x RTT time) then re-transmit. If you still don't see the return packet, back off on the waiting time, and re-transmit; repeat until you give up.

Another option is to keep the packet rate flowing -- say you want to always send at least 1 packet a second, even if there are no messages to send. For games, this is good, because it allows you to quickly detect dropped players, and it will keep any NAT translation table alive. Some broken NAT gateways may time out UDP connections as quickly as a dozen or so seconds if there's no reply.

Finally, you want to separate "messages" (the units of information you communicate) from "packets" (the UDP datagrams you send over the network.) Typically, it's more efficient to acknowledge and sequence number datagrams, and then complete/re-transmit messages based on what you know about what you put into each datagram.
enum Bool { True, False, FileNotFound };

TCP always waits for a return packet that contains an ACK, even if it may contain no data. In UDP, this would be the same: waiting for a return packet with no data. If you don't see that return packet in a while (say, 2x RTT time) then re-transmit. If you still don't see the return packet, back off on the waiting time, and re-transmit; repeat until you give up.
Ok, good to know!


Another option is to keep the packet rate flowing -- say you want to always send at least 1 packet a second, even if there are no messages to send. For games, this is good, because it allows you to quickly detect dropped players, and it will keep any NAT translation table alive. Some broken NAT gateways may time out UDP connections as quickly as a dozen or so seconds if there's no reply.
Yes, this "feels" like the correct way. I noticed the ENet uses a default ping interval of 500ms that always is running, no matter what which I think sends ACKS also (Just started poking around in the ENet sources, so might be wrong here). I also saw the TNL talk from GDC a few years back (2008?) where the guy said that they always send the same package size at the same interval so that routers, etc. don't de-prioritize their data when their traffic volume goes down (paraphrasing here).


Finally, you want to separate "messages" (the units of information you communicate) from "packets" (the UDP datagrams you send over the network.) Typically, it's more efficient to acknowledge and sequence number datagrams, and then complete/re-transmit messages based on what you know about what you put into each datagram.
Yes, I already do this - and I actually have a question on this related to the problem in my original post in this thread: Internally in my own little implementation I have different messages types, each defined as a class with a byte id assigned to it, which has two methods "pack" and "unpack", a very common pattern. Now these messages can be sent "Unreliable-drop-late" or "Reliable-in-order" but also actually "Reliable-out-of-order" (didn't bother mentioning it in the original post), so I've come up with another edge case I'm not sure how to solve, and I hope I can explain this properly:

So assume that I sent packages back and forth, and then send my last "Load Map" message, which doesn't arrive at the client, now this message is for arguments sake "Reliable-out-of-order". Now if I re-send this message in a new packet, there is a super teeny-tiny chance that the *previous* package with this same message will arrive (case of broken routers, over-load, network slowdown, whatever it may be) and since the next package is sent with the same message, but a new packet/datagram sequence number, they could actually both arrive, one after the other, and it would look like I got two "Load Map" messages.

Basically what I'm thinking of: How would I de-dupe a reliable-out-of-order message that arrives in two different packages. Now I can come up a million solutions to this, like attaching the "original" packet sequence the message was transmitted inside of originally, but that spirals out of control if the next packet is also dropped, then we have two possible culprits, etc.

Now maybe this is not a huge issue, and I'm not sure I even need reliable-out-of-order messages. Unreliable are never re-sent so there is no need to de-dupe them, and Reliable-in-order are sent with an internal event sequence numbers (which is cleverly bit-packed so that in most cases it only takes up (7 + (ReliableEvents-1)) bits extra space, so they are easy to de-dupe as they have internal seq. numbers, but reliable-out-of-order with the silly edge case of actually having the package you "thought" was lost be delivered anyway.
Each reliable message channel needs its own sequence number, in addition to the overall datagram sequence number. This lets you know which messages have already been delivered or not.
You can also say that any datagram that arrives late is always entirely dropped, even if it contains "reliable" data. Instead, use your reliability mechanism to re-transmit that data. This will let the other end know for sure that the problem you're talking about will never happen.

Other miscellaneous notes:
- A "package" is something you mail at the post office, or put under the Christmas tree. A "packet" is something that travels through network wires.
- If you never let the set of outstanding messages in a squence be bigger than 127 items, then you can use a single byte for each sequence number, and just use the appropriate wrapping semantics to map it back to a real sequence number. This will let you always encode sequence numbers as a byte, rather than using variable-size encoding. If you want to ack multiple packets, I've found that a single byte for seq number, and another byte for the 8 previous numbers, works pretty well, too, and avoids bit-slicing bytes.
enum Bool { True, False, FileNotFound };

Each reliable message channel needs its own sequence number, in addition to the overall datagram sequence number. This lets you know which messages have already been delivered or not.
Yeah I suppose this is the key, right now I was only giving separate sequence numbers to messages in the reliable-in-order channel, suppose I need to give that too reliable-out-of-order also.


You can also say that any datagram that arrives late is always entirely dropped, even if it contains "reliable" data. Instead, use your reliability mechanism to re-transmit that data. This will let the other end know for sure that the problem you're talking about will never happen.
Yes, but this would still not fix the very very slim chance edge case I described in my original post (I re-send something which I think is lost, but in fact is not, and the two datagrams with the same message in it ends up arriving in order).

However; I already do this as I drop all the unreliable data that allows in late packets, and just grab the reliable in-order and reliable out-of-order messages in the packet if it's late (based on datagram sequence #'s)


- A "package" is something you mail at the post office, or put under the Christmas tree. A "packet" is something that travels through network wires.
Yeah, that's me not being native English ;P

- If you never let the set of outstanding messages in a squence be bigger than 127 items,
Are you talking about datagram sequences or message sequences now, or both? I suppose it does not matter, I already do this for datagram packet sequences and reliable-in-order messages, however it does slightly worry me for doing it to messages themselves as messages are a lot more frequent then packets (about 15-20 packets/second is common, and I have a window size of 64, which gives me about ~3-4 seconds of leeway), but messages can be many and frequent - but then again I suppose if you're sending more then 20-30 reliable messages per second you have another problem on your hands.

then you can use a single byte for each sequence number, and just use the appropriate wrapping semantics to map it back to a real sequence number.
I assume you mean something like this:

Expected Sequence: 5
Arrived Sequence: 10
Relative Sequence Number +5 (5 a-head of the expected)

And yes, I already do this for my reliable-in-order messages. Though I am not 100% on what you mean with "real" sequence number? If it matters the calculation looks like this:


// Calculate distance for 10 bit sequence numbers
int sequence_distance(int sequence, int expected)
{
ushort unsignedSequence = (ushort)(sequence << 6);
ushort unsignedExpected = (ushort)(expected << 6;
return ((short)(unsignedSequence - unsignedExpected)) >> 6;
}


This will let you always encode sequence numbers as a byte, rather than using variable-size encoding. If you want to ack multiple packets, I've found that a single byte for seq number, and another byte for the 8 previous numbers, works pretty well, too, and avoids bit-slicing bytes.
My packet sequence numbers are 10 bit, My message sequence numbers are 8 bit (might increase it to I get more leeway if I send a lot of small messages), but the messages are encoded like this (pseudo code):


// Start at -2 because -1 + 1 == 0 which is a valid seq #
int prev_msg_seq_nr = -2;

for each message in reliable_queue do
bool is_right_after_prev = (prev_msg_seq_nr + 1) == message->seq_nr;
write_flag(is_right_after_prev);

if(!is_right_after_prev)
write_int(message->seq_nr, 8);

prev_msg_seq_nr = message->seq_nr:

message->pack_into_bitstream();

/// etc ...


This ends up working out so that in most cases the seq_nr only takes up one bit per message except for the first one where it takes 9 bits.I use a window size of 64 for data-gram packets currently, so maybe my sequence range for datagrams (10 bits) is to big, and I could do with 8-9 bits instead.

Edit

I suppose my whole problem can be avoided if you just never send any packets you are "uncertain" about, basically always drop late packages on the client, never re-send a packet from the server until the client has ACK:ed a packet which comes after it in the sequence. While this can cause some extra delay for reliable data, you should not really depend on reliable data anyway for game critical things. And just make sure to always exchange packets between peers every N:th millisecond, no matter if you have data or not to keep the data flowing (which deals with a bunch of routing related problems anyway, like getting dropped or NAT tables not staying recent, etc.)

One of the reasons I was asking this is because if you have a small window size for datagrams, if you have high enough packet loss it's possible to have all packets dropped or all acks dropped, but then again trying to salvage that situation is just going to degrade the system with a lot bloat for super high packet loss situations anyway so might aswell just drop the client.
With "real" sequence number I'm assuming that you actually use a larger counter for the sequence number, and just sending the lower bits. You can also do the same math by only keeping the lowest 8 bits around and letting it wrap frequently; it's functionally equivalent. I was just brought up thinking of the "sequence byte" as an optimization of sending a conceptually infinitely increasing sequence number.

Re-sending should not be a problem, as long as you ignore a duplicate message. The network may duplicate a UDP datagram anyway, outside of your control, so you can drop dups (meaning "effective sequence number <= what you've already seen") on receipt. Do the same for each reliable channel -- if you've already seen that particular message number, don't re-deliver it. If you do that, you can spam reliable messages that are waiting in each outgoing packet, until you get an ack for a packet that the message is in.

And, as you're saying, if you're sending gobs of reliable messages per second, perhaps you could think of some other way of accomplishing your goal :-)
enum Bool { True, False, FileNotFound };

One of the reasons I was asking this is because if you have a small window size for datagrams, if you have high enough packet loss it's possible to have all packets dropped or all acks dropped, but then again trying to salvage that situation is just going to degrade the system with a lot bloat for super high packet loss situations anyway so might aswell just drop the client.


Yup. You don't really want troublesome clients in your game anyway.

BTW the ack / sqn encoding is relative to your maximum window size. Often SQN / ACKs are not absolute (32 / 64 bit numbers), but relative. Saves a few bits.


bool sqn_greater(unsigned short a, unsigned short b) { return (a > b) && (a <= b + 0x1000) || (b > a) && (b > a + (0x1000); }
bool short sqn_greater_equal(unsigned short a, unsigned short b) { return (a == b) || sqn_greater(a, b); }
unsigned short sqn_add(unsigned short a, unsigned short b) { return (a + b); }
unsigned short sqn_diff(unsigned short a, unsigned short b) { ASSERT(sqn_greater_equal(a, b)); return (a - b); }



The maximum window size here is 32768 (half the range of a unsigned short, or 16 bits). If your client stray outside your window, or your message buffer is greater than 32KB, then you either kick the client (harsh!), or keep resending the data in the window until it's acknowledged before you can send some new data. If you end up in that situation, you may end up filling up your message buffer anyway, if you push new messages faster than the client can acknowledge them.

Coincidently, there is also a throughput limit associated with high latencies (> 1000 ms).

And when you come to resend data, you obviously want to reduce your throughput to reduce packet drop and choking your bandwidth, or spread the bandwidth available among multiple clients, which is basically flow control. There are various algorithms based on latency, and artificial bandwidth throttling that you can apply.

BTW, you don't have to use 16 bits, can be anything depending on what works for you. You can limit the window to say, 10 bits, which means you will start resending data if the client hasn't acknowledged the past 1024 bytes (or whatever atomic unit you choose).

Everything is better with Metal.


BTW the ack / sqn encoding is relative to your maximum window size. Often SQN / ACKs are not absolute (32 / 64 bit numbers), but relative. Saves a few bits.
Yes, this is how I've implemented it also - maybe not exactly as you describe it, but for me it works like this:

SQN is 10 bits, so 1024 possible numbers
Window is default 65 wide (configurable up to half the SQN size)
SQN wraps around at 1023 ... 0 so the number that comes after 1023 is 0, basically just doing sqn = (sqn + 1) & 1023; when increasing the SQN
The distance from a sequence number to another is calculated like this:


unsigned short sequenceDistance(unsigned short a, unsigned short b) {
return ((signed short) (a << 6) - (b << 6)) >> 6;
}


This gives the distance from 1023 to 0 as +1, and the distance from 0 to 1023 as -1, Distance from the same number to itself is 0.

Now, I think I've gotten it correct, but I still have a question if this is sound:

When I want to send a packet I check the distance from the sqn number of the "oldest packet we have not heard anything about" to the "sqn to use for next packet", if that is < windowSize, I can send a new packet. I never resend a dropped sequence if I get a NACK for it, I instead re-queue the messages in it that were reliable and re-send them in the next outgoing packet. This allows me to not fill the sequence window just because you're unlucky and get the "oldest" packet dropped, and then re-send it over and over and still have it dropped on you, stopping you from incrementing the sequence as that would put you outside the window.


The maximum window size here is 32768 (half the range of a unsigned short, or 16 bits). If your client stray outside your window, or your message buffer is greater than 32KB, then you either kick the client (harsh!), or keep resending the data in the window until it's acknowledged before you can send some new data. If you end up in that situation, you may end up filling up your message buffer anyway, if you push new messages faster than the client can acknowledge them.
This confused me a bit, I realize that obviously how much data you can send/receive effects how fast you can ACK and what you ACK, etc. but the window size for me is artificial window over the sequenced packets you send, but it seems like you're talking about the window size as a receive buffer here? Maybe I'm just confused, but it's not clear on what you're trying to say to me at least!


And when you come to resend data, you obviously want to reduce your throughput to reduce packet drop and choking your bandwidth, or spread the bandwidth available among multiple clients, which is basically flow control. There are various algorithms based on latency, and artificial bandwidth throttling that you can apply.
I'm interested in algorithms for doing flow control, I already have a fixed packet size and rate (together forming total bytes/second) that can be set per-connection, the library works so that it "asks" the game layer for data when it detects it can send more data, you don't pummel it with data, it will ask you when a packet should leave depending on the "Rate" setting.


BTW, you don't have to use 16 bits, can be anything depending on what works for you. You can limit the window to say, 10 bits, which means you will start resending data if the client hasn't acknowledged the past 1024 bytes (or whatever atomic unit you choose).
Yes, I use a 10 bit sequence with a configurable window size per "virtual connection", which defaults to 64.
In my experience, flow control doesn't work well for real-time games (just like it doesn't work for, say, IP telephony.)

If you run out of window space -- 64 packets in this case -- it's likely the client on the other end is either gone, or having a terrible experience already. At that point, it's not going to catch up. You might as well kick it; it won't be a productive part of the gaming session at that point. Unless the game has some very special semantics, such as a slow turn-based game. But then, why would you be using UDP? :-)
enum Bool { True, False, FileNotFound };

This confused me a bit, I realize that obviously how much data you can send/receive effects how fast you can ACK and what you ACK, etc. but the window size for me is artificial window over the sequenced packets you send, but it seems like you're talking about the window size as a receive buffer here? Maybe I'm just confused, but it's not clear on what you're trying to say to me at least!


No, I'm just saying that usually you have a fixed sized buffer (say, 64K) where you push data one end, and transmit to the client the other end (often done as a ring buffer), and if your buffer becomes full because your client hasn't acknowledged fast enough (trying to send faster than is received), then you need to deal with the case.

For example, if you call send() with TCP / IP, the function will return the number of bytes actually added to the buffer which may be less than the number of bytes you tried to send, because the buffer is running out of memory.

Not everyone cares though. It's nit picking. Some just use dynamic allocation with gemoetric growth and shrink to adapt for slow / lossy connections. Again, I would just kick them and not bother, but you may have 'spikes' at some points in the game, and where the buffer fills up in short amounts of time, so kicking a client because he hasn't acknowledged a X amount of bytes can be a rather arbitrary rule.

Everything is better with Metal.

This topic is closed to new replies.

Advertisement