FastIPC FastIPC v1.0.0
include/fipc.hpp · header-only · C++20

C++

One header over the C API: fipc::listener, fipc::connection, fipc::queue and fipc::service own their handles and close them when destroyed; every call returns a fipc::result<T>, so it builds with -fno-exceptions and without RTTI; timeouts are std::chrono durations. It wraps the 30 functions of fipc.h and adds no ABI of its own, so it works with the same fastipc.dll / libfastipc.so / libfastipc.dylib, and its processes talk to every other language's.

Zigzig fetch --save git+https://github.com/fastipc/fastipc

Server and client

two processes, one name

Run the server in one process, then the client in another; fipc::stream_connect waits up to its timeout for a server to create the stream and accept. A result converts to true when it holds a value, and -> reaches it. 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.cpp
#include <cctype>
#include <string>

#include <fipc.hpp>

int main()
{
    auto listener = fipc::stream_create("my_channel", 1 << 20);  // rings of 1 MiB each way
    if (!listener)
        return 1;
    auto conn = listener->accept();  // waits for a client
    if (!conn)
        return 1;
    std::string message;
    if (!conn->receive(message))
        return 1;
    for (char& c : message)
        c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
    return conn->send(message) ? 0 : 1;
}
client.cpp
#include <chrono>
#include <cstdio>
#include <string>

#include <fipc.hpp>

int main()
{
    auto conn = fipc::stream_connect("my_channel", std::chrono::seconds(5));
    if (!conn)
    {
        std::fprintf(stderr, "connect: %s\n", conn.error().name());
        return 1;
    }
    std::string reply;
    if (!conn->send("ping") || !conn->receive(reply))
        return 1;
    std::printf("%s\n", reply.c_str());  // PING
    return 0;
}

Build

the Zig build system · no CMake

The recommended way is the Zig build system: no CMake or other build tool, and Zig 0.17.0 is a single download. It builds the library for your target and compiles your C++ with its bundled Clang. The dependency's artifact fastipc is the shared library; linking it puts fipc.h and fipc.hpp on the include path. The repository's examples/c-cpp is such a project, with these servers and clients in C and C++.

Any other C++20 compiler works too: take the headers and the library from the GitHub release's archive for your OS, or from zig build in a checkout (zig-out/include, zig-out/lib, zig-out/bin). On macOS, link with an rpath to the dylib's folder, and clear a browser-downloaded archive's quarantine flag (xattr -d com.apple.quarantine lib/libfastipc.dylib; see the C page). On Windows, define FASTIPC_SHARED and put fastipc.dll next to the program; with MSVC and /EHs-c-, also define _HAS_EXCEPTIONS=0, as its standard library requires.

build.zig
const fastipc = b.dependency("fipc", .{
    .target = target,
    .optimize = optimize,
}).artifact("fastipc");
exe.root_module.link_libcpp = true;
exe.root_module.addCSourceFile(.{
    .file = b.path("src/server.cpp"),
    .flags = &.{"-std=c++20"},
});
exe.root_module.addCMacro("FASTIPC_SHARED", "1"); // Windows
exe.root_module.linkLibrary(fastipc);
b.installArtifact(fastipc); // the library next to the programs
shell: other compilers
# Linux, from the release archive
g++ -std=c++20 -Iinclude server.cpp -Llib -lfastipc -o server
LD_LIBRARY_PATH=lib ./server

# macOS, from the release archive
clang++ -std=c++20 -Iinclude server.cpp -Llib -lfastipc -Wl,-rpath,@executable_path/lib -o server
./server

# Windows, MSVC (a developer prompt)
cl /std:c++20 /EHsc /DFASTIPC_SHARED /Iinclude server.cpp lib\fastipc.lib
copy bin\fastipc.dll .

Results and timeouts

fipc::result<T> · fipc::errc mirrors fipc_result_t

Every call that can fail returns a [[nodiscard]] fipc::result<T>, or fipc::status for calls that return nothing: like std::expected, it converts to true when it holds a value, *r and r-> reach it, and r.error() says why there is none. The error compares equal to its code: fipc::errc::timeout, disconnected, cancelled, too_large (error().len is the room the message needs; it stays queued), invalid, no_memory or addr_in_use, and a subscriber's overrun and unsubscribed or a service client's abandoned (below); error().name() is the C name.

The timeout comes last: any std::chrono duration (rounded up to whole milliseconds), fipc::no_wait, or fipc::forever, the default of the calls that send, receive or accept. disconnected is final for that connection: destroy it, then accept or connect a new one.

wait at most 100 ms
std::string message;
auto got = conn.receive(message, std::chrono::milliseconds(100));
if (got)
    handle(message);
else if (got.error() != fipc::errc::timeout)
    return 1;  // fipc::errc::disconnected: the peer is gone

Messages

bytes of any size, no header

A connection carries messages of any size (at least one byte). send takes a std::string_view or a std::span<const std::byte>. receive fills a std::string or std::vector, resized to the message, whose capacity carries over to the next; receive_into fills a fixed buffer and returns the length.

send
if (!conn.send("hello"))  // std::string_view
    return 1;
std::vector<std::byte> data = encode(state);
if (!conn.send(data))  // std::span<const std::byte>
    return 1;
receive into a fixed buffer
char buf[256];
auto len = conn.receive_into(buf);
if (!len && len.error() == fipc::errc::too_large)
{
    // nothing was taken: make room, then again
    std::vector<char> big(len.error().len);
    len = conn.receive_into(big);
}

Zero-copy

up to conn.max_piece() bytes

Write into a fipc::send_slot of the ring and commit() it; a slot destroyed without its commit sends nothing. Read a fipc::receive_slot of the ring, which frees its room when destroyed. While a slot lives, its side of the connection makes no other call, and it must not outlive the connection; a slot whose turn a later acquire ended does nothing.

send in place
if (auto slot = conn.send_acquire(64))  // room in the ring
{
    int n = std::snprintf(reinterpret_cast<char*>(slot->data()), slot->size(), "tick %d", tick);
    if (!slot->commit(static_cast<std::size_t>(n)))  // publishes n bytes
        return 1;
}
receive in place
if (auto message = conn.receive_acquire())  // read-only, in the ring
    handle(message->bytes());  // std::span<const std::byte>
// destroyed here: its room is free again

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. fipc::broadcast_create returns the publisher's fipc::connection; the publisher's process admits the subscribers on a thread of the library's, so no thread of yours waits for them. The publisher waits for the first one (publisher->wait_for_members(1)) before it sends tick 1 to tick 3; destroying the publisher ends the broadcast and frees the name. The subscriber joins gated (fipc::broadcast_join with fipc::subscriber_role::gated), so it misses nothing (the publisher waits for it), and receives until errc::disconnected, which comes after the last message. A lossy subscriber (fipc::subscriber_role::lossy) never holds the publisher back: one that falls a lap behind gets errc::overrun, with the number of messages lost in error().len. A gated one that holds the publisher back for the broadcast's max_stall is unsubscribed (errc::unsubscribed).

publisher.cpp
#include <chrono>
#include <string>

#include <fipc.hpp>

int main()
{
    // 64 KiB of messages, up to 8 subscribers; one that holds the publisher back 5 s is dropped
    auto publisher = fipc::broadcast_create("my_broadcast", 1 << 16, 8, std::chrono::seconds(5));
    if (!publisher)
        return 1;
    if (publisher->wait_for_members(1))  // waits for the first subscriber
    {
        for (int i = 1; i <= 3; i++)
            (void) publisher->send("tick " + std::to_string(i));
    }
    return 0;  // closing the publisher ends the broadcast: subscribers get FIPC_DISCONNECTED after the ticks
}
subscriber.cpp
#include <chrono>
#include <cstdio>
#include <string>

#include <fipc.hpp>

int main()
{
    // gated: misses nothing (the publisher waits for it)
    auto subscriber = fipc::broadcast_join("my_broadcast", fipc::subscriber_role::gated, std::chrono::seconds(5));
    if (!subscriber)
    {
        std::fprintf(stderr, "join: %s\n", subscriber.error().name());
        return 1;
    }
    std::string tick;
    for (;;)
    {
        auto received = subscriber->receive(tick);
        if (!received)  // errc::disconnected: the publisher closed
            return received.error() == fipc::errc::disconnected ? 0 : 1;
        std::printf("%s\n", tick.c_str());  // tick 1, tick 2, tick 3
    }
}

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, 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 (fipc::queue_create: a fipc::queue) and joins it as its producer with fipc::queue_join, as any process does: the owner's process admits every joiner on a thread of the library's, so the thread that created the queue may join it. It sends job 1 to job 3 and waits for the consumer (queue->wait_for_members). The fipc::queue is the queue: destroying it ends the queue, so keep it while the queue is used; the consumer receives what is left, then errc::disconnected. Other processes join with fipc::queue_join (fipc::queue_role::producer, fipc::queue_role::consumer); a member's destruction never ends the queue.

producer.cpp
#include <chrono>
#include <string>

#include <fipc.hpp>

int main()
{
    // 4 KiB of messages per producer, 1 producer and 1 consumer: this process owns the queue and is its producer
    auto queue = fipc::queue_create("my_queue", 4096, 1, 1);
    if (!queue)
        return 1;
    auto producer = fipc::queue_join("my_queue", fipc::queue_role::producer, std::chrono::seconds(5));
    if (!producer)
        return 1;
    for (int i = 1; i <= 3; i++)
        (void) producer->send("job " + std::to_string(i));          // waits in the queue until a consumer takes it
    (void) queue->wait_for_members(fipc::queue_role::consumer, 1);  // waits for the consumer to join
    producer->close();
    return 0;  // closing the queue ends it: the consumer gets FIPC_DISCONNECTED after the jobs
}
consumer.cpp
#include <chrono>
#include <cstdio>
#include <string>

#include <fipc.hpp>

int main()
{
    auto consumer = fipc::queue_join("my_queue", fipc::queue_role::consumer, std::chrono::seconds(5));
    if (!consumer)
    {
        std::fprintf(stderr, "join: %s\n", consumer.error().name());
        return 1;
    }
    std::string job;
    for (;;)
    {
        auto received = consumer->receive(job);
        if (!received)  // errc::disconnected: the queue ended
            return received.error() == fipc::errc::disconnected ? 0 : 1;
        std::printf("%s\n", job.c_str());  // job 1, job 2, job 3
    }
}

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() or receive_into() (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
receive_acquire(), then the slot's release() (or its destruction) at once, then the workat the release, before the workas aboveat most once: no duplicates
receive_acquire(), the work, then the slot's release() (or its destruction)at 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 a client receives its replies in the order of its requests. Here the server creates the service (fipc::service_create: a fipc::service) and joins it as its server with fipc::service_join (fipc::service_role::server) and answers three requests with their uppercase; destroying the fipc::service ends the service. The client joins (fipc::service_role::client) and calls ping 1 to ping 3: call() sends a request and receives its reply, and the client prints each. A request whose server ended before it replied gets errc::abandoned.

service_server.cpp
#include <cctype>
#include <chrono>
#include <string>

#include <fipc.hpp>

int main()
{
    // 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
    auto service = fipc::service_create("my_service", 4096, 256, 1, 1);
    if (!service)
        return 1;
    auto server = fipc::service_join("my_service", fipc::service_role::server, std::chrono::seconds(5));
    if (!server)
        return 1;
    std::string request;
    for (int i = 1; i <= 3; i++)  // three requests, then the end
    {
        if (!server->receive(request))  // the next request of any client
            return 1;
        for (char& c : request)
            c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
        if (!server->send(request))  // the reply goes to its client, and never waits
            return 1;
    }
    server->close();
    return 0;  // destroying the service ends it: the client still receives the replies sent
}
service_client.cpp
#include <chrono>
#include <cstdio>
#include <string>

#include <fipc.hpp>

int main()
{
    auto client = fipc::service_join("my_service", fipc::service_role::client, std::chrono::seconds(5));
    if (!client)
    {
        std::fprintf(stderr, "join: %s\n", client.error().name());
        return 1;
    }
    std::string reply;
    for (int i = 1; i <= 3; i++)
    {
        // sends the request and receives its reply; errc::abandoned if the server ended before it replied
        auto called = client->call("ping " + std::to_string(i), reply);
        if (!called)
        {
            std::fprintf(stderr, "call: %s\n", called.error().name());
            return 1;
        }
        std::printf("%s\n", reply.c_str());  // PING 1, PING 2, PING 3
    }
    return 0;
}

The calls. A service's connections are fipc::connections, and what each call does depends on the role. Requests and replies are of one piece, 1 to max_message bytes, the owner's choice at fipc::service_create (1 to a quarter of the capacity); max_piece() returns it on both roles.

CallOn a clientOn a server
send(), send_acquire() and the slot's commit()Sends a request. Waits up to its timeout for room in the client's ring.Replies to the request it holds; never waits. errc::invalid with no request held, errc::too_large over max_piece().
receive(), receive_into()The reply to its oldest request whose reply it hasn't received, waiting for it if it hasn't come; errc::abandoned if that request's server ended before it replied.The next request of any client, copied; the server then holds it until it replies.
call()Sends a request and receives its reply into a std::string or std::vector, resized to it, within one timeout; errc::abandoned if its server ended before it replied.errc::invalid, as on any connection other than a service's client.
receive_acquire(), then the slot's releaseThe next reply in place, until the slot's release or destruction.The next request in place, valid until the reply is sent; the slot's release does nothing.
close() (or the destructor)Leaves the service: requests no server took are dropped, and the replies to those already taken are lost.Leaves the service; a request it still holds is answered errc::abandoned.

Threads

one sender, one receiver · fipc::canceler

The C API's rule holds: on a connection, one thread at a time sends and one at a time receives, and the two may run at once; a service client's call() does both, so no other thread sends or receives while it runs. On a listener, one thread at a time accepts. A broadcast's, a queue's or a service's owner needs no thread for its joiners: its process admits them on a thread of the library's, so it may join from any thread. max_piece(), cancel() and the wait_for_members calls work from any thread.

To stop a thread that waits, take a fipc::canceler first and call cancel() from any thread: every call that waits, then or later, returns fipc::errc::cancelled. Then join the thread and destroy the handle. A canceler doesn't keep its handle open: once the handle is closed, cancelling does nothing.

stop a reader thread
fipc::canceler canceler = conn.get_canceler();
std::thread reader([&conn] {
    std::string message;
    while (conn.receive(message))  // until cancelled or disconnected
        handle(message);
});
// ...
canceler.cancel();  // the reader's receive returns fipc::errc::cancelled
reader.join();
CallWhat it does
fipc::stream_create(name, capacity)Server: claims the name for a stream and returns its fipc::listener; errc::addr_in_use if another stream, broadcast, queue or service holds it.
listener.accept(timeout)Sets the next client's connection up on the calling thread and returns its fipc::connection. One client at a time: destroy it before the next accept. fipc::no_wait polls.
listener.cancel() · get_canceler()Cancel waiting accepts · a fipc::canceler that does it from any thread. Destroying the listener stops listening; accepted connections stay open.
fipc::stream_connect(name, timeout)Client: connects to the stream on the name, waiting for a server to create it and accept: client and server run on different threads or processes.
fipc::broadcast_create(name, capacity, max_subscribers, max_stall)Publisher: claims the name for a broadcast and returns the publisher's fipc::connection, whose sends go to every subscriber; its process admits the subscribers on a thread of the library's. Destroying the publisher ends the broadcast and frees the name. Doesn't wait.
fipc::broadcast_join(name, role, timeout)Subscriber: joins the broadcast on the name as fipc::subscriber_role::lossy or fipc::subscriber_role::gated (misses nothing), waiting for the publisher to create it, to have a free slot and to admit it; it receives the messages sent from its admission on.
publisher.wait_for_members(at_least, timeout)A publisher's count of its joined subscribers, waiting until at least at_least are (0 returns at once). At errc::timeout or errc::cancelled, error().len is the count. Any thread.
fipc::queue_create(name, capacity, max_producers, max_consumers)Owner: claims the name for a work queue and returns its fipc::queue; its process admits the members on a thread of the library's. Destroying the queue ends it. Doesn't wait.
fipc::queue_join(name, role, timeout)Producer or consumer: joins the work queue on the name as fipc::queue_role::producer or fipc::queue_role::consumer, waiting for the owner to create it, for a free place and to admit it. The owner joins its own queue this way too, from any thread.
queue.wait_for_members(role, at_least, timeout) · queue.cancel() · queue.close()The owner's count of the joined members of a role (or of both, without one), waiting until at least at_least are; errc::disconnected once the queue has ended · cancel waiting counts · end the queue now (the destructor does it too). Any thread but the close.
fipc::service_create(name, capacity, max_message, max_clients, max_servers)Owner: claims the name for a service and returns its fipc::service; its process admits the members on a thread of the library's. Destroying the service ends it. Doesn't wait.
fipc::service_join(name, role, timeout)Client or server: joins the service on the name as fipc::service_role::client or fipc::service_role::server, waiting for the owner to create it, for a free place and to admit it. The owner joins its own service this way too, from any thread.
service.wait_for_members(role, at_least, timeout) · service.cancel() · get_canceler() · service.close()The owner's count of the joined members of a role (or of both, without one), waiting until at least at_least are; errc::disconnected once the service has ended · cancel waiting counts · a canceler for any thread · end the service now (the destructor does it too). Any thread but the close.
send(message, timeout)Sends one message of any size: a std::string_view or std::span<const std::byte>.
receive(buffer, timeout)Receives one message into a std::string or std::vector, resized to it; returns the length. A lossy subscriber that fell a lap behind gets errc::overrun, with the number of messages it lost in error().len; a service's client gets errc::abandoned for a request whose server ended before it replied.
call(request, reply, timeout)A service's client: sends the request and receives its reply into a std::string or std::vector, resized to it; returns the length. errc::abandoned if its server ended before it replied; it never returns another request's reply (above).
receive_into(span, timeout)Receives into a fixed buffer; errc::too_large (with error().len) if it doesn't fit; errc::overrun as receive.
send_many(messages, timeout) · receive_many(buf, lens, timeout)Batches (fipc_send_many, fipc_recv_many): sends a std::span of messages, published together, and returns how many went (on an error, error().len); receives every message already there that fits buf, packed back to back, their lengths in lens, and returns how many. Batch both ends.
send_acquire(len, timeout) · slot->commit(len)Zero-copy send.
receive_acquire(timeout) · destroy the slotZero-copy receive.
max_piece() · cancel() · get_canceler() · close()The zero-copy limit · cancel waiting calls · a canceler for any thread · end the connection now (the destructor does it too).
native_handle() · connection(fipc_conn_t*) · connection(fipc_conn_t*, role)The C handle, for calls of fipc.h · take one over · take a service member's over, a client's with its count of the replies owed, for call().

The C++ benchmark runs the C benchmark's loops through this header: their numbers agree within the noise, so the wrapper adds nothing measurable (details).