Coroutines

A coroutine is a generalization of a function.

When you call a function, it creates a stack frame, and then the function runs until it reaches a return statement, and then the stack frame is destroyed. At the callsite, the next line of code does not run until the function returns.

A coroutine is like a function, but it can be suspended and resumed any number of times, and then it must be explicitly destroyed. When a coroutine suspends, it returns to the resumer.

Minimal Coroutine Example

Declare a coroutine with the async keyword. The expression in angle brackets must evaluate to a struct which has these fields:

  • allocFn: fn (self: *Allocator, byte_count: usize, alignment: u29) Error![]u8 - where Error can be any error set.
  • freeFn: fn (self: *Allocator, old_mem: []u8) void

You may notice that this corresponds to the std.mem.Allocator interface. This makes it convenient to integrate with existing allocators. Note, however, that the language feature does not depend on the standard library, and any struct which has these fields is allowed.

Omitting the angle bracket expression when defining an async function makes the function generic. Zig will infer the allocator type when the async function is called.

Call a coroutine with the async keyword. Here, the expression in angle brackets is a pointer to the allocator struct that the coroutine expects.

The result of an async function call is a promise->T type, where T is the return type of the async function. Once a promise has been created, it must be consumed, either with cancel or await:

Async functions start executing when created, so in the following example, the entire async function completes before it is canceled:

test.zig

  1. const std = @import("std");
  2. const assert = std.debug.assert;
  3. var x: i32 = 1;
  4. test "create a coroutine and cancel it" {
  5. const p = try async<std.debug.global_allocator> simpleAsyncFn();
  6. comptime assert(@typeOf(p) == promise->void);
  7. cancel p;
  8. assert(x == 2);
  9. }
  10. async<*std.mem.Allocator> fn simpleAsyncFn() void {
  11. x += 1;
  12. }
  1. $ zig test test.zig
  2. Test 1/1 create a coroutine and cancel it...OK
  3. All tests passed.

Suspend and Resume

At any point, an async function may suspend itself. This causes control flow to return to the caller or resumer. The following code demonstrates where control flow goes:

test.zig

  1. const std = @import("std");
  2. const assert = std.debug.assert;
  3. test "coroutine suspend, resume, cancel" {
  4. seq('a');
  5. const p = try async<std.debug.global_allocator> testAsyncSeq();
  6. seq('c');
  7. resume p;
  8. seq('f');
  9. cancel p;
  10. seq('g');
  11. assert(std.mem.eql(u8, points, "abcdefg"));
  12. }
  13. async fn testAsyncSeq() void {
  14. defer seq('e');
  15. seq('b');
  16. suspend;
  17. seq('d');
  18. }
  19. var points = []u8{0} ** "abcdefg".len;
  20. var index: usize = 0;
  21. fn seq(c: u8) void {
  22. points[index] = c;
  23. index += 1;
  24. }
  1. $ zig test test.zig
  2. Test 1/1 coroutine suspend, resume, cancel...OK
  3. All tests passed.

When an async function suspends itself, it must be sure that it will be resumed or canceled somehow, for example by registering its promise handle in an event loop. Use a suspend capture block to gain access to the promise:

test.zig

  1. const std = @import("std");
  2. const assert = std.debug.assert;
  3. test "coroutine suspend with block" {
  4. const p = try async<std.debug.global_allocator> testSuspendBlock();
  5. std.debug.assert(!result);
  6. resume a_promise;
  7. std.debug.assert(result);
  8. cancel p;
  9. }
  10. var a_promise: promise = undefined;
  11. var result = false;
  12. async fn testSuspendBlock() void {
  13. suspend {
  14. comptime assert(@typeOf(@handle()) == promise->void);
  15. a_promise = @handle();
  16. }
  17. result = true;
  18. }
  1. $ zig test test.zig
  2. Test 1/1 coroutine suspend with block...OK
  3. All tests passed.

Every suspend point in an async function represents a point at which the coroutine could be destroyed. If that happens, defer expressions that are in scope are run, as well as errdefer expressions.

Await counts as a suspend point.

Resuming from Suspend Blocks

Upon entering a suspend block, the coroutine is already considered suspended, and can be resumed. For example, if you started another kernel thread, and had that thread call resume on the promise handle provided by the suspend block, the new thread would begin executing after the suspend block, while the old thread continued executing the suspend block.

However, the coroutine can be directly resumed from the suspend block, in which case it never returns to its resumer and continues executing.

test.zig

  1. const std = @import("std");
  2. const assert = std.debug.assert;
  3. test "resume from suspend" {
  4. var buf: [500]u8 = undefined;
  5. var a = &std.heap.FixedBufferAllocator.init(buf[0..]).allocator;
  6. var my_result: i32 = 1;
  7. const p = try async<a> testResumeFromSuspend(&my_result);
  8. cancel p;
  9. std.debug.assert(my_result == 2);
  10. }
  11. async fn testResumeFromSuspend(my_result: *i32) void {
  12. suspend {
  13. resume @handle();
  14. }
  15. my_result.* += 1;
  16. suspend;
  17. my_result.* += 1;
  18. }
  1. $ zig test test.zig
  2. Test 1/1 resume from suspend...OK
  3. All tests passed.

This is guaranteed to be a tail call, and therefore will not cause a new stack frame.

Await

The await keyword is used to coordinate with an async function's return statement.

await is valid only in an async function, and it takes as an operand a promise handle. If the async function associated with the promise handle has already returned, then await destroys the target async function, and gives the return value. Otherwise, await suspends the current async function, registering its promise handle with the target coroutine. It becomes the target coroutine's responsibility to have ensured that it will be resumed or destroyed. When the target coroutine reaches its return statement, it gives the return value to the awaiter, destroys itself, and then resumes the awaiter.

A promise handle must be consumed exactly once after it is created, either by cancel or await.

await counts as a suspend point, and therefore at every await, a coroutine can be potentially destroyed, which would run defer and errdefer expressions.

test.zig

  1. const std = @import("std");
  2. const assert = std.debug.assert;
  3. var a_promise: promise = undefined;
  4. var final_result: i32 = 0;
  5. test "coroutine await" {
  6. seq('a');
  7. const p = async<std.debug.global_allocator> amain() catch unreachable;
  8. seq('f');
  9. resume a_promise;
  10. seq('i');
  11. assert(final_result == 1234);
  12. assert(std.mem.eql(u8, seq_points, "abcdefghi"));
  13. }
  14. async fn amain() void {
  15. seq('b');
  16. const p = async another() catch unreachable;
  17. seq('e');
  18. final_result = await p;
  19. seq('h');
  20. }
  21. async fn another() i32 {
  22. seq('c');
  23. suspend {
  24. seq('d');
  25. a_promise = @handle();
  26. }
  27. seq('g');
  28. return 1234;
  29. }
  30. var seq_points = []u8{0} ** "abcdefghi".len;
  31. var seq_index: usize = 0;
  32. fn seq(c: u8) void {
  33. seq_points[seq_index] = c;
  34. seq_index += 1;
  35. }
  1. $ zig test test.zig
  2. Test 1/1 coroutine await...OK
  3. All tests passed.

In general, suspend is lower level than await. Most application code will use only async and await, but event loop implementations will make use of suspend internally.

Open Issues

There are a few issues with coroutines that are considered unresolved. Best be aware of them, as the situation is likely to change before 1.0.0:

  • Async functions have optimizations disabled - even in release modes - due to an LLVM bug.
  • There are some situations where we can know statically that there will not be memory allocation failure, but Zig still forces us to handle it. TODO file an issue for this and link it here.
  • Zig does not take advantage of LLVM's allocation elision optimization for coroutines. It crashed LLVM when I tried to do it the first time. This is related to the other 2 bullet points here. See #802.