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`

7 Likes

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

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

Cooperative cancellation for detached std.Io tasks

While experimenting with a Fire-and-Forget task reaper built on top of std.Io.Select.concurrent(), I ran into a shutdown latency issue and ended up introducing a cooperative cancellation mechanism.

I am curious whether others have approached this problem differently.

Problem

A detached task may spend most of its lifetime waiting.

For example, a heartbeat or retry actor often performs work, sleeps for a period of time, and then repeats:

while (true) {
    try sendHeartbeat();
    try std.Io.sleep(delay);
}

When shutting down, std.Io.Select.cancel() correctly cancels the managed Future, but if the task is currently sleeping, shutdown still waits until the task wakes up and reaches a cooperative point.

This is not specific to sleep(). Any long-running wait without an explicit cooperative check has the same issue.

Observation

std.Io cancellation appears to be cooperative.

That is generally desirable because it avoids forcibly terminating a task in the middle of execution.

However, for detached background actors, it is sometimes useful to provide an additional shared cancellation state that can be checked periodically by the task itself.

The task can then exit voluntarily at well-defined points.

Possible approaches

One option is similar to .NET’s CancellationToken pattern: pass the token explicitly as a function argument.

var token: CancellationToken = .{};

try io.concurrent(
    heartbeatTask,
    .{ channel, delay, &token },
);

The downside is that every function signature must explicitly include the cancellation parameter.

Another option is to treat the first argument as a receiver and inject the cancellation state through a field on that receiver.

For example:

const HeartbeatTask = struct {
    cancellation_token: ?*CancellationToken = null,
    // ...
};

This keeps cancellation-related state encapsulated in the receiver type.

Example implementation

The following is a simplified version of the approach I am currently experimenting with.

const CancellationToken = struct {
    state: bool = false,

    const Self = @This();

    pub fn cancel(self: *Self) void {
        @atomicStore(bool, &self.state, true, .release);
    }

    pub fn isCanceled(self: *const Self) bool {
        return @atomicLoad(bool, &self.state, .acquire);
    }
};

A reaper injects the shared token into the receiver before dispatching the task:

pub fn detach(
    self: *Self,
    function: anytype,
    args: std.meta.ArgsTuple(@TypeOf(function)),
) std.Io.ConcurrentError!void {
    var new_args: std.meta.ArgsTuple(@TypeOf(function)) = undefined;

    inline for (@typeInfo(@TypeOf(args)).@"struct".fields, 0..) |field, i| {
        if (i == 0) {
            if (@hasField(field.type, "cancellation_token")) {
                var receiver = args[i];
                receiver.cancellation_token = &self.shared_token;
                new_args[i] = receiver;
            }
        } else {
            new_args[i] = args[i];
        }
    }

    try self.select.concurrent(.processed, function, new_args);
}

The task can then periodically check the token:

pub fn run(self: *HeartbeatTask) !void {
    while (true) {
        if (self.cancellation_token.?.isCanceled()) {
            return;
        }

        try sendHeartbeat();

        try std.Io.sleep(self.delay);
    }
}

In practice I would likely split a long sleep into smaller intervals so cancellation latency remains bounded.

Lifetime considerations

The task only stores a pointer to the token.

As long as the reaper outlives all detached tasks, the token can be embedded directly in the reaper and referenced by the task.

Because the token owner already has a longer lifetime than the task, no additional pinning or heap allocation is required.

Notes

The example uses:

@hasField(field.type, "cancellation_token")

to detect whether the receiver supports cancellation.

Using:

@hasField(@TypeOf(args[0]), "cancellation_token")

may be a more robust alternative.

I have not yet explored enough edge cases to have a strong opinion on which is preferable.

Question

Has anyone found a cleaner way to inject shared cancellation state into std.meta.ArgsTuple-based APIs such as std.Io.concurrent() or std.Io.async()?

No? Cancelation wakes up sleepers. I think your entire premise is wrong. Did you actually test your assumptions?

1 Like

Thanks - I think the confusion may be about the layer I was referring to.

I agree that std.Io.sleep is cancellable and that cancellation can wake a sleeping task.

What I was trying to describe is a situation where a task is already inside a long-running sleep (or similar wait) when it is managed by std.Io.Select. In that case, calling std.Io.Select.cancelDiscard does wake the task, but I still observe that the system transitions into a waiting state for task completion rather than immediately terminating the task.

So my concern is not whether sleep itself is interruptible, but whether cancellation alone guarantees immediate termination of a detached task in this scheduling model.

I may still be misunderstanding parts of the behavior, so happy to be corrected.

Why don’t you use std.Io.Group for fire and forget tasks?

Anyway, keep in mind, that std.Io is an interface, there are different implementations.

1 Like

It can’t guarantee that because it cares about correctness.

Tasks may have resources they need to clean up even when cancelled. The caller may have resources associated with the task(s) cancelled, cleaning them before the task finishes could cause a data race that may result in a use after free.

Waiting also allows the runtime to reuse the calling task, which is necessary for implementing concurrency in an environment where you don’t have hardware concurrency.

A forceful termination could easily be the wrong thing for code and result in incorrect, buggy, unstable or undefined behaviour. So at the very least it should not be the default, and ideally more annoying to use.

Cancellation is a notification, it is not, and should not, be conflated with forceful termination.

double ā€˜l’ cancellation just to annoy Andrew :stuck_out_tongue:

3 Likes

I used std.Io.Select instead of std.Io.Group because I needed an event-loop-friendly, non-blocking way to reclaim completed tasks, rather than waiting for full completion of a task set.

std.Io.Group is closer to a construct that waits for all tasks to complete, which didn’t match this use case.

You don’t need to wait on the group at all. The tasks reap themselves. Did you know that Io.Select is implemented using Io.Group?

1 Like

I agree with the design rationale - especially regarding safety, cleanup, and avoiding forced termination.

My observation is more about the runtime behavior in a detached setup: after cancellation, the system appears to transition into a state where it still waits for task completion, even when the task was intended to be fire-and-forget.

So my concern is less about whether cancellation should force termination, and more about what guarantees (if any) exist for completion semantics in this execution model.

Yes, internally it is built on top of Io.Group.

However, Io.Group does not expose a way to opportunistically wait for already-terminated tasks.

That’s why Io.Select is used here instead - it provides a way to observe and collect completed tasks without waiting for the full group to finish.

What do you do with the task results you get from Io.Select?

My point is, if your goal is to fire-and-forget tasks, just cancel them on exit, then Io.Select just adds unnecessary layer on top of the Io.Group. And keep in mind that the queue inside Io.Select is also blocking and can deadlock if you don’t reap the tasks, while you have no such problem with Io.Group.

var group: std.Io.Group = .init;
defer grup.cancel(io);

try group.concurrent(io, taskFn, task_args);
try group.concurrent(io, taskFn, task_args);
try group.concurrent(io, taskFn, task_args);

// do whatever else you need to do in your app

You don’t need to wait on the group.

5 Likes

You are right that std.Io.Group can also be used in a fire-and-forget style, so my concern is not specifically about waiting for all tasks.

What I’m trying to reason about is a different aspect: when different tasks have different cooperative cancellation / suspension points, it is not guaranteed that they will converge to termination in a uniform way, even if the runtime provides cancellation.

So my interest in std.Io.Select here is more about observing and managing completion in an event-loop style, rather than the Group vs Select distinction itself.

Then I’m lost. The premise of std.Io cancelation is simple: every blocking operation will return error.Canceled after the task is cancelled. Your code is supposed to bubble the error up the stack, never swallow it. Correct code therefore has immediate cancelation. What is the situation where tasks don’t get canceled immediately? That would be a bug in the particular function, not the system.

2 Likes

I agree with the runtime model: cancellation propagates as error.Canceled within a single task.

My concern is at the workflow layer, where multiple subtasks with different cooperative cancellation points are composed. In that case, the observed behavior depends on composition rather than per-operation cancellation correctness.

This is also why a Group-style wait-for-all model does not fully match a fire-and-forget / reaper-style design.

I’m focusing on cancellation composition across task boundaries, not on the correctness of the runtime cancellation itself.

You are still ignoring the fact that you do not need to wait on tasks when using std.Io.Group.

What specific cancellation composition do you see as problem?

I don’t think the ā€˜forget’ part goes along with a systems programming language? add to that the obligation to handle errors syntax-wise is to make it harder to forget not to handle it as I remember Andrew stated it in some video. and it looks like you are trying to abstract too much in your code, or

aaand the man I’m quoting debunked me lol

I just tried refactoring your code to be smaller

pub const SS = struct {
    const Self = @This();

    select: std.Io.Select(union(enum) {}),
    shared_token: enum(u8) { canceled, what_else_should_i_add_here_HUH },

    pub fn detach(
        self: *Self,
        function: anytype,
        args: std.meta.ArgsTuple(@TypeOf(function)),
    ) std.Io.ConcurrentError!void {
        // can't wait for the |T| syntax
        // then this would be just new_args: T ...
        // see issue 32099
        var new_args: @TypeOf(args) = undefined;

        const fields = @typeInfo(@TypeOf(args)).@"struct".fields;
        inline for (fields[0..], 0..) |_, i| new_args[i] = args[i];
        if (@hasField(fields[0].type, "cancellation_token")) {
            new_args[0].cancellation_token = &self.shared_token;
        }

        try self.select.concurrent(.processed, function, new_args);
    }
};

maybe you want to look at it from the perspective of something like golang or the system itself rather than a high level language like C#, which I consider [the high level languages] like frameworks on top of C/Zig/Rust where you think of what the language provides rather than what the system can do, and also I think the Io model there is different anyways.

we might be able to achieve it by the following methods:

  1. golang style queue
  2. using an internal std.Io.Group
  3. using a futex to do something

I really still don’t know what you want to do exactly in the task or how do you handle the result or what is your general use case

1 Like

A concrete example would be a long-lived actor-like component.

The component owns resources such as sockets and channels, while detached background tasks perform periodic work (heartbeat, maintenance, event forwarding, etc.) using those resources.

Those tasks only receive access to the resources, not ownership of their lifetime.

When the component shuts down, it must eventually destroy those resources. Before doing so, I want to notify detached tasks to stop accessing them and converge toward termination.

In many cases this may not cause any practical issue. A task may naturally observe an error and terminate on its own. However, if a task continues to access a resource while shutdown is in progress, the resulting failure may surface far away from the actual shutdown point and be difficult to reason about.

From this perspective, the cancellation token is less about ā€œkilling a taskā€ and more about communicating that the resource owner is shutting down.

1 Like