rather than "What Python's asyncio primitives get wrong" this seems more like "why we chose one asyncio primitive (queue) instead of others (event and condition)"
also, halfway through the post, the problem grows a new requirement:
> Instead of waking consumers and asking "is the current state what you want?", buffer every transition into a per-consumer queue. Each consumer drains its own queue and checks each transition individually. The consumer never misses a state.
if buffering every state change is a requirement, then...yeah, you're gonna need a buffer of some kind. the previous proposed solutions (polling, event, condition) would never have worked.
given the full requirements up-front, you can jump straight to "just use a queue" - with the downside that it would make for a less interesting blog post.
also, this is using queues without any size limit, which seems like a memory leak waiting to happen if events ever get enqueued more quickly than they can be consumed. notably, this could not happen with the simpler use cases that could be satisfied by events and conditions.
> A threading.Lock protects the value and queue list.
unless I'm missing something obvious, this seems like it should be an asyncio.Lock?
For locking I am guessing they want multithreading, each with an event loop.
In attempt 2 the old school C way of writing the state machine would work just fine in python, avoid a bunch of the boilerplate and avoid the “state setter needs to know a bunch of stuff” problem. Basically you make the states as a table and put the methods you need in the table so in python a dictionary is convenient. Then you have
> def set_state(new_state):
> state = new_state
> events[new_state].set()
Aaand you’re done. When you add a new state, you add an event corresponding to that state into the events table. If the stuff you would put into a conditional in set_state is more complicated, you could make a state transition method and link to it in the table. Or you could make a nested dict or whatever. It’s not hard, and the fact that the author doesn’t know an idomatic way to write a fsm definitely isn’t something that’s wrong with python’s asyncio and shared state.In general if you’re writing a state machine and you have a lot of “if curr_state == SOME_STATE” logic, chances are it would be better if you used tables.
Genuine question, because this feels like a sensible solution to the problem as stated in the article.
No mention of asynchrony, multithreading, or the race condition that TFA encountered.
https://docs.python.org/3/library/queue.html#queue.Queue.tas...
I've written wrappers to handle things the way I want, but it always feels like a bit of a hack. (Usually I use a stop sentinal internally and reach inside to unbound the queue before I send it to avoid blocking). Just wish it were built in.
Good, that's an antipattern in the coroutines concurrency model.
You can often take the naive solution and it will be the correct one. Your code will looks like your intent.
TFA's first attempt:
async def drain_requests():
while state != "closing":
await asyncio.sleep(0.1)
print("draining pending requests")
Got it. Let's port it to STM: let drain_requests = do
atomically (
do s <- readTVar state
when (s /= "closing")
retry )
print("draining pending requests")
Thread-safe and no busy-waiting. No mention of 'notify', 'sleep'. No attempt to evade the concurrency issues, as in the articles "The fix: per-consumer queues - Each consumer drains its own queue and checks each transition individually."There's so many solutions in the middle, I have this theory that most people that get into async don't really know what threading is. Maybe they have a world vision where before 2023 python just could not do more than one thing at once, that's what the GIL was right? But now after 3.12 Guido really pulled himself by the bootstraps and removed the GIL and implemented async and now python can do more than one thing at a time so they start learning about async to be able to do more than one thing at a time.
This is a huge disconnect between what python devs are actually building, a different api towards concurrency. And some junior devs that think they are learning bleeding edge stuff when they are actually learning fundamentals through a very contrived lens.
It 100% comes from ex-node devs, I will save the node criticism, but node has a very specific concurrency model, and node devs that try out python sometimes run to asyncio as a way to soften the learning curve of the new language. And that's how they get into this mess.
The python devs are working on these features because they have to work on something, and updates to foundational tech are supposed to have effects in decades, it's very rare that you need to use bleeding edge features. In 95% of the cases, you should be restricting yourself to using features from versions that are 5-10 years old, especially if you come from other languages! You should start old to new, not new to old.
Sorry, for the rant, or if I misjudged, making a broader claim based on multiple perspectives.
Python's asyncio library is single threaded, so I'm not sure why you are talking about threads and asyncio like they have anything to do with each other.
Python has been able to do more then one thing at a time for a long time. That's what the multiprocess library is for. It's not an ideal solution, but it does exist.
Ok, not OS threads, but it de facto creates application/green threads.
>That's what the multiprocess library is for. It's not an ideal solution, but it does exist.
Philosophical argument but, I'd say multiprocess is not python doing many things, there would be many python runtimes (each doing A Thing), and the OS would be the one doing multiple things / scheduling.
Python's asyncio is tasked based, the event loop cannot switch out a task until it reaches a yield point.
Or in short, green threads like Go uses are preemptive multitasking, the task based model asyncio uses is cooperative. A CPU bound python task can block the event loop forever if it never yields, goroutines generally can't.
It's not a philosophical question at all. A single python program can use the multiprocessing library to run multiple chunks of work in parallel. It's heavier weight thanks to the need to basically run a full python interpreter in an os process, but it provides the functionality. And the fact that it's scheduled by the OS is irrelevant. Plenty of languages use the underlying OS threading capabilities to manage threads instead of their own runtime. Both Java and C# for example (though I think Java is adding green threads now).
You're conflating several different distinct ideas. It's a common mistake I see at work all the time. Took a good amount of reading for me to untangle them all.
Edit: I left out stackless coroutines vs stackful thread like runtime. That would be more accurate then the preemptive vs cooperative stuff, but either way the gist of my comment is correct.
I think I was wrong then. The difference between:
a = threading.Thread(request,url1).start()
b = threading.Thread(request,url2).start()
a.join()
b.join()
With similar asyncio code is that async code has to explicitly signal when the task is allowed to switch. So async is used for when greater control is required over when the scheduler can work on the two different tasks, presumably to avoid race conditions. It would be used in cases similar to where semaphores would have been used.
I do still think that it's unpythonic in that whatever you can do with async you can do without, (two ways to do things), and most of the usecases of this will be people coming from node, and people who don't know of more basic concurrent techniques.
I'm looking at the Original Article again, and it just looks like they are implementing a more complex pub-sub control flow system instead of using if statements (because that's too boring?). The traditional solution would use a socket which is essentially a thread, and the states of the connection would just be managed by TCP instead of recreated at the application layer.
I just can't shake the notion that the vast majority of cases async code in python is bad code. Of course the devs are not idiots, but my thesis is that they are bored and have to implement something, and it has an intended use case, but the bulk of usage will be for the 'wrong' reasons.
>You're conflating several different distinct ideas. It's a common mistake I see at work all the time. Took a good amount of reading for me to untangle them all.
If you have a source material to recommend on the subject (assuming I am already aware on traditional OS scheduling of processes, threads and green threads) I would be interested in reading that. It seems that even if my thesis is True, I need to understand for what precise usecases BDFL and company are developing this async thing into the language itself.
Regarding the fire-and-forget pattern I can think of at least two issues, let me illustrate them with the following example:
import asyncio
from typing import Any, Coroutine
_background_tasks = set[asyncio.Task[None]]()
def _fire_and_forget(coro: Coroutine[Any, Any, None]) -> None:
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
async def _run_a() -> None: # to illustrate issue A
raise RuntimeError()
async def _run_b() -> None: # to illustrate issue B
await asyncio.sleep(1)
print('done')
async def main() -> None:
# issue A: Exceptions can't be caught
try:
_fire_and_forget(_run_a())
except RuntimeError as exc:
print(f'Exception caught: {exc}')
# issue B: Task won't complete
_fire_and_forget(_run_b())
if __name__ == '__main__':
asyncio.run(main())
Feel free to comment out either task in the main function to observe the resulting behaviors individually.For issue A: Any raised error in the background task can't be caught and will crash the running main thread (the process)
For issue B: Background tasks won't be completed if the main thread comes to a halt
With the decision for the fire-and-forget pattern you'll make a deliberate choice to leave any control of the runtime up to blind chance. So from an engineering POV that pattern isn't a solution-pattern to some real problem, it's rather a problem-pattern that demands a reworked solution.
> How do you want it to be structured
Take a look at the caveats for FastAPI/Starlette Background Tasks: https://fastapi.tiangolo.com/tutorial/background-tasks/#cave...
Losing control of a background task (and therefor the runtime) might be fine for some demo project, but I think you'll want to notice raised errors in any serious production system, especially for any work that takes several minutes to complete.
In my case, I specifically want an independent execution of a task. Admittedly, it has to catch its own exceptions and deal with them, as you pointed out, because that's part of being independent.
(Technically, in issue A it doesn't crash the running thread. The event loop catches the exception, but it complains later when the task is garbage collected. Issue B is fine for my use - when the event loop shuts down, it cancels remaining tasks, which is exactly right for my server.)