Zig's New Async I/O

36 Likes

I’m excited about these changes. I think they’ll be a big improvement over the current API, even for regular blocking IO operations.

Question – will the convention for the IO parameter extend to all parts of the standard library (i.e. even std.debug)?

I think std.debug.print for example will continue to have its same definition:

/// Print to stderr, silently returning on failure. Intended for use in "printf
/// debugging". Use `std.log` functions for proper logging.
///
/// Uses a 64-byte buffer for formatted printing which is flushed before this
/// function returns.
pub fn print(comptime fmt: []const u8, args: anytype) void {
    var buffer: [64]u8 = undefined;
    const bw = lockStderrWriter(&buffer);
    defer unlockStderrWriter();
    nosuspend bw.print(fmt, args) catch return;
}

As for loading debug info, that might fall under the same umbrella, or it might be better to make it more reusable.

Another question is the default log function. Probably it will continue to have the same behavior, writing log messages to stderr, independent from any Io implementation, relying on the user to override that behavior if desired.

The important thing is that third party, reusable packages in the ecosystem will use this pattern and therefore be useful with any I/O strategy.

2 Likes

I actually have a question: What actually is the purpose of nosuspend now?

Until now it was as far as I understand things for handling certain cases about async functions, but since they don’t exist anymore (on a language level), what does it do?

Before:

The nosuspend keyword can be used in front of a block, statement or expression, to mark a scope where no suspension points are reached. In particular, inside a nosuspend scope:

  • Using the suspend keyword results in a compile error.
  • Using await on a function frame which hasn’t completed yet results in safety-checked Undefined Behavior.
  • Calling an async function may result in safety-checked Undefined Behavior, because it’s equivalent to await async some_async_fn(), which contains an await.

Code inside a nosuspend scope does not cause the enclosing function to become an async function.

Now: nothing

In the future, it might be reused for the equivalent purpose as before: assert that no suspension points will occur within the block.

Just to clarify: “suspension points” is referring specifically to stackless coroutines, which require language-level support (see the corresponding proposal). nosuspend doesn’t have any special interaction with the std.Io interface; if it stays in the language it will specifically be related to that proposal.

1 Like

Hi,
with this Io abstraction, it should be possible to implement some kind of capability based Io (e.g. file system access), something like cap-std. Is this something that is envisioned or planned for the Zig Standard library or is it something currently “left” for the community in the form of a 3rd party library?

Super interesting work, btw… I need to dig deeper into it, especially since I consider myself as a self-proclaimed async Rust expert :wink:

1 Like

Going to be fun to implement something like task prioritization under cooperative multitasking on microcontrollers. Sounds like this io interface will make that relatively easy. Really looking forward to improved code reusabilty!!!

2 Likes

Another question: I could not find the Io interface on Github. Can someone provide a link to it?

The async-await-demo branch has, well, a demo of the new async await stuff, and this gist shows a usage example: async await demo · GitHub

None of this is ready for prime time, but you can take it for a spin. The threadpool implementation works on all platforms, the event loop one (green threads) only works on x86_64 linux for now.

1 Like

Here is the link to the Io vTable in the demo branch: Io vTable.
For everyone who wants to dig deeper.

1 Like

Very excited about this new API and everything it enables.

After reading your blog post in combination with that demo gist, I do have
one concern: getting cleanup right in the face of refactoring, both when moving code around and when introducing errors to functions.

If I understand correctly, the rule is that any async operation happening before a try await must have a pending defer cancel (or an await), otherwise we have a bug.

Isn’t it relatively easy to go wrong if you introduce errors to functions that previously were infallible?

Example: if calcSum down the line is changed to also return an error and you recompile, you’ll get a compile error because you don’t have try in front of first_half.await and second_half.await

Now, as one is used to in this situation, you add the try to call sites and
everything seems to work in the happy path. But what if calcSum fails at runtime?

As there’s a missing defer cancel on second_half, we have bug. You won’t forget to add the try because the compiler tells you too, but I fear it’s too easy to miss or forget spots where cancelation is also needed when errors are introduced.

If this is correct, we might need tooling to help avoid these issues. Always doing defer-cancel just in case doesn’t seem right.

Maybe it’s possible to make the equivalent of arenas for async so you only need one defer in functions that uses a non-trivial amount of async. Something like io.scopedIo() paired with a single defer which takes care of any cleanup? If a “DebugIo” is planned to catch these and good coverage is possible, that could be a fine solution too.

1 Like

This is already something you need to keep in mind for anything in Zig really, including general memory allocations, not just async io or similar.

For memory allocations we have good idioms and tools to help prevent and find bugs (DebugAllocator and valgrind to find bugs, ArenaAllocator to, among other things, help reduce cognitive load, etc). That’s why I brought it up really - to ask if we’ll have similar tooling and idioms for async io.

I think this is a bit different, because any function can be used by both regular code and async code. Once you introduce failures, you have to check for async callsites and see if introducing a new early exit (due to try) also means adding cleanup elsewhere.

5 Likes

You can make a DebugIo that gives you stack traces when you deinit it and it sees that some Future was not awaited or canceled.

9 Likes

the ability to have DebugIo is one of the benefits of Io as an interface that Andrew gave, so it will probably happen

5 Likes

Sounds good, hope to see that in std

1 Like

Looking forward to messing around with this on FreeBSD. I’m happy that the ZSF and folks are not rushing to just jam in whatever async strategy into the language to keep up with hype or pressure. Just keep on doing exactly what you guys are doing. I’m having a lot of fun writing my personal work in Zig and releasing a lot of them as well, and looking forward to maintaining my robust, optimal, and reusable apps :slight_smile:.

8 Likes

In function signature, should we put (allocator, io) or (io, allocator) ?

5 Likes

damn, hard question. IMO io should go first

5 Likes