Select
Future.await waits for one task. Group.await waits for all of them.
std.Io.Select is the third shape: wait for whichever finishes first, then
decide what to do about the others. A request against a timeout is the case
you will write most often.
const std = @import("std");
/// Whichever arm wins, the result arrives as this union, tagged with the arm
/// that produced it.
const Outcome = union(enum) {
reply: u32,
timeout: void,
};
fn request(io: std.Io, delay: std.Io.Duration) u32 {
io.sleep(delay, .awake) catch return 0;
return 42;
}
fn deadline(io: std.Io, after: std.Io.Duration) void {
io.sleep(after, .awake) catch {};
}
/// Runs both arms and reports the one that finished first, cancelling the
/// other. The margin between the two durations is an hour, so the winner is
/// decided by the code and not by how loaded the machine is.
fn race(
io: std.Io,
out: *std.Io.Writer,
reply_after: std.Io.Duration,
timeout_after: std.Io.Duration,
) !void {
var slots: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &slots);
try select.concurrent(.reply, request, .{ io, reply_after });
try select.concurrent(.timeout, deadline, .{ io, timeout_after });
switch (try select.await()) {
.reply => |value| try out.print("reply: {d}\n", .{value}),
.timeout => try out.print("timed out\n", .{}),
}
// The losing arm is still running. A select owns its tasks, so cancelling
// the select stops it, and nothing is left behind when this returns.
select.cancelDiscard();
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
var buf: [256]u8 = undefined;
var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
const out = &file_writer.interface;
try race(io, out, .fromMilliseconds(1), .fromSeconds(3600));
try race(io, out, .fromSeconds(3600), .fromMilliseconds(1));
try out.flush();
}The union is the result
const Outcome = union(enum) {
reply: u32,
timeout: void,
};
var slots: [2]Outcome = undefined;
var select: std.Io.Select(Outcome) = .init(io, &slots);
Each arm is a field. Select(U) is a Group and a Queue(U) together, so the
buffer you pass is the queue’s, and it must have room for every task you spawn:
cancelling with less space than that deadlocks, because the tasks being
cancelled still need somewhere to put their results.
try select.concurrent(.reply, request, .{ io, reply_after });
try select.concurrent(.timeout, deadline, .{ io, timeout_after });
switch (try select.await()) {
.reply => |value| ...,
.timeout => ...,
}
The field name is comptime, and the function’s return type has to match that
field, so an arm returning the wrong type is a compile error rather than a
mis-tagged union at run time. await returns as soon as any arm completes;
call it again for the next one, or stop.
The losers are still running
select.cancelDiscard();
A select owns its tasks the way a Group does, and the arm that lost is still
in flight when await returns. cancelDiscard requests cancellation on all of
them and waits until they are done, throwing the results away. Use cancel
instead when an arm can return something that has to be freed: it hands back
one outstanding result per call, and you loop until it returns null.
Skipping this does not leak in the ordinary sense, but the select cannot be destroyed while a task still points into its buffer, so there is nothing to be gained by leaving it out.
Use concurrent for the arms
select.async exists and matches Group.async, but a race between arms that
are allowed to run inline is not a race. On an Io that runs tasks inline, the
first arm spawned runs to completion first and wins every time, which for a
timeout arm means sleeping out the entire timeout before the request is even
started. The arms here use concurrent for that reason, and the snippet is
native because concurrent is what wasm cannot provide.
The durations in the snippet differ by an hour in each direction. That is deliberate: the winner is decided by the code rather than by how loaded the machine is, so the expected output holds on CI. A race whose margin is milliseconds is a flaky test, not a demonstration.