Reputation: 522
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
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