Async Functions

When a function is called, a frame is pushed to the stack, the function runs until it reaches a return statement, and then the frame is popped from the stack. At the callsite, the following code does not run until the function returns.

An async function is a function whose callsite is split into an async initiation, followed by an await completion. Its frame is provided explicitly by the caller, and it can be suspended and resumed any number of times.

Zig infers that a function is async when it observes that the function contains a suspension point. Async functions can be called the same as normal functions. A function call of an async function is a suspend point.

Suspend and Resume

At any point, a function may suspend itself. This causes control flow to return to the callsite (in the case of the first suspension), or resumer (in the case of subsequent suspensions).

test.zig

  1. const std = @import("std");
  2. const expect = std.testing.expect;
  3. var x: i32 = 1;
  4. test "suspend with no resume" {
  5. var frame = async func();
  6. expect(x == 2);
  7. }
  8. fn func() void {
  9. x += 1;
  10. suspend;
  11. // This line is never reached because the suspend has no matching resume.
  12. x += 1;
  13. }
  1. $ zig test test.zig
  2. 1/1 test "suspend with no resume"... OK
  3. All 1 tests passed.

In the same way that each allocation should have a corresponding free, Each suspend should have a corresponding resume. A suspend block allows a function to put a pointer to its own frame somewhere, for example into an event loop, even if that action will perform a resume operation on a different thread. @frame provides access to the async function frame pointer.

test.zig

  1. const std = @import("std");
  2. const expect = std.testing.expect;
  3. var the_frame: anyframe = undefined;
  4. var result = false;
  5. test "async function suspend with block" {
  6. _ = async testSuspendBlock();
  7. expect(!result);
  8. resume the_frame;
  9. expect(result);
  10. }
  11. fn testSuspendBlock() void {
  12. suspend {
  13. comptime expect(@TypeOf(@frame()) == *@Frame(testSuspendBlock));
  14. the_frame = @frame();
  15. }
  16. result = true;
  17. }
  1. $ zig test test.zig
  2. 1/1 test "async function suspend with block"... OK
  3. All 1 tests passed.

suspend causes a function to be async.

Resuming from Suspend Blocks

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

However, the async function 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 expect = std.testing.expect;
  3. test "resume from suspend" {
  4. var my_result: i32 = 1;
  5. _ = async testResumeFromSuspend(&my_result);
  6. std.testing.expect(my_result == 2);
  7. }
  8. fn testResumeFromSuspend(my_result: *i32) void {
  9. suspend {
  10. resume @frame();
  11. }
  12. my_result.* += 1;
  13. suspend;
  14. my_result.* += 1;
  15. }
  1. $ zig test test.zig
  2. 1/1 test "resume from suspend"... OK
  3. All 1 tests passed.

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

Async and Await

In the same way that every suspend has a matching resume, every async has a matching await.

test.zig

  1. const std = @import("std");
  2. const expect = std.testing.expect;
  3. test "async and await" {
  4. // Here we have an exception where we do not match an async
  5. // with an await. The test block is not async and so cannot
  6. // have a suspend point in it.
  7. // This is well-defined behavior, and everything is OK here.
  8. // Note however that there would be no way to collect the
  9. // return value of amain, if it were something other than void.
  10. _ = async amain();
  11. }
  12. fn amain() void {
  13. var frame = async func();
  14. comptime expect(@TypeOf(frame) == @Frame(func));
  15. const ptr: anyframe->void = &frame;
  16. const any_ptr: anyframe = ptr;
  17. resume any_ptr;
  18. await ptr;
  19. }
  20. fn func() void {
  21. suspend;
  22. }
  1. $ zig test test.zig
  2. 1/1 test "async and await"... OK
  3. All 1 tests passed.

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

await is a suspend point, and takes as an operand anything that coerces to anyframe->T.

There is a common misconception that await resumes the target function. It is the other way around: it suspends until the target function completes. In the event that the target function has already completed, await does not suspend; instead it copies the return value directly from the target function’s frame.

test.zig

  1. const std = @import("std");
  2. const expect = std.testing.expect;
  3. var the_frame: anyframe = undefined;
  4. var final_result: i32 = 0;
  5. test "async function await" {
  6. seq('a');
  7. _ = async amain();
  8. seq('f');
  9. resume the_frame;
  10. seq('i');
  11. expect(final_result == 1234);
  12. expect(std.mem.eql(u8, &seq_points, "abcdefghi"));
  13. }
  14. fn amain() void {
  15. seq('b');
  16. var f = async another();
  17. seq('e');
  18. final_result = await f;
  19. seq('h');
  20. }
  21. fn another() i32 {
  22. seq('c');
  23. suspend {
  24. seq('d');
  25. the_frame = @frame();
  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. 1/1 test "async function await"... OK
  3. All 1 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.

Async Function Example

Putting all of this together, here is an example of typical async/await usage:

async.zig

  1. const std = @import("std");
  2. const Allocator = std.mem.Allocator;
  3. pub fn main() void {
  4. _ = async amainWrap();
  5. // Typically we would use an event loop to manage resuming async functions,
  6. // but in this example we hard code what the event loop would do,
  7. // to make things deterministic.
  8. resume global_file_frame;
  9. resume global_download_frame;
  10. }
  11. fn amainWrap() void {
  12. amain() catch |e| {
  13. std.debug.print("{}\n", .{e});
  14. if (@errorReturnTrace()) |trace| {
  15. std.debug.dumpStackTrace(trace.*);
  16. }
  17. std.process.exit(1);
  18. };
  19. }
  20. fn amain() !void {
  21. const allocator = std.heap.page_allocator;
  22. var download_frame = async fetchUrl(allocator, "https://example.com/");
  23. var awaited_download_frame = false;
  24. errdefer if (!awaited_download_frame) {
  25. if (await download_frame) |r| allocator.free(r) else |_| {}
  26. };
  27. var file_frame = async readFile(allocator, "something.txt");
  28. var awaited_file_frame = false;
  29. errdefer if (!awaited_file_frame) {
  30. if (await file_frame) |r| allocator.free(r) else |_| {}
  31. };
  32. awaited_file_frame = true;
  33. const file_text = try await file_frame;
  34. defer allocator.free(file_text);
  35. awaited_download_frame = true;
  36. const download_text = try await download_frame;
  37. defer allocator.free(download_text);
  38. std.debug.print("download_text: {}\n", .{download_text});
  39. std.debug.print("file_text: {}\n", .{file_text});
  40. }
  41. var global_download_frame: anyframe = undefined;
  42. fn fetchUrl(allocator: *Allocator, url: []const u8) ![]u8 {
  43. const result = try std.mem.dupe(allocator, u8, "this is the downloaded url contents");
  44. errdefer allocator.free(result);
  45. suspend {
  46. global_download_frame = @frame();
  47. }
  48. std.debug.print("fetchUrl returning\n", .{});
  49. return result;
  50. }
  51. var global_file_frame: anyframe = undefined;
  52. fn readFile(allocator: *Allocator, filename: []const u8) ![]u8 {
  53. const result = try std.mem.dupe(allocator, u8, "this is the file contents");
  54. errdefer allocator.free(result);
  55. suspend {
  56. global_file_frame = @frame();
  57. }
  58. std.debug.print("readFile returning\n", .{});
  59. return result;
  60. }
  1. $ zig build-exe async.zig
  2. $ ./async
  3. readFile returning
  4. fetchUrl returning
  5. download_text: this is the downloaded url contents
  6. file_text: this is the file contents

Now we remove the suspend and resume code, and observe the same behavior, with one tiny difference:

blocking.zig

  1. const std = @import("std");
  2. const Allocator = std.mem.Allocator;
  3. pub fn main() void {
  4. _ = async amainWrap();
  5. }
  6. fn amainWrap() void {
  7. amain() catch |e| {
  8. std.debug.print("{}\n", .{e});
  9. if (@errorReturnTrace()) |trace| {
  10. std.debug.dumpStackTrace(trace.*);
  11. }
  12. std.process.exit(1);
  13. };
  14. }
  15. fn amain() !void {
  16. const allocator = std.heap.page_allocator;
  17. var download_frame = async fetchUrl(allocator, "https://example.com/");
  18. var awaited_download_frame = false;
  19. errdefer if (!awaited_download_frame) {
  20. if (await download_frame) |r| allocator.free(r) else |_| {}
  21. };
  22. var file_frame = async readFile(allocator, "something.txt");
  23. var awaited_file_frame = false;
  24. errdefer if (!awaited_file_frame) {
  25. if (await file_frame) |r| allocator.free(r) else |_| {}
  26. };
  27. awaited_file_frame = true;
  28. const file_text = try await file_frame;
  29. defer allocator.free(file_text);
  30. awaited_download_frame = true;
  31. const download_text = try await download_frame;
  32. defer allocator.free(download_text);
  33. std.debug.print("download_text: {}\n", .{download_text});
  34. std.debug.print("file_text: {}\n", .{file_text});
  35. }
  36. fn fetchUrl(allocator: *Allocator, url: []const u8) ![]u8 {
  37. const result = try std.mem.dupe(allocator, u8, "this is the downloaded url contents");
  38. errdefer allocator.free(result);
  39. std.debug.print("fetchUrl returning\n", .{});
  40. return result;
  41. }
  42. fn readFile(allocator: *Allocator, filename: []const u8) ![]u8 {
  43. const result = try std.mem.dupe(allocator, u8, "this is the file contents");
  44. errdefer allocator.free(result);
  45. std.debug.print("readFile returning\n", .{});
  46. return result;
  47. }
  1. $ zig build-exe blocking.zig
  2. $ ./blocking
  3. fetchUrl returning
  4. readFile returning
  5. download_text: this is the downloaded url contents
  6. file_text: this is the file contents

Previously, the fetchUrl and readFile functions suspended, and were resumed in an order determined by the main function. Now, since there are no suspend points, the order of the printed “… returning” messages is determined by the order of async callsites.