5 ms·
What Python’s asyncio primitives get wrong about shared state
- pothamk 7mo ago[flagged]
- ipnon 7mo agoAnd in that case you begin to wonder why use Python at all? The language struggles to give developers the granularity needed to finely manage threads like C++, and it doesn't have the actor model first class like Erlang. I love Python, but I love Fortran and Lisp too. They've all served their purpose and it's time to move on, even though there is already incredible momentum behind it.
- SkiFire13 7mo agoI don't fully agree with this. Yes, surely shared mutable state can suffer from similar issues, however the cooperative nature of coroutines makes this much easier to handle. OS threads are preemptive and actually run in parallel, so you have to be aware of CPU concurrency and always be ready for a context switch.
- foltik 7mo agoIt’s just some AI generated pseudo insight, look at the rest of their comments.
- cyberax 7mo agoHard disagree. Co-routines are utter hell. They should have never become popular. With traditional locking, the locked segment is usually very clear. It's possible to use race detectors to verify that objects are accessed with consistent locking. The yield points are also clear. With async stuff, ANY await point can change ANY state. And await points are common, even sometimes for things like logging. There are also no tools to verify the consistent "locking". So I often spend hours staring blankly at logs, trying to reconstruct a possible sequence of callbacks that could have led to a bug. E.g.: https://github.com/expo/expo/issues/39428 https://github.com/expo/expo/issues/39428
- ori_b 7mo agoAsync and await is manually scheduling threads. So, if you're quite careful about what functions you call, you can arrange things so that you don't get concurrency when you don't want it. Being careful about what functions you call is quite fragile and tedious, and doesn't compose well: what if a library changes when it adds a yield point? Overall, async/await is a result of people programming like it's 2003, when threads were still very expensive.
- vlovich123 7mo agoThreads are still expensive in Python - can’t use them for concurrency really like you can with async io afaik.
- ori_b 7mo agoI would be surprised if they were particularly expensive. There's a GIL, so you don't get concurrency benefits -- but that mainly makes them behave like async.
- josephg 7mo agoThis works a lot better in JavaScript, which is exactly this model - a single threaded executor with async await. The problem you talk about is solved with function colouring. Async functions are marked as such. In general, sync functions can’t call async functions. (Well, you can invoke them. You just can’t run them to completion before returning). For all the complaints about function colouring, I’m glad JavaScript has them. A sync function becoming an async function is a breaking API change. This is much better than the situation in Python, where yield points are invisible.
- pocksuppet 7mo agoUntil every function is async.
- nine_k 7mo agoIf a function is not pure, it very likely has to be async.
- bootsmann 7mo agoThis is why we moved a lot of our concurrent python project to golang. There were a couple of cases where some engineer built the system by implicitly relying on the assumption that some coroutine would run blocking until a certain point was reached (avoiding a potential data race) that was then later broken by another change. At least in go we know we cannot rely on this so the concurrency safety has to be considered at all times, leading to better code.
- shablulman 7mo ago[flagged]
- TZubiri 7mo agoI'm sorry but how do you jump from 1. Polling to 2. Asyncio 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.
- dbt00 7mo agoI think they were already in the async world and needed message passing -- the polling code was also in python async.
- scuff3d 7mo agoAs of 3.14 running without the GIL is optional, but the default still has the GIL in place. 3.13 had it as experimental, but not officially supported. 3.12 and back are all GIL all day. 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.
- dbt00 7mo agoWhat about a more general message-passing mailbox approach? This works really well in the Erlang/gen_server/gen_fsm world. (and in plenty of other contexts, but Erlang's OTP is still some of the best, simplest incarnation of these things)
- scuff3d 7mo ago“The problem with most programming languages is that they implement concurrency as libraries on top of sequential languages. Erlang is a concurrent language at the core; everything else is just a poor imitation implemented in libraries.” -Joe Armstrong
- rciorba 7mo agoI mean, the "one-queue per consumer" they eventually ended up with, is basically an inbox that the sequential process reads from.
- ydj 7mo agoI think it’s not so much that the asyncio primitives got wrong about shared state, as much as is what the authors got wrong about the usage of those primitives. They are classic concurrency primitives that’s been around for almost half a century. They work as designed, but require some care to use correctly.
- jsanders9 7mo agoAgreed. This isn't an asyncio problem, it's just not how those primatices work.
- evil-olive 7mo agothe title seems unnecessarily clickbaity. 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?
- ggm 7mo agoyes. I felt something very similar. I do think there is value in pointing out the pitfalls naieve users (me!) can make assuming things which aren't true about ordering of events, states. Queues with lock regions are also really nice because they are (as I understand it) very cheap: so making a thread or other concurrency primitive which writes into a queue under lock, and gets out of the way, aligns nicely with having some mothership process which reads queues under lock in a deterministic way. Actual event order can vary. but you should be able to know you had an event putting you into state A, as well as the terminal event state B you jumped into without doing work needed for state A.
- rpz 7mo agoReminds me of https://geocar.sdf1.org/fast-servers.html https://geocar.sdf1.org/fast-servers.html
- phs2501 7mo agoThe one thing I wish stock python queues had an option for (async or otherwise) was some kind of explicit termination. e.g. be split into producers and consumers, and have consumers indicate iteration complete when all producers have finished (and vice versa - signal producers that all consumers have gone away). You can kind of kludge around it in one direction with stop sentinals but it's a lot more awkward to deal with - especially if your queues are bounded as then you can get into the situation where you block trying to push the stop sentinal onto the queue as it's full.
- matheist 7mo agoDoes task_done not do what you want? https://docs.python.org/3/library/queue.html#queue.Queue.task_done https://docs.python.org/3/library/queue.html#queue.Queue.tas...
- phs2501 7mo agoNot really. It's certainly intended for the basic "fan out m tasks to n workers, and the fanout producer wants to know when they're all done" and can be abused for some more, but I don't think it does anything to help with the "consumer died, I want the producers to be able to know this rather than just continuing to push messages into a queue forever" case. 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.
- three14 7mo agoThe thing that burned me with asyncio primitives is that calling an async function doesn't even schedule it (by default). The pattern for fire-and-forget is impossible to guess when coming from any other language - although it's called out in the docs. You must also call create_task AND you must maintain a collection of tasks because otherwise the task might not run, because it can be garbage collected before running, AND you must clean up completed tasks from your collection.
- whilenot-dev 7mo ago> The pattern for fire-and-forget is impossible Good, that's an antipattern in the coroutines concurrency model.
- three14 7mo agoThen someone should really update the official python docs that explain the fire-and-forget pattern (https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task https://docs.python.org/3/library/asyncio-task.html#asyncio....)! I had a FastAPI server, and calling a particular endpoint is supposed to kick off some work in the background. The background work does very little CPU work, but does often need to await more work for several minutes, so it's a good fit for asyncio. How do you want it to be structured? (In other words, on the level of human requirements, it IS fire and forget.)
- whilenot-dev 7mo agoConcurrency based on coroutines is making use of cooperative multitasking, leaving the concurrent execution of tasks up to the runtime. To elaborate a bit on what that implies, let me just ask you the following question: Is there something less cooperative than a task that doesn't yield its control back to the main thread? 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/#caveat 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.
- lukaslalinsky 7mo agoThey are trying to use condition variable without a mutex and see missed wake ups. That's a textbook error, no? I'm surprised asyncio.Condition even allows that mode of operation.
- mrkeen 7mo agoThis is one of those cases where software transactional memory really shines. 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."
- pocksuppet 7mo agoIn most STM models this is a busy-wait implemented with STM? Only Haskell blocks on `retry`
- seanhunter 7mo agoA better title would be: “Person who doesn’t know how to write state machines struggles to write a state machine”. 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.
- cl3misch 7mo agoIs this being downvoted because of the tone, or because state machines are unpopular/inappropriate in this case? Genuine question, because this feels like a sensible solution to the problem as stated in the article.
- mrkeen 7mo agoIt made no reference to the 'shared' in 'shared state'. No mention of asynchrony, multithreading, or the race condition that TFA encountered.
- seanhunter 7mo agoThe “attempt 2” was literally a state machine implementation which the author rejected because they didn’t know how to do it properly and so did it badly using a bunch of if then else logic.
- krasikra 7mo ago[dead]
- darkhorse222 7mo agoThe event one seems perfectly fine with a dictionary or even a class that wraps your events and pairs with an event object subscribers can attach to with a singleton attribute.