Repository navigation
Proposal: stackless coroutines as low-level primitives #23446
Description
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) anytypeAll relevant builtins begin @async, making the scope of this language feature very clear.
Mapping concepts from #6025 to this proposal:
@Frame()andanyframetypes do not exist; raw byte buffers and opaque pointers are used.@frame()is called@asyncFrame().async foo()is equivalent to@asyncInitfollowed by@asyncResume.await frameis equivalent to repeated@asyncResumecalls until one returnsnull.nosuspend foo(arg)is equivalent to@asyncNoSuspendCall(foo, .{arg}).- Actually,
nosuspend foo(arg)is just@asyncInitfollowed by@asyncResumebut asserting that the latter returnsnull!
- Actually,
suspendis@asyncSuspend(). There is no longer a "suspend block"; I elaborate on this below.resumeis@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
ethernetsellout commentedon Apr 3, 2025
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
@asyncFramewould return the correct type in the given example if the async function is inlined.mlugg commentedon Apr 3, 2025
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
[]u8you 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.alexrp commentedon Apr 3, 2025
The
@asyncResumename 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?andrewrk commentedon Apr 3, 2025
Can you share how you would implement the
Iointerface with these primitives?mlugg commentedon Apr 3, 2025
Absolutely -- I've sketched out a vague implementation locally, I'll try to neaten it up and post it here soon.
mlugg commentedon Apr 3, 2025
@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, andnow. (I've not defined the vtable, but those 4 functions are at the bottom of the file.https://gist.github.com/mlugg/ba402a8b1c863ebbba0996481dade3b3
InKryption commentedon Apr 3, 2025
Just to have this written down:
Are there significant use cases where the caller of
@asyncSuspendwould want to send to@asyncResumea frame other than its current@asyncFrame?If not, would it be reasonable for
@asyncResumeto return a tuple of*anyopaque, *AsyncFrame, where the async frame returned would be the@asyncFramethe caller of@asyncSuspendwould have to pass through the*anyopaquepointer anyway?If that is reasonable, could
@asyncFrame()be removed altogether, in favor of this thread-through being directly encoded by@asyncResume?andrewrk commentedon Apr 3, 2025
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:
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.KilianHanich commentedon Apr 3, 2025
So, from the proposal I guess something like this would work:
Now, obviously this example isn't all that useful, since one can write a "yielder" considerable easier, but it shows a few things:
nullso that the return of@asyncResumewould be in practice?*?SomeType, so you know that it's finished and now you need to call@asyncResumeagain 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:
*anyopaqueand instead let the type be figured out by the compiler similar to@memsetand@memcpy.@asyncInitshould additionally take the parameter to the function as an additional parameter which gets checked at compile time that types match.@asyncResumeand @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:
KilianHanich commentedon Apr 3, 2025
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?
andrewrk commentedon Apr 3, 2025
I think the generator use case would be better handled with
Io.Queue:By passing a non-empty buffer to
initit 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.
mlugg commentedon Apr 4, 2025
This is, as you say, subjective, but FWIW the
suspendblock 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@asyncResumealso return the frame pointer, because it must always resume the frame later, and this eliminates the need for@asyncFrameentirely.)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
Ioimplementation. The whole point of theIointerface 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 theIointerface, perhaps with a usage like: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,
@asyncbuiltins are meant to be low-level primitives which you can build useful abstractions on top of. If we had the classicsuspend/resumesyntax, 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 hypotheticalIointerface extension instead, leaving the door open to this application using green threads or something.andrewrk commentedon Apr 4, 2025
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:63 remaining items
alethkit commentedon Aug 21, 2025
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.
mlugg commentedon Aug 21, 2025
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.Iointerface. Fibers are easy mode; they don't require any language support whatsoever, just a little sprinkling of inline assembly to switch between different tasks.windoze commentedon Aug 26, 2025
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.
mlugg commentedon Aug 27, 2025
This proposal is for stackless coroutines.
Static stack size computation, which is indeed desirable for stackful coroutines, is tracked by #157.
KilianHanich commentedon Aug 27, 2025
What mlugg said, but to add to that, it's not just required by stackful coroutines, but also by threads, or any program really.
windoze commentedon Aug 27, 2025
@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.
iacore commentedon Oct 9, 2025
Kotlin has the
suspendkeyword.suspend fun foo() { }lovely-error commentedon Oct 23, 2025
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.
On async side the call syntax should look like this
What do you think?ne
kprotty commentedon Oct 24, 2025
@lovely-error
The old design had an
asyncframe->Twhich 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@fieldParentPtrbuiltin).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 butConFunis stored (as a tag/callback) in theopaque.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.
lovely-error commentedon Oct 25, 2025
Sorry. I should've perhaps shown the intended use of the primitives.
Also I don't think color blindness is a meaningful concept
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....
lovely-error commentedon Oct 26, 2025
I thought about it some more, and it appears that my initial assessment can be refined.
New interface
Usage
I should explain
lovely-error commentedon Oct 31, 2025
I expressed this in rust, but it turned out to be terrible (cus it rust).
wdyt @mlugg ?
andrewrk commentedon Apr 22, 2026
Part of this issue will be re-evaluating whether #5277 is relevant.
@frameto allow for inlining async calls #5277 as a duplicate of this issue