Search code examples
pythonwhile-looppython-asyncio

While loop and asyncio.sleep() blocks whole coroutine


I want to write a bluetooth script with the help of bleak and encountered some issues with asyncio and while loops. I tried to create a minimal working environment in order to explain my problem:

In the while loop I'm awaiting the result of match_command(). This is a problem if I'm starting a new loop in this function as it will never terminate. It seems that asyncio.sleep() sets the whole main task to sleep instead of only periodic_loop().

The command line is supposed to ask for an user input. If it is "0", a periodic task is supposed to start. But the user is supposed to still be able to use the command line and enter a new input.

import asyncio
from aioconsole import ainput

stop_periodic = 0
stop_main = 0


class Commander:
    def __init__(self, counter: int):
        self.counter = counter

    async def get_command(self):
        command = await ainput(f"Instance {self.counter}\n0: periodic; 1: hi; 2: stop periodic; 3: stop "
                               f"main\nEnter command: ")
        await match_command(command)


async def periodic_loop():
    i = 0
    print("periodic loop")
    while stop_periodic == 0:
        print(f"Iteration {i}")
        i += 1
        await asyncio.sleep(2)


async def match_command(command):
    global stop_periodic, stop_main
    match command:
        case "0":
            stop_periodic = 0
            await periodic_loop()
        case "1":
            print("do something")
        case "2":
            stop_periodic = 1
        case "3":
            stop_main = 1


async def main():
    i = 1
    new_instance = Commander(0)

    while stop_main == 0:
        await new_instance.get_command()


if __name__ == '__main__':
    asyncio.run(main())

I'm a bit lost. I tried asyncio.createTask(), TaskGroups and meddled with futures but I'm simply not getting anywhere. The most promising option was something along

        async with asyncio.TaskGroup() as tg:
            if command == "0":
                task1 = tg.create_task(match_command(command))
                new_instance = Commander(i)
                task2 = tg.create_task(new_instance.get_command())
            else:
                await match_command(command)

which allowed me to create a new Command instance if the user input was "0". However, that still meant there could only be one periodic task. What if I wanted 4 of them?

So I guess if I have a periodic task, I need to create a new Commander instance in order to have a new task to which the program can switch while asyncio.sleep() is putting the periodic task to sleep. If I try to do it for more than 2 instances though it gets really nested in my code and I'm lacking the skill to have a clean solution.

Do you have any suggetions? Thanks a lot!


Solution

  • Your problem is this part:

        match command:
            case "0":
                stop_periodic = 0
                await periodic_loop()
    

    Whenever you call async tasks inline using the await expression, the current code will stop and wait for the awaited expression to resolve - just like ordinary synchronous function calls. This ensures one write code in order which is able to run concurrently with other tasks. So when the await periodic_loop() line is reached, the code in periodic_loop is executed, and never returns, which means the first get_command call in main will never return as well, as this is an inner call to that one.

    What you need to do is to create a new task on the event loop to execute the periodic_loop, and, instead of awaiting for it, just keep track of it in some data structure (so that you can cancel/check its status later, and avoid that it get lost in limbo)

    so - changing these parts should suffice, and it is trivial to add code to cancel your running loops upon a specific user entry later on.

    import asyncio
    from aioconsole import ainput
    
    stop_periodic = 0
    stop_main = 0
    running_tasks = set()
    ...
    
    async def match_command(command):
        global stop_periodic, stop_main
        match command:
            case "0":
                stop_periodic = 0
                running_tasks.add(asyncio.create_task(periodic_loop()))
            case "1":
                ...
    

    (Note that I used a set for keeping a reference to the task, but you can use whatever data structure you want, even a single global variable, or a dictionary. Actually, if you want one single instance of the periodic_loop to be running at a given time, those are easier to check for an already running one)