⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eOn an older Zig?

Cancellation

A task you started is a task you may need to stop: the client hung up, the timeout fired, the user pressed Ctrl-C. Cancellation in Zig is a request, not a kill. You ask, and the task finds out at its next cancellation point.

Built and run natively by CI. Cancelling a task that is still running needs an Io that can run it somewhere else, and wasm32-wasi cannot.
const std = @import("std");

/// Sleeps for an hour, or until someone cancels it. `io.sleep` is a
/// *cancellation point*: a function on `Io` that can return `error.Canceled`.
fn nap(io: std.Io) std.Io.Cancelable!void {
    try io.sleep(.fromSeconds(3600), .awake);
}

/// Same nap, but recording how it ended so the group below can be inspected
/// after the fact.
fn trackedNap(io: std.Io, status: *[]const u8) std.Io.Cancelable!void {
    io.sleep(.fromSeconds(3600), .awake) catch |err| {
        status.* = @errorName(err);
        return err;
    };
    status.* = "finished";
}

/// Pure computation calls nothing on `Io`, so it has no cancellation point of
/// its own and would spin forever. `io.checkCancel` is the point you add.
fn count(io: std.Io, counter: *u64) std.Io.Cancelable!void {
    while (true) {
        try io.checkCancel();
        counter.* += 1;
    }
}

fn double(value: u32) u32 {
    return value * 2;
}

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    var buf: [512]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
    const out = &file_writer.interface;

    // `cancel` is `await` plus a cancellation request: it blocks until the task
    // is done, and hands back what the task returned.
    var sleeping = try io.concurrent(nap, .{io});
    if (sleeping.cancel(io)) |_| {
        try out.print("nap: finished\n", .{});
    } else |err| {
        try out.print("nap: {s}\n", .{@errorName(err)});
    }

    // Cancelling a task that already finished is not an error. There was no
    // cancellation point left to reach, so you get the result.
    var finished = io.async(double, .{21});
    try out.print("double: {d}\n", .{finished.cancel(io)});

    // The counting task only notices because it asks. Take the `checkCancel`
    // out and this line never prints.
    var counter: u64 = 0;
    var counting = try io.concurrent(count, .{ io, &counter });
    if (counting.cancel(io)) |_| {
        try out.print("count: finished\n", .{});
    } else |err| {
        try out.print("count: {s}\n", .{@errorName(err)});
    }

    // A Group cancels as a whole: every member gets the request, and `cancel`
    // returns once all of them have stopped.
    var status: [3][]const u8 = @splat("still running");
    var group: std.Io.Group = .init;
    for (&status) |*slot| group.async(io, trackedNap, .{ io, slot });
    group.cancel(io);
    for (status, 0..) |s, i| try out.print("group task {d}: {s}\n", .{ i, s });

    try out.flush();
}

cancel is await plus a request

var sleeping = try io.concurrent(nap, .{io});
if (sleeping.cancel(io)) |_| { ... } else |err| { ... }

Future.cancel blocks until the task is done, exactly like await, and hands back what the task returned. The difference is that it first asks the task to stop. So a cancelled task still gets to run its defers and release what it holds; there is no point at which its stack is yanked out from under it.

Cancelling a task that already finished is not an error. There was no cancellation point left to reach, so cancel just returns the result. Both cancel and await are idempotent, and neither is threadsafe.

A cancellation point is a call on Io

The request is delivered at the next function the task calls on its Io that can return error.Canceled: a sleep, a read, a lock, an accept. That is the whole rule, and it has a consequence:

fn count(io: std.Io, counter: *u64) std.Io.Cancelable!void {
    while (true) {
        try io.checkCancel();
        counter.* += 1;
    }
}

Pure computation calls nothing on Io, so it has no cancellation point and cannot be cancelled. Delete the checkCancel above and the loop spins forever with the request sitting undelivered. io.checkCancel exists to put a point into code that would otherwise have none. Long compute loops need one; how often depends on how quickly you need it to stop.

error.Canceled is not a failure to swallow

Only the next cancellation point reports it. After that the request is spent, and later points return normally, so a task that catches error.Canceled and carries on will not be told again. That makes ignoring it a bug in almost every case: propagate it, and let the caller who asked for the cancellation see that it happened.

Two escape hatches exist for when you genuinely need to finish something first. io.recancel re-arms a request you have handled so the next point sees it again, and Io.CancelProtection (via io.swapCancelProtection) blocks delivery across a region that must not be interrupted, such as a rollback or a flush. Both are for the cleanup path, not for staying alive.

A Group cancels as a whole

var group: std.Io.Group = .init;
for (&status) |*slot| group.async(io, trackedNap, .{ io, slot });
group.cancel(io);

Every member gets the request, and cancel returns once all of them have stopped. This is the property that makes structured concurrency worth the parameter: a server holding one Group of connection handlers shuts down by cancelling the group, with no list of threads to track and no handler left running after the scope that started it is gone.

A Group is also a cancellation boundary. Its tasks must return something coercible to Cancelable!void, and a member returning error.Canceled stops at the group rather than propagating out of it. Group.await returns error.Canceled when the task doing the awaiting is itself cancelled, not because a member was. If you want to know how a member ended, record it, the way the snippet writes each task’s outcome into a slot.

Cancellation is why lock can fail

Io.Mutex.lock, io.sleep and Io.RwLock.lock all return Cancelable!void where older code had a plain void. It is not that locking became unreliable. Cancelable is a one-member error set and that member is error.Canceled, so a signature whose only error is that one is telling you it is a cancellation point and nothing more. Where a call genuinely must not be one, the standard library spells it out: lockUncancelable, futexWaitUncancelable.