Comment by amluto

Comment by amluto 20 hours ago

13 replies

I find this example quite interesting:

       var a_future = io.async(saveFile, .{io, data, "saveA.txt"});
        var b_future = io.async(saveFile, .{io, data, "saveB.txt"});

        const a_result = a_future.await(io);
        const b_result = b_future.await(io);
In Rust or Python, if you make a coroutine (by calling an async function, for example), then that coroutine will not generally be guaranteed to make progress unless someone is waiting for it (i.e. polling it as needed). In contrast, if you stick the coroutine in a task, the task gets scheduled by the runtime and makes progress when the runtime is able to schedule it. But creating a task is an explicit operation and can, if the programmer wants, be done in a structured way (often called “structured concurrency”) where tasks are never created outside of some scope that contains them.

From this example, if the example allows the thing that is “io.async”ed to progress all by self, then I guess it’s creating a task that lives until it finishes or is cancelled by getting destroyed.

This is certainly a valid design, but it’s not the direction that other languages seem to be choosing.

jayd16 19 hours ago

C# works like this as well, no? In fact C# can (will?) run the async function on the calling thread until a yield is hit.

  • throwup238 19 hours ago

    So do Python and Javascript. I think most languages with async/await also support noop-ing the yield if the future is already resolved. It’s only when you create a new task/promise that stuff is guaranteed to get scheduled instead of possibly running immediately.

    • amluto 17 hours ago

      I can't quite parse what you're saying.

      Python works like this:

          import asyncio
      
          async def sleepy() -> None:
              print('Sleepy started')
              await asyncio.sleep(0.25)
              print('Sleepy resumed once')
              await asyncio.sleep(0.25)
              print('Sleepy resumed and is done!')
      
      
          async def main():
              sleepy_future = sleepy()
              print('Started a sleepy')
      
              await asyncio.sleep(2)
              print('Main woke back up.  Time to await the sleepy.')
      
              await sleepy_future
      
          if __name__ == "__main__":
              asyncio.run(main())
      
      Running it does this:

          $ python3 ./silly_async.py
          Started a sleepy
          Main woke back up.  Time to await the sleepy.
          Sleepy started
          Sleepy resumed once
          Sleepy resumed and is done!
      
      So there mere act of creating a coroutine does not cause the runtime to run it. But if you explicitly create a task, it does get run:

          import asyncio
      
          async def sleepy() -> None:
              print('Sleepy started')
              await asyncio.sleep(0.25)
              print('Sleepy resumed once')
              await asyncio.sleep(0.25)
              print('Sleepy resumed and is done!')
      
      
          async def main():
              sleepy_future = sleepy()
              print('Started a sleepy')
      
              sleepy_task = asyncio.create_task(sleepy_future)
              print('The sleepy future is now in a task')
      
              await asyncio.sleep(2)
              print('Main woke back up.  Time to await the task.')
      
              await sleepy_task
      
          if __name__ == "__main__":
              asyncio.run(main())
      
          $ python3 ./silly_async.py
          Started a sleepy
          The sleepy future is now in a task
          Sleepy started
          Sleepy resumed once
          Sleepy resumed and is done!
          Main woke back up.  Time to await the task.
      
      I personally like the behavior of coroutines not running unless you tell them to run -- it makes it easier to reason about what code runs when. But I do not particularly like the way that Python obscures the difference between a future-like thing that is a coroutine and a future-like thing that is a task.
      • throwup238 14 hours ago

        That’s exactly the behavior I’m describing.

        `sleepy_future = sleepy()` creates the state machine without running anything, `create_task` actually schedules it to run via a queue, `asyncio.sleep` suspends the main task so that the newly scheduled task can run, and `await sleepy_task` either yields the main task until sleepy_task can finish, or no-ops immediately if it has already finished without yielding the main task.

        My original point is that last bit is a very common optimization in languages with async/await since if the future has already resolved, there’s no reason to suspend the current task and pay the switching overhead if the task isn’t blocked waiting for anything.

      • int_19h 13 hours ago

        > I personally like the behavior of coroutines not running unless you tell them to run -- it makes it easier to reason about what code runs when.

        In .NET the difference was known as "hot" vs "cold" tasks.

        "Hot" tasks - which is what .NET does with C# async/await - have one advantage in that they get to run any code that validates the arguments right away and fail right there at the point of the call, which is easier to debug.

        But one can argue that such validation should properly be separate from function body in the first place - in DbC terms it's the contract of the function.

throwawaymaths 13 hours ago

is it not the case that in zig, the execution happens in a_future.await?

I presume that:

io.async 1 stores in io "hey please work on this"

io.async 2 stores in io "hey also please work on this"

in the case where io is evented with some "provided event loop":

await #1 runs through both 1 and 2 interleavedly, and if 2 finishes before 1, it puts a pin on it, and then returns a_result when 1 is completed.

await #2 "no-executions" if 1 finished after 2, but if there is still work to be done for 2, then it keeps going until the results for 2 are all in.

There's no "task that's running somewere mysteriously" unless you pick threaded io, in which case, yeah, io.async actually kicks shit off, and if the cpu takes a big fat nap on the calling thread between the asyncs and the awaits, progress might have been made (which wouldn't be the case if you were evented).

  • amluto 8 hours ago

    There’s a material distinction. In Zig (by my reading of the article — I haven’t tried it), as you say:

    > await #1 runs through both 1 and 2 interleavedly, and if 2 finishes before 1, it puts a pin on it, and then returns a_result when 1 is completed.

    In Rust or Python, awaiting a future runs that future and possibly other tasks, but it does not run other non-task futures. The second async operation would be a non-task future and would not make progress as a result of awaiting the first future.

    It looks like Zig’s io.async sometimes creates what those other languages call a task.

    • throwawaymaths 8 hours ago

      i am not familiar with rust and i gave up on python async years ago so i have no frame of reference here. but im really not sure why theres a need to distinguish between tasks and non tasks?

      importantly in zig the execution isnt just limited to #1 and #2. if the caller of this function initiated a #3 before all of this it could also get run stuffed in that .await, for example.

messe 20 hours ago

It's not guaranteed in Zig either.

Neither task future is guaranteed to do anything until .await(io) is called on it. Whether it starts immediately (possibly on the same thread), or queued on a thread pool, or yields to an event loop, is entirely dependent on the Io runtime the user chooses.

  • amluto 18 hours ago

    It’s not guaranteed, but, according to the article, that’s how it works in the Evented model:

    > When using an Io.Threaded instance, the async() function doesn't actually do anything asynchronously — it just runs the provided function right away. So, with that version of the interface, the function first saves file A and then file B. With an Io.Evented instance, the operations are actually asynchronous, and the program can save both files at once.

    Andrew Kelley’s blog (https://andrewkelley.me/post/zig-new-async-io-text-version.h...) discusses io.concurrent, which forces actual concurrency, and it’s distinctly non-structured. It even seems to require the caller to make sure that they don’t mess up and keep a task alive longer than whatever objects the task might reference:

        var producer_task = try io.concurrent(producer, .{
            io, &queue, "never gonna give you up",
        });
        defer producer_task.cancel(io) catch {};
    
    Having personally contemplated this design space a little bit, I think I like Zig’s approach a bit more than I like the corresponding ideas in C and C++, as Zig at least has defer and tries to be somewhat helpful in avoiding the really obvious screwups. But I think I prefer Rust’s approach or an actual GC/ref-counting system (Python, Go, JS, etc) even more: outside of toy examples, it’s fairly common for asynchronous operations to conceptually outlast single function calls, and it’s really really easy to fail to accurately analyze the lifetime of some object, and having the language prevent code from accessing something beyond its lifetime is very, very nice. Both the Rust approach of statically verifying the lifetime and the GC approach of automatically extending the lifetime mostly solve the problem.

    But this stuff is brand new in Zig, and I’ve never written Zig code at all, and maybe it will actually work very well.

    • messe 18 hours ago

      Ah, I think we might have been talking over each other. I'm referring to the interface not guaranteeing anything, not the particular implementation. The Io interface itself doesn't guarantee that anything will have started until the call to await returns.