Skip to content

Proposal: stackless coroutines as low-level primitives #23446

Open

Description

@mlugg

This proposal assumes the existence of #23367.

Background

At the time of writing, @andrewrk and @jacobly0 are exploring the idea of implementing async/await in userland, based on the new Io interface. This allows using different approaches to implement async, such as single-threaded blocking, thread pool blocking, or green threads / fibers. The latter approach is likely to become the preferred method, because it is fairly performant and conceptually simple. At this point it seems incredibly likely that this will become the path forward for async/await in Zig; it has a lot of advantages!

However, stackful coroutines via green threads do have some flaws. One of the most significant is that they simply aren't supported on some targets, such as Wasm and SPIR-V. Wasm is a particularly interesting example, because using stackless async in Wasm can be desirable in order to integrate with the JavaScript event loop -- for instance, expensive work in Wasm ought to periodically yield to the browser to allow other work to happen. The only way we can really do that is through stackless coroutines, which require language-level support. In general, stackless coroutines will have broader support than stackful coroutines, because all of the suspend/resume machinery is lowered by the compiler to synchronous constructs.

#6025 proposes re-introducing stackless coroutines, as in stage1, to the self-hosted compiler. This proposal competes with #6025 by taking the alternative approach to provide simpler primitives upon which a stackless std.Io implementation can be built, forgoing nice syntax and type safety in favour of a simpler language specification and compiler implementation. This proposal assumes that language features pertaining to "old" async (e.g. the async, await, and anyframe keywords) are removed.

Proposal

Add the following declaration to std.builtin:

pub const AsyncFrame = opaque {};

Implement the following builtins:

/// Returns the required size of the async frame buffer for the given function or function pointer.
/// Function pointers must be restricted (#23367).
/// The result is always comptime-known.
@asyncFrameSize(func: anytype) usize;

/// Initializes an async frame in `frame_buf`, such that calling `@asyncResume` on the
/// returned frame pointer will begin executing the function.
/// `func` may be a function or function pointer.
/// Asserts that `frame_buf.len` matches `@asyncFrameSize(func)`.
/// The parameter to `func` is given at the call to `@asyncResume`.
/// "Frame buffer" and "frame pointer" mean different things. The returned frame pointer
/// is somewhere within `frame_buf`, but its exact address is arbitrary. 
@asyncInit(frame_buf: []u8, func: *const fn (*anyopaque) void) *std.builtin.AsyncFrame;

/// Given a function's frame pointer, resumes that function.
/// Despite the name, this is also how you first start running an async function after calling `@asyncInit`.
/// If this is the first time resuming (i.e. this frame was just initialized), `arg` is passed to the `func`
/// given in `@asyncInit`. Otherwise, `arg` is returned by the `@asyncSuspend` we are resuming from.
/// Returns the argument to the `@asyncSuspend` call, or `null` if the function completed without suspending.
/// If the call completed, it is Safety-Checked Illegal Behavior to call `@asyncResume` on this frame again.
@asyncResume(frame: *std.builtin.AsyncFrame, arg: *anyopaque) ?*anyopaque;

/// Suspends the current function. All live values are spilled to its async frame buffer if necessary.
/// After the function is suspended, control flow returns to the nearest `@asyncResume` site.
/// The `data` argument is returned from `@asyncResume`.
@asyncSuspend(data: *anyopaque) *anyopaque;

/// Retrieve the current function's async frame pointer.
/// If this is not an async function (i.e. it has no suspension points), `null` is returned.
@asyncFrame() ?*std.builtin.AsyncFrame;

// EDIT: this last builtin is unnecessary! Disregard it...
// /// Like `@call` with `std.builtin.CallModifier.auto`, but asserts that the call never suspends, even
// /// if it is an async function. In other words, the callee having suspension points does not give the
// /// caller a suspension point. If the callee suspends, Safety-Checked Illegal Behavior is invoked.
// @asyncNoSuspendCall(func: anytype, args: anytype) anytype

All relevant builtins begin @async, making the scope of this language feature very clear.

Mapping concepts from #6025 to this proposal:

  • @Frame() and anyframe types do not exist; raw byte buffers and opaque pointers are used.
  • @frame() is called @asyncFrame().
  • async foo() is equivalent to @asyncInit followed by @asyncResume.
  • await frame is equivalent to repeated @asyncResume calls until one returns null.
  • nosuspend foo(arg) is equivalent to @asyncNoSuspendCall(foo, .{arg}).
    • Actually, nosuspend foo(arg) is just @asyncInit followed by @asyncResume but asserting that the latter returns null!
  • suspend is @asyncSuspend(). There is no longer a "suspend block"; I elaborate on this below.
  • resume is @asyncResume(). I elaborate on the argument and return value of this builtin below.

A function is async if it has any "suspension point". A suspension point is:

  • a usage of @asyncSuspend, or
  • a call of another async function (including through @call)

An async function must have callconv(.auto).

Suspending and Resuming

Under #6025, when a function suspends, control flow moves to its "suspend block" before being passed to the resume or async site. This block is generally responsible for actually queuing the asynchronous operation, and storing the @frame() somewhere ready to be resumed when the operation completes. The crucial thing is that once in the suspend { ... } block, the function is already considered suspended, so it is safe for another thread to resume it.

This proposal takes a different approach for simplicitly. The @asyncSuspend builtin takes a value of type *anyopaque, which is returned by the corresponding @asyncResume() call. That @asyncResume() call can then use that arbitrary data to queue the operation and store the async frame somewhere. The code which calls @asyncSuspend would include in that pointer the current @asyncFrame(), so that the event loop knows which frame pointer to resume later, and information about the reason for suspension, so that the event loop can schedule the appropriate action. Later, when the function is resumed, an argument (again of type *anyopaque) is passed to @asyncResume, and this value is returned from the @asyncSuspend we are resuming from to act as a kind of "result".

This approach wasn't possible under the old design, because writing var frame = async foo(); needed to queue the asynchronous operation without requiring the user to put extra code after that async invocation. However, the equivalent for this code is now var future = io.async(foo, .{});, so the async implementation is free to implement this machinery.

Implementation Notes

The implementation still internally has the concept of "awaiters", due to function calls. When a function directly calls another async function, the caller creates a frame buffer nested in its own frame, initializes it, marks itself as the "awaiter" inside that frame, and then calls the callee. However, this internal "awaiter" field would only be for direct function calls; it wouldn't apply to io.await, which would be implemented in the event loop by (very vaguely, ignoring parallelism/atomic issues):

  • Storing, in the event loop, that the awaiter is waiting for the awaitee
  • @asyncSuspend()ing the awaiter
  • Allowing the event loop to repeatedly resume the awaitee until it finishes
  • Once the awaitee finishes, resuming the awaiter again, based on the event loop's own state

I'm not sure if this point is novel (I wasn't deeply familiar with the legacy async implementation), but: this call (one async function directly calling another) can actually be a tail call, because the callee will then tail call the caller again once it returns (due to the caller being marked as its "awaiter" in this internal field). This decreases stack usage, and also speeds up suspension, because there's no need to unwind the whole stack.

One nice thing about async and await being implemented in userland is that the @async builtins do not need to be aware of threads. In the legacy async implementation, each frame included one or two atomic fields to deal with an await racing with frame completion. In contrast, this proposal offloads that work into the Io implementation, which can choose how to handle it. This could be a performance advantage; LLVM struggles to optimize around those atomics, so a stackless-coroutine-based event loop which knows it's single threaded (even if the application as a whole is not -fsingle-threaded) could choose not to use an atomic flag, helping the optimizer.

Closing Notes

There are details of this proposal that need to be hashed out; coroutines are complicated! There are also probably things I need to specify which I haven't (like clarifying how this interacts with function pointers). Nonetheless, I wanted to put this proposal up to invite discussion on this potential path forward which I, for one, am quite excited by.

Activity

  1. added
    proposalThis issue suggests language modifications. If it also has the "accepted" label then it is planned.
    on Apr 3, 2025
  2. added this to the 0.15.0 milestone on Apr 3, 2025
  3. ethernetsellout commented on Apr 3, 2025

    @ethernetsellout

    Lack of atomic ops is not the only help to the optimizer; this would essentially solve the problem noted in #5277. Under this proposal, there are no frame types, so @asyncFrame would return the correct type in the given example if the async function is inlined.

  4. mlugg commented on Apr 3, 2025

    @mlugg
    MemberAuthor

    I've edited this proposal to tweak how frames work a little. I now distinguish between a "frame buffer" and a "frame pointer". The former is a []u8 you pass to @asyncInit, while the latter is a *std.builtin.AsyncFrame (an opaque type) which lives inside that buffer. This avoids needing to store slices in event loops to represent frames which need resuming. It also avoids potential API footguns, because you now just never hold a frame that you shouldn't resume (frames should be "threaded through" async calls; every frame the event loop gets it should resume exactly once). This approach eliminates the need for @asyncFrameAlign; the machine code just requests an oversized buffer so it can align the pointer internally. This is simpler, and also more friendly to incremental compilation.

  5. alexrp commented on Apr 3, 2025

    @alexrp
    Member

    The @asyncResume name really rubs me the wrong way since it's also used to start an async function. If you want to keep it as a single builtin rather than splitting it in two, what do you think about @asyncStep, @asyncAdvance, or something like that?

  6. andrewrk commented on Apr 3, 2025

    @andrewrk
    Member

    Can you share how you would implement the Io interface with these primitives?

  7. mlugg commented on Apr 3, 2025

    @mlugg
    MemberAuthor

    Can you share how you would implement the Io interface with these primitives?

    Absolutely -- I've sketched out a vague implementation locally, I'll try to neaten it up and post it here soon.

  8. mlugg commented on Apr 3, 2025

    @mlugg
    MemberAuthor

    @andrewrk

    Okay, it's only proof-of-concept quality and I definitely will have made some mistakes, but hopefully this gets the point across; it's a partial implementation of a single-threaded (I didn't want to think about synchronization) event loop based on this proposal.

    Any actual IO (that is, OS interop stuff) is stubbed. For the purposes of the example, I've implemented async, await, sleep, and now. (I've not defined the vtable, but those 4 functions are at the bottom of the file.

    https://gist.github.com/mlugg/ba402a8b1c863ebbba0996481dade3b3

  9. InKryption commented on Apr 3, 2025

    @InKryption
    Contributor

    Just to have this written down:
    Are there significant use cases where the caller of @asyncSuspend would want to send to @asyncResume a frame other than its current @asyncFrame?

    If not, would it be reasonable for @asyncResume to return a tuple of *anyopaque, *AsyncFrame, where the async frame returned would be the @asyncFrame the caller of @asyncSuspend would have to pass through the *anyopaque pointer anyway?
    If that is reasonable, could @asyncFrame() be removed altogether, in favor of this thread-through being directly encoded by @asyncResume?

  10. andrewrk commented on Apr 3, 2025

    @andrewrk
    Member

    I'm not sold on the argument for changing the way suspend and resume works. I found that to be subjectively more intuitive the old way. Objectively, it is more flexible. Consider the use case of making a game and you want to use the event loop but you also want to make some of your own primitives for animation:

    /// blocks until `n` frames have been rendered
    fn waitNGameFrames(n: usize) void {
        var entry: FrameWaiter = .{
            .node = .{},
            .remaining = n,
            .async_frame = @asyncFrame().?,
        };
        suspend {
            game.frame_waiters.append(&entry);
        }
    }
    
    fn mainLoop() void {
        // ...
        var opt_waiter = game.frame_waiters;
        while (opt_waiter) |waiter| {
            waiter.remaining -= 1;
            if (waiter.remaining == 0) {
                game.frame_waiters.remove(&waiter.node);
                resume waiter.async_frame;
            } else {
                 opt_waiter = waiter.node.next;
            }
        }
        // ...
    }

    If I understand correctly, with this proposal, the event loop implementation would end up trying to handle this "wait n game frames" suspension in handleSuspension, which is an application concept. So we get a compile error due to suspend info not including application-specific things in the event loop? That's clearly worse than the alternative.

  11. KilianHanich commented on Apr 3, 2025

    @KilianHanich

    So, from the proposal I guess something like this would work:

    fn Generator(T: type, function: *const fn(*T) void) type {
        return struct {
            buffer: [@asyncFrameSize(function)]u8,
            frame: *std.builtin.AsyncFrame,
            start: T,
            const Self = @This();
    
            pub fn init(start: T) Self {
                var new: Self = undefined;
                new.frame = @asyncInit(&new.buffer, function);
                new.start = start;
                return new;
            }
    
            pub fn next(self: *Self) ?T {
                var e: T = self.start;
                if (@asyncResume(self.frame, &e)) |_| {
                    return e;
                } else {
                    return null;
                }
            }
        };
    }
    
    fn yielder(start: *u32) void {
        var e = start.*;
        var ptr = start;
        while (true) {
            ptr = @asyncSuspend(ptr);
            e = std.math.add(u32, e, 1) catch break;
            ptr.* = e;
        }
    }
    
    pub fn main() void {
        var iter = Generator(u32, yielder).init(0);
        while (iter.next()) |e| {
            std.debug.print("{}\n", .{e});
        }
    }

    Now, obviously this example isn't all that useful, since one can write a "yielder" considerable easier, but it shows a few things:

    1. You always need to give the resume builtin a pointer, even if you don't really need a value.
    2. The pointer "start" inside of the coroutine is only valid until the first suspend. That's a potential footgun which you could run into easily.
    3. If the thing you want to "yield" has a different type than the starting parameters, is that supported? For example here one couldn't easily have a start and a stop value.
    4. What do you do when you don't want to yield a value, but "void" as in "I am not finished yet". Ok, one could do that by yielding null so that the return of @asyncResume would be in practice ?*?SomeType, so you know that it's finished and now you need to call @asyncResume again for (possible) cleanup I guess.

    I general I get one question: Why do the functions take a pointer? Sure, it's technically an anyopaque, but in practice one wouldn't yield different types (What kind of code would actually look like that?) and at least the parameter types are known from the get-go. As it is, you don't really get any help from the compiler in regards to type safety.

    So here are some changes I would do for better type safety and ergonomics:

    1. Remove the *anyopaque and instead let the type be figured out by the compiler similar to @memset and @memcpy.
    2. @asyncInit should additionally take the parameter to the function as an additional parameter which gets checked at compile time that types match.
    3. @asyncResume and @asyncSuspenddon't take pointers, but values. The types of the parameter to@asyncResumeand the return type of@asyncSuspendare compile time check the same, same goes for the return type of@asyncResume(with the different that it's an optional) and the parameter to@asyncSuspend`.

    With these changes, the code would look like this:

    fn Generator(T: type, function: *const fn(anytype) void) type {
        return struct {
            buffer: [@asyncFrameSize(function)]u8,
            frame: *std.builtin.AsyncFrame,
            const Self = @This();
    
            pub fn init(start: anytype) Self {
                var new: Self = undefined;
                new.frame = @asyncInit(&new.buffer, function, start);
                return new;
            }
    
            pub fn next(self: *Self) ?T {
                if (@asyncResume(self.frame, void{})) |e| {
                    return e;
                } else {
                    return null;
                }
            }
        };
    }
    
    fn yielder(params: struct{u32, u32}) void {
        const e = params[0];
        while (e <= params[1]) {
            @asyncSuspend(e);
            e = std.math.add(u32, e, 1) catch break;
        }
    }
    
    pub fn main() void {
        var iter = Generator(u32, yielder).init(.{0, 10});
        while (iter.next()) |e| {
            std.debug.print("{}\n", .{e});
        }
    }
  12. KilianHanich commented on Apr 3, 2025

    @KilianHanich

    I also have a question: The basic promise of #6025 is that the compiler ensures that you don't have function colouring.

    How does this proposal ensure it?

  13. andrewrk commented on Apr 3, 2025

    @andrewrk
    Member

    I think the generator use case would be better handled with Io.Queue:

    fn yielder(start: usize, end: usize, queue: *Io.Queue(?usize)) error{Canceled}!void {
        for (start..end) |i| {
            try queue.putOne(i);
        }
        try queue.putOne(null);
    }
    
    fn example(io: Io) error{Canceled}!void {
        var queue: Io.Queue(?usize) = .init(&.{});
        var iter = io.async(yielder, .{0, 10});
        defer iter.cancel(io) catch {};
    
        while (try queue.getOne()) |e| {
            std.log.debug("{d}", .{e});
        }
    }

    By passing a non-empty buffer to init it will make the yielder able to batch more work. Also the queue allows multiple producers / multiple consumers.

    As you can see there are no function colors.

  14. mlugg commented on Apr 4, 2025

    @mlugg
    MemberAuthor

    I'm not sold on the argument for changing the way suspend and resume works. I found that to be subjectively more intuitive the old way.

    This is, as you say, subjective, but FWIW the suspend block was always quite confusing to me -- I never really understood it when I looked into async before my compiler dev days, although that could have just been that it was explained poorly to me. In contrast, I find the approach outlined in this proposal -- the ability to pass an arbitrary piece of data from suspender to resumer and vice versa -- obviously and significantly simpler. (Incidentally, I'm also a big fan of InK's suggestion to have @asyncResume also return the frame pointer, because it must always resume the frame later, and this eliminates the need for @asyncFrame entirely.)

    Objectively, it is more flexible.

    I agree, and your snippet demonstrates that quite well. However: the example you provide is not actually desirable code to write! Consider that writing the code you have given would effectively lock the user into using stackless coroutines for their Io implementation. The whole point of the Io interface is for software to be agnostic to how IO and async stuff is actually happening. As such, I think your example would be better written using new functions on the Io interface, perhaps with a usage like:

    fn waitNGameFrames(game: *Game, n: usize) void {
        game.io.suspend(waitNGameFramesInner, .{ game, n });
    }
    fn waitNGameFramesInner(game: *Game, n: usize, suspended: *std.Io.AnySuspended) void {
        // I'm glossing over the data structure details here since I'm typing
        // this comment on mobile which makes it very awkward, sorry!
        game.frame_waiters.append(.{
            .suspended = suspended,
            .remaining = n,
        });
    }
    
    fn mainLoop(frame_waiters) void {
        // Do the same kinda logic you had (again, glossing over details
        // here), but instead of `resume` syntax, you write:
        game.io.resume(waiter.suspended);
    }

    Forgive the sorta-pseudocode; but the exact details of the API or how this hypothetical game uses it aren't really the point here. The point is that in my view, this is an obviously better approach to this particular problem, because it allows any event loop implementation to support userland suspend/resume for cases like this.

    As always, it kind of comes back to friction. Under this proposal, @async builtins are meant to be low-level primitives which you can build useful abstractions on top of. If we had the classic suspend/resume syntax, it would be more appealing to users with use cases like this to use that syntax in their applications, thereby locking themselves in to stackless async. Adding the extra friction to this use case of having to deal with more direct communication between suspended and resumer makes it much more appealing to use this hypothetical Io interface extension instead, leaving the door open to this application using green threads or something.

  15. andrewrk commented on Apr 4, 2025

    @andrewrk
    Member

    std.Io.AnySuspended

    hmm I don't think that's the primitive one would use if the goal was abstracting over the Io implementation. In such case it would be a Condition:

    const FrameWaiter = struct {
        // heap would probably be better but this is simpler
        // to reason about for now
        node: std.DoublyLinkedList.Node = .{},
        remaining: usize,
        condition: Io.Condition = .{},
    };
    
    const Game = struct {
        frame_waiters: std.DoublyLinkedList,
        frame_waiter_mutex: Io.Mutex = .{},
    };
    
    /// blocks until `n` frames have been rendered
    fn waitNGameFrames(game: *Game, n: usize) void {
        const io = game.io;
        var entry: FrameWaiter = .{ .remaining = n };
        game.frame_waiter_mutex.lock(io) catch return; // error.Canceled
        defer game.frame_waiter_mutex.unlock(io);
        game.frame_waiters.append(&entry.node);
        entry.condition.wait(io, &game.frame_waiter_mutex) catch return; // error.Canceled
    }
    
    fn mainLoop(game: *Game) void {
        const io = game.io;
        // ...
        {
            game.frame_waiter_mutex.lock(io) catch return; // error.Canceled
            defer game.frame_waiter_mutex.unlock(io);
            var opt_waiter = game.frame_waiters;
            while (opt_waiter) |waiter_node| {
                opt_waiter = waiter_node.next;
                const waiter: *FrameWaiter = @fieldParentPtr("node", waiter_node);
                waiter.remaining -= 1;
                if (waiter.remaining == 0) {
                    game.frame_waiters.remove(waiter_node);
                    waiter.condition.signal(io);
                }
            }
        }
        // ...
    }
  16. 63 remaining items

  17. alethkit commented on Aug 21, 2025

    @alethkit

    I am unsure if the proposed stackless coroutines map onto fibers, but they seem to be related.

    With that in mind, https://arxiv.org/pdf/2104.00250 describes the work done to bring fibers as a low-level primitive in OCaml 5.0, which can implement asynchrony, in addition to another non-local control flow mechanisms, which may be of interest.

    The challenge would be figuring out how to get manual memory management to work, since the OCaml team's implementation relies on a concurrent garbage collector.

  18. mlugg commented on Aug 21, 2025

    @mlugg
    MemberAuthor

    Fibers are stackful coroutines, where you have multiple execution stacks and implement suspend/resume by switching between them. This issue is about stackless coroutines, where the compiler converts user functions into state machines so that suspend/resume can be implemented through normal return/call logic.

    We already have a functional proof-of-concept for a fibers-based implementation of the upcoming std.Io interface. Fibers are easy mode; they don't require any language support whatsoever, just a little sprinkling of inline assembly to switch between different tasks.

  19. windoze commented on Aug 26, 2025

    @windoze

    Stackful coroutines requires the stack size to be known before creation, but the actual stack size can only be known at runtime.
    I'm not sure if it can be a universal solution.

  20. mlugg commented on Aug 27, 2025

    @mlugg
    MemberAuthor

    This proposal is for stackless coroutines.

    Static stack size computation, which is indeed desirable for stackful coroutines, is tracked by #157.

  21. KilianHanich commented on Aug 27, 2025

    @KilianHanich

    Stackful coroutines requires the stack size to be known before creation, but the actual stack size can only be known at runtime. I'm not sure if it can be a universal solution.

    What mlugg said, but to add to that, it's not just required by stackful coroutines, but also by threads, or any program really.

  22. windoze commented on Aug 27, 2025

    @windoze

    @mlugg
    I'm aware of the topic of this thread and I was replying @alethkit

    The stack of stackful coroutine is a real stack instead of a logical "frame" in stackless coroutines, "real" means it contains not only the frame of the coroutine itself, but also everthing on the call chain started from the coroutine, in case you use a recursive function from a stackful coroutine, the stack may overflow. So even you can get the function frame size, but still it's useless in stackful coroutine implementations.

    @KilianHanich the problem is, we often have much more coroutines/fibers than threads, because that's often the very reason of using coroutines/fibers, especially in server-side programs, in that case the default/minimal stack size becomes a bigger trouble.

  23. iacore commented on Oct 9, 2025

    @iacore
    Contributor

    Kotlin has the suspend keyword.

    suspend fun foo() { }

  24. lovely-error commented on Oct 23, 2025

    @lovely-error

    I feel like it can be done differently.
    It would be nice if the result of an async fun was at the start of the frame to make retrieving it as simple as a pointer cast.
    In rust, coros are lowered to state machines and this means that dispatch tag has to be stored in the frame. i feel it is suboptimal and coros should be lowered to CPS with runtime implementations handling where to put continuation functions. Those can be placed in a tagged allocation or such.

    async funcs are different from sync funcs so they rather be differentiated in syntax. something like async fn f1() void { ... } maybe?

    I think below is a reasonable interface for coroutines.

    // continuation function for any coroutine. accepts frame ptr and resumption data, and maybe returns continuation and ptr to data from async side
    const ConFun = *const fn (*anyopaque, *anyopaque) ?struct {ConFun, *anyopaque};
    
    // used to "pass control" from the async side to the executor side
    @coro_suspend(_:*anyopaque) *anyopaque
    
    const StartFun = *const fn (*anyopaque) ?struct {ConFun, *anyopaque};
    
    // func to get to first yield point of a coro
    @coro_start_fun(_:anytype) StartFun
    
    // way to materialise frame type of any coro to see its layout (size and alignment!)
    @Frame(_:anytype) type

    On async side the call syntax should look like this

    async fn f1() u32 { ... }
    async fn f2() void {
      _ = fn1().await; 
    }

    What do you think?ne

  25. kprotty commented on Oct 24, 2025

    @kprotty
    Contributor

    @lovely-error

    async fun was at the start of the frame to make retrieving it as simple as a pointer cast.

    The old design had an asyncframe->T which was the type-erased (T was optional) representation of a resumable frame (the result of ConFun in your example). It can be stored anywhere in the Frame without overhead (see @fieldParentPtr builtin).

    dispatch tag has to be stored in the frame. i feel it is suboptimal

    A "dispatch tag" is just a simpler representation; Since it's switch'd on during resume, and the switch statement is usually a jump table, it could instead be a "dispatch callback" to similar effect. It's the same as your {ConFun, opaque} idea but ConFun is stored (as a tag/callback) in the opaque.

    async funcs are different from sync funcs so they rather be differentiated in syntax

    Not sure if it's still the plan, but originally Zig's async is meant to be "color blind": implicitly derived instead of explicitly stated in the function signature. Doing so avoids having two versions of a function if their only difference is how they (generically) suspend.

  26. lovely-error commented on Oct 25, 2025

    @lovely-error

    Sorry. I should've perhaps shown the intended use of the primitives.

    async fn ex1(val: u32) u32 {
    
        const suspend_val_1 = val + 1;
    
        const resume_val_ptr_1_untyped = @coro_suspend(&suspend_val_1);
    
        const resume_val_1 = @as(*u32, @ptrCast(resume_val_ptr_1_untyped)).*;
        if (num != 44) @panic("unexpected!");
    
        return resume_val_2;
    }
    
    fn sync_fun() void {
        var frame: Frame(ex1) = ex1(42); // arguments are copied into the frame
    
        // @memcpy frame if desired
    
        const start_fun = @coro_start_fun(ex1);
        
        const outcome_1 = start_fun(&frame);
        // after this, coro frame must remain in stable memory location, or suspend data ptr would get invalidated
    
        // the second value is what coro passes to sync side
        const suspend_argument_1 = @as(*u32, @ptrCast(outcome_1.?[1])).*;
        if (suspend_argument_1 != 43) @panic("unexpected!");
    
        const resume_val = suspend_argument_1 + 1 ;
    
        // the execution of coro continues from the related @coro_suspend point
        const outcome_2 = outcome_1.?[0](&frame, &resume_val);
    
        // the coro must have finished, frame is movable now!
        if (outcome_2 != null) { @panic("unexpected"); }
    
        // correct by construction of frame of async funs
        const coro_result = @as(*u32, @ptrCast(&frame)).*;
    
        if (coro_result != 44) { @panic("unexpected"); }
    }

    Also I don't think color blindness is a meaningful concept

    but ConFun is stored (as a tag/callback) in the opaque.

    It would be better to kinda let user decide where to store those.
    I suggest that continuations are represented by function pointers that return function pointers that return function pointers....

  27. lovely-error commented on Oct 26, 2025

    @lovely-error

    I thought about it some more, and it appears that my initial assessment can be refined.
    New interface

    // continuation function for any coroutine. accepts frame ptr and resumption data, and maybe returns continuation and ptr to data from async side
    const ConFun = *const fn (*anyopaque, *anyopaque) ?struct {ConFun, *anyopaque};
    
    // used to "pass control" from the async side to the executor side
    @coro_suspend(_:*anyopaque) *anyopaque
    
    const StartFun = *const fn (*anyopaque) ?struct {ConFun, *anyopaque};
    
    // func to get to first yield point of a coro
    @coro_start_fun(_:anytype) StartFun
    
    // way to materialise frame type of any coro to see its layout (size and alignment!)
    @Frame(_:anytype) type
    
    // size and layout of a frame from function pointer
    @coro_frame_layout(_:anytype) struct {u32,u32}
    
    // copies arguments into frame
    @coro_frame_init(_:anytype, _:anytype, _:anytype) void

    Usage

    async fn ex1(val: u32) u32 {
    
        const suspend_val_1 = val + 1;
    
        // suspend only makes sense in coroutines
        const resume_val_ptr_1_untyped = @coro_suspend(&suspend_val_1);
    
        const resume_val_1 = @as(*u32, @ptrCast(resume_val_ptr_1_untyped)).*;
        if (num != 44) @panic("unexpected!");
    
        return resume_val_2;
    }
    
    fn sync_fun() void {
       // async funs have to be lowered to a specific layout such that 
       // [Layout][frame_init_fun_ptr][start_fun_code_bytes]
       //                              ^this is a pointer goten from &some_async_fun operation
       const async_fun_ptr: *const async fn (u32) u32 = &ex1; 
    
        var frame: @Frame(ex1) = undefined; 
    
        @coro_frame_init(async_fun_ptr, &frame, .{ 42 }); // arguments are copied into the frame
    
        // @memcpy frame if desired
    
        const start_fun = @coro_start_fun(async_fun_ptr);
        
        const outcome_1 = start_fun(&frame);
        // after this, coro frame must remain in stable memory location, or suspend data ptr would get invalidated
    
        // the second value is what coro passes to sync side
        const suspend_argument_1 = @as(*u32, @ptrCast(outcome_1.?[1])).*;
        if (suspend_argument_1 != 43) @panic("unexpected!");
    
        const resume_val = suspend_argument_1 + 1 ;
    
        // the execution of coro continues from the related @coro_suspend point
        const outcome_2 = outcome_1.?[0](&frame, &resume_val);
    
        // the coro must have finished, frame is movable now!
        if (outcome_2 != null) { @panic("unexpected"); }
    
        // correct by construction of frame of async funs
        const coro_result = @as(*u32, @ptrCast(&frame)).*;
    
        if (coro_result != 44) { @panic("unexpected"); }
    }

    I should explain

    async fn ex2 () u32 { ... }
    async fn ex3 () void {
        _: u32 = ex2().await; // this effectively acts as inline. async functions cant be naively recursive, but they can be made recursive with support from a runtime/runner/executor/whateve you wanna call it
       _: @Frame(ex2) = ex2();
    }
  28. lovely-error commented on Oct 31, 2025

    @lovely-error

    I expressed this in rust, but it turned out to be terrible (cus it rust).

    wdyt @mlugg ?

  29. andrewrk commented on Apr 22, 2026

    @andrewrk
    Member

    Part of this issue will be re-evaluating whether #5277 is relevant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    proposalThis issue suggests language modifications. If it also has the "accepted" label then it is planned.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions