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.
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.
//
// `concurrent`, not `async`. `async` may run the task on this thread, and
// the threaded `Io` does once it is out of spare capacity: `async_limit`
// defaults to one less than the core count. A nap run on this thread naps
// for an hour here, and `group.cancel` below is never reached. It hangs
// whenever the group is at least as large as the core count, so it passes
// on a development machine and deadlocks on a small one.
var status: [3][]const u8 = @splat("still running");
var group: std.Io.Group = .init;
for (&status) |*slot| try group.concurrent(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();
}Run it yourself:zig run cancellation.zig
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.
io.recancel re-arms a request you have already handled, so the next point
reports it again. io.swapCancelProtection(.blocked) stops delivery entirely
across a region that must not be interrupted, such as a rollback or a flush;
it returns the previous state, so you put it back in a defer. 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| try group.concurrent(io, trackedNap, .{ io, slot });
group.cancel(io);
Every member gets the request, and cancel returns once all of them have
stopped. This is what makes structured
concurrency worth the extra parameter.
A server holding one Group of connection handlers shuts down by cancelling
the group. There is 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.
Use concurrent for a task you intend to cancel
That loop says group.concurrent, not group.async, and the difference is not
style. async is allowed to run the task on the calling thread. The threaded
Io does exactly that once it runs out of spare capacity. Its async_limit
defaults to one less than the number of logical cores, and past that limit
async runs the task immediately instead of scheduling it.
For a task that finishes on its own that is fine. It is why
concurrency starts eight accumulate
tasks with group.async: each one adds a number and returns, so running inline
costs nothing but parallelism.
For a task that only ends when cancelled it is a deadlock. trackedNap sleeps
for an hour. Run inline, it sleeps for an hour on the thread that was about to
call group.cancel, and that call is never reached.
This is not a race. It happens every time the group is at least as large as the core count, and never below it. Three sleepers hang on a machine with one, two or three cores and pass on four. That is the worst shape a bug can have: it passes on the machine you wrote it on, and hangs on the smaller one you deploy to. This snippet had it, and it took a CI runner with four cores and something else running to find it.
concurrent makes the promise async withholds. The task runs somewhere else,
or the call fails with error.ConcurrencyUnavailable and you are told. That is
also why nap and count further up are started with io.concurrent.
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. 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.