Quick Start
This guide shows you how to create a minimal TCP server with zerg, accept connections, read data, and send a response.
Minimal Server
using zerg.Engine;
using zerg.Engine.Configs;
// 1. Create the engine with configuration
var engine = new Engine(new EngineOptions
{
Port = 8080,
ReactorCount = Environment.ProcessorCount
});
// 2. Start listening -- spawns acceptor + reactor threads
engine.Listen();
// 3. Graceful shutdown on Enter key
var cts = new CancellationTokenSource();
_ = Task.Run(() =>
{
Console.ReadLine();
engine.Stop();
cts.Cancel();
});
// 4. Accept loop
try
{
while (engine.ServerRunning)
{
var connection = await engine.AcceptAsync(cts.Token);
if (connection is null) continue;
// Fire-and-forget handler per connection
_ = HandleConnectionAsync(connection);
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Server stopped.");
}Connection Handler
Each accepted connection gives you a Connection object. The read/write cycle follows this pattern:
- ReadAsync – wait for inbound data
- Process – inspect received buffers
- ReturnRing – return kernel buffers after processing
- Write – stage response bytes
- FlushAsync – send staged bytes to the kernel
- ResetRead – prepare for the next read cycle
static async Task HandleConnectionAsync(Connection connection)
{
while (true)
{
// Wait for data from the client
var result = await connection.ReadAsync();
if (result.IsClosed)
break;
// Get all received buffers as managed memory
var rings = connection.GetAllSnapshotRingsAsUnmanagedMemory(result);
// Build a ReadOnlySequence for parsing
ReadOnlySequence<byte> sequence = rings.ToReadOnlySequence();
// ... parse the request from sequence ...
// Return buffers to the kernel buffer ring
rings.ReturnRingBuffers(connection.Reactor);
// Write a response
connection.Write("HTTP/1.1 200 OK\r\nContent-Length: 13\r\nContent-Type: text/plain\r\n\r\nHello, World!"u8);
// Flush to kernel and wait for send completion
await connection.FlushAsync();
// Ready for the next read
connection.ResetRead();
}
}What Happens Under the Hood
When you call engine.Listen():
- An acceptor thread starts with its own
io_uringinstance and arms multishot accept on the listening socket - N reactor threads start, each with their own
io_uringinstance and pre-allocated buffer ring - As clients connect, the acceptor distributes file descriptors to reactors in round-robin order
- Each reactor arms multishot recv with buffer selection for its connections
AcceptAsyncreturns connections as they are fully registered in a reactor
When you call connection.ReadAsync():
- If data is already queued in the connection’s SPSC ring, it returns immediately
- Otherwise, the calling task parks until the reactor delivers data via a CQE completion
- The returned
RingSnapshotcontains a snapshot boundary so you drain exactly the data that was available at that point
When you call connection.FlushAsync():
- The write tail is captured as the flush target
- The connection is enqueued to the reactor’s flush queue
- The reactor issues a
sendSQE and handles partial sends automatically - The
ValueTaskcompletes when all staged bytes have been sent
Next Steps
- Configuration – tune buffer sizes, reactor count, and ring flags
- Architecture – understand the acceptor + reactor model
- Zero-Allocation Guide – allocation-free read and write patterns