Axle v0.14.1

Async I/O

The network and DNS resources expose an *Async verb that returns a Future<T>. From the caller’s perspective the API is identical regardless of what backs it — await the future, handle errors with try/catch, compose with other futures freely.

Two mechanisms back async I/O under the hood:

  • Reactor I/O (sockets): the socket is put in non-blocking mode and its fd is registered with the OS event queue — epoll on Linux, kqueue on macOS/BSD, IOCP on Windows. The event loop wakes the coroutine the instant that fd is reported ready. No thread is blocked.
  • Offload workers (DNS, and a hostname connectAsync): the blocking call is queued to a fixed pool of 4 worker threads; the event loop wakes the coroutine when the job publishes its result.

The difference matters for scale: reactor I/O handles thousands of simultaneous connections with zero extra threads; offload work runs at most 4 blocking operations at a time and queues the rest, so a burst of DNS lookups is serialised behind the pool rather than spawning a thread each.

HTTP is its own case: HttpClient::getAsync / postAsync are written in Axle and hand the round-trip to an OS thread with spawn, answering a Task<T> rather than a Future<T> (see HTTP below).

File I/O has no async verb — there is no fd the reactor can watch for a regular file, so file work belongs in the thread world: hand it to a spawned Task and .join() it. See Multithreading § Files run on threads.


Sockets

use std::net::Socket;
use std::net::ServerSocket;

// The accept loop lives in a PLAIN `fn`: an `await` inside a loop is
// only restricted in an `async fn` (E0721). Here each `await
// acceptAsync()` drives the event loop, which in turn advances every
// in-flight client coroutine — so the loop is the event-loop driver.
fn serve(port : i32) : void ! IOException {
    let srv : ServerSocket = new ServerSocket(port);
    srv.bind();
    while (true) {
        let peer : Socket = await srv.acceptAsync();  // drives the loop
        handleClient(peer);                           // fire-and-forget coroutine
    }
}

fn buildResponse(req : string) : string {
    return "HTTP/1.1 200 OK\r\nContent-Length: " + req.length + "\r\n\r\n" + req;
}

async fn handleClient(peer : Socket) : void ! IOException {
    let req  : string = await peer.readAsync(4096);
    let resp : string = buildResponse(req);
    await peer.writeAsync(resp);
    peer.close();
}

fn main() : i32 ! IOException {
    serve(8080);
    return 0;
}

acceptAsync, readAsync, and writeAsync integrate directly with the event loop’s reactor. Each call to await acceptAsync() drives the loop, which watches the server socket’s fd and every client socket’s fd at once; the moment any becomes ready the matching coroutine advances. No thread is blocked; every client is its own coroutine.

Socket methods

MethodReturnsNotes
connectAsync()Future<void>TCP handshake; suspends until connected
readAsync(max)Future<string>Read up to max bytes
writeAsync(data)Future<void>Write all bytes; suspends until drained
acceptAsync()Future<Socket>Accept the next incoming connection

All throw IOException (raised at the await).

Echo server — many clients, one thread

use std::net::Socket;
use std::net::ServerSocket;

// A coroutine can't `await` in a loop (E0721), so a multi-message
// session recurses instead: handle one message, then fire the next
// round as a fresh coroutine the loop will drive. `close()` can raise
// `IOException`, so the coroutine declares it in its `!` set (the
// fire-and-forget caller never consumes the future, so nothing has to
// catch it).
async fn echoClient(peer : Socket) : void ! IOException {
    try {
        let msg : string = await peer.readAsync(1024);
        if (msg.length == 0) {
            peer.close();          // peer hung up
            return;
        }
        await peer.writeAsync(msg);
        echoClient(peer);          // next round
    } catch e : IOException {
        peer.close();              // connection error — clean up
    }
}

// Plain `fn` accept loop (await-in-loop is legal outside an `async fn`).
fn echoServer(port : i32) : void ! IOException {
    let srv : ServerSocket = new ServerSocket(port);
    srv.bind();
    while (true) {
        let peer : Socket = await srv.acceptAsync();
        echoClient(peer);   // creates a Future, does NOT await it here
                            // — the loop drives it automatically
    }
}

fn main() : i32 ! IOException {
    echoServer(9000);
    return 0;
}

echoClient(peer) creates a new Future<void> without awaiting it. The event loop picks it up on the next round. Thousands of clients can be connected simultaneously; each is its own suspended coroutine, costing only its heap frame.


Files — use a thread, not the reactor

There is no File async verb. Concurrent file work runs in the thread world: spawn a Task per file and .join() the results, so the OS schedules the blocking reads across cores.

use std::io::BufferedReader;
use std::io::FileInputStream;

fn loadLines(path : string) : i32 ! IOException, FileNotFoundException {
    let reader : BufferedReader = new BufferedReader(new FileInputStream(path));
    defer reader.close();
    let n : i32 = 0;
    let line : string = reader.readLine();
    while (!line.isEmpty()) { n = n + 1; line = reader.readLine(); }
    return n;
}

// `spawn`ing a throwing function carries its error set to the caller,
// so `main` re-declares `! IOException, FileNotFoundException`.
fn main() : i32 ! IOException, FileNotFoundException {
    let paths : string[3] = ["a.txt", "b.txt", "c.txt"];
    let jobs  : Task<i32>[3];
    for (i of 0..3) { jobs[i] = spawn loadLines(paths[i]); }   // each starts now

    let total : i32 = 0;
    for (j of 0..3) { total = total + jobs[j].join(); }
    return total;
}

All three files are read concurrently, each on its own worker thread. See Files and I/O for the synchronous file surface and Multithreading for the thread world.


DNS

DNS lookups have no fd to watch, so they run on a worker thread:

use std::dns::dnsResolveAsync;

fn main() : i32 ! IOException {
    let hosts : string[] = [
        "api.example.com",
        "cdn.example.com",
        "db.example.com",
    ];
    // Array sizes are integer literals in Axle; size the future array to
    // the host count.
    let futures : Future<string>[3];
    for (i of 0..3) {
        futures[i] = dnsResolveAsync(hosts[i]);   // all lookups start now
    }
    // `await` inside a loop is fine here — `main` is a plain `fn`, not an
    // `async fn` (that is what E0721 restricts). Each await drives the loop.
    for (j of 0..3) {
        let ip : string = await futures[j];        // first IP, or "" on none
        println(ip);
    }
    return 0;
}

All lookups run in parallel. Each dnsResolveAsync call is queued to the offload pool — 4 reused worker threads, not one thread per call — so more than 4 concurrent lookups simply wait their turn; the driving loop suspends until each resolves.

FunctionReturnsNotes
dnsResolveAsync(host)Future<string>First IP, or "" on no result; IOException on failure (use std::dns::dnsResolveAsync)

HTTP

HttpClient::getAsync and postAsync run the full HTTP round-trip — connect, request, response — on a worker thread, and answer a Task<string> you join():

use std::net::HttpClient;

// `! IOException`: starting the work with `getAsync` is itself a
// throwing call (its signature declares it), so the call sites need the
// error set; the per-request error at the `join()` is handled by the `try`.
fn main() : i32 ! IOException {
    let urls : string[] = [
        "https://api.service-a.com/data",
        "https://api.service-b.com/data",
        "https://api.service-c.com/data",
    ];
    let pending : Task<string>[3];
    for (i of 0..3) {
        pending[i] = HttpClient::getAsync(urls[i]);   // all requests in flight
    }
    let ok : i32 = 0;
    for (j of 0..3) {
        try {
            let body : string = pending[j].join();
            if (body.length > 0) { ok = ok + 1; }
        } catch e : IOException {
            // skip the failed endpoint
        }
    }
    return ok;
}
MethodReturnsNotes
HttpClient::getAsync(url)Task<string>GET; body as string
HttpClient::postAsync(url, body)Task<string>POST with body

A Task<T>, not a Future<T>: a future is what calling an async fn mints, and these are ordinary static functions handing work to a thread. Inside an async fn, join() suspends like an await does — so both compose with the event loop.

Both http:// and https:// are served. TLS verifies the server against a trust store compiled into the runtime, so a program that connects on one machine connects on all of them; a certificate the store does not vouch for raises IOException naming the reason.


Timeouts on any async I/O

.timeout(ms) works uniformly across all async I/O:

use std::net::HttpClient;
use std::dns::dnsResolveAsync;

async fn reliableResolve(host : string) : string {
    try {
        // `.timeout(ms)` IS the await-with-deadline — no extra `await`.
        return dnsResolveAsync(host).timeout(5000);
    } catch e : TimeoutException {
        return "";
    } catch e : IOException {
        return "";            // the lookup itself can fail, too
    }
}

// HTTP carries its own deadline, on the request rather than on the
// wait: `.timeout(ms)` belongs to `Future<T>`, and `getAsync` answers a
// `Task<T>`. The socket deadline is the stronger guarantee anyway — it
// bounds a peer that accepts and then says nothing.
fn reliableFetch(url : string) : string {
    try {
        return HttpClient::request(url)
            .timeoutMillis(3000)
            .get()
            .body();
    } catch e : IOException {
        return "";
    }
}

If the deadline hits, the future is cancelled and TimeoutException is thrown. The same try/catch catches both timeout and IO errors. For HTTP, the deadline rides the request and surfaces as IOException.


Error handling

All async I/O errors are raised at the await site, so you handle them exactly like synchronous errors:

use std::net::HttpClient;

async fn safeGet(url : string) : string {
    try {
        return HttpClient::getAsync(url).join();
    } catch e : IOException {
        return "error: " + e.message;
    }
}

You can also let the error propagate up by declaring it in the ! error set on the async fn — the caller’s await will re-throw it.


Under the hood

Reactor I/O (sockets)

Non-blocking sockets use the platform’s event notification:

  • Linux/macOS: O_NONBLOCK + SOCK_NONBLOCK; connect raises EINPROGRESS, and the fd is registered for writability.
  • Windows: blocking connect on a pool worker (same Future<T> semantics from the caller’s perspective).

The fd is registered with the OS event queue once, as a oneshot, keyed by the future’s handle. A round of the event loop then attempts only the handles the kernel actually reported ready — not a rescan of every pending operation. An attempt that comes back WouldBlock re-arms its oneshot; one that completes disarms the registration.

The event queue is the key: it holds the interest set inside the kernel and sleeps the OS thread until any registered fd becomes ready, then returns just the ready ones. One wait watches a thousand sockets; the thread burns no CPU while nothing is happening, then wakes and resumes exactly the coroutines whose fds fired:

  event loop (one OS thread)                   kernel interest set
  ──────────────────────────                   ───────────────────
  attempt the fds reported ready ──► none left
  wait(timeout)             ── sleeps ──►      fd₄ ─ socket, no data yet
        (thread parked, 0% CPU)                fd₇ ─ socket, no data yet
                                               fd₉ ─ socket ← bytes arrive!
  kernel returns: [handle₉]     ◄──────────────┘
  attempt handle₉ → complete, disarm fd₉
  resume coroutine C₉ from its saved __state
  loop again ──► wait(timeout)

The wait is bounded by the nearest pending timer deadline and, failing that, by a 50 ms ceiling — so a timer never oversleeps and a passively-ready dependency (a thread join, an offload job) is still re-checked promptly.

Contrast the thread world, where a thousand blocked reads would mean a thousand parked OS threads (a thousand stacks). Here it is one thread, one kernel wait, and a thousand cheap coroutine frames — which is why reactor I/O scales to connection counts the thread world cannot.

Each OS thread that runs coroutines has its own event queue and its own task table, so two threads driving futures never contend.

Offload workers (DNS, hostname connectAsync)

A bounded pool of 4 persistent worker threads drains a shared job queue; a worker runs the blocking call and publishes the result (success value or exception class + message) into the job’s slot, which the event loop polls. A worker is reused across jobs — it is not one thread per operation — so more than 4 concurrent blocking calls simply queue. The calling coroutine suspends the instant the job is submitted and resumes once the slot is filled. Errors from the worker are raised on the consuming thread at the await, so try { await op } catch works normally.


See also

asyncfuturesionetworking