TCP connections
TCP connections
Every reactor listens on the server's TCP ports and accepts its own connections. Your handler gets each one as a TcpConnection on the reactor that accepted it: its receives and sends go through that reactor's ring, and their completions resume the handler inline on that reactor's thread.
How it fits together#
ServerConfig.Tcp, a TcpOptions, configures the transport. It defaults to a listener on port 8080; null opens no TCP listener at all, which is what a server that only speaks QUIC wants. Each reactor opens its own SO_REUSEPORT listener on every port, so the kernel spreads new connections across the reactors, and a connection stays on the reactor that accepted it until it closes.
For each accepted connection the reactor calls TcpHandle(reactor, connection) once, on its own thread. The handler runs until an await has to wait; from then on, the reactor's completion loop resumes it directly when its receive or send completes - no thread pool, no queue in between. A handler is ordinary async code: one loop per connection, written top to bottom.
A complete echo server#
using ioxide;
using ioxide.utils;
var config = new ServerConfig
{
ReactorCount = Environment.ProcessorCount, // one reactor per core (the default is 12)
Tcp = new TcpOptions { Port = 7000 },
};
var threads = new Thread[config.ReactorCount];
for (int i = 0; i < threads.Length; i++)
{
var reactor = new Reactor(i, config);
// Runs once per accepted connection, on this reactor's thread.
reactor.TcpHandle = async (r, conn) =>
{
try
{
while (true)
{
// Everything received so far, or the close. Resumes inline on the reactor.
RecvSnapshot snapshot = await conn.ReadAsync();
while (conn.TryGetItem(snapshot, out SpscRecvRing.Item item))
{
if (item.HasBuffer)
{
conn.Write(item.AsSpan()); // copy into the connection's write slab
conn.ReturnBuffer(in item); // and give the receive buffer back
}
}
await conn.FlushAsync(); // one send for everything staged
if (snapshot.IsClosed) return; // the peer closed
conn.ResetRead(); // re-arm the read for the next snapshot
}
}
finally
{
conn.DecRef(); // the handler's reference, released once
}
};
threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
threads[i].Start();
}
foreach (Thread thread in threads) thread.Join();
Run it and connect with nc 127.0.0.1 7000: what you type comes back. The loop is the whole raw API in miniature:
ReadAsyncreturns aRecvSnapshot: a position in the connection's receive queue, and whether the connection has closed. It completes at once when bytes are already queued.TryGetItemtakes each queued receive up to that position. Its bytes sit in a buffer the kernel filled - nothing was copied - andReturnBufferhands the buffer back to the reactor's ring.Writecopies into the connection's write slab.FlushAsyncsends everything staged as one operation and completes when the kernel has sent it.IsClosedon the snapshot means the connection is gone: the snapshot still carries the last bytes received, but nothing more will come. OtherwiseResetReadre-arms the read for the next turn.DecRef, in afinally, lets go of the connection.
The repository's Playground/Tcp/Raw is this loop answering HTTP with a fixed response; Tcp/Pipe, Tcp/Incremental, Tcp/Big and Tcp/TaskRun each change one thing about it.
What the handler owns#
A connection has two owners, each holding a reference: the reactor, for its receive side, and your handler. It is recycled only when both have let go, so it is never reused under a handler that is still running. What belongs to the handler:
- Its reference. Release it with
DecRef, once, in afinally. If the peer is still connected,DecRefsends it a FIN once the last flush is out, and the reactor keeps reading until the peer closes, for at mostReadTimeoutMs. When both references are gone the connection is recycled - its descriptor closed, its object pooled for the next accept - so afterDecRefthe object may already belong to another connection. Never touch it again. - Its faults. A handler that throws is logged to standard error (
[r0] connection handler faulted: ...) and its reference is released for it. The handler's release counts once per connection, whoever makes it, so afinallyis safe whatever the handler throws. Before 0.17.339, a handler that released and then threw was released twice, which recycled the connection under the reactor and could later close a descriptor number already handed to another connection. - The read side. One
ReadAsyncat a time - a second, while one waits, throwsInvalidOperationException- andResetReadafter every snapshot. - Every buffer it takes. Each item
TryGetItemhands out goes back withReturnBuffer, exactly once. A buffer kept is gone from its reactor's ring for good; why. - The write slab. One writer, one flush at a time, and no writing while a flush is in progress.
ResetRead is not optional. The read signal stays completed until it is reset. Without it, the next read that has to wait returns the previous snapshot again, the read after that throws, and a receive that lands meanwhile throws on the reactor thread - which ends the reactor: Reactor.OnFault hears of it, and without a handler there the process exits.
Never block the reactor thread: .Result or .Wait() there waits for a completion only that thread can run. Ordinary awaits are fine - the reactor's synchronization context brings a continuation that completed elsewhere, such as a Task.Run, back to the reactor before it touches the connection again.
Raw, pipes or a stream#
| Raw API | Pipes | Stream | |
|---|---|---|---|
| Types | TcpConnection itself | TcpConnectionPipeReader, TcpConnectionPipeWriter, or both as TcpConnectionDualPipe | TcpConnectionStream |
| Received bytes | one item per receive, in the ring's buffer | a ReadOnlySequence<byte> over the same buffers, held until consumed | copied into your array |
| A message split across receives | yours to carry | the reader carries it | yours to carry |
| Built for | the shortest path, for a protocol you frame yourself | parsers, and everything that takes an IDuplexPipe: ioxide.http2, ioxide.nghttp2, the TLS pipes | SslStream and other Stream APIs |
The pipes cost little: in the repository's benchmark the Tcp/Pipe sample used 2.36 us of CPU per request against 2.30 us for Tcp/Raw (the numbers). On top of a connection, TLS terminates through TlsService.AcceptAsync(conn) and serves through a TlsConnectionDualPipe, and HTTP/2 runs over any IDuplexPipe. ioxide ships no HTTP/1.1 library: the samples frame it by hand, in a few lines.
Where to go next#
Reading and writing
Snapshots and buffers, the receive queue, shared and incremental rings, the write slab, flushes, zero-copy sends, files, pipes and streams.
Lifetime and limits
Listeners and ports, how a connection ends, read and send timeouts, the pool, running out of descriptors.
Core runtime
The reactor underneath: its ring, inline completions and synchronization context.
TLS and kTLS
Terminating TLS on a TCP connection, in BoringSSL or the kernel.