Fibers and a cooperative scheduler. A jaic extension, not official Jai: other Jai compilers
have no such module, and it may change between jaic releases. See docs/extensions/io-engine.md.
Example
Io :: #import"Extensions/Io";
results: *Io.Channel(int); // a fiber's procedure cannot capture locals: share through a global or pass data
main :: () {
Io.io_run(() { // the scheduler runs on this thread
results = Io.make_channel(int, 4);
defer Io.free_channel(results);
for1..4 Io.spawn((n: int) { Io.sleep(10 * n); Io.send(results, n); }, it);
total := 0;
for1..4 {
value, err := Io.recv(results);
assert(err == .NONE);
total += value;
}
assert(total == 10);
});
}
Doesn't run in the browser (skipped on wasm).
Show moreShow less
A fiber is a lightweight thread that the program switches itself: it has its own small stack,
and runs until it blocks (sleep, yield, a channel, a Wait_Group, a Fiber_Mutex), when the
scheduler runs another one. All fibers of an io_run share one OS thread, so they never run at
the same time and need no locks to share data. A blocking call returns .CANCELED once
cancel(fiber) has been called for its fiber.
Fibers need the compiler's stack-switching intrinsics, which native builds and the interpreter
(jaic -interp) have and WebAssembly does not: importing this module for wasm is a compile error.
The stack of each fiber, in bytes, including its guard page. Address space only: the OS commits
pages as the fiber touches them (Windows commits it all up front).
Runs proc as the first fiber of a new scheduler on this thread and returns when it has
returned. Fibers it started that are still alive then are canceled, and run until they end.
Starts proc as a new fiber on the current scheduler; it first runs when the caller blocks or
yields. It gets a copy of the caller's context (with temporary storage of its own) and
data is copied for it.
Io :: #import"Extensions/Io";
main :: () {
Io.io_run(() {
results := Io.make_channel(int, 1);
defer Io.free_channel(results);
Io.spawn((out: *Io.Channel(int)) { Io.send(out, 42); }, results);
value, error := Io.recv(results); // waits for the spawned fiberassert(error == .NONE && value == 42);
});
}
Makes the blocking calls of fiber return .CANCELED, now and from now on. The call it is
blocked in, if any, returns at once. Does nothing for a fiber that has ended.
Gives the memory of the running fiber's stack that no frame uses any more back to the system: the
pages below the current frames that earlier, deeper calls touched. Call it before a long wait
(an idle connection) so those pages do not stay resident; it costs a system call, so not in a
hot path. The stack's contents below the caller are lost, which nothing can rely on anyway.
Does nothing outside a fiber or on a platform without a way to discard pages.
Puts value into the channel, blocking while it is full. .CLOSED if the channel is (or becomes)
closed, .CANCELED if the fiber is canceled while it waits.
Listens on address (a literal such as ":8080", port 0 picks a free one; see
local_address). reuse_port lets several listeners, one per thread, share the port; the
kernel then spreads the connections between them.
Io :: #import"Extensions/Io";
main :: () {
Io.io_run(() {
listener, error := Io.tcp_listen("127.0.0.1:0");
assert(error == .NONE);
defer Io.close(listener);
address, _ := Io.local_address(listener);
assert(address.port != 0); // the system picked a free port
_, bad := Io.tcp_listen("not an address");
assert(bad == .INVALID);
});
}
Listens on the socket file path. The file must not exist yet; with replace an existing
file at path is removed first (whatever it is, so use it only for a path you own). The
socket file stays after close: remove it when the server is done.
The far end of a connected stream. On the accepting side this is the client's address and its ephemeral port; on the connecting
side it is the server's.
Io :: #import"Extensions/Io";
main :: () {
Io.io_run(() {
listener, _ := Io.tcp_listen("127.0.0.1:0");
defer Io.close(listener);
address, _ := Io.local_address(listener);
client, connect_error := Io.connect(address);
assert(connect_error == .NONE);
defer Io.close(client);
server, accept_error := Io.accept(listener);
assert(accept_error == .NONE);
defer Io.close(server);
far, _ := Io.peer_address(client);
assert(far.port == address.port); // the client's peer is the listener
near, _ := Io.local_address(client);
seen, _ := Io.peer_address(server);
assert(seen.port == near.port); // the server sees the client's own port
});
}
Waits until read would not wait: bytes have arrived, the peer closed its end, or the connection
failed (read then says which). Nothing is consumed and no buffer is needed, so a fiber can wait
for a quiet connection and take its memory only when there is something to read. Ends with
.TIMED_OUT or .CANCELED like any other wait, and .CLOSED if the socket is closed meanwhile.
Looks host up (an address literal, or a name for the system's resolver, which
runs on a helper thread so only this fiber waits) and gives its first address, IPv4 preferred.
Whether wait_readable works on reader, which is when its source supplies a wait_proc (a TCP stream does; a file or a pipe
end does not). When it is false, wait_readable returns .UNSUPPORTED.
Reads to the end of the source into memory from context.allocator (free it with free).
size_hint is the expected length, when it is known. On any error the bytes read so far are
freed.
Reads a document to the end of reader and decodes it as a T: the same value or error as
from_json(T, text) for the same bytes, however they arrive (a pipe written in small pieces,
a socket). The pieces are gathered in one buffer that is freed before this returns. A reader
that fails gives a .READ error naming the Io_Error; a document over options.max_size
is a .LIMIT error and the rest of the stream is left unread. The reader is not closed.
The one way to bound a wait. Limits every IO call (and sleep) in body to milliseconds
from now: one that is still waiting then returns .TIMED_OUT, and so does a call made after the
limit, even when data is ready. 0 is a limit that has already passed. Deadlines nest; the
earlier one wins, and the previous deadline is back when body ends. A negative milliseconds
adds no limit (NO_DEADLINE), so a timeout setting that may be off needs no second code path.
Channel and lock operations are not IO and ignore it.
Io :: #import"Extensions/Io";
main :: () {
Io.io_run(() {
client, server := Io.make_pipe();
buffer: [8] u8;
error: Io.Io_Error;
Io.with_deadline(20, #code {
_, error = Io.read(client, buffer); // nothing is ever sent
});
assert(error == .TIMED_OUT);
});
}
Runs body clear of the running fiber's deadline and cancel, for cleanup that has to happen
after one of them fired: telling a server to stop a query, a goodbye message. body has its own
limit of milliseconds (negative: none), and with_deadline inside it nests as usual. When
body ends, the outer deadline is back, and the fiber is canceled if it was before or a cancel
came during body.
The backend the running scheduler uses: .IO_URING, .EPOLL, .KQUEUE, .IOCP, .IORING, or
.NONE where IO is not implemented. Set JAI_IO_BACKEND to io_uring, epoll, kqueue,
iocp or ioring before io_run to ask for one; a backend the system cannot offer is
replaced (see backend_reason).
Why the running scheduler does not use the backend that was asked for or is the first choice on
this system (for example "io_uring is disabled here"). Empty when it does.
Starts count schedulers, each on a thread of its own, runs proc as the first fiber of every
one, and returns when all have finished. Fibers never move between them. With reuse_port
listeners (tcp_listen(..., reuse_port = true)) each thread accepts its own connections.
The value a library stored for key on the running scheduler, or null. Keys are addresses of
something the library owns (a global works). This is the per-thread variable the language does
not have: the Http client keeps its connection pool here.
Stores value for key on the running scheduler; drop (if not null) gets it when the
scheduler ends. Replaces an earlier value for the key without dropping it.
Makes SIGINT and SIGTERM set the flag interrupted reads, instead of ending the process. The
handler is installed once for the whole program; calling this again does nothing.
A free submission entry. With none free, the queued ones go to the kernel first, and
completions are reaped so that a full completion queue cannot hold submission back.
Operations still waiting on a descriptor would keep the file alive past the close, so they
are canceled first. The cancel is hard-linked so that finding nothing to cancel does not
stop the close.
accept through one multishot request per listener instead of one request per connection.
Returns false (and does nothing) when this call has to use a one-shot accept instead:
another fiber is already waiting on the listener, or the request is being canceled.
The listener fd is being closed: sockets accepted ahead of time are closed, and the slot
goes when the kernel has ended its request (the close cancels it).
The file offset goes into the OVERLAPPED. A negative one means the end of the file (for
appending handles, which only have FILE_APPEND_DATA access) or the start.
Why a blocking call returned early or failed. platform_code() gives the system's own error
number for the last failure of the running fiber, which is how OTHER stays useful.
Where a fiber is in its life: waiting in the pool, ready, running, blocked on a timer, channel or mutex, or finished. It is the
scheduler's own bookkeeping; programs rarely need it.
A name for one fiber, returned by spawn. It stays safe to use after the fiber has ended: the generation tells a reused fiber
slot from the one the handle named, so cancel and join on a stale handle do nothing.
A bounded queue of T between fibers. send blocks while it is full, recv while it is empty.
With capacity 0 a send and a receive meet: each waits for the other.
A lock for fibers. A fiber that cannot take it waits without blocking the thread, so unlike
Thread's Mutex it is safe to hold across a sleep or a channel operation. It is not
recursive, and waiters get it in the order they asked.
Fibers waiting until another fiber says that something changed. Unlike channels and locks,
wait honours with_deadline. A condition carries no value and remembers nothing: a notify
with nobody waiting is lost, so wait in a loop that checks the state itself.
Io :: #import"Extensions/Io";
changed: Io.Condition;
ready: bool;
main :: () {
Io.io_run(() {
error: Io.Io_Error;
// Nothing changes `ready` here, so the wait ends at the deadline.
Io.with_deadline(20, #code {
while !ready {
error = Io.wait(*changed); // .TIMED_OUT, .CANCELEDif error != .NONE break;
}
});
assert(error == .TIMED_OUT);
});
}
The IP version of an Address: IPV4, IPV6, or NONE for an Address that was never filled in (and for what
parse_address returns when the text is not an address).
address, ok := parse_address("[::1]:8080");
assert(ok && address.family == .IPV6 && address.port == 8080);
address, ok = parse_address("127.0.0.1:80");
assert(ok && address.family == .IPV4);
address, ok = parse_address("not an address");
assert(!ok && address.family == .NONE);
Waits until read would not have to wait: a byte is there, the source ended, or it failed (the
read that follows says which). Nothing is consumed, so it needs no buffer.
A source of bytes: a pointer to its data and the procedures to use on it. reader_of makes one for a file, a TCP stream or
a pipe end, and a struct of your own can fill in read_proc (and optionally close_proc and wait_proc). A Reader is a
handle: copy it freely, it owns nothing, and the source must stay alive while it is used. close on it closes the source.
A sink for bytes, the counterpart of Reader: a pointer to its data, a write_proc and an optional close_proc. writer_of
makes one for a file, a TCP stream or a pipe end. It is a handle that owns nothing; close on it closes the sink.
The system facility a scheduler waits on: IO_URING or EPOLL on Linux, KQUEUE on macOS, IOCP or IORING on Windows. The
scheduler picks the best one the machine offers; the JAI_IO_BACKEND environment variable names another (io_uring, epoll,
kqueue, iocp, ioring). NONE is a system with no IO, where every operation fails with .UNSUPPORTED. Behaviour seen
through the API is the same on all of them; they differ in speed and in the kernel versions they need.
What set_scheduler_local is given to release a value when its scheduler ends. It runs on the
scheduler's thread after every fiber has ended, so it must not block: close sockets with
close(stream, now = true).
The ring versions to try, newest first: IORING_VERSION_3 is 300 in ioringapi.h. File writes
need one of the later versions; the self test builds a write entry to find out which a
system accepts.