Fire and forgotten std.Io.Future

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.

Instead of anyerror use a defined error set.

You also don’t need to heap allocate, instead:

// not create since it doesn't heap allocate
fn init(self: *Self, io: std.Io) void {
    self.tasks = .init(io, &self.buffer);
}

// used like this:
var reaper: DetachedTaskReaper(10) = undefined;
reaper.init(io);

Initialisation is always preferable over creation as it gives control over the creation to the caller.

Lastly; the semantic of group.async (used by Select.async) mean select could deadlock, though no Io implementation currently has this behaviour. But the semantics could also change `groupAsync` vs `Group.async`

4 Likes

Fire and forget tasks is Group.concurrent on a Group that lives in main() (or as a global var)

7 Likes

Thanks, this makes a lot more sense now.

I also realized after reading #15313 that async is intentionally much weaker semantically than I initially assumed. In particular, I had missed that execution itself is not guaranteed until await /cancel , and that eager execution combined with a full completion queue could potentially block as well.

So for actual fire-and-forget style execution, concurrent definitely seems like the more appropriate primitive.

Also good point regarding pinning / self-referential storage. The original init(self: *Self) form is technically fine if the object never moves after initialization, but it would probably be too easy to accidentally shoot myself in the foot by returning or copying the struct later.

For example:

fn makeReaper(io: std.Io) DetachedTaskReaper(10) {
    var r: DetachedTaskReaper(10) = undefined;
    r.init(io);
    return r;
}

Heap allocation/pinning is likely the safer API shape there.