chesnutcase
chesnutcase

Reputation: 522

Restart Async Coroutine without waiting for other coroutines to complete

I'm practising asynchronous programming in Python with the following problem:

Simulate multiple people eating from the same food bowl with a set number of servings of food. Each person can take x servings of food at a time and then chews the food for y seconds (simulated with a blocking call). A person can take and chew their food independently from other people as long as there is still food in the bowl.

Define classes for each eater and the food bowl. The end goal is to have a function in the food bowl class that accepts a list of people and gets them to start eating from the bowl until the bowl is empty. A message should be printed to stdout whenever a person takes food from the bowl.

For instance, if I have a food bowl with 25 servings of food, and three people, A, B and C:

Thus, the expected output (print to stdout) should be:

(t=0) Person A takes 2 servings of food, leaving 23 servings in the bowl.
(t=0) Person B takes 3 servings of food, leaving 20 servings in the bowl.
(t=0) Person C takes 5 servings of food, leaving 15 servings in the bowl.
(t=2) Person C takes 5 servings of food, leaving 10 servings in the bowl.
(t=3) Person A takes 2 servings of food, leaving 8 servings in the bowl.
(t=4) Person B takes 3 servings of food, leaving 5 servings in the bowl.
(t=4) Person C takes 5 servings of food, leaving 0 servings in the bowl.
(t=4) The bowl is empty!

(At times like t=4 where two people are ready to take another serving, the order does not matter) The code is my attempt:

import asyncio
import time


class Person():
    def __init__(self, name, serving_size, time_to_eat):
        self.name = name
        self.serving_size = serving_size
        self.time_to_eat = time_to_eat

    async def eat_from(self, foodbowl):
        servings_taken = self.serving_size if foodbowl.qty >= self.serving_size else foodbowl.qty
        foodbowl.qty -= servings_taken
        t = round(time.time() - foodbowl.start_time)
        print("(t={}) Person {} picks up {} servings of food, leaving {} servings in the bowl.".format(t, self.name, servings_taken, foodbowl.qty))
        await asyncio.sleep(self.time_to_eat)
        return servings_taken


class FoodBowl():
    def __init__(self, qty):
        self.qty = qty

    async def assign_eaters(self, eaters):
        self.start_time = time.time()
        while self.qty > 0:
            await asyncio.gather(*[eater.eat_from(self) for eater in eaters])
        t = round(time.time() - self.start_time)
        print("The bowl is empty!")


bowl = FoodBowl(25)
person_1 = Person("A", 2, 3)
person_2 = Person("B", 3, 4)
person_3 = Person("C", 5, 2)
asyncio.run(bowl.assign_eaters([person_1, person_2, person_3]))

However, my attempt results in the following behaviour:

(t=0) Person A picks up 2 servings of food, leaving 23 servings in the bowl.
(t=0) Person B picks up 3 servings of food, leaving 20 servings in the bowl.
(t=0) Person C picks up 5 servings of food, leaving 15 servings in the bowl.
(t=4) Person A picks up 2 servings of food, leaving 13 servings in the bowl.
(t=4) Person B picks up 3 servings of food, leaving 10 servings in the bowl.
(t=4) Person C picks up 5 servings of food, leaving 5 servings in the bowl.
(t=8) Person A picks up 2 servings of food, leaving 3 servings in the bowl.
(t=8) Person B picks up 3 servings of food, leaving 0 servings in the bowl.
(t=8) Person C picks up 0 servings of food, leaving 0 servings in the bowl.
The bowl is empty!

It can be seen that each person waits for everyone to finish eating their serving before reaching for the bowl again. Looking at my code, I know this is because I awaited asyncio.gather() on the eat functions, and thus it will wait for all three persons to finish eating before anyone can start eating again.

I know this is wrong but I don't know what I can use in the asyncio library do solve this. I'm thinking of the eat_from coroutine automatically restarting as long as there is still food in the bowl. How do I accomplish this, or is there a better approach to this problem?

Upvotes: 1

Views: 903

Answers (1)

user4815162342
user4815162342

Reputation: 155046

I know [waiting for all three persons to finish eating before anyone can start eating again] is wrong but I don't know what I can use in the asyncio library do solve this.

You can use wait(return_when=asyncio.FIRST_COMPLETED) to wait for any of the eaters to finish, instead of waiting for all of them, as the current code does. Whenever an eater completes eating, spawn a new coroutine for the same eater, effectively "restarting" it. This requires a reference from the task returned by wait to the eater; such a reference can be easily attached to the Task object. The code could look like this:

async def assign_eaters(self, eaters):
    self.start_time = time.time()
    # create the initial tasks...
    pending = [asyncio.create_task(eater.eat_from(self))
               for eater in eaters]
    # ...and store references to their respective eaters
    for t, eater in zip(pending, eaters):
        t.eater = eater

    while True:
        done, pending = await asyncio.wait(
            pending, return_when=asyncio.FIRST_COMPLETED)
        if self.qty == 0:
            break
        for t in done:
            # re-create the coroutines that have finished
            new = asyncio.create_task(t.eater.eat_from(self))
            new.eater = t.eater
            pending.add(new)
    t = round(time.time() - self.start_time)
    print("The bowl is empty!")

This results in the expected output, at the cost of some complexity. But if you are ready to change your approach, there is a much simpler possibility: make each eater an independent actor that continues eating until there is no more food in the bowl. Then you don't need to "restart" the eaters, simply because they won't have exited in the first place, at least so long as there's food in the bowl:

async def eat_from(self, foodbowl):
    while foodbowl.qty:
        servings_taken = self.serving_size \
            if foodbowl.qty >= self.serving_size else foodbowl.qty
        foodbowl.qty -= servings_taken
        t = round(time.time() - foodbowl.start_time)
        print("(t={}) Person {} picks up {} servings of food, "
              "leaving {} servings in the bowl."
              .format(t, self.name, servings_taken, foodbowl.qty))
        await asyncio.sleep(self.time_to_eat)

assign_eaters no longer needs a loop and reverts to using a simple gather:

async def assign_eaters(self, eaters):
    self.start_time = time.time()
    await asyncio.gather(*[eater.eat_from(self) for eater in eaters])
    t = round(time.time() - self.start_time)
    print("The bowl is empty!")

This simpler code again results in the expected output. The only "downside" is that the change required inverting control: the bowl no longer drives the eating process, it's now done autonomously by each eater, with the bowl passively waiting for them to finish. Looking at the problem statement, however, this seems as not only acceptable, but perhaps even the sought-after solution. It is stated that the food bowl function should get the people "to start eating from the bowl until the bowl is empty". "Start eating" implies that the bowl merely initiates the process, and that each person does their own eating - which is how the second version works.

Upvotes: 1

Related Questions