Kelly Brown
Posted on June 10, 2023
Some quick context: Jawbone is my game library.
Motivation
The big secret game project I'm working on right now is multiplayer and uses UDP for its networking. Naturally, at the start of all this, I looked inside .NET itself since there are classes for working with UDP, but I found myself unable to stomach the offerings there. I have a couple requirements of the code I work with in game dev.
First, allocations must be kept to a minimum. In C#, this usually means I wanna see two things: structs and spans. Where possible, I want to keep things off the heap: structs sit nicely on the stack in local contexts. From there, if I must use the heap, I want data packed tightly, and structs sit nicely side-by-side in an array or in the body of another class/struct. And lastly, I want to slice my arrays freely. If I need to work with some portion of an array, spans let me do that without copying the whole array. Also, circling back the first point in this paragraph, if I must allocate memory, I don't want my library code doing that invisibly. Let me allocate my own byte arrays once and simply reuse them by way of Span<byte>
and ReadOnlySpan<byte>
.
Second, dynamic dispatch must be kept to a minimum. This impacts both performance and readability. Where possible, I don't want my code branching like crazy when performing a straightforward operation. I don't need polymorphism most of the time. I don't need last-minute runtime decisions most of the time. I frequently know the truth up front and would like to compile that truth into my engine.
So, how do the UDP classes in .NET measure up to these standards? Well, they're not great. Please note that I understand that classes in the core libraries aspire to be all things to all people and are dealing with decades of legacy code/usage by this point. I'm not attacking the people who made this. Regardless, I feel there is value in explaining exactly what could be improved for contexts like mine.
System.Net.Sockets.UdpClient
Let's look at the main contender: UdpClient. Right out of the gate, I'm not thrilled with the design.
First off, I'm not going to touch ReceiveAsync
or BeginReceive
/EndReceive
. I love async C# but not in game dev. So, that just leaves me with Receive
(docs).
public byte[] Receive(ref IPEndPoint? remoteEP)
See the problem? Well, there are two problems, but we'll get to the other problem later. The method allocates and returns a byte array. I find it strange there is no overload that accepts a Span<byte>
yet to let me fill my own buffer. The Send
method has already received some love.
public int Send(
ReadOnlySpan<byte> datagram,
IPEndPoint? endPoint)
You know what's funny? Take a look at the source code for Receive
.
int received = Client.ReceiveFrom(_buffer, MaxUDPSize, 0, ref tempRemoteEP);
remoteEP = (IPEndPoint)tempRemoteEP;
// because we don't return the actual length, we need to ensure the returned buffer
// has the appropriate length.
if (received < MaxUDPSize)
{
byte[] newBuffer = new byte[received];
Buffer.BlockCopy(_buffer, 0, newBuffer, 0, received);
return newBuffer;
}
return _buffer;
It writes to an interim buffer anyway. C'mon! Just let me pass in a Span<byte>
.
System.Net.Sockets.Socket
Oh well. That's not gonna work. So, let's move onto the next contender: Socket. This looks much better. It's lower level. It provides nearly direct access to the socket itself. And right out of the gate, it solves the primary allocation problem (docs).
public int ReceiveFrom(
Span<byte> buffer,
ref EndPoint remoteEP)
Sending is fine too.
public int SendTo(
ReadOnlySpan<byte> buffer,
EndPoint remoteEP)
So, now what? We solved most of the allocation problem... but now we are contending with the lovely address/endpoint classes, which also introduces dynamic dispatch.
System.Net.IPAddress
As cool as it is that .NET supports a royal cacophony of address families, all we really care about is IPv4 and IPv6. Of the two, IPv4 is arguably the most important even today, but supporting both is straightforward. (Seriously, think about just how many games would break if IPv4 ceased being an option.)
So, that brings us to IPAddress, which is the catchall datatype for socket addresses either IPv4 or IPv6.
Strike 1: Heap objects
So, this class has decided that all instances must live in the heap. Thanks for that. Oh, but it gets better.
/// <summary>
/// This field is only used for IPv6 addresses. A null value indicates that this instance is an IPv4 address.
/// </summary>
private readonly ushort[]? _numbers;
So, an IPv6 address is two heap objects. Wonderful. It just gets worse as you go down the method list. Requesting the address in bytes allocates a byte array for you. Again, I know this is a casualty of the C# mantra of 2005 ("Allocations are free! The garbage collector will save us all!"), but this was honestly never necessary. There should have been allocation-free ways of doing this.
From there, more heap layers join the party thanks to EndPoint
itself being an abstract class. All an "endpoint" needs to do is combine an address and a port.
Strike 2: Invisible caching
/// <summary>
/// A lazily initialized cache of the result of calling <see cref="ToString"/>.
/// </summary>
private string? _toString;
/// <summary>
/// A lazily initialized cache of the <see cref="GetHashCode"/> value.
/// </summary>
private int _hashCode;
I mean, this doesn't bother me too much. I appreciate that the devs are keeping an eye on performance for the typical coder who just wants to Get Things Done™. Caching the hash code feels odd to me given that hash codes should always be relatively fast.
Regardless, I don't want to see this behavior native to a type. I'd rather have a Cached<T>
wrapper for special cases.
Strike 3: Object-Oriented Nonsense
So, an IPAddress
can be either an IPv4 address or an IPv6 address. I think I understand the desired convenience here: you don't really care what kind of address it is; you just want to store it one place. My problem is that there really isn't a situation where I need or want that.
If I open a socket and bind it to an IPv4 address, all addresses are 32 bits. If I bind it to an IPv6 address, all addresses are 128 bits. If I want to operate my socket in dual mode, the addresses are still always represented using 128 bits; an IPv4 address is simply represented in a special format.
If I know the exact sizes of my addresses before even opening the socket, why should I pay the price of all this garbage collector activity?
We can do better
Even setting aside all my gripes about allocations or performance, I just want a simpler API for working with UDP. In my next post, I'll cover the basics of networking in Jawbone.
Posted on June 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.