FastIPC FastIPC v1.0.0
Fipc on NuGet · using FastIpc

C#

Two layers. FipcStream, FipcBroadcast, FipcQueue and FipcService make FipcListener, FipcConnection, FipcQueue and FipcService objects on SafeHandles that are safe to dispose from any thread. Underneath, Fipc declares the C API one to one over IntPtr handles, for the hottest loops. The package targets .NET Standard 2.1 and carries the native libraries for Linux x64 and ARM64 (glibc 2.34+), Windows x64 and macOS 14.4+ on Apple Silicon (osx-arm64).

Installdotnet add package Fipc

Server and client

two processes, one name

A stream's server creates a name (FipcStream.Create) and accepts one client at a time on its listener; a client connects to the name (FipcStream.Connect), which waits for the server to create the stream and accept, so the two run in different processes or on different threads. Each program is a console project of its own (dotnet new console) with the Fipc package. Timeouts are int milliseconds, the last parameter: 0 doesn't wait, Fipc.Forever (−1, like Timeout.Infinite) waits for ever, and is the default where a call waits. The pair makes the exchange every example on this site makes, the message ping, answered in upper case: this client works with any language's server, and this server with any language's client (every pair of languages is tested).

Server/Program.cs
using System.Text;
using FastIpc;

// rings of 1 MiB each way; Accept waits for a client
using var listener = FipcStream.Create("my_channel", 1 << 20);
using var conn = listener.Accept();
if (conn.Receive(out byte[] message) == FipcResult.Ok)
{
    string upper = Encoding.UTF8.GetString(message)
        .ToUpperInvariant();
    conn.Send(Encoding.UTF8.GetBytes(upper));
}
Client/Program.cs
using System.Text;
using FastIpc;

// waits up to 5 s for the server, else throws
using var conn = FipcStream.Connect("my_channel", 5000);
conn.Send("ping"u8);
conn.Receive(out byte[] reply);
Console.WriteLine(Encoding.UTF8.GetString(reply));  // PING

Messages

bytes of any size, no header

A connection carries messages: Send takes a message of any size, and Receive returns each one whole in a new array (or fills your span). This server echoes every message back until its client leaves, which ends its next Receive with Disconnected.

echo server
// rings of 1 MiB each way
using var listener = FipcStream.Create("demo", 1 << 20);
using var conn = listener.Accept();
while (conn.Receive(out byte[] msg) == FipcResult.Ok)
    conn.Send(msg);   // echo
// Disconnected: the client closed or exited
echo client
using var conn = FipcStream.Connect("demo", 5000);
conn.Send("hello"u8);
if (conn.Receive(out byte[] reply) == FipcResult.Ok)
    Console.WriteLine(Encoding.UTF8.GetString(reply));

Setup throws, traffic returns results

FipcException, FipcResult

Setup throws: a create, connect or join, and the listener's Accept(), return their object or throw a FipcException, whose Result says why (AddrInUse, Timeout, ...). It happens once per connection, so an exception's cost doesn't matter there. Catch only the failure you expect, with catch (FipcException e) when (e.Result == ...), and let the rest propagate: this client gives up after 5 s without a server, and a server of another version or user (Invalid) ends it.

Everything after setup returns a FipcResult, where an exception would cost about as much as a round trip: Ok, Timeout, Disconnected, Cancelled, TooLarge or Invalid, and on a broadcast Overrun (a lossy subscriber lost messages: LostMessages says how many) or Unsubscribed, and on a service Abandoned (the request's server ended before it replied). A loop that polls for clients calls listener.Accept(out var conn, 0), which returns its result too. A mistake throws as .NET's own calls do, such as a call after Dispose (ObjectDisposedException).

a client: the failure it expects
static int Ping()
{
    FipcConnection conn;
    try
    {
        conn = FipcStream.Connect("orders", timeoutMs: 5000);
    }
    catch (FipcException e) when (e.Result == FipcResult.Timeout)
    {
        Console.Error.WriteLine("no server on 'orders' within 5 s");
        return 2;
    }
    // any other FipcException (Invalid: a server of another version or user) propagates
    using (conn)
    {
        if (conn.Send("ping"u8) != FipcResult.Ok || conn.Receive(out byte[] reply) != FipcResult.Ok)
            return 1;   // the server left
        Console.WriteLine(Encoding.UTF8.GetString(reply));
    }
    return 0;
}

Every result an arm. Write each loop's decision as a switch expression over the FipcResult, with an arm for every named result and no _ arm. The compiler checks switch expressions, never statements, and with these two settings a missing result fails the build: a result added in a later version breaks every such switch until it is handled. An unnamed value (an int cast to the enum) throws SwitchExpressionException, so its warning, CS8524, is off.

The arms sort the results into a loop's normal endings (Disconnected, Cancelled), what it recovers from (TooLarge, Overrun, Abandoned, Unsubscribed) and the rest, bugs that throw. In production has a loop of each kind. UnreachableException is .NET 7 and later: under Unity (netstandard2.1) throw an InvalidOperationException.

the project file
<PropertyGroup>
  <WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors> <!-- a FipcResult with no arm fails the build -->
  <NoWarn>$(NoWarn);CS8524</NoWarn> <!-- an unnamed value throws SwitchExpressionException -->
</PropertyGroup>
every loop's last arm
static Exception Unexpected(string call, FipcResult result) =>
    new UnreachableException($"{call}: {Fipc.ResultStr(result)}");

Zero-copy

up to conn.MaxPiece bytes

Write the message into a Span<byte> of the ring and commit it; read a ReadOnlySpan<byte> of the ring and release it. A span stays valid until its commit or release. A message longer than MaxPiece gets TooLarge from SendAcquire: the copying Send takes it, in pieces.

send in place, else in pieces
static FipcResult SendFrame(FipcConnection conn, Frame frame)
{
    FipcResult result = conn.SendAcquire(frame.Size, out Span<byte> room);   // room in the ring
    if (result == FipcResult.Ok)
        return conn.SendCommit(frame.EncodeInto(room));   // publishes what it wrote; never waits
    if (result == FipcResult.TooLarge)
        return conn.Send(frame.ToArray());   // over MaxPiece: the copying Send takes it in pieces
    return result;   // Timeout, Disconnected or Cancelled: the caller's to decide
}
receive in place
if (conn.ReceiveAcquire(out ReadOnlySpan<byte> msg)
    == FipcResult.Ok)
{
    Handle(msg);            // read-only, in the ring
    conn.ReceiveRelease();  // frees its room
}

Batches

many messages per call, both ends

SendMany(messages, out int sent, timeoutMs) sends a span of messages (byte[] or ReadOnlyMemory<byte>) in order and publishes them together, with at most one wake of the receivers; the timeout bounds the whole call (Fipc.NoWait: only what fits now). Every message is checked before any goes, and sent says how many went, at every result.

ReceiveMany(buffer, lengths, out int count, timeoutMs) waits for the next message, then takes every further one already there that fits buffer, packed back to back, their lengths in lengths; ReceiveMany(out byte[][] messages, maxCount, timeoutMs) returns them as arrays. Any result but Ok is the first message's, as Receive returns it. A broadcast's subscriber takes those its publisher has moved past; a service's client sends a batch of requests (owed as its sends are) and receives their replies in order; a server's batches return Invalid. Batch both ends: a receiver that takes one message per call falls behind a batching sender.

a batch each way
byte[][] batch = { "one"u8.ToArray(), "two"u8.ToArray() };
conn.SendMany(batch, out int sent);

var buf = new byte[65536];
var lengths = new int[64];
if (peer.ReceiveMany(buf, lengths, out int count)
    == FipcResult.Ok)
{
    int at = 0;
    for (int i = 0; i < count; at += lengths[i++])
        Handle(buf.AsSpan(at, lengths[i]));
}

Broadcast

one publisher, many subscribers

A broadcast sends every message of one publisher to each of its subscribers, in any processes of the user; each message is stored once, in memory every subscriber maps. Messages are of one piece, up to a quarter of the capacity (MaxPiece). FipcBroadcast.Create returns the publisher, a FipcConnection and the broadcast's one handle; its Dispose ends the broadcast and frees the name. The publisher's process admits each subscriber by itself, on a thread of the library's: the publisher only waits for the first one (publisher.WaitForMembers(1, out int count, ...); count holds the count at a Timeout too) before it sends tick 1 to tick 3. The subscriber joins gated (FipcBroadcast.Join with FipcSubscriberRole.Gated), so it misses nothing (the publisher waits for it), may receive in place (ReceiveAcquire), and receives until Disconnected, which comes after the last message. A lossy subscriber (FipcSubscriberRole.Lossy) never holds the publisher back: one that falls a lap behind gets Overrun, and the connection's LostMessages (or the span receive's length) says how many messages it lost. A gated one that holds the publisher back for the broadcast's maxStallMs is unsubscribed (Unsubscribed).

Publisher/Program.cs
using System.Text;
using FastIpc;

// 64 KiB of messages, up to 8 subscribers; one that holds
// the publisher back 5 s is dropped
using (var publisher = FipcBroadcast.Create("my_broadcast",
           1 << 16, 8, 5000))
{
    // waits for the first subscriber
    if (publisher.WaitForMembers(1, out _) != FipcResult.Ok)
        return 1;
    for (int i = 1; i <= 3; i++)
        publisher.Send(Encoding.UTF8.GetBytes($"tick {i}"));
}
// disposing the publisher ends the broadcast: subscribers
// get Disconnected after the ticks
return 0;
Subscriber/Program.cs
using System.Text;
using FastIpc;

// gated: misses nothing (the publisher waits for it)
using var sub = FipcBroadcast.Join("my_broadcast",
    FipcSubscriberRole.Gated, 5000);
FipcResult result;
while ((result = sub.Receive(out byte[] tick))
       == FipcResult.Ok)
    Console.WriteLine(Encoding.UTF8.GetString(tick));
// tick 1, tick 2, tick 3, then Disconnected: the
// publisher closed
return result == FipcResult.Disconnected ? 0 : 1;

Work queue

many producers, each message to one consumer

A work queue takes messages from up to 63 producers and hands each to one of up to 64 consumers, in any processes of the user. Each producer has a ring of its own; a consumer takes a message of any producer, and the queue promises no order. Messages are of one piece, up to a quarter of the capacity (MaxPiece), and wait in the queue until a consumer takes them. Each message goes to one consumer, and again, to another, only if that consumer's process ends before acknowledging it (delivery, below). Here the producer creates the queue (FipcQueue.Create: a FipcQueue), joins it as its producer (FipcQueue.Join with FipcQueueRole.Producer: the owner's process admits every joiner by itself, its own included), sends job 1 to job 3, which wait in the queue, and waits for the consumer (queue.WaitForMembers(FipcQueueRole.Consumer, 1, ...); null counts both roles). The queue's Dispose ends it: the consumer receives what is left, then Disconnected, and a producer's sends return Disconnected from then on. A queue lives until Dispose or the end of its process; dropping the object doesn't end it: unlike this binding's other handles, a FipcQueue has no finalizer, as its use lives in its members, in this process and in others. Producers and consumers of other processes join with FipcQueue.Join (FipcQueueRole.Producer, FipcQueueRole.Consumer); a producer's or a consumer's Dispose never ends the queue.

Producer/Program.cs
using System.Text;
using FastIpc;

// 4 KiB of messages per producer, 1 producer and 1 consumer:
// this process owns the queue and is its producer
using (var queue = FipcQueue.Create("my_queue", 4096, 1, 1))
using (var producer = FipcQueue.Join("my_queue",
           FipcQueueRole.Producer, 5000))
{
    for (int i = 1; i <= 3; i++)
    {
        // waits in the queue until a consumer takes it
        if (producer.Send(Encoding.UTF8.GetBytes($"job {i}"))
            != FipcResult.Ok)
            return 1;
    }
    // waits for the consumer to join
    if (queue.WaitForMembers(FipcQueueRole.Consumer, 1, out _)
        != FipcResult.Ok)
        return 1;
}
// disposing the queue ends it: the consumer gets
// Disconnected after the jobs
return 0;
Consumer/Program.cs
using System.Text;
using FastIpc;

using var consumer = FipcQueue.Join("my_queue",
    FipcQueueRole.Consumer, 5000);
FipcResult result;
while ((result = consumer.Receive(out byte[] job))
       == FipcResult.Ok)
    Console.WriteLine(Encoding.UTF8.GetString(job));
// job 1, job 2, job 3, then Disconnected: the queue
// ended
return result == FipcResult.Disconnected ? 0 : 1;

Delivery. Each message goes to one consumer. It goes again, to another consumer, only if the process of the consumer that took it ends before acknowledging it, and when a consumer acknowledges is its own choice, not the queue's. A message received in place is acknowledged at its release (or the consumer's next receive, or its close).

How the consumer receivesWhen it acknowledgesIf its process endsWhat the application gets
Receive (copying)inside the call, before it returnsinside the call: the message goes to another consumer, and this one never saw it; after the call returned: it doesn't go againat most once: no duplicates
ReceiveAcquire, then ReceiveRelease at once, then the workat the release, before the workas aboveat most once: no duplicates
ReceiveAcquire, the work, then ReceiveReleaseat the release, after the workbefore the release: the message goes to another consumerat least once: a duplicate is possible, so the work must be idempotent

Service

many clients, a pool of servers, with replies

A service takes requests from up to 64 clients and hands each to one of up to 64 servers, in any processes of the user; the server that took a request replies to it, and the reply goes back to the client that sent it. A server waits for the requests of every client at once (one Receive), and the servers of a pool share the load: each takes the next request of any client. Each client has a ring of its own, which holds its requests and the room for their replies, so a server never waits for a client. Requests and replies are of one piece, 1 to maxMessage bytes, the owner's choice (MaxPiece). Here the server creates the service (FipcService.Create: a FipcService), joins it as its server (FipcService.Join with FipcServiceRole.Server: the owner's process admits every joiner by itself, its own included) and answers three requests with their uppercase. The client joins (FipcServiceRole.Client) and calls ping 1 to ping 3: Call sends a request and receives its reply. The service's Dispose ends it: clients' sends and servers' receives return Disconnected, and a client receives the replies that came before the end, then Disconnected; a client's or a server's Dispose never ends the service (a server's answers the request it holds Abandoned). A FipcService has no finalizer, as a FipcQueue has none: a service lives until Dispose or the end of its process, and dropping the object doesn't end it.

ServiceServer/Program.cs
using System.Text;
using FastIpc;

// 4 KiB per client, requests and replies of up to 256 bytes,
// 1 client and 1 server: this process owns the service and
// is its server
using (var service = FipcService.Create("my_service", 4096,
           256, 1, 1))
using (var server = FipcService.Join("my_service",
           FipcServiceRole.Server, 5000))
{
    // three requests, then the end
    for (int i = 1; i <= 3; i++)
    {
        // the next request of any client
        if (server.Receive(out byte[] request) != FipcResult.Ok)
            return 1;
        string upper = Encoding.UTF8.GetString(request)
            .ToUpperInvariant();
        // the reply goes to its client, and never waits
        if (server.Send(Encoding.UTF8.GetBytes(upper),
                Fipc.NoWait) != FipcResult.Ok)
            return 1;
    }
}
// disposing the service ends it: the client still receives
// the replies sent
return 0;
ServiceClient/Program.cs
using System.Text;
using FastIpc;

using var client = FipcService.Join("my_service",
    FipcServiceRole.Client, 5000);
for (int i = 1; i <= 3; i++)
{
    // sends the request and returns its reply; Abandoned if
    // the server ended before it replied
    if (client.Call(Encoding.UTF8.GetBytes($"ping {i}"),
            out byte[] reply) != FipcResult.Ok)
        return 1;
    // PING 1, PING 2, PING 3
    Console.WriteLine(Encoding.UTF8.GetString(reply));
}
return 0;

In production: a stream server

one client after another, stopped by a token

A server that runs for good serves one client after another on its listener, and stops when its CancellationToken is cancelled (connect Ctrl+C to a CancellationTokenSource): token.Register(listener.Cancel) makes the waiting Accept() throw Cancelled, and the registration made for each connection makes its waiting receive return Cancelled. Serve reuses one buffer and grows it for a message that doesn't fit (TooLarge takes nothing, so the next receive gets the message), and returns at the client's end or the cancel. Its client is the one above. Every block from here on is compiled and run by the binding's tests.

RunServer
static void RunServer(CancellationToken token)
{
    using var listener = FipcStream.Create("orders", 1 << 20);
    using var stopListening = token.Register(listener.Cancel);
    try
    {
        while (true)
        {
            using var conn = listener.Accept();   // the next client
            using var stopServing = token.Register(conn.Cancel);
            Serve(conn);
        }
    }
    catch (FipcException e) when (e.Result == FipcResult.Cancelled)
    {
        // the token cancelled the listener: shutting down
    }
}
Serve
static void Serve(FipcConnection conn)
{
    var buffer = new byte[64 * 1024];   // reused: no allocation per message
    while (true)
    {
        FipcResult result = conn.Receive(buffer, out int length);
        bool more = result switch
        {
            FipcResult.Ok => Reply(conn, buffer.AsSpan(0, length)),
            FipcResult.TooLarge => Grow(ref buffer, length),   // nothing was taken: again
            FipcResult.Disconnected => false,   // the client left: accept the next
            FipcResult.Cancelled => false,      // shutting down
            FipcResult.Invalid => throw Unexpected("Receive", result),   // a bug: two receiving threads
            FipcResult.Timeout or FipcResult.NoMemory or FipcResult.AddrInUse or FipcResult.Overrun
                or FipcResult.Unsubscribed or FipcResult.Abandoned
                => throw Unexpected("Receive", result),   // never on a stream, waiting for ever
        };
        if (!more)
            return;
    }
}

static bool Grow(ref byte[] buffer, int needed)
{
    buffer = new byte[needed];
    return true;
}

static bool Reply(FipcConnection conn, ReadOnlySpan<byte> request)
{
    FipcResult result = conn.Send(Respond(request));
    return result switch
    {
        FipcResult.Ok => true,
        FipcResult.Disconnected or FipcResult.Cancelled => false,
        FipcResult.Timeout or FipcResult.TooLarge or FipcResult.Invalid or FipcResult.NoMemory
            or FipcResult.AddrInUse or FipcResult.Overrun or FipcResult.Unsubscribed
            or FipcResult.Abandoned => throw Unexpected("Send", result),
    };
}

Games and frame loops

never block a frame

A frame loop never waits. Each frame, Tick lets a client in with the polling Accept(out conn, Fipc.NoWait), which returns its result instead of throwing (a client takes one or two frames to get in), then takes every message that came, with Fipc.NoWait, until Timeout: nothing more this frame. A message longer than the buffer stays queued (TooLarge, with its length), so the buffer grows to it and the same frame reads it. The client's end frees the slot for the next one.

GameEndpoint
sealed class GameEndpoint : IDisposable
{
    private readonly FipcListener _listener = FipcStream.Create("game", 1 << 22);
    private FipcConnection? _conn;
    private byte[] _buffer = new byte[64 * 1024];

    public void Tick()   // once per frame: takes what came, never waits
    {
        if (_conn is null && !TryAccept())
            return;
        while (true)
        {
            FipcResult result = _conn!.Receive(_buffer, out int length, Fipc.NoWait);
            bool more = result switch
            {
                FipcResult.Ok => Dispatch(_buffer.AsSpan(0, length)),
                FipcResult.TooLarge => Grow(ref _buffer, length),   // stays queued: read it now
                FipcResult.Timeout => false,        // nothing more this frame
                FipcResult.Disconnected => Drop(),  // the client left: free the slot for the next
                FipcResult.Cancelled => false,      // shutting down
                FipcResult.Invalid or FipcResult.NoMemory or FipcResult.AddrInUse or FipcResult.Overrun
                    or FipcResult.Unsubscribed or FipcResult.Abandoned
                    => throw Unexpected("Receive", result),
            };
            if (!more)
                return;
        }
    }

    private bool TryAccept()
    {
        FipcResult result = _listener.Accept(out _conn, Fipc.NoWait);   // the polling form: never throws
        return result switch
        {
            FipcResult.Ok => true,
            FipcResult.Timeout or FipcResult.Cancelled => false,   // no client yet, or shutting down
            FipcResult.NoMemory => throw Unexpected("Accept", result),   // failed for good: create it again
            FipcResult.Invalid or FipcResult.Disconnected or FipcResult.TooLarge or FipcResult.AddrInUse
                or FipcResult.Overrun or FipcResult.Unsubscribed or FipcResult.Abandoned
                => throw Unexpected("Accept", result),
        };
    }

    private bool Drop()
    {
        _conn!.Dispose();
        _conn = null;
        return false;
    }

    public void Dispose()
    {
        _conn?.Dispose();
        _listener.Dispose();
    }
}

In production: broadcast

a publisher, a lossy subscriber, a gated one

The publisher waits up to 10 s for two subscribers, then sends to whoever joined. The lossy subscriber's buffer holds the longest message (MaxPiece), so it never gets TooLarge; one that falls a lap behind gets Overrun, and LostMessages says how many ticks it missed. The gated subscriber misses nothing and reads each tick in place, which holds the publisher back until its release; one that holds it back past maxStallMs gets Unsubscribed, for good.

Publish
static void Publish(IEnumerable<byte[]> ticks)
{
    // the publisher's connection is the broadcast: disposing it ends the broadcast
    using var publisher = FipcBroadcast.Create("ticks", 1 << 20, maxSubscribers: 16, maxStallMs: 500);
    if (publisher.WaitForMembers(atLeast: 2, out int joined, timeoutMs: 10_000) != FipcResult.Ok)
        Console.Error.WriteLine($"starting with {joined} subscribers");
    foreach (byte[] tick in ticks)
    {
        FipcResult result = publisher.Send(tick);   // to every subscriber joined now
        bool more = result switch
        {
            FipcResult.Ok => true,
            FipcResult.Cancelled => false,   // shutting down
            FipcResult.TooLarge or FipcResult.Invalid    // over publisher.MaxPiece, or empty
                => throw new ArgumentException(Fipc.ResultStr(result), nameof(ticks)),
            FipcResult.Timeout or FipcResult.Disconnected or FipcResult.NoMemory or FipcResult.AddrInUse
                or FipcResult.Overrun or FipcResult.Unsubscribed or FipcResult.Abandoned
                => throw Unexpected("Send", result),
        };
        if (!more)
            return;
    }
}
WatchTicks
static void WatchTicks()
{
    // lossy: never holds the publisher back, and may miss ticks
    using var sub = FipcBroadcast.Join("ticks", FipcSubscriberRole.Lossy);
    var buffer = new byte[sub.MaxPiece];   // the longest message: never TooLarge
    while (true)
    {
        FipcResult result = sub.Receive(buffer, out int length);
        bool more = result switch
        {
            FipcResult.Ok => Show(buffer.AsSpan(0, length)),
            FipcResult.Overrun => Missed(sub.LostMessages),   // a lap behind: goes on from a recent tick
            FipcResult.Disconnected => false,   // the publisher ended the broadcast
            FipcResult.Cancelled => false,      // shutting down
            FipcResult.Timeout or FipcResult.TooLarge or FipcResult.Invalid or FipcResult.NoMemory
                or FipcResult.AddrInUse or FipcResult.Unsubscribed or FipcResult.Abandoned
                => throw Unexpected("Receive", result),
        };
        if (!more)
            return;
    }
}
AuditTicks
static void AuditTicks()
{
    // gated: misses nothing (the publisher waits for it), so it may read in place, with no copy
    using var audit = FipcBroadcast.Join("ticks", FipcSubscriberRole.Gated);
    while (true)
    {
        FipcResult result = audit.ReceiveAcquire(out ReadOnlySpan<byte> tick);
        bool more = result switch
        {
            FipcResult.Ok => Append(tick),   // holds the publisher back until the release: be quick
            FipcResult.Unsubscribed => Dropped(),   // held it back past maxStallMs: out for good
            FipcResult.Disconnected => false,   // the publisher ended the broadcast
            FipcResult.Cancelled => false,      // shutting down
            FipcResult.Timeout or FipcResult.TooLarge or FipcResult.Invalid or FipcResult.NoMemory
                or FipcResult.AddrInUse or FipcResult.Overrun or FipcResult.Abandoned
                => throw Unexpected("ReceiveAcquire", result),
        };
        audit.ReceiveRelease();   // frees the tick for the publisher; a no-op without one
        if (!more)
            return;
    }
}

In production: work queue

a batch in, consumers stopped by a token

The owner joins its queue as a producer and sends the jobs in one batch, with one wake for all of them; sent says how many went, at every result, so a cancelled batch can put the rest back. Each consumer takes jobs until the queue ends (Disconnected, once nothing is left in it) or its token cancels it: token.Register(consumer.Cancel) makes the waiting receive return Cancelled.

Produce
static void Produce(byte[][] jobs)
{
    using var queue = FipcQueue.Create("jobs", 1 << 20, maxProducers: 4, maxConsumers: 8);
    using var producer = FipcQueue.Join("jobs", FipcQueueRole.Producer);   // the owner joins as any member
    queue.WaitForMembers(FipcQueueRole.Consumer, atLeast: 1, out _);
    FipcResult result = producer.SendMany(jobs, out int sent);   // one wake for the whole batch
    _ = result switch
    {
        FipcResult.Ok => true,
        FipcResult.Cancelled => Requeue(jobs.AsSpan(sent)),   // `sent` went, at every result
        FipcResult.TooLarge or FipcResult.Invalid    // a job over MaxPiece, or empty: checked before any went
            => throw new ArgumentException(Fipc.ResultStr(result), nameof(jobs)),
        FipcResult.Timeout or FipcResult.Disconnected or FipcResult.NoMemory or FipcResult.AddrInUse
            or FipcResult.Overrun or FipcResult.Unsubscribed or FipcResult.Abandoned
            => throw Unexpected("SendMany", result),
    };
    // disposing the queue ends it: the consumers take what is left, then Disconnected
}
Consume
static void Consume(CancellationToken token)
{
    using var consumer = FipcQueue.Join("jobs", FipcQueueRole.Consumer);
    using var stop = token.Register(consumer.Cancel);   // stops a waiting receive, from any thread
    while (true)
    {
        FipcResult result = consumer.Receive(out byte[] job);
        bool more = result switch
        {
            FipcResult.Ok => Work(job),   // each job goes to one consumer
            FipcResult.Disconnected => false,   // the queue ended, and nothing is left in it
            FipcResult.Cancelled => false,      // the token stopped it
            FipcResult.Timeout or FipcResult.TooLarge or FipcResult.Invalid or FipcResult.NoMemory
                or FipcResult.AddrInUse or FipcResult.Overrun or FipcResult.Unsubscribed
                or FipcResult.Abandoned => throw Unexpected("Receive", result),
        };
        if (!more)
            return;
    }
}

In production: service

a pool of servers, a client that asks again

The owner starts four servers and, when the token is cancelled, disposes the service: each server's receive then returns Disconnected, and the owner joins the threads. A server's join waits for a client, so each joins with a timeout and looks at the token between tries. A server reads each request in place, in its client's ring, and its reply, a Send that never waits, ends it. The client's Quote asks again after Abandoned (the request's server ended before it replied, and the request may or may not have run: ask again only what is safe to repeat), and gives up after a Timeout, whose late reply the next Call drops first.

RunPricing
static void RunPricing(CancellationToken token)
{
    using var service = FipcService.Create("pricing", 1 << 20, maxMessage: 4096, maxClients: 64,
        maxServers: 4);
    var servers = new Thread[4];
    for (int i = 0; i < servers.Length; i++)
        (servers[i] = new Thread(() => ServeQuotes(token))).Start();
    token.WaitHandle.WaitOne();
    service.Dispose();   // ends the service: each server's receive returns Disconnected
    foreach (Thread server in servers)
        server.Join();
}
ServeQuotes
static void ServeQuotes(CancellationToken token)
{
    FipcConnection server;
    while (true)
    {
        try
        {
            server = FipcService.Join("pricing", FipcServiceRole.Server, timeoutMs: 500);
            break;
        }
        catch (FipcException e) when (e.Result == FipcResult.Timeout)
        {
            // a server's join waits for a client: look at the token now and then
            if (token.IsCancellationRequested)
                return;
        }
    }
    using (server)
    {
        Span<byte> reply = stackalloc byte[4096];
        while (true)
        {
            // the request is read in place, in its client's ring, until the reply
            FipcResult result = server.ReceiveAcquire(out ReadOnlySpan<byte> request);
            bool more = result switch
            {
                FipcResult.Ok => server.Send(reply[..Price(request, reply)]) == FipcResult.Ok,
                FipcResult.Disconnected => false,   // the service ended
                FipcResult.Cancelled => false,      // shutting down
                FipcResult.Invalid => throw Unexpected("ReceiveAcquire", result),   // a request not replied to
                FipcResult.Timeout or FipcResult.TooLarge or FipcResult.NoMemory or FipcResult.AddrInUse
                    or FipcResult.Overrun or FipcResult.Unsubscribed or FipcResult.Abandoned
                    => throw Unexpected("ReceiveAcquire", result),
            };
            if (!more)
                return;
        }
    }
}
Quote
static decimal? Quote(FipcConnection client, string symbol)
{
    for (int attempt = 0; attempt < 3; attempt++)
    {
        FipcResult result = client.Call(Encoding.UTF8.GetBytes(symbol), out byte[] reply, timeoutMs: 200);
        bool again = result switch
        {
            FipcResult.Ok => false,
            FipcResult.Abandoned => true,   // its server ended first: a quote is safe to ask again
            FipcResult.Timeout => false,    // the reply stays owed: the next Call drops it first
            FipcResult.Disconnected or FipcResult.Cancelled => false,   // the service ended, or stopping
            FipcResult.TooLarge or FipcResult.Invalid    // checked before anything is sent
                => throw new ArgumentException(Fipc.ResultStr(result), nameof(symbol)),
            FipcResult.NoMemory or FipcResult.AddrInUse or FipcResult.Overrun or FipcResult.Unsubscribed
                => throw Unexpected("Call", result),
        };
        if (result == FipcResult.Ok)
            return ParsePrice(reply);
        if (!again)
            return null;
    }
    return null;
}

Threads and Dispose

SafeHandle

The raw layer

static class Fipc

Fipc declares the C API one to one, over IntPtr handles, pointers or spans. It costs nothing beyond the native call. Each object-layer call holds its SafeHandle, which costs about 15 ns a call, or about 30 ns per message sent and received (measured).

It enforces nothing either: Close needs the handle to itself (cancel, join the threads that use it, then close), and closing under a running call frees memory the call still uses.

raw calls
Fipc.StreamConnect("my_channel", out IntPtr conn, 5000);
Fipc.Send(conn, "hello"u8, Fipc.Forever);
Fipc.Close(conn);