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.
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.