A little help with `Io` please. I am trying to understand how to use the new `Io` interface for Zig

So, I am trying to implement async io in zig using the new Io interface. I understand writing my own event loop, but the mental model is still not clear.

Any good example code or links would be really appreciated.

Here are the resources I am using at the moment:

@andrewrk ‘s blog post: zig-new-async-io-text-version

Zig code std.Io implementation: zig/src/branch/master/lib/std/Io.zig and corresponding tests: std/Io/test.zig

It would be easier to help you, if you describe what you are trying to achieve.

1 Like

I come from the JavaScript world.

I am trying to replicate async await from JavaScript here. Like the mental model. (of course the languages and the way they work are different)

I understand how promises work. But async await needs a certain co-routine kind of thing. Can’t actually reproduce that.

Also maybe lack of documentation is the reason behind the fact that I don’t understand.

I read this implementation of yours: zio/examples/coro_demo.zig at zig-0.16 · lalinsky/zio · GitHub . Still not convinced this is the best way to write it. I am sure even you are not happy with this limited design.

Something is missing. And I don’t know what. I need to unlock my brain. Need a really convincing example

I think you are on the wrong track. I think it would really help if you could describe what you are trying to program. Talking about async/await is not helpful, because in JS, these are keywords to declare and call stackless coroutines, a concept that doesn’t exist in Zig.

In Zig 0.16, everything you see looks like regular blocking code, even if it uses non-blocking primitives under the hood. The Io interface is there specifically to make it not relevant what the implementation does.

If you want to wait, you use io.sleep. If you want to read from a socket, you call stream.reader(io, &buffer) on a TCP stream and use the reader interface as usual.

You only need to use io.async or io.concurrent if you want to do things “in the background”, for example:

    var group: Io.Group = .init;
    defer group.cancel(io);

    while (true) {
        const stream = try server.accept(io);
        errdefer stream.close(io);

        try group.concurrent(io, handleClient, .{ io, stream });
    }

If you write down the exact use case, I can help you.

And ignore the coro_demo.zig example from my project, that’s for the low-level library, it’s not for regular use. If you want a more real example from there, take a look at this one: zio/examples/tcp_echo_server_stdio.zig at zig-0.16 · lalinsky/zio · GitHub

2 Likes

Thanks for the reply. And seeing your code in zio I know you have done a lot of work and brainstorming on this. Let me come to you with a good implementation example. Or rather a really good question. I think you are the best person around here to answer my doubts

Okay. Consider this function:

fn only_morning_fn(event_loop: *EventLoop) !u32 {
  const hour_of_the_day = Date.nowHours();
  if (hour_of_the_day < 12) return error.TooEarly;
  return std.crypto.random.int(u32);
}

I am brainstorming on how can I promisify this function. Like in the event loop it should only be resolved when its afternoon

Please consider that everything in this thread is a hypothetical brainstorming. I will be writing a proper post/thread later on with working examples. Right now I am only brainstorming

You can use std.Io.async:

fn doStuff(io: std.Io) !void {
    var future = io.async(onlyMorningFn, .{});
    defer future.cancel(io) catch {};

    // do some other stuff while onlyMorningFn runs
    
    const result = future.await(io);
    if (result) |random_value| {
        std.debug.print("{d}\n", .{random_value});
    } catch |err| {
        try std.testing.expect(err == error.TooEarly);
        std.debug.print("it was too early\n", .{});
    }
}
1 Like

Right now, there isn’t anything special you need to do for a function to work with std.Io. This can change in the future when stackless coroutines are introduced.

Here is a simple code that I have written to test my implementation and understanding:

fn Promise(comptime T: type) type {
  err: ?error,
  result: T,

  return union {
    res: T = undefined,
    err: ?error = undefined,
  };
}

/// some random code that throws `RandomError` for some reason
fn randomInt(): RandomError!u32 {}

const OnlyMorningErr = error { TooEarlyErr, RandomErr };

fn only_morning_fn() OnlyMorningErr!u32 {
  const hour_of_the_day = rt.now(); // let's get the 
  if (hour_of_the_day >= 12) return error.TooLateErr;
  return try randomInt();
}

fn only_morning_promise_fn() PromisePending!Promise(u32) {
  const res = only_morning_fn() catch |err| {
    if (err == .TooLateErr) return err.PromisePending;
    return Promise(u32){ .err = err };
  };
  return Promise(u32){ .result = res };
}

const OnlyEveningErr = error { TooEarlyErr, RandomErr };

fn only_evening_fn() OnlyEveningErr!u32 {
  const hour_of_the_day = rt.now(); // let's get the 
  if (hour_of_the_day >= 12) return error.TooEarlyErr;
  return try randomInt(); // throws `RandomErr`
}
fn only_evening_promise_fn() PromisePendingErr!u32 {
  const res = only_evening_fn() catch |err| {
    if (err == .TooEarlyErr) return err.PromisePending;
    return Promise(u32){ .err = err };
  };
  return Promise(u32){ .result = res };
}

fn run_async(rt: Runtime) !u32 {
  const res1: Promise(u32) = try only_morning_promise_fn();
  if (res1.err) |err| return err; // so, handle any error if the error is not `error.PromisePending`

  const res2: Promise(u32) = try only_evening_promise_fn();
  if (res2.err) |err| return err;

  return res1.result + res2.result;
}

const Runtime = struct {
  // queue stores the function and the arguments
  // this is some random data-structure, to be brainstormed later
  arr: Array;

  fn run(self: Runtime) void {
    for (self.arr.items) |item| {
      // note that we only peek the first element of the queue here
      // we don't just pop it
      
      const func = item.func;
      const args = item.args;

      func(args) catch |err| {
        if (err == .PromisePending) continue;
        return err;
      }
      self.arr.pop();
    }

    if (self.arr.items.len > 0) {
      self.run();
    }
  }

  fn async(self: Runtime, func: anytype, args: anytype) !T {
    // register the `func` to the event queue
    try self.arr.append(func, args);
  }
};

fn main() !void {
  const rt = Runtime.init();
  try rt.async(async_fn, .{});
  try rt.async(only_morning_fn, .{});
  try rt.async(only_evening_fn, .{});
  rt.run();
}

Can you review this.

It doesn’t run. Just kind of like a pseudo code to understand async await

For something like this, you use io.concurrent() to spawn two tasks for the functions, in them you do io.sleep() and then return a random value. In the parent function you await() the tasks and combine the results.

Note that this is going to be extremely wasteful with std.Io.Threaded, but that’s the only way to do it. The Io interface doesn’t provide you any kind of event loop to hook into. With the future std.Io.Evented, it’s a lot less wasteful, depending on how they end up implementing stacks. With zio, it’s mostly fine, the cost is minimal, plus you have to option to access the event loop, where the cost of waiting like this is about zero.

2 Likes

Is the implementation for "non-parallelizeable” tasks right? consider only_evening_fn is dependent on only_morning_fn result (hypothetical)

I want to have the simplest mental model before I start doing async tasks in my code.

First the simplest way mental model has to be there. Consider that everything I do is single-threaded.

The Io interface is not very helpful with these kind of futures/promises. You would basically need to implement this yourself in terms of having your own mini event loop in one task, and use e.g. std.Io.Event so that you have something to wait on in the tasks that need the value.

If you absolutely need something like this, using zio directly might give you more options, but you would have to touch the lower level APIs, but then you lose some compatibility with the rest of other Zig ecosystem.

However, I’d really start with the goal of what you want to do. If you want to think of Io in terms of “async” code, you will not be very happy. The goal of the interface is to escape that model, give you blocking APIs to work with and structure the code the way you would if you used threads.

3 Likes

Here’s one of the Zig core team members explaining some of the theory behind the new IO interface:

Like has been mentioned, the goal isn’t JS-style async programming, but to provide an IO model that’s agnostic of the concurrency implementation, so all client code will look the same but can run in different contexts as needed.

Edit: Link is correct now.

4 Likes

Yes. I figured it out. Let me come up with a mini-DSL. I think I am getting onto something here. I want you to review it. I went over your zio runtime. Its awesome. @lalinsky

While you may disagree with me, the mental model behind JS’s async/await is one of the best I have seen for single-threaded runtimes. Just the mental model. I really don’t like Go’s concurrency (although it helps with multi-threading) in terms of the model

1 Like

Don’t expect or try to get JS style async, It’s just not what zig is trying to do.

The example you provided is neither Io bound nor computationally bound, making it async is pointless and will only make performance worse.

Async is not a magical tool that speeds up everything, it is a specific tool that only applies to specific things.

2 Likes

I know the only_morning_fn is not computationally bound or anything. Its just a basic example to understand the mental model. That is definitely not something I would put up in an event loop. lol

I started working on zio after I tried to use libuv (the lib that node.js uses) in Zig and really really disliked the callback APIs. The fact that you need to allocate closures makes it extremely awkward to use. Therefore I started exploring the option of coroutines. The model inside zio is exactly what node.js uses, you can even use zio.ev the same way you would use libuv, it’s just that for most code, coroutine-baed code is actually much easier to read. I’m still thinking of a way to bridge the worlds better, I think zio.select is one way of doing that, I just need to add more primitives that can be awaited on that way and then you could express the promises you were showing here, but that’s way beyond the scope of std.Io.

2 Likes

fuck it. I am going down in assembly.

If you’re coming from Javascript, I have a direct comparison here for all the common use cases of JS asynchrony.

2 Likes