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):
- Every function has a color.
- The way you call a function depends on its color.
- You can only call a red function from within another red function.
- Red functions are more painful to call.
- 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:
- A gut feeling based on everything I know about concurrency, structured concurrency, and the structured programming theorem.
- The original “What is Zig’s ‘Colorblind’ Async/Await?” blog post.
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 75(!) 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 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 getred()
to beasync
withawait
.) - “[
@frame()
] does not mark a suspension point, but it does cause the function in scope to become anasync
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 thanawait
. Most application code will use onlyasync
andawait
, but event loop implementations will make use ofsuspend
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:
- “Zig async functions do not suffer from function coloring.”
- “Zig’s functions are colorblind”. Notice that there is no qualification on this statement.
- “Zig has the same codebase that supports both evented I/O and blocking I/O because of the non-existence of function coloring.” (Emphasis added.)
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.