While experimenting with std.Io.Future , I noticed that true fire-and-forget style execution is somewhat difficult.
std.Io.Future is heap allocated internally, so if ownership is completely abandoned, the leak detector will eventually complain because nobody awaits or reclaims the future.
In practice, this means futures usually need some form of completion handling, even if the caller does not care about the result itself.
So I tried approaching this from a different angle:
Instead of awaiting futures directly, what if completed futures were periodically “reaped” by a dedicated type whose sole responsibility is reclaiming detached tasks?
The downside is that this requires periodic polling/ticking of the reaper.
I ended up with something like this:
fn DetachedTaskReaper(comptime buffer_size: comptime_int) type {
return struct {
buffer: [buffer_size]TaskResult = undefined,
tasks: std.Io.Select(TaskResult),
const Self = @This();
pub fn create(io: std.Io, allocator: std.mem.Allocator) !*Self {
var self = try allocator.create(Self);
self.* = .{
.tasks = std.Io.Select(TaskResult).init(io, &self.buffer),
};
return self;
}
pub fn deinit(self: *Self, allocator: std.mem.Allocator) void {
self.tasks.cancelDiscard();
allocator.destroy(self);
}
pub fn spawn(self: *Self, function: anytype, args: std.meta.ArgsTuple(@TypeOf(function))) void {
// Note: after reading the discussion below,
// concurrent() is probably the more appropriate
// primitive here than async().
self.tasks.async(.item, function, args);
}
pub fn tick(self: *Self) !void {
var buffer: [buffer_size]TaskResult = undefined;
const len = try self.tasks.awaitMany(&buffer, 0);
for (buffer[0..len]) |result| {
result.item catch |err| {
std.log.err("Detached task has error: {s}", .{ @errorName(err) });
};
}
}
const TaskResult = union(enum) { item: anyerror!void };
};
}
Example usage:
var reaper = try DetachedTaskReaper(1).create(io, gpa);
defer reaper.deinit(gpa);
reaper.spawn(...);
while (true) {
...
try reaper.tick();
...
}
This effectively behaves like a detached task completion scavenger/reaper.
One thing I had to be careful about was self-referential storage (Select.init(..., &self.buffer) ), which is why the reaper itself is heap allocated and pinned.
So far this has been working reasonably well for detached tasks.