FastIPC FastIPC v1.0.0
io.github.fastipc:fipc on Maven Central · module io.github.fastipc

Java

Four classes, Listener, Connection, WorkQueue and Service, made by the static methods of Fipc (createStream, connectStream, createBroadcast, joinBroadcast, createQueue, joinQueue, createService, joinService), over the C API through the FFM API (java.lang.foreign), for Java 22 and later: no JNI, no dependencies. A call that doesn't succeed throws FipcException. All four are AutoCloseable and safe to close from any thread. The jar carries the native library for Windows x64, Linux x64 and ARM64 (glibc 2.34+) and macOS 14.4+ on Apple Silicon.

Gradleimplementation("io.github.fastipc:fipc:1.0.0")

Server and client

two processes, one name

Start the two in either order, in two terminals: Fipc.connectStream waits up to its timeout for a server to create the stream and accept. With the jar on the class path, the source launcher runs each file as it is: java --enable-native-access=ALL-UNNAMED -cp fipc-1.0.0.jar Server.java. 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.

Server.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import io.github.fastipc.Listener;

public class Server {
    public static void main(String[] args) {
        // rings of 1 MiB each way; accept() waits for a client
        try (Listener listener =
                 Fipc.createStream("my_channel", 1 << 20);
             Connection conn = listener.accept()) {
            String text = new String(conn.receive());
            conn.send(text.toUpperCase().getBytes());
        }
    }
}
Client.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import java.time.Duration;

public class Client {
    public static void main(String[] args) {
        try (Connection conn = Fipc.connectStream("my_channel",
                 Duration.ofSeconds(5))) {
            conn.send("ping".getBytes());
            byte[] reply = conn.receive();
            System.out.println(new String(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 buffer). This server echoes every message back until its client leaves, which ends its next receive with DISCONNECTED.

EchoServer.java
import io.github.fastipc.*;

public class EchoServer {
    public static void main(String[] args) {
        // rings of 1 MiB each way: a power of two, 1 KiB to 2 GiB
        try (Listener listener = Fipc.createStream("demo", 1 << 20);
             Connection conn = listener.accept()) {
            while (true) {
                conn.send(conn.receive());   // echo, any size
            }
        } catch (FipcException e) {
            if (e.result() != Result.DISCONNECTED) throw e;
            System.out.println("client gone");
        }
    }
}
EchoClient.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import java.time.Duration;

public class EchoClient {
    public static void main(String[] args) {
        try (Connection conn =
                 Fipc.connectStream("demo", Duration.ofSeconds(5))) {
            for (String word : new String[] {"hello", "shared", "memory"}) {
                conn.send(word.getBytes());
                System.out.println(new String(conn.receive()));
            }
        }
    }
}

Errors and timeouts

FipcException.result() is a Result

Every call that can wait has an overload without a timeout, which waits for ever, and one that takes a java.time.Duration: Fipc.NO_WAIT (Duration.ZERO) doesn't wait, Fipc.FOREVER waits for ever. A call that doesn't succeed throws FipcException, unchecked, whose result() says why: TIMEOUT, DISCONNECTED, CANCELLED, TOO_LARGE, INVALID, NO_MEMORY or ADDR_IN_USE, on a broadcast OVERRUN (a lossy subscriber lost messages: lostMessages() says how many) or UNSUBSCRIBED, and on a service's client ABANDONED (the server that took the request ended before it replied).

DISCONNECTED is final for that connection: close it, then accept or connect a new one. A call on a closed listener or connection throws IllegalStateException.

poll without blocking
byte[] msg;
try {
    msg = conn.receive(Duration.ofMillis(100));
} catch (FipcException e) {
    if (e.result() != Result.TIMEOUT) throw e;
    msg = null;                    // nothing yet
}

Zero-copy

up to conn.maxPiece() bytes

Write into a MemorySegment of the ring and commit; read a read-only MemorySegment of the ring and release it. A segment is valid until its commit or release and confined to the thread that acquired it: touching it afterwards, or from another thread, throws instead of reaching memory that is no longer yours.

send in place
MemorySegment room = conn.sendAcquire(5);   // in the ring
room.copyFrom(MemorySegment.ofArray("hello".getBytes()));
conn.sendCommit(5);
receive in place
MemorySegment message = conn.receiveAcquire();  // read-only, in the ring
handle(message);
conn.receiveRelease();                          // frees its room

Batches

many messages per call, both ends

sendMany(messages, [timeout]) sends a List<byte[]> in order and publishes it together, with at most one wake of the receivers, and returns how many went; the timeout bounds the whole call (Fipc.NO_WAIT: only what fits now). Every message is checked before any goes. A FipcException is the first message that didn't go, and its sentMessages() says how many did.

receiveMany(maxCount, [timeout]) waits for the next message, then takes every further one already there and returns them as a List<byte[]>; receiveMany(segment, lengths, timeout) packs them into your MemorySegment, their lengths in a long[]. 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 throw INVALID. Batch both ends: a receiver that takes one message per call falls behind a batching sender.

a batch each way
int sent = conn.sendMany(List.of(
    "one".getBytes(), "two".getBytes()));

for (byte[] msg : peer.receiveMany(64)) {
    handle(msg);
}

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()). Fipc.createBroadcast returns the publisher's Connection, the broadcast's one handle. The publisher's process admits subscribers by itself, so no thread of yours waits for them: the publisher waits for the first one (publisher.waitForMembers(1); at a TIMEOUT the exception's memberCount() holds the count) before it sends tick 1 to tick 3; closing the publisher ends the broadcast and frees the name. The subscriber joins gated (Fipc.joinBroadcast with SubscriberRole.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 (SubscriberRole.LOSSY) never holds the publisher back: one that falls a lap behind gets OVERRUN, and the exception's lostMessages(), a long, says how many messages it lost. A gated one that holds the publisher back for the broadcast's maxStall is unsubscribed (UNSUBSCRIBED).

Publisher.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import java.time.Duration;

public class Publisher {
    public static void main(String[] args) {
        // 64 KiB of messages, up to 8 subscribers; one that
        // holds the publisher back 5 s is dropped
        try (Connection publisher = Fipc.createBroadcast(
                 "my_broadcast", 1 << 16, 8, Duration.ofSeconds(5))) {
            publisher.waitForMembers(1);  // waits for the first one
            for (int i = 1; i <= 3; i++) {
                publisher.send(("tick " + i).getBytes());
            }
        }
        // closing the publisher ends the broadcast: subscribers
        // get DISCONNECTED after the ticks
    }
}
Subscriber.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import io.github.fastipc.FipcException;
import io.github.fastipc.Result;
import io.github.fastipc.SubscriberRole;
import java.time.Duration;

public class Subscriber {
    public static void main(String[] args) {
        // gated: misses nothing (the publisher waits for it)
        try (Connection sub = Fipc.joinBroadcast("my_broadcast",
                 SubscriberRole.GATED, Duration.ofSeconds(5))) {
            while (true) {
                byte[] tick = sub.receive();
                System.out.println(new String(tick));  // tick 1..3
            }
        } catch (FipcException e) {
            // DISCONNECTED: the publisher closed
            if (e.result() != Result.DISCONNECTED) throw e;
        }
    }
}

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 (Fipc.createQueue returns a WorkQueue), joins it as a producer (Fipc.joinQueue with QueueRole.PRODUCER), sends job 1 to job 3, which wait in the queue, and waits for the consumer to join (queue.waitForMembers(QueueRole.CONSUMER, 1)). The owner's process admits every producer and consumer by itself, the owner's own too. queue.close() ends the queue: the consumer receives what is left, then DISCONNECTED, and a producer's sends throw DISCONNECTED from then on; a producer's or a consumer's close never ends it. A queue lives until close() or the end of its process; dropping the object doesn't end it: unlike the other handles, a WorkQueue has no Cleaner, as its use lives in its members.

Producer.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import io.github.fastipc.QueueRole;
import io.github.fastipc.WorkQueue;

public class Producer {
    public static void main(String[] args) {
        // 4 KiB of messages per producer, 1 producer and 1
        // consumer: this process owns the queue and is its
        // producer
        try (WorkQueue queue = Fipc.createQueue("my_queue", 4096,
                 1, 1)) {
            try (Connection producer = Fipc.joinQueue("my_queue",
                     QueueRole.PRODUCER)) {
                for (int i = 1; i <= 3; i++) {
                    // waits in the queue until a consumer takes it
                    producer.send(("job " + i).getBytes());
                }
                // waits for the consumer to join
                queue.waitForMembers(QueueRole.CONSUMER, 1);
            }
        }
        // closing the queue ends it: the consumer gets
        // DISCONNECTED after the jobs
    }
}
Consumer.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import io.github.fastipc.FipcException;
import io.github.fastipc.QueueRole;
import io.github.fastipc.Result;
import java.time.Duration;

public class Consumer {
    public static void main(String[] args) {
        try (Connection consumer = Fipc.joinQueue("my_queue",
                 QueueRole.CONSUMER, Duration.ofSeconds(5))) {
            while (true) {
                byte[] job = consumer.receive();
                System.out.println(new String(job));  // job 1..3
            }
        } catch (FipcException e) {
            // DISCONNECTED: the queue ended
            if (e.result() != Result.DISCONNECTED) throw e;
        }
    }
}

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 receiveRelease()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 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 (Fipc.createService returns a Service), joins it as its server (Fipc.joinService with ServiceRole.SERVER: the owner's process admits every joiner by itself, its own included) and answers three requests with their uppercase. The client joins (ServiceRole.CLIENT) and calls ping 1 to ping 3: call sends a request and returns its reply. The service's close() ends it: clients' sends and servers' receives throw DISCONNECTED, and a client receives the replies that came before the end, then DISCONNECTED; a client's or a server's close never ends the service (a server's answers the request it holds ABANDONED). A Service, as a WorkQueue, has no Cleaner: a service lives until close() or the end of its process, and dropping the object doesn't end it.

ServiceServer.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import io.github.fastipc.Service;
import io.github.fastipc.ServiceRole;
import java.time.Duration;

public class ServiceServer {
    public static void main(String[] args) {
        // 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
        try (Service service = Fipc.createService("my_service",
                 4096, 256, 1, 1)) {
            try (Connection server = Fipc.joinService("my_service",
                     ServiceRole.SERVER, Duration.ofSeconds(5))) {
                // three requests, then the end
                for (int i = 1; i <= 3; i++) {
                    // the next request of any client
                    String request = new String(server.receive());
                    // the reply goes to its client, and never waits
                    server.send(request.toUpperCase().getBytes());
                }
            }
        }
        // closing the service ends it: the client still
        // receives the replies sent
    }
}
ServiceClient.java
import io.github.fastipc.Connection;
import io.github.fastipc.Fipc;
import io.github.fastipc.ServiceRole;
import java.time.Duration;

public class ServiceClient {
    public static void main(String[] args) {
        try (Connection client = Fipc.joinService("my_service",
                 ServiceRole.CLIENT, Duration.ofSeconds(5))) {
            for (int i = 1; i <= 3; i++) {
                // sends the request and returns its reply; throws
                // FipcException (ABANDONED) if the server ended
                // before it replied
                byte[] reply = client.call(("ping " + i).getBytes());
                System.out.println(new String(reply));  // PING 1..3
            }
        }
    }
}

Any language on the other end

one protocol

The Server and Client above work with a peer in any of the ten languages: every pair of languages is tested.

Threads and close

close, then join

On a connection, one thread at a time sends and one thread at a time receives, and the two may run at once. cancel() and close() may be called from any thread, at any time. close() cancels the connection, so a waiting call throws CANCELLED, and the native close runs once the calls in progress have returned: no call ever runs on a closed connection.

Waits are not interruptible: Thread.interrupt() doesn't wake a call; cancel() and close() do. A waiting call blocks its thread in native code, which pins a virtual thread, so use platform threads for calls that wait long. A listener or connection that is never closed is closed by a Cleaner once it is unreachable; a work queue or a service never is.

stop a reader thread
Thread reader = Thread.ofPlatform().start(() -> {
    try {
        while (true) {
            handle(new String(conn.receive()));
        }
    } catch (FipcException e) {
        // CANCELLED (closed) or DISCONNECTED
    }
});
...
conn.close();       // wakes the reader: CANCELLED
reader.join();

Install and native access

Java 22+ · FFM API

The binding calls restricted methods of the FFM API, so grant it native access, or the JVM prints a warning on the first call: --enable-native-access=ALL-UNNAMED with the jar on the class path, --enable-native-access=io.github.fastipc on the module path, or Enable-Native-Access: ALL-UNNAMED in an executable jar's manifest.

The jar's native library is extracted once per content into the user's cache (%LOCALAPPDATA%\fipc, ~/.cache/fipc, on macOS ~/Library/Caches/fipc) and checked before each load. The system property fastipc.library.path names another one; Fipc.libraryPath() says which was loaded.

build.gradle.kts
plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("io.github.fastipc:fipc:1.0.0")
}

application {
    mainClass = "Server"
    applicationDefaultJvmArgs =
        listOf("--enable-native-access=ALL-UNNAMED")
}

Reference

io.github.fastipc
CallWhat it does
Fipc.createStream(name, capacity)Claims the name for a stream and returns its Listener; 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 Connection. One client at a time: close it before the next accept. Fipc.NO_WAIT polls.
listener.cancel() · close()Cancel waiting accepts · free the name (any thread); accepted connections stay open.
Fipc.connectStream(name, [timeout])Connects to the stream's server, waiting for it to create the stream and accept: client and server run on different threads or processes.
send(message, [timeout])Sends one message of any size: a byte[] (or a range of one), a ByteBuffer or a MemorySegment.
receive([timeout])Receives one message of any size as a new byte[].
receive(buffer, [timeout])Receives into your ByteBuffer or MemorySegment and returns the length; TOO_LARGE if it doesn't fit.
sendMany(messages, [timeout]) · receiveMany(maxCount, [timeout])Batches: send a List<byte[]>, published together, and return how many went (on an error, sentMessages()) · receive every message already there, up to maxCount, as a list (or into a segment, their lengths in a long[]). Batch both ends.
sendAcquire(n) · sendCommit(n)Zero-copy send.
receiveAcquire() · receiveRelease()Zero-copy receive.
maxPiece() · cancel() · close()The zero-copy limit · cancel waiting calls · end the connection (any thread).
Fipc.createBroadcast(name, capacity, maxSubscribers, maxStall)Claims the name for a broadcast and returns the publisher's Connection; closing it ends the broadcast.
publisher.waitForMembers([atLeast, [timeout]])The number of joined subscribers; with atLeast, waits for that many. memberCount() holds the count at a TIMEOUT.
Fipc.joinBroadcast(name, role, [timeout])Joins the broadcast as a SubscriberRole.GATED or LOSSY subscriber; it receives, then DISCONNECTED once the publisher is closed.
Fipc.createQueue(name, capacity, maxProducers, maxConsumers)Claims the name for a work queue and returns its WorkQueue, which lives until close() or the end of the process.
queue.waitForMembers([role,] atLeast, [timeout])Waits until that many members of the QueueRole (without one, of both roles) are joined, and returns the count.
queue.cancel() · close()Cancel waiting waits · end the queue (any thread).
Fipc.joinQueue(name, role, [timeout])Joins the queue as a QueueRole.PRODUCER, which sends, or a CONSUMER, which receives each message no other consumer took, then DISCONNECTED once the queue has ended. The owner joins its own queue so too.
Fipc.createService(name, capacity, maxMessage, maxClients, maxServers)Claims the name for a service and returns its Service, which lives until close() or the end of the process.
service.waitForMembers([role,] atLeast, [timeout])Waits until that many members of the ServiceRole (without one, of both roles) are joined, and returns the count.
service.cancel() · close()Cancel waiting waits · end the service (any thread).
Fipc.joinService(name, role, [timeout])Joins the service as a ServiceRole.CLIENT, which sends requests and receives their replies in order, or a SERVER, which receives the next request of any client and replies with its next send. The owner joins its own service so too.
call(request, [timeout])A service's client: sends the request and returns its reply; ABANDONED if its server ended before it replied.

Measured through this binding, copied 16-byte messages run at 14 to 15 million per second between two processes on the x86-64 baseline machine and 27 to 35 million on Apple's M1, after the JIT's warm-up (details).