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).
dotnet add package FipcServer and client
two processes, one nameA 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).
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));
}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)); // PINGMessages
bytes of any size, no headerA 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.
// 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 exitedusing 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, FipcResultSetup 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).
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.
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors> <!-- a FipcResult with no arm fails the build -->
<NoWarn>$(NoWarn);CS8524</NoWarn> <!-- an unnamed value throws SwitchExpressionException -->
</PropertyGroup>static Exception Unexpected(string call, FipcResult result) =>
new UnreachableException($"{call}: {Fipc.ResultStr(result)}");Zero-copy
up to conn.MaxPiece bytesWrite 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.
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
}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 endsSendMany(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.
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 subscribersA 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).
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;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 consumerA 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.
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;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 receives | When it acknowledges | If its process ends | What the application gets |
|---|---|---|---|
Receive (copying) | inside the call, before it returns | inside the call: the message goes to another consumer, and this one never saw it; after the call returned: it doesn't go again | at most once: no duplicates |
ReceiveAcquire, then ReceiveRelease at once, then the work | at the release, before the work | as above | at most once: no duplicates |
ReceiveAcquire, the work, then ReceiveRelease | at the release, after the work | before the release: the message goes to another consumer | at least once: a duplicate is possible, so the work must be idempotent |
- Choose by the work, per consumer. Work that may safely run twice (rendering, a computation, re-indexing, converting a file) acknowledges after it; work whose effects must never repeat (a payment, an email, a motor command) acknowledges before it.
- Not covered: a consumer that hangs while its process runs (there are no timeouts, by design); the owner's process, with which the queue ends; a reboot. A consumer holds at most one unacknowledged message at a time, the one received in place.
- A message that goes again comes in no set order, after messages its producer sent later: the queue promises no order anyway.
- A message held in place holds back its producer's room until its release: a consumer that acknowledges after a long job keeps that message's room, and so the room of the producer's messages after it, until then.
- A message that crashes every consumer that takes it, acknowledged after the work, goes to each consumer in turn, with no limit: acknowledge before the work where that can happen.
Service
many clients, a pool of servers, with repliesA 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.
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;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;- A client's replies come in the order of its requests.
Send(request, ...)sends a request, andReceive(into a span or a new array, or in placeReceiveAcquireuntilReceiveRelease) receives the reply to its oldest request whose reply it hasn't received yet, whichever server replies first. A client may send several requests before it receives (as many as its ring has room for: each holds its length rounded up to 64 bytes, plusmaxMessagerounded up to 64, until its reply has been received), and the servers work on them at once. A receive that times out or is cancelled leaves its reply to come: the next receive returns it. Call(request, out byte[] reply, timeoutMs)never returns another request's reply. A call checks its request first:Invalidfor an empty one,TooLargeoverMaxPiece, at once, even while a reply is owed. Then a call on a cancelled connection returnsCancelledat once, whatever its timeout, and sends nothing: it could never receive the reply. The connection keeps count of the replies still owed: a call whose receive timed out or was cancelled leaves its reply owed, and the next call first receives and drops that reply (at most one is owed, since every call drops it first; anAbandonedin its place counts too), within its own timeout and before it sends. A call that had to drop an owed reply first sends its request only if its timeout has time left: once a positive timeout has run out, it returnsTimeoutwithout sending, so no server sees that request; withNoWait(0) no step waits, and it sends if there is room. After a call that gave up, a plainReceivereturns that call's late reply (orAbandoned) first: replies come in request order. A call returnsInvalidwhile a request sent withSend(orSendCommit) still has its reply to be received: that reply is yours, so receive it first; on any connection other than a service's client it returnsInvalidtoo. A call sends and receives, so no other thread may send or receive on the connection meanwhile.- A server holds one request at a time.
Receivetakes the next request of any client (each client's in the order it sent them), andSend(reply, ...)replies to it and never waits; until then its receives returnInvalid, and a reply with no request held returnsInvalid. A request received in place stays valid until the reply'sSendorSendCommit, which ends its span (so doesReceiveRelease, the next receive, orDisposeon the receiving thread; aReceiveReleaseleaves the request held until its reply). A server'sDisposeanswers a request it hasn't replied toAbandoned: one held in place, at its span's end. A reply to a client that has left is dropped. A server may receive on one thread and reply on another. Abandoned: a client's receive (or call) returns it when the server that took the request ended before it replied: it disposed its connection, or its process ended. The request counts as answered, and the next receive returns the next reply. An abandoned request may or may not have been carried out; the service never sends it again, and the client decides whether to.- The owner's service:
service.WaitForMembers(role, atLeast, out int count, timeoutMs)waits until that many members of the role (FipcServiceRole.Client,FipcServiceRole.Server, ornullfor both) have joined;service.Cancel()makes its waits returnCancelled, from any thread. Clients wait while no server is joined, and servers while no client is. - Not covered: a server that hangs while its process runs (there are no timeouts, by design: its client waits, within its own timeouts); the owner's process, with which the service ends.
In production: a stream server
one client after another, stopped by a tokenA 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.
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
}
}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 frameA 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.
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 oneThe 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.
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;
}
}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;
}
}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 tokenThe 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.
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
}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 againThe 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.
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();
}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;
}
}
}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- On a connection, one thread at a time sends and one receives; the two may run at once.
CancelandDisposemay be called from any thread, at any time. Disposecancels the handle, so a waiting call returnsCancelled, and the native close runs once the calls in progress have returned. No call ever runs on a closed connection, and a zero-copy span stays valid until its commit or release (a service server's request held in place: until its reply), when the close then runs.- To stop an IO thread:
Dispose, then join the thread with a timeout. - A
Disconnectedconnection is final: dispose it, then accept or connect a new one. - A listener or a connection that is never disposed is closed by its finalizer once it is unreachable (a publisher's ends its broadcast). A
FipcQueueor aFipcServicehas none: it lives untilDisposeor the end of its process. ItsWaitForMembers,CancelandDisposemay be called from any thread.
The raw layer
static class FipcFipc 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.
Fipc.StreamConnect("my_channel", out IntPtr conn, 5000);
Fipc.Send(conn, "hello"u8, Fipc.Forever);
Fipc.Close(conn);