How to run Python coroutine synchronously until first await like in C#?

Viewed 190

I'm working with some asyncio Python code and I encountered a problem with sequencing of coroutines. I have some experience with C#, so I will use it as an example.

In C# I can write:

using System;
using System.Threading.Tasks;

public class Program
{
    async static Task<int> foo(int n, int delay)
    {
        Console.WriteLine("Foo start {0}",n);
        await Task.Delay(delay);
        Console.WriteLine("Foo end {0}",n);
        return 0;
    }

    public static void Main()
    {
        Task<int> t1 = foo(1,300);
        Task<int> t2 = foo(2,200);
        
        Task.WaitAll(new []{t2,t1});// Reverse on purpose
    }
}

Which results in the following, notice the start of coroutines matches the call order, not the wait order. This is guaranteed due to how the coroutine functions are partitioned into a generator. I.e. they always run synchronously until the first await.

Foo start 1
Foo start 2
Foo end 2
Foo end 1

But when I do the same thing in Python I get different order.

import asyncio
async def foo(n,delay):
    print(f"Foo start {n}")
    await asyncio.sleep(delay/1000)
    print(f"Foo end {n}")
    return 0


async def main():
    t1 = foo(1,300)
    t2 = foo(2,200)
    
    await asyncio.gather(t2,t1) # Reverse on purpose
    
asyncio.run(main())
Foo start 2
Foo start 1
Foo end 2
Foo end 1

I also know why this is - because foo(1,300) returns a generator which does not start until the coroutine is awaited/scheduled.

My question is how to achieve C#-like behaviour in Python, meaning the coroutines start as they are called without interruption until first await?

I've tried

t1 = asyncio.create_task(foo(1,300))
t2 = asyncio.create_task(foo(2,200))

which fixed the issue but is the scheduling order really guaranteed?

2 Answers

asyncio.create_task(), as the name says, creates a task.Task instance. The task is scheduled within the constructor of task.Task with

self._loop.call_soon(self.__step, context=self._context)

From the docstring of BaseEventLoop.call_soon:

Arrange for a callback to be called as soon as possible. This operates as a FIFO queue: callbacks are called in the order in which they are registered. Each callback will be called exactly once.

So as long as you are using an event loop that doesn't override this behaviour I would say you can use create_task the way you intend to. As far as I can tell, none of the event loops in asyncio do.

Have you tried this:

t1 = asyncio.create_task(foo(1,300))
await asyncio.sleep(0.0)
t2 = asyncio.create_task(foo(2,200))
await asyncio.sleep(0.0)

This should always work, since it yields to the event loop after each task creation. Each task should get started and run to the first await, before the next task is created. I say "should" because I haven't actually done this in practice myself. But I would strongly expect it work under all situations.

If you've got a lot of tasks, you can clean up the code with a little function like this:

async def start_sequential(*coros):
    for c in coros:
        asyncio.create_task(c)
        await asyncio.sleep(0.0)

c1 = foo(1, 300)
c2 = foo(2, 200)
await start_sequential(c1, c2)
Related