Please see the disclaimer.

Introduction

The title is the thesis: the Zig programming language has function colors. And in the rest of this post, I will prove that and try to explain what it means for programmers using Zig.

This post is not here to ascribe any intention to Zig proponents, especially the leadership and employees of the Zig Software Foundation. I did that once, and I regret it.

Instead, the post is meant to be as objective as possible, while letting the chips fall where they may.

Review of Function Colors

First, what are “function colors”?

They are a reference to a classic blog post by Bob Nystrom.

In it, he describes how, in programming languages that use async as the concurrency model, some functions cannot be used without special work.

The bolded text is the key, but people lose that because of a list of five things in Bob’s post. Thus, they think that function colors require that all five traits be present.

This is false; the five things he listed were traits of a “hypothetical” (JavaScript) programming language.

The five traits are (and I am quoting Bob Nystrom here):

  1. Every function has a color.
  2. The way you call a function depends on its color.
  3. You can only call a red function from within another red function.
  4. Red functions are more painful to call.
  5. Some core library functions are red.

Some may complain that the definition of function colors I gave above does not fall out of Bob Nystrom’s list, but I argue that it does.

First, in my definition, it is implicit that every function has a color. That’s the “some functions” in the beginning, which implicitly says there’s a difference between functions. This means my definition fulfills point 1.

Second, since “some functions” need “special work” to use, and because Bob Nystrom was deliberately vague about what he meant by “painful,” we can call those functions “red” functions, and my definition hits point 4, if we change the word “call” to “use.”

Yes, there is a difference between calling a function and using a function. I will explain the difference later.

Third, if you change the word “call” to “use” in point 2, I believe my definition would fit it.

Third, point 5 is mostly about the standard library, so I don’t think my definition needs to take it into account.

My definition, however, does not fit point 3. This is on purpose; from the first time that I read Bob Nystrom’s post, I never believed that point 3 was a prerequisite for function colors.

This difference of opinion is probably what caused most of the conflict I alluded to earlier, so I thought that I would lay it out, then make my case for it, along with the case that Zig has function colors according to my definition.

“Use” vs. “Call”

So why was I careful to say that if you change the word “call” to the word “use” in points 2 and 4, my definition fits?

Because calling a function is only part of using a function.

First, calling a function is only one of several ways to use a function. You can:

  • assign it to a function pointer,
  • pass it, as a function pointer, to other functions,

among other things.

But even if you just call the function, that is not all you need to do to use it.

You need to understand it, and use it according to its preconditions and use its results according to the postconditions.

It is this understanding step that can make functions “painful” to use, and as we will see, Zig cannot make this step easy in some cases.

Though it does make it easy in most cases, and this is Zig’s innovation.

Why I Believed Zig Has Function Colors

My belief that Zig has function colors stems from two things:

My Gut Feeling

The first one, that gut feeling, is that async-based programming languages will always have function colors as I have defined them.

This is why I defined them the way I did. I have always felt, quite consciously, that programming languages did not need to have point 3, that the existence of point 3 was purely from bad design.

I will emphasize: I have no proof of this. It’s only a hunch.

Though the hunch is based on the fact that “there is no such thing as a free lunch.”

I also don’t think I have the mathematical chops to write a proof for it either.

But I have not needed to prove it because (as far as I know) only one programming language that uses async as its concurrency model claims to not have function colors.

Instead, since I’m a programmer, and I’m starting to learn cryptography through the art of breaking things, I’m decided to prove Zig has function colors by torturing it.

Colorblind Blog Post

But to prove it, I’ve got to understand Zig’s model.

When I first heard of Zig’s model, and with my hunch, I read the blog post in question, which was written by Loris Cro, Vice President of Community for the Zig Software Foundation.

In it, he describes through what you can do with Zig’s async model, showing how Zig’s model is (mostly) colorblind.

I read the whole thing, and I admit that I was looking for weaknesses.

Loris Cro wrote one section called “Understanding the limits.” Naturally, I looked there, and I found a weakness.

One of the FAQ’s in the section says this:

Q: SO I DON’T EVEN HAVE TO THINK ABOUT NORMAL FUNCTIONS VS COROUTINES IN MY LIBRARY?

No, occasionally you will have to. As an example, if you’re allowing your users to pass to your library function pointers at runtime, you will need to make sure to use the right calling convention based on whether the function is async or not. You normally don’t have to think about it because the compiler is able to do the work for you at compile-time, but that can’t happen for runtime-known values.

The silver lining is that you have at your disposal all the tools to account for all the possibilities in a simple and clear way. Once you get the details right, the code will be no more complicated than it has to be, and your library will be easy to use.

Let me highlight one piece:

if you’re allowing your users to pass to your library function pointers at runtime, you will need to make sure to use the right calling convention based on whether the function is async or not.

(Emphasis added.)

“Calling convention” sounds suspiciously like the need to call functions in a special way, which would fit Bob Nystrom’s point 2 without changing it to use the word “use” instead.

Breaking Zig

So that was my plan of attack: use Loris Cro’s own example of function pointers.

But I’m not a Zig user; I had never touched it before.

So I went to the language reference, started learning, and started writing code.

All code was compiled by Zig version 0.9.1, the version packaged by Gentoo, on an up-to-date Gentoo x86_64 system.

You can get a zip file of all 67(!) of these tests, as well as a Makefile to build them, here.

The default target is the clean target, and to build individual tests, just run make <test_name>, where <test_name> is the file name without the .zig extension. For example, to build test01.zig, run make test01.

Originally, I ran all of these tests up to test10*.zig, with the exception of test01_1.zig, on Zig version 0.9.0, and reproduced the same results, but I have checked that the same results happen on 0.9.1.

Suspend and Resume

The first items I found were suspend and resume, so I tried those and immediately hit pay dirt.

This was my code:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

var ready: bool = false;

fn red(millis: u64) void {
    suspend {}
    std.time.sleep(millis);
    ready = true;
}

fn blue(millis: u64) void {
    std.time.sleep(millis);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    fns[func](millis);

    if (!ready) {
        std.os.abort();
    }
}

For those that don’t know Zig, this basically has two functions, one of which has a suspend, and the other that doesn’t. Then, at runtime, a random number generator is used to decide which one to call.

The random number generator is used to make sure the compiler can’t inline the call.

Saved as test01.zig and compiled with zig build-exe test01.zig, it produced a test01 binary. Half the time, it would work perfectly as expected when blue() is called. The other half, when red was called, it did not.

However, the problem was not caused by the std.os.abort(); it was a segfault when calling red(). The output is below:

fn(u64) void@205d40, fn(u64) void@205e70
Calling red
Segmentation fault at address 0x42e
/home/gavin/Code/Tests/zig/zig_async/test01.zig:6:1: 0x205d69 in red (test01)
fn red(millis: u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test01.zig:42:14: 0x22d659 in main (test01)
    fns[func](millis);
             ^
/usr/lib/zig/std/start.zig:561:37: 0x226aba in std.start.callMain (test01)
            const result = root.main() catch |err| {
                                    ^
/usr/lib/zig/std/start.zig:495:12: 0x2076ae in std.start.callMainWithArgs (test01)
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
/usr/lib/zig/std/start.zig:409:17: 0x206746 in std.start.posixCallMainAndExit (test01)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x206552 in std.start._start (test01)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

I was blown away by how very easy it was to break the compiler.

But the crucial thing here is that the program segfaulted when calling red(). This means that red() was expecting to be called in a certain way, and it was not.

That is a perfect match to Bob Nystrom’s point 2, even without my change.

However, there were two snags.

First, Loris Cro said that having a suspend without a corresponding resume was undefined behavior. I argue that it’s not, or that if it technically is, it is well-behaved, and users are taught that it’s okay.

The reason is this: having a suspend without a resume is used several times in the language reference itself. One example is resume_from_suspend.zig, which uses the fact that there is actually one more suspend than resume. In fact, if run, it supposedly passes the internal test.

Thus, even if it is technically UB, their own documentation implies otherwise by using it in examples such as suspend_no_resume.zig.

But I fixed that (after the fact) by creating test01_1.zig:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

var ready: bool = false;
var f: anyframe = undefined;

fn red(millis: u64) void {
    suspend {
        f = @frame();
    }
    std.time.sleep(millis);
    ready = true;
}

fn blue(millis: u64) void {
    std.time.sleep(millis);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    fns[func](millis);

    if (!ready) {
        std.os.abort();
    }

    if (func == 0) {
        resume f;
    }
}

I get the same segfault:

fn(u64) void@205d40, fn(u64) void@205e80
Calling red
Segmentation fault at address 0x4d7
/home/gavin/Code/Tests/zig/zig_async/test01_1.zig:7:1: 0x205d6d in red (test01_1)
fn red(millis: u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test01_1.zig:45:14: 0x22d687 in main (test01_1)
    fns[func](millis);
             ^
/usr/lib/zig/std/start.zig:561:37: 0x226aca in std.start.callMain (test01_1)
            const result = root.main() catch |err| {
                                    ^
/usr/lib/zig/std/start.zig:495:12: 0x2076be in std.start.callMainWithArgs (test01_1)
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
/usr/lib/zig/std/start.zig:409:17: 0x206756 in std.start.posixCallMainAndExit (test01_1)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x206562 in std.start._start (test01_1)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

Second, Andrew Kelley said that I had found a bug in the compiler and claimed that the compiler should have given this error:

./test.zig:40:14: error: function is not comptime-known; @asyncCall required fns[func](millis);

Okay, fair enough, so I made two programs to see if I could get the compiler to give me the error.

The first, test02.zig, looks like this:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

var ready: bool = false;

fn red(millis: *u64) void {
    suspend {}
    std.time.sleep(millis.*);
    ready = true;
}

fn blue(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    var bytes: [64]u8 align(@alignOf(@Frame(fns[func]))) = undefined;
    const f = @asyncCall(&bytes, {}, fns[func], .{&millis});

    if (!ready) {
        std.os.abort();
    }

    resume f;
}

The output that the compiler gave was this:

zig build-exe test02.zig
./test02.zig:42:48: error: unable to evaluate constant expression
    var bytes: [64]u8 align(@alignOf(@Frame(fns[func]))) = undefined;
                                               ^
make: *** [Makefile:7: test02] Error 1

That was not the error I was looking for, so I cheesed it with test03.zig:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

var ready: bool = false;

fn red(millis: *u64) void {
    suspend {}
    std.time.sleep(millis.*);
    ready = true;
}

fn blue(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    var bytes: [64]u8 align(@alignOf(@Frame(red))) = undefined;
    const f = @asyncCall(&bytes, {}, fns[func], .{&millis});

    if (!ready) {
        std.os.abort();
    }

    resume f;
}

The compiler gave this output:

zig build-exe test03.zig
./test03.zig:43:41: error: expected async function, found 'fn(*u64) void'
    const f = @asyncCall(&bytes, {}, fns[func], .{&millis});
                                        ^
make: *** [Makefile:10: test03] Error 1

The compiler “expected [an] async function.” This is code for “I wanted a red function, and you gave me a blue one.”

I tried to get it to accept it with this code from test04.zig:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

var ready: bool = false;

fn red(millis: *u64) void {
    suspend {}
    std.time.sleep(millis.*);
    ready = true;
}

fn blue_ish(millis: *u64) void {
    suspend {}
    std.time.sleep(millis.*);
}

fn blue(millis: *u64) void {
    var bytes: [64]u8 align(@alignOf(@Frame(blue_ish))) = undefined;
    const f = @asyncCall(&bytes, {}, blue_ish, .{millis});
    resume f;
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    var bytes: [64]u8 align(@alignOf(@Frame(red))) = undefined;
    const f = @asyncCall(&bytes, {}, fns[func], .{&millis});

    if (!ready) {
        std.os.abort();
    }

    resume f;
}

The compiler still complained:

zig build-exe test04.zig
./test04.zig:50:41: error: expected async function, found 'fn(*u64) void'
    const f = @asyncCall(&bytes, {}, fns[func], .{&millis});
                                        ^
make: *** [Makefile:13: test04] Error 1

I tried again with test05.zig:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

var ready: bool = false;

fn red(millis: *u64) void {
    suspend {}
    std.time.sleep(millis.*);
    ready = true;
}

fn blue(millis: *u64) void {
    ready = true;
    suspend {}
    std.time.sleep(millis.*);
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    var bytes: [64]u8 align(@alignOf(@Frame(red))) = undefined;
    const f = @asyncCall(&bytes, {}, fns[func], .{&millis});

    if (!ready) {
        std.os.abort();
    }

    resume f;
}

No luck:

zig build-exe test05.zig
./test05.zig:44:41: error: expected async function, found 'fn(*u64) void'
    const f = @asyncCall(&bytes, {}, fns[func], .{&millis});
                                        ^
make: *** [Makefile:16: test05] Error 1

But in all of these cases, I want to draw special attention to what I had to do in order to be able to have a resume: I had to do a special @asyncCall(). That certainly looks like it fits Bob Nystrom’s point 2 and point 4.

Thus, I argue that, when using suspend and resume with function pointers, Zig has function colors.

But I was not finished yet.

Async and Await

Next, I wrote the following:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

pub const io_mode = .evented;

var ready: bool = false;

fn async_ready(millis: *u64) void {
    suspend {}
    std.time.sleep(millis.*);
    ready = true;
}

fn red(millis: *u64) void {
    var f = async async_ready(millis);
    const ptr: anyframe->void = &f;
    const any_ptr: anyframe = ptr;
    await ptr;
    resume any_ptr;
}

fn blue(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    fns[func](&millis);

    if (!ready) {
        std.os.abort();
    }
}

In essence, it tries to call either red() or blue() normally, but this time, red() has a helper that it calls. The helper uses suspend, and red() awaits it and also resumes it.

On this point, the Zig language reference is confusing. It seems to say that an await will unsuspend a suspended function, but I’m not sure. In any case, I decided to massage my code into that form, specifically, the same form as async_await.zig, which has a suspend function called, resumed, and waited on by its caller.

The result is test06a.zig:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

pub const io_mode = .evented;

var ready: bool = false;

fn async_ready(millis: *u64) void {
    suspend {}
    std.time.sleep(millis.*);
    ready = true;
}

fn red(millis: *u64) void {
    var f = async async_ready(millis);
    const ptr: anyframe->void = &f;
    const any_ptr: anyframe = ptr;
    resume any_ptr;
    await ptr;
}

fn blue(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    fns[func](&millis);

    if (!ready) {
        std.os.abort();
    }
}

And the result was this:

fn(*u64) void@2084e0, fn(*u64) void@208700
Calling red
thread 25887 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test06a.zig:14:1: 0x208617 in red (test06a)
fn red(millis: *u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test06a.zig:52:14: 0x24bf68 in main (test06a)
    fns[func](&millis);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x25519d in std.event.loop.Loop.workerRun (test06a)
                        resume handle;
                        ^
/usr/lib/zig/std/event/loop.zig:702:23: 0x2398dd in std.event.loop.Loop.run (test06a)
        self.workerRun();
                      ^
/usr/lib/zig/std/start.zig:488:21: 0x20b469 in std.start.callMainWithArgs (test06a)
            loop.run();
                    ^
/usr/lib/zig/std/start.zig:409:17: 0x209576 in std.start.posixCallMainAndExit (test06a)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x209382 in std.start._start (test06a)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

Even worse, the panic happens when blue() is called!

fn(*u64) void@2084e0, fn(*u64) void@208700
Calling blue
thread 34522 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test06a.zig:22:1: 0x2087c1 in blue (test06a)
fn blue(millis: *u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test06a.zig:52:14: 0x24bf68 in main (test06a)
    fns[func](&millis);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x25519d in std.event.loop.Loop.workerRun (test06a)
                        resume handle;
                        ^
/usr/lib/zig/std/event/loop.zig:702:23: 0x2398dd in std.event.loop.Loop.run (test06a)
        self.workerRun();
                      ^
/usr/lib/zig/std/start.zig:488:21: 0x20b469 in std.start.callMainWithArgs (test06a)
            loop.run();
                    ^
/usr/lib/zig/std/start.zig:409:17: 0x209576 in std.start.posixCallMainAndExit (test06a)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x209382 in std.start._start (test06a)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

The one thing I was not doing that the language reference’s async_await.zig was doing was calling the function with async, so I did that in test06a_1.zig:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

pub const io_mode = .evented;

var ready: bool = false;

fn async_ready(millis: *u64) void {
    suspend {}
    std.time.sleep(millis.*);
    ready = true;
}

fn red(millis: *u64) void {
    var f = async async_ready(millis);
    const ptr: anyframe->void = &f;
    const any_ptr: anyframe = ptr;
    resume any_ptr;
    await ptr;
}

fn blue(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    var f = async fns[func](&millis);

    const ptr: anyframe->void = &f;
    await ptr;

    if (!ready) {
        std.os.abort();
    }
}

And it gave me a compile error:

zig build-exe test06a_1.zig
./test06a_1.zig:52:22: error: expected async function, found 'fn(*u64) void'
    var f = async fns[func](&millis);
                     ^
make: *** [Makefile:22: test06a_1] Error 1

This example shows that there is a difference between async and non-async functions. I feel like it’s a good example because it directly imitates an example in Zig’s own language reference, except for function pointers, and it does not compile.

It is my opinion that it does not because of function colors.

I decided to change both to blocking mode, yielding test06a_2.zig and test06a_3.zig, respectively.

The only change was changing the pub const io_mode = .evented; line to pub const io_mode = .blocking;.

test06a_2.zig compiled, and blue() even ran correctly. red() gave me this:

fn(*u64) void@205d90, fn(*u64) void@205fb0
Calling red
thread 46785 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test06a_2.zig:14:1: 0x205ec3 in red (test06a_2)
fn red(millis: *u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test06a_2.zig:52:14: 0x22d799 in main (test06a_2)
    fns[func](&millis);
             ^
/usr/lib/zig/std/start.zig:561:37: 0x226bfa in std.start.callMain (test06a_2)
            const result = root.main() catch |err| {
                                    ^
/usr/lib/zig/std/start.zig:495:12: 0x2077ee in std.start.callMainWithArgs (test06a_2)
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
/usr/lib/zig/std/start.zig:409:17: 0x206886 in std.start.posixCallMainAndExit (test06a_2)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x206692 in std.start._start (test06a_2)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

So it appears that the async_await.zig example in the language reference is wrong.

test06a_3.zig gave me a similar compiler error to test06a_1.zig.

But what happens if I reverse the resume any_ptr; and await ptr; lines in all four? This produced test06b{,_1,_2,_3}.zig.

Same exact results.

Next, I removed the await ptr; line from all four, giving me test07{,_1,_2,_3}.zig.

test07.zig compiled, and red() ran correctly. blue() gave me this error:

fn(*u64) void@2084b0, fn(*u64) void@208540
Calling blue
thread 64161 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test07.zig:21:1: 0x208601 in blue (test07)
fn blue(millis: *u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test07.zig:51:14: 0x24bda8 in main (test07)
    fns[func](&millis);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x254fdd in std.event.loop.Loop.workerRun (test07)
                        resume handle;
                        ^
/usr/lib/zig/std/event/loop.zig:702:23: 0x23971d in std.event.loop.Loop.run (test07)
        self.workerRun();
                      ^
/usr/lib/zig/std/start.zig:488:21: 0x20b2a9 in std.start.callMainWithArgs (test07)
            loop.run();
                    ^
/usr/lib/zig/std/start.zig:409:17: 0x2093b6 in std.start.posixCallMainAndExit (test07)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x2091c2 in std.start._start (test07)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

test07_1.zig refused to compile with this error:

zig build-exe test07_1.zig
./test07_1.zig:51:22: error: expected async function, found 'fn(*u64) void'
    var f = async fns[func](&millis);
                     ^
make: *** [Makefile:46: test07_1] Error 1

test07_2.zig compiled, and both red() and blue() worked!

test07_3.zig refused to compile with this error:

zig build-exe test07_3.zig
./test07_3.zig:51:22: error: expected async function, found 'fn(*u64) void'
    var f = async fns[func](&millis);
                     ^
make: *** [Makefile:52: test07_3] Error 1

Next, I took the test06b series, and turned them into the test08 series, with one change: I deleted the suspend in async_ready().

They produced the exact same results as the test06b series.

Then I took the test08 series and turned them into the test09 series by removing the await ptr; line.

test09.zig compiled, but this time, it gave me two different errors for red() and blue().

red() gave me this:

fn(*u64) void@208490, fn(*u64) void@208520
Calling red
thread 20057 panic: awaiting function resumed
/home/gavin/Code/Tests/zig/zig_async/test09.zig:9:19: 0x24cabc in async_ready (test09)
    std.time.sleep(millis.*);
                  ^
/home/gavin/Code/Tests/zig/zig_async/test09.zig:17:5: 0x208515 in red (test09)
    resume any_ptr;
    ^
/home/gavin/Code/Tests/zig/zig_async/test09.zig:50:14: 0x24bd88 in main (test09)
    fns[func](&millis);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x254fad in std.event.loop.Loop.workerRun (test09)
                        resume handle;
                        ^
/usr/lib/zig/std/event/loop.zig:702:23: 0x2396fd in std.event.loop.Loop.run (test09)
        self.workerRun();
                      ^
/usr/lib/zig/std/start.zig:488:21: 0x20b289 in std.start.callMainWithArgs (test09)
            loop.run();
                    ^
/usr/lib/zig/std/start.zig:409:17: 0x209396 in std.start.posixCallMainAndExit (test09)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x2091a2 in std.start._start (test09)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
thread 20072 panic: resumed a non-suspended function
/home/gavin/Code/Tests/zig/zig_async/test09.zig:8:1: 0x24ca94 in async_ready (test09)
fn async_ready(millis: *u64) void {
^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x254fad in std.event.loop.Loop.workerRun (test09)
                        resume handle;
                        ^
/usr/lib/zig/std/Thread.zig:359:13: 0x2630ff in std.Thread.callFn (test09)
            @call(.{}, f, args);
            ^
/usr/lib/zig/std/Thread.zig:875:30: 0x263086 in std.Thread.Instance.entryFn (test09)
                return callFn(f, self.fn_args);
                             ^
???:?:?: 0x265c6e in ??? (???)
Aborted

blue() gave me this:

fn(*u64) void@208490, fn(*u64) void@208520
Calling blue
thread 20130 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test09.zig:20:1: 0x2085e1 in blue (test09)
fn blue(millis: *u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test09.zig:50:14: 0x24bd88 in main (test09)
    fns[func](&millis);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x254fad in std.event.loop.Loop.workerRun (test09)
                        resume handle;
                        ^
/usr/lib/zig/std/event/loop.zig:702:23: 0x2396fd in std.event.loop.Loop.run (test09)
        self.workerRun();
                      ^
/usr/lib/zig/std/start.zig:488:21: 0x20b289 in std.start.callMainWithArgs (test09)
            loop.run();
                    ^
/usr/lib/zig/std/start.zig:409:17: 0x209396 in std.start.posixCallMainAndExit (test09)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x2091a2 in std.start._start (test09)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

So this time, red() gave the same error, as well as different error. I have no idea what it means.

test09_1.zig gave the familiar compiler error:

zig build-exe test09_1.zig
./test09_1.zig:48:22: error: expected async function, found 'fn(*u64) void'
    var f = async fns[func](&millis);
                     ^
make: *** [Makefile:70: test09_1] Error 1

test09_2.zig broke up the nice pattern! It compiles, and blue() succeeds, but red() segfaults!

fn(*u64) void@205c90, fn(*u64) void@205ce0
Calling red
Segmentation fault at address 0x7ffde698c4c8
???:?:?: 0x7ffde698c4c8 in ??? (???)
Aborted

Once again, somehow, a calling convention was violated. And if we look, there is a resume! That was also there for the last segfault, so I presume resume is one thing that marks a function as async.

test09_3.zig gave me the familiar compiler error as well:

zig build-exe test09_3.zig
./test09_3.zig:50:22: error: expected async function, found 'fn(*u64) void'
    var f = async fns[func](&millis);
                     ^
make: *** [Makefile:76: test09_3] Error 1

For the test10 series, I decided to do something slightly different, and more devious.

This is test10.zig:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

var ready: bool = false;

fn async_ready(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

fn red(millis: *u64) void {
    var f = async async_ready(millis);
    const ptr: anyframe->void = &f;
    await ptr;
}

fn blue(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    fns[func](&millis);

    if (!ready) {
        std.os.abort();
    }
}

blue() is plain. async_ready() is plain, despite its name. (I was lazy.) However, red() calls async_ready() with async and uses await on it.

test10.zig compiles, and blue() and red() both give me the familiar error:

fn(*u64) void@205d90, fn(*u64) void@205f70
Calling red
thread 27175 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test10.zig:11:1: 0x205e7e in red (test10)
fn red(millis: *u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test10.zig:47:14: 0x22d759 in main (test10)
    fns[func](&millis);
             ^
/usr/lib/zig/std/start.zig:561:37: 0x226bba in std.start.callMain (test10)
            const result = root.main() catch |err| {
                                    ^
/usr/lib/zig/std/start.zig:495:12: 0x2077ae in std.start.callMainWithArgs (test10)
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
/usr/lib/zig/std/start.zig:409:17: 0x206846 in std.start.posixCallMainAndExit (test10)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x206652 in std.start._start (test10)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

test10_1.zig looks like this:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

pub const io_mode = .evented;

var ready: bool = false;

fn async_ready(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

fn red(millis: *u64) void {
    var f = async async_ready(millis);
    const ptr: anyframe->void = &f;
    await ptr;
}

fn blue(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    var f = async fns[func](&millis);

    const ptr: anyframe->void = &f;
    await ptr;

    if (!ready) {
        std.os.abort();
    }
}

It gives the familiar compile error:

zig build-exe test10_1.zig
./test10_1.zig:49:22: error: expected async function, found 'fn(*u64) void'
    var f = async fns[func](&millis);
                     ^
make: *** [Makefile:82: test10_1] Error 1

test10_2.zig looks like test10.zig, except it’s blocking:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

pub const io_mode = .blocking;

var ready: bool = false;

fn async_ready(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

fn red(millis: *u64) void {
    var f = async async_ready(millis);
    const ptr: anyframe->void = &f;
    await ptr;
}

fn blue(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
    }
    else {
        try stdout.print("Calling red\n", .{});
    }

    fns[func](&millis);

    if (!ready) {
        std.os.abort();
    }
}

test10_2.zig compiled, and once again, blue() ran correctly, while red() gave the familiar error.

test10_3.zig looks like test10_1.zig except its blocking, and it also gives the same compiler error.

For the test11 series, I was even more devious.

Here is test11.zig:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

pub const io_mode = .evented;

var ready: bool = false;

fn async_ready(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

fn red(millis: *u64, f: ?*@Frame(async_ready)) void {
    _ = millis;
    const ptr: anyframe->void = f.?;
    await ptr;
}

fn blue(millis: *u64, f: ?*@Frame(async_ready)) void {
    _ = f;
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;
    var f: ?*@Frame(async_ready) = null;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
        f = null;
    }
    else {
        try stdout.print("Calling red\n", .{});
        var f2 = async async_ready(&millis);
        f = &f2;
    }

    fns[func](&millis, f);

    if (!ready) {
        std.os.abort();
    }
}

This takes the test10 series and flips red() on its head: instead of calling async_ready() with async, the code calls async_ready() with async before calling red() and passes in the pointer to the function to await on, which is what red() does.

test11.zig does compile, and as is usual, red() and blue() give the same error:

fn(*u64, ?*@Frame(async_ready)) void@208600, fn(*u64, ?*@Frame(async_ready)) void@2087d0
Calling red
thread 24965 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test11.zig:13:1: 0x208690 in red (test11)
fn red(millis: *u64, f: ?*@Frame(async_ready)) void {
^
/home/gavin/Code/Tests/zig/zig_async/test11.zig:54:14: 0x24bfdd in main (test11)
    fns[func](&millis, f);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x2552ad in std.event.loop.Loop.workerRun (test11)
                        resume handle;
                        ^
/usr/lib/zig/std/Thread.zig:359:13: 0x2633ff in std.Thread.callFn (test11)
            @call(.{}, f, args);
            ^
/usr/lib/zig/std/Thread.zig:875:30: 0x263386 in std.Thread.Instance.entryFn (test11)
                return callFn(f, self.fn_args);
                             ^
???:?:?: 0x265f6e in ??? (???)
Aborted

test11_1.zig looks like this:

const std = @import("std");
const RndGen = std.rand.DefaultPrng;

pub const io_mode = .evented;

var ready: bool = false;

fn async_ready(millis: *u64) void {
    std.time.sleep(millis.*);
    ready = true;
}

fn red(millis: *u64, f: ?*@Frame(async_ready)) void {
    _ = millis;
    const ptr: anyframe->void = f.?;
    await ptr;
}

fn blue(millis: *u64, f: ?*@Frame(async_ready)) void {
    _ = f;
    std.time.sleep(millis.*);
    ready = true;
}

const fns = [2]@TypeOf(blue) { red, blue };

pub fn main() !void {

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}, {s}\n", .{red, blue});

    var rnd = RndGen.init(@bitCast(u64, std.time.milliTimestamp()));
    var rand = rnd.random();

    var millis = rand.uintAtMost(u64, 2000);
    while (millis < 1000) {
        millis = rand.uintAtMost(u64, 2000);
    }

    var b = rand.boolean();
    var func: u32 = if (b) 1 else 0;
    var f: ?*@Frame(async_ready) = null;

    if (func == 1) {
        try stdout.print("Calling blue\n", .{});
        f = null;
    }
    else {
        try stdout.print("Calling red\n", .{});
        var f2 = async async_ready(&millis);
        f = &f2;
    }

    f = async fns[func](&millis, f);

    if (!ready) {
        std.os.abort();
    }

    const ptr: anyframe->void = f.?;
    await ptr;
}

As you can see, the only change is that the function pointer is called with async, as with all other *_1.zig tests.

This one gives the familiar compile error:

zig build-exe test11_1.zig
./test11_1.zig:54:18: error: expected async function, found 'fn(*u64, ?*@Frame(async_ready)) void'
    f = async fns[func](&millis, f);
                 ^
make: *** [Makefile:94: test11_1] Error 1

I likewise made a test11_2.zig and test11_3.zig, which just changes from evented to blocking.

test11_2.zig compiles, blue() runs without error, and red() gives the same error as usual:

fn(*u64, ?*@Frame(async_ready)) void@205db0, fn(*u64, ?*@Frame(async_ready)) void@205f80
Calling red
thread 29581 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test11_2.zig:13:1: 0x205e40 in red (test11_2)
fn red(millis: *u64, f: ?*@Frame(async_ready)) void {
^
/home/gavin/Code/Tests/zig/zig_async/test11_2.zig:54:14: 0x22d7c1 in main (test11_2)
    fns[func](&millis, f);
             ^
/usr/lib/zig/std/start.zig:561:37: 0x226bca in std.start.callMain (test11_2)
            const result = root.main() catch |err| {
                                    ^
/usr/lib/zig/std/start.zig:495:12: 0x2077be in std.start.callMainWithArgs (test11_2)
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
/usr/lib/zig/std/start.zig:409:17: 0x206856 in std.start.posixCallMainAndExit (test11_2)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x206662 in std.start._start (test11_2)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

test11_3.zig gives the familiar compile error:

zig build-exe test11_3.zig
./test11_3.zig:54:18: error: expected async function, found 'fn(*u64, ?*@Frame(async_ready)) void'
    f = async fns[func](&millis, f);
                 ^
make: *** [Makefile:100: test11_3] Error 1

Compiler Errors

I want to take a second to talk about the pattern I have seen up to this point.

First, two of the four examples in every series starting with test06a do not compile! And the error is always the same, that an async function was expected.

It is my opinion that Zig was expecting red functions and found the type of a blue function instead, which if true, means Zig has function colors.

But why was an async function required? In all of the cases, I called the function pointer with the async keyword.

However, I think I have a trick up my sleeve to make the compiler errors go away.

So I copied the test10 series to make the test12 series, and I copied the test11 series to make the test13 series. In both new series, the only thing I did was to change the @TypeOf(blue) to @TypeOf(red).

I did this for a curious reason: the Zig language reference says:

Zig infers that a function is async when it observes that the function contains a suspension point.

Yes, that is correct: the language reference admits that Zig has async and non-async functions. Once I read that, I was sure that Zig actually has function colors.

Also, it says that Zig “infers” when a function is async. When I first heard about Zig’s model, this is what I assumed it did: it statically figured out which functions were async (red), and which were non-async (blue), and if it couldn’t, then it would have to fall back on exposing function colors.

But then it also says this:

await is a suspend point.

So any function that uses the await keyword should be an async function, right?

I figured this out after I initially made a mistake in test11_2.zig, where after the if (!ready) statement and block, I had the following:


    if (f != null) {
        const ptr: anyframe->void = f.?;
        await ptr;
    }

It gave me the following compiler error:

zig build-exe test11_2.zig
/usr/lib/zig/std/start.zig:473:1: error: function with calling convention 'Inline' cannot be async
inline fn initEventLoopAndCallMain() u8 {
^
/usr/lib/zig/std/start.zig:495:12: note: async function call here
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
/usr/lib/zig/std/start.zig:561:37: note: async function call here
            const result = root.main() catch |err| {
                                    ^
./test11_2.zig:62:9: note: await here is a suspend point
        await ptr;
        ^
make: *** [Makefile:97: test11_2] Error 1

That extra await made main() async!

So my goal was to make red() async.

That was why the test11 series was crafted so that red() would not call a function with async but it would await on one.

And yet, the results were the same. Why?

Speculation and Opinion

I am giving myself a little space to express a subjective opinion here.

Zig’s async model is confusing to me.

It appears that eventing turns a lot of stuff into async, but not everything because I still get compiler errors. It also appears that, despite having matching async and await in test11_2.zig, something is wrong with the code.

Or there is a bug in the compiler. Andrew Kelley said I ran into one, so maybe I ran into more?

What’s more, the language reference says that using await should make red() an async function, but it’s not, for some reason, and I have no idea why.

Maybe it’s because I am much dumber than the Zig team, but if that’s the case, I don’t think Zig is a good language because everyone is stupid at times. We can all be tired, sick, rushed, or just simply misunderstand the documentation due to a different starting mental model than the Zig team expected of the audience reading the language reference.

The other thing I don’t understand is all of the “resumed an async function which already returned” errors I am getting. They seem to happen when calling red().

The only thing I can think of is that red() is async, but the compiler is giving compiler errors when it shouldn’t in some cases, and in others, it generated code to resume red() rather than call it normally.

And then there’s the case of async_ready() which in many cases, is not async, yet the compiler allows me to call it as though it is, with async.

But this is all speculation.

Back to the regularly scheduled mischief.

async async_ready()

My first thought about how to fix the problem was to make sure async_ready() was actually async. According to the language reference, I can do that putting in a suspension point.

So I took test11 series and copied them into the test14 series. I only made one change, by adding the following code to async_ready() after the std.time.sleep(millis.*); line:

    suspend {
        std.time.sleep(millis.*);
        resume @frame();
    }

With a suspend point, async_ready() should properly be async now. However, I have a resume for it because the language reference says that await “suspends until the target function completes.” Specifically, it does not say that it resumes the target function; only that it suspends the parent until the target completes.

test14.zig compiled, but it gave me two errors when red() runs:

fn(*u64, ?*@Frame(async_ready)) void@208620, fn(*u64, ?*@Frame(async_ready)) void@2087f0
Calling red
thread 11620 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test14.zig:17:1: 0x2086b0 in red (test14)
fn red(millis: *u64, f: ?*@Frame(async_ready)) void {
^
/home/gavin/Code/Tests/zig/zig_async/test14.zig:58:14: 0x24bfc8 in main (test14)
    fns[func](&millis, f);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x25527d in std.event.loop.Loop.workerRun (test14)
                        resume handle;
                        ^
/usr/lib/zig/std/event/loop.zig:702:23: 0x2398ad in std.event.loop.Loop.run (test14)
        self.workerRun();
                      ^
/usr/lib/zig/std/start.zig:488:21: 0x20b439 in std.start.callMainWithArgs (test14)
            loop.run();
                    ^
/usr/lib/zig/std/start.zig:409:17: 0x209546 in std.start.posixCallMainAndExit (test14)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x209352 in std.start._start (test14)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
thread 11635 panic: resumed a non-suspended function
/home/gavin/Code/Tests/zig/zig_async/test14.zig:8:1: 0x24ccb4 in async_ready (test14)
fn async_ready(millis: *u64) void {
^
/home/gavin/Code/Tests/zig/zig_async/test14.zig:12:9: 0x24cdc8 in async_ready (test14)
        resume @frame();
        ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x25527d in std.event.loop.Loop.workerRun (test14)
                        resume handle;
                        ^
/usr/lib/zig/std/Thread.zig:359:13: 0x2633cf in std.Thread.callFn (test14)
            @call(.{}, f, args);
            ^
/usr/lib/zig/std/Thread.zig:875:30: 0x263356 in std.Thread.Instance.entryFn (test14)
                return callFn(f, self.fn_args);
                             ^
???:?:?: 0x265f3e in ??? (???)
Aborted

On the other hand, blue() produces only the familiar error:

fn(*u64, ?*@Frame(async_ready)) void@208620, fn(*u64, ?*@Frame(async_ready)) void@2087f0
Calling blue
thread 11694 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test14.zig:23:1: 0x2088b1 in blue (test14)
fn blue(millis: *u64, f: ?*@Frame(async_ready)) void {
^
/home/gavin/Code/Tests/zig/zig_async/test14.zig:58:14: 0x24bfc8 in main (test14)
    fns[func](&millis, f);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x25527d in std.event.loop.Loop.workerRun (test14)
                        resume handle;
                        ^
/usr/lib/zig/std/event/loop.zig:702:23: 0x2398ad in std.event.loop.Loop.run (test14)
        self.workerRun();
                      ^
/usr/lib/zig/std/start.zig:488:21: 0x20b439 in std.start.callMainWithArgs (test14)
            loop.run();
                    ^
/usr/lib/zig/std/start.zig:409:17: 0x209546 in std.start.posixCallMainAndExit (test14)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x209352 in std.start._start (test14)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

test14_1.zig gave me the same compile error as always:

zig build-exe test14_1.zig
./test14_1.zig:58:18: error: expected async function, found 'fn(*u64, ?*@Frame(async_ready)) void'
    f = async fns[func](&millis, f);
                 ^
make: *** [Makefile:177: test14_1] Error 1

test14_2.zig compiles, blue() runs fine, and red() gives the usual error:

fn(*u64, ?*@Frame(async_ready)) void@205db0, fn(*u64, ?*@Frame(async_ready)) void@205f80
Calling red
thread 61447 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test14_2.zig:17:1: 0x205e40 in red (test14_2)
fn red(millis: *u64, f: ?*@Frame(async_ready)) void {
^
/home/gavin/Code/Tests/zig/zig_async/test14_2.zig:58:14: 0x22d7f2 in main (test14_2)
    fns[func](&millis, f);
             ^
/usr/lib/zig/std/start.zig:561:37: 0x226bca in std.start.callMain (test14_2)
            const result = root.main() catch |err| {
                                    ^
/usr/lib/zig/std/start.zig:495:12: 0x2077be in std.start.callMainWithArgs (test14_2)
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
/usr/lib/zig/std/start.zig:409:17: 0x206856 in std.start.posixCallMainAndExit (test14_2)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x206662 in std.start._start (test14_2)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

And test14_3.zig gave me the usual compiler error:

zig build-exe test14_3.zig
./test14_3.zig:58:18: error: expected async function, found 'fn(*u64, ?*@Frame(async_ready)) void'
    f = async fns[func](&millis, f);
                 ^
make: *** [Makefile:183: test14_3] Error 1

So what if I remove the resume @frame(); line from the test14 series to make the test15 series?

test15.zig compiles and gives the usual error for both red() and blue():

fn(*u64, ?*@Frame(async_ready)) void@208620, fn(*u64, ?*@Frame(async_ready)) void@2087f0
Calling red
thread 56522 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test15.zig:16:1: 0x2086b0 in red (test15)
fn red(millis: *u64, f: ?*@Frame(async_ready)) void {
^
/home/gavin/Code/Tests/zig/zig_async/test15.zig:57:14: 0x24bfc8 in main (test15)
    fns[func](&millis, f);
             ^
/usr/lib/zig/std/event/loop.zig:1415:25: 0x25525d in std.event.loop.Loop.workerRun (test15)
                        resume handle;
                        ^
/usr/lib/zig/std/event/loop.zig:702:23: 0x2398ad in std.event.loop.Loop.run (test15)
        self.workerRun();
                      ^
/usr/lib/zig/std/start.zig:488:21: 0x20b439 in std.start.callMainWithArgs (test15)
            loop.run();
                    ^
/usr/lib/zig/std/start.zig:409:17: 0x209546 in std.start.posixCallMainAndExit (test15)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x209352 in std.start._start (test15)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

test15_1.zig gives the usual compiler error:

zig build-exe test15_1.zig
./test15_1.zig:57:18: error: expected async function, found 'fn(*u64, ?*@Frame(async_ready)) void'
    f = async fns[func](&millis, f);
                 ^
make: *** [Makefile:193: test15_1] Error 1

test15_2.zig compiles, and blue() runs fine, but red(), as usual, gives an error:

fn(*u64, ?*@Frame(async_ready)) void@205db0, fn(*u64, ?*@Frame(async_ready)) void@205f80
Calling red
thread 65510 panic: resumed an async function which already returned
/home/gavin/Code/Tests/zig/zig_async/test15_2.zig:16:1: 0x205e40 in red (test15_2)
fn red(millis: *u64, f: ?*@Frame(async_ready)) void {
^
/home/gavin/Code/Tests/zig/zig_async/test15_2.zig:57:14: 0x22d7ef in main (test15_2)
    fns[func](&millis, f);
             ^
/usr/lib/zig/std/start.zig:561:37: 0x226bca in std.start.callMain (test15_2)
            const result = root.main() catch |err| {
                                    ^
/usr/lib/zig/std/start.zig:495:12: 0x2077be in std.start.callMainWithArgs (test15_2)
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
/usr/lib/zig/std/start.zig:409:17: 0x206856 in std.start.posixCallMainAndExit (test15_2)
    std.os.exit(@call(.{ .modifier = .always_inline }, callMainWithArgs, .{ argc, argv, envp }));
                ^
/usr/lib/zig/std/start.zig:322:5: 0x206662 in std.start._start (test15_2)
    @call(.{ .modifier = .never_inline }, posixCallMainAndExit, .{});
    ^
Aborted

And obviously, test15_3.zig is unchanged:

zig build-exe test15_3.zig
./test15_3.zig:57:18: error: expected async function, found 'fn(*u64, ?*@Frame(async_ready)) void'
    f = async fns[func](&millis, f);
                 ^
make: *** [Makefile:199: test15_3] Error 1

What if I make the test15 series into the test16 series by making the suspend block in async_ready() empty?

Same exact results as the test15 series.

This doesn’t make sense to me because one of the examples in the language reference, suspend_no_resume.zig, calls a function with suspend using async, and it works.

And all of that does not answer the question why the test*_1.zig and the test*_3.zig files won’t compile. Maybe it’s because they expect @TypeOf(blue), and blue() is not async?

So I copied the test14 series to the test17 series, the test15 series to the test18 series, and the test16 series to the test19 series. In all cases, the only change I made was to change @TypeOf(blue) to @TypeOf(red).

I got the exact same results!

This really does not make sense to me because, per the “function with calling convention ‘Inline’ cannot be async” compiler error I got with a mistake in test11_2.zig. That error happened because the existence of an await made main() an async function. red() has await, so it should be async and its type should be async. Yet I still get the compiler errors.

Something must be wrong either with my brain, the code, or the compiler.

Opinion here: it’s probably all three. However, I’m failing to see what is wrong with the code because as far as I know, not all of my tests have undefined behavior.

But I have one last trick up my sleeve to make async_ready() async.

About the builtin function @frame(), the language reference says,

This function does not mark a suspension point, but it does cause the function in scope to become an async function.

So I copied the test19 series to the test20 and test21 series. I replaced all instances of the suspend{} in async_ready() with g_frame = @frame(); (and added g_frame as a global frame variable), and in the test20 series, I changed @TypeOf(red) back to @TypeOf(blue), just in case.

I got the same results as with the test19 series.

And now, I am out of ideas. async_ready() and red() should be async, but somehow, the compiler and runtime errors are still there.

The True Shape of Zig’s async Model

With all of that said, what exactly does Zig’s async model look like?

This section is pure speculation, based on my tests, of course. But I am not a Zig expert, and the results of these tests have left me with more questions than answers.

First, I think the documentation is mostly correct, albeit unclear.

It says,

Zig infers that a function is async when it observes that the function contains a suspension point.

This is a pretty clear definition of an async function, and I think this is good because it focuses us on what we need: we need to know what creates a suspension point.

So let’s look at all of the ways a function can have a suspension point:

  • “A function call of an async function is a suspend point.”
  • Use of the suspend construct is obviously a suspension point.
  • await is a suspend point.” (Hence, my trying to get red() to be async with await.)
  • “[@frame()] does not mark a suspension point, but it does cause the function in scope to become an async function.” (This appears to be the one exception to the “suspension point” rule.)

Async functions can be called the same as normal functions.

This is usually, but not always, the case.

However, before I continue, let me say a little bit of praise for Zig: this is innovation. This is a piece of good work that, most of the time, async functions can be called the same as normal functions.

But of course, beware once you move functions from compile-time to runtime because there is where the conveniences of Zig’s async model cannot help you (much).

In short, like with all concurrency models (including my favorite!), you still need to understand the model fully before you can write correct code in that model.

Possible Complaints

In this section, I will try to preemptively address possible complaints.

Artificial Code

The first complaint I expect is that all of my examples are artificial code.

Well, yes. They were designed to tease out the reality of Zig’s async story because the documentation was Greek to me.

But as every compiler engineer will tell you, they would prefer small examples of code to trigger bugs or other conditions for testing.

I also believe that small examples can always be turned into actual, useful code. In this case, imagine you are trying to use interfaces in Zig. You will use a lot of function pointers, and I’m sure that some situation could cause you grief if you are unaware that Zig’s functions are colored.

Undefined Behavior

Another complaint is that most, or all, of my tests exhibit undefined behavior.

As I said before, I believe that is unfair because:

  • The examples in the language reference would also have undefined behavior.
  • The “Async Functions” section in the language reference never says anything is undefined behavior.

In fact, the only time it says anything related to async is undefined behavior (as far as I can tell) is passing a buffer that is too small to @asyncCall().

Low-Level

Another possible complaint is that I only demonstrated function colors using low-level constructs that most people won’t use.

For example, Loris Cro told me that suspend and resume are low-level constructs. The language reference agrees:

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.

So, it could be the case that only library writers need to care about function colors in Zig, right?

I agree that, for the most part, only library writers need to care, but I disagree that only library writers need to care.

First, if a non-library writer is using something like Zig interfaces and needs to use an async function, they have to care.

This is a big reason I emphasize “use” rather than “call.”

Second, await makes functions async, and await is not low-level. The proof that await makes functions async is above.

Third, async is, in fact, viral. The language reference says,

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.

(Emphasis added.)

In other words, if you call an async function, your function gets a suspend point, which means that your function becomes async.

In fact, if you look at Bob Nystrom’s point 3, “You can only call a red function from within another red function,” this means that Zig fits point 3!

What Zig does differently than Bob Nystrom’s “hypothetical” language is that if it sees you call a red function from a blue function, it shrugs its shoulders, makes the blue function red, and does not tell you. This gives the appearance of being able to call red functions from blue functions while still only allowing red functions to call red functions.

With this in mind, Zig actually fits point 1 through 4; this means that it fits all of the points that matter! (Point 5, of course, applies just to the standard library.)

I argue that this means that Zig has function colors even if you do not accept that my definition fits the points given by Bob Nystrom.

Fourth, Zig programmers may be under the false impression that Zig does not have function colors.

Opinions incoming.

Loris Cro’s original post is titled “What is Zig’s ‘Colorblind’ Async/Await?” I like that title because it is accurate: most of the time, Zig’s async is colorblind because most of the time, async functions can be called the same as normal functions.

But I think that Zig proponents may have gone too far. I don’t know how it happened; perhaps it was just shorthand. But most of the references to Zig’s async model that I have seen say things like:

The way these claims are worded may lead some newcomers, or people who are not language lawyers, to think that you don’t have to worry about function colors at all in Zig.

I hope that this post has shown otherwise. I also hope that Zig proponents can be careful to not (accidentally or otherwise) mislead programmers about Zig’s async model.

Conclusion

This post has been one of my longest, though that’s entirely down to the pages of code, compiler errors, and runtime errors I shared verbatim.

So what did I learn?

First, that my gut feeling was (I think) correct.

Second, I now have more proof that structured concurrency is the right method (at least for me) to create a concurrency model in a programming language. This additional proof includes:

  • The fact that structured concurrency does not have the function color problem.
  • The fact that Swift has implemented structured concurrency.
  • Experience with real code demonstrating that structured concurrency works and is easier to reason about (for me).
    • It even allows me to use a stack-based allocator basically for free!
    • Which, in turn, allows me to safely implement exceptions and other error handling in C, as well as many other niceties.

Third, I learned, yet again, that I should always let a cooler head prevail. I am bad at it. I hope I got just a bit better with this post.