Hacker News
points by Mond_ 2 days ago | 0 comments
Are you sure you're up to date?

The async design changed multiple times, and the current approach is that of requiring passing an I/O interface that encapsulates the logic, same approach as for passing around an allocator.

This effectively makes all I/O bound code "quasi-async", and it's all explicit and the caller is in control.

[–] simonask 1 day ago
As far as I understand it, the idea is that the IO interface is "magic" in the sense that it affects which version of a function gets called: either the synchronous, linear version, or the asynchronous, stackless, suspensible state machine version.

This has a lot of implications, especially for function pointers and other forms of dynamic dispatch.

My personal take is that async code is fundamentally different from sync code, because the weights of various tradeoffs change dramatically. In my view, "colorless" functions are not really a worthwhile goal in the first place. But I've been wrong before, so it may turn out to be great, we'll see.

[–] lukaslalinsky 1 day ago
There is no magic about the I/O interface. It's a regular dynamic dispatch, the same as if you use virtual functions in C++. Currently, and in the foreseeable future, there are only two options how it can run, blocking mode where each task occupies a dedicated thread, or using stackful coroutines. The interface implements blocking functions, there is no async waiting anywhere in there. But because it's dynamic dispatch, it allows the async implementation to do the "prepare operation, suspend until completed, wake up" dance, where the threaded implementation would just run the blocking syscall directly. There are plans to add stackless coroutines, but that will require many language restrictions to happen first, so it's not going to happen any time soon and when it does, the interface will likely change to accomodate it.
[–] elcritch 1 day ago
> it allows the async implementation to do the "prepare operation, suspend until completed, wake up" dance, where the threaded implementation would just run the blocking syscall directly. There are plans to add stackless coroutines

Yeah so that's a lot of hidden magic and implicit logic. ¯\_(ツ)_/¯

[–] Zambyte 1 day ago
It's all implemented as explicit userspace code that follows the same rules as the rest of the language. There is quite literally no implicit logic.
[–] tialaramex 1 day ago
"It's actually explicit if you look at how it's implemented" is true for everything.

If you look at how it's implemented you can see that the classic "XOR a register with itself to zero that register" trick is in fact basically free in a modern CPU rather than using up the ALU because of how register renaming is implemented. But hopefully we're not pretending that "You could buy the CPU vendor's technical manuals" makes this now explicit.

[–] weebull 1 day ago
The key point is, it's not language magic. It's library magic. You can write your own IO implementation if you want it to work a different way.
[–] Zambyte 1 day ago
In most languages, async is implemented as a special feature in the compiler, not as userspace code. You can't just jump-to-definition of async/await keywords in languages that use them. async/await are not keywords in Zig, they are regular function calls. There is obviously a categorically different level of insight between jumping to library code and digging into compiler internals.
[–] lukaslalinsky 1 day ago
While you could say that, all the operations are documented behavior. You need to assume they will block. If the blocking happens in userspace runtime or in the kernel doesn't matter. And you, as the application author, are the one choosing which runtime to use. This just allows the libraries to be generic.
[–] boomlinde 1 day ago
Hidden from those who don't read it.
[–] imtringued 22 hours ago
This is AI generated so it could be wrong:

    const std = @import("std");
    
    fn performTasks(io: std.Io) !void {
        var group: std.Io.Group = .init;
        errdefer group.cancel(io);

        // Launch concurrent background jobs
        try group.async(io, simulateWorker, .{ io, 1 });
        try group.async(io, simulateWorker, .{ io, 2 });

        // Wait for all group tasks to complete
        try group.await(io);
    }
    
    fn simulateWorker(io: std.Io, id: usize) void {
        std.log.info("Worker {d} started...", .{id});
        
        // Any blocking operation (like sleeping) routes through 'io'
        // Under the hood, this will either block an OS thread or yield an event loop stack.
        io.sleep(.fromSeconds(1), .awake) catch {};
        
        std.log.info("Worker {d} finished!", .{id});
    }
    
    pub fn main() !void {
        var gpa_alloc = std.heap.GeneralPurposeAllocator(.{}){};
        defer _ = gpa_alloc.deinit();
        const allocator = gpa_alloc.allocator();
    
        // Initialize the Threaded I/O backend
        var threaded = std.Io.Threaded.init(allocator, .{
            .async_limit = std.Io.Limit.limited(4), // Max concurrent tasks in the pool
        });
        defer threaded.deinit();
        
        // Extract the generic I/O interface pointer
        const io = threaded.io();

        std.log.info("Running on Threaded backend:", .{});
        try performTasks(io);
    }


    pub fn main() !void {
        var gpa_alloc = std.heap.GeneralPurposeAllocator(.{}){};
        defer _ = gpa_alloc.deinit();
        const allocator = gpa_alloc.allocator();

        // Initialize the Evented (non-blocking) backend
        var evented: std.Io.Evented = undefined;
        try evented.init(allocator, .{
            .thread_limit = 4, // Worker threads running the event loop
        });
        defer evented.deinit();
    
        // Extract the exact same generic interface
        const io = evented.io();

        std.log.info("Running on Evented backend:", .{});
        try performTasks(io);
    }


std.Io.Threaded and std.Io.Evented are two types of IO backends. std.Io.Threaded uses blocking IO primitives and Evented uses asynchronous IO primitives. Switching between backends is done via dynamic dispatch, the used backend is passed in via an explicit function parameter.

The only real question you should have is, how does the "non-blocking" case work when the interface is still a synchronous call? Well, the answer is quite boring... You just call into the scheduler and it runs the other task inside the blocked operation.

I was promised hidden magic and implicit logic! Now I want a refund!

[–] maleldil 1 day ago
Doesn't that assume there are no intrinsic differences between single-threaded, multi-threaded and async execution? Plenty of code works fine single-threaded but breaks once it's run multi-threaded or async, and some works fine multi-threaded but breaks under async. With explicit async you have precise control over where you yield back to the event loop. With Zig's Io you don't get that unless you deliberately design for it, and at that point you've just got an implicitly coloured function.
[–] lukaslalinsky 1 day ago
There is a difference between single-threaded and multi-threaded, but if you use `io.concurrent` or `io.async`, you are expected to have your code assume multi-threaded enviroment. And there is no visible behavior change between blocking and non-blocking backend, assuming you are not calling external libraries that do their own I/O. So code written and tested on blocking threaded I/O, will work just fine on async implementation. All I/O operations are the yield point.