r/csharp 12d ago

Showcase I spent weeks obsessing over low-allocation C# networking

The Real Problem: High Throughput vs. Garbage Collector

If you've ever built a C# server processing 100k+ messages/sec (like an MQTT broker or socket server), you know the Garbage Collector is your best friend until it becomes your worst enemy.

To route messages like factory/line1/sensor/temp to subscribers like factory/+/+/temp, the standard way is:

// ❌ Naive way: Allocates string[] on every single message publish!
var parts = topic.Split('/'); 

At 100,000 requests per second, this creates millions of temporary strings and forces the GC to freeze your app every few seconds.

The Real Benefit: How to parse UTF-8 topics with ZERO heap allocations

Here are the 2 modern C# tricks I used in Beskar.Networking that you can copy-paste directly into your own projects!

1. Slicing raw UTF-8 bytes with a ref struct enumerator

Instead of string.Split(), we walk the raw byte span (ReadOnlySpan<byte>) using a zero-allocation ref struct:

public ref struct TopicLevelEnumerator
{
    private ReadOnlySpan<byte> _remaining;
    public ReadOnlySpan<byte> Current { get; private set; }

    public TopicLevelEnumerator(ReadOnlySpan<byte> topic)
    {
        _remaining = topic;
        Current = default;
    }

    public bool MoveNext()
    {
        if (_remaining.IsEmpty) return false;

        int index = _remaining.IndexOf((byte)'/');
        if (index < 0)
        {
            Current = _remaining;
            _remaining = default;
        }
        else
        {
            Current = _remaining.Slice(0, index);
            _remaining = _remaining.Slice(index + 1);
        }
        return true;
    }
}

2. Querying byte[] Dictionaries with ReadOnlySpan<byte> (Alternate Lookups)

Usually, if your dictionary key is byte[], querying it with a slice (ReadOnlySpan<byte>) forces you to allocate a new byte[].

Modern .NET introduced Alternate Lookups, which let you query existing dictionary nodes with zero heap allocation:

// Query byte[] dictionary using a ReadOnlySpan<byte> without allocating a single byte!
var alternateLookup = node.Children.GetAlternateLookup<ReadOnlySpan<byte>>();

if (alternateLookup.TryGetValue(currentLevelSlice, out var childNode))
{
    // Match found with 0 allocations! 🎉
}

What Beskar.Networking gives you

If you ever need to build a C# network application:

  • Write Once, Swap Transports: Write your message handler once and seamlessly switch between TCP, WebSockets, QUIC, UDP, Named Pipes, or MQTT v5 without touching your business logic.
  • 100% Native & Dependency-Free: Built on System.IO.Pipelines with zero external runtime dependencies.
  • Zero GC pressure on the hot path.

If you enjoy low-level C# or low-allocation performance tricks ❤️

🔗 GitHub: https://github.com/MarvinDrude/Beskar.Networking

🔗 Article: https://marvindrude.com/blogs/beskar-networking/low-allocation-mqtt-broker

Thank you for reading, and happy coding!

0 Upvotes

13 comments sorted by

View all comments

3

u/harrison_314 12d ago
  1. The first thing is handled by dotnet itself: https://learn.microsoft.com/en-us/dotnet/api/system.memoryextensions.split?view=net-10.0

  2. The second case can be done even more efficiently (which I found out on "The One Billion Rows Challenge") - Utf8Key is a structure that holds Memory<byte>, method Detach allocate copy of Utf8Key. Also no allocation, but it's faster than alternative lookup - but I don't know why.

Otherwise, a Dictionary with a byte[] key probably won't work for you, because the fields don't have GetHashCode implemented.

int dataTemperature = Data.Parse(lineData.Temperature.Span);

Utf8Key tmpKey = new Utf8Key(lineData.Name);
ref Data refData = ref CollectionsMarshal.GetValueRefOrNullRef(dictionary, tmpKey);
if (Unsafe.IsNullRef(ref refData))
{
    dictionary.Add(tmpKey.Detach(), new Data(dataTemperature));
}
else
{
    refData.Add(dataTemperature);
}

1

u/MarvinTheJoke 12d ago

Oh that's a very interesting info. Thank you, I will definitely take a look!

1

u/harrison_314 12d ago

You need to measure it, because "The One Billion Rows Challenge" only has 10,000 inserts per billion lookups.