Hacker News new | ask | show | jobs
by CraigJPerry 6 days ago
There are so many footguns[1] in async python but it really has stolen the zeitgeist of modern python web. Synchronous django has a lot to commend it. If you deploy it reasonably well (workers, behind a caching reverse proxy etc) it's easy to operate in production even under duress.

It's not going to be the right stack for long lived websocket connections or whatever but for a CRUD-ish or enterprise app, often very productive.

[1] buffer bloat is too easy to sleep walk into

    queue = asyncio.Queue()  # oops
unbounded concurrency

    await asyncio.gather(*(fetch(item) for item in items))  # look mum! no outbound sockets left or maybe even no file descriptors
accidental blocking

    data = requests.get(url).json()  # oops! we're blocking the event loop
and sure you can setup a separate task to measure the event loop latency and alert on that but this is the kinda thing you'll never find in a tutorial, you just need to experience this stuff and figure out a solution you like

I can go on and on about async python (cancellation bugs, leaking a task - that has a cataclysmic failure mode where if you forget to hold a reference to your asyncio.create_task() then it's all weak refs in that machinery so your task can get garbage collected before it ran or completed in production! super tricky forensics. Then there's all the obvious stuff like race conditions, new ways of creating deadlocks, blah blah i really can go on for days.

7 comments

Everytime I have to deal with websockets in Django, I want to rip my eyeballs out.

Most of the time what I really want is just push support, and don't need bidirectional communication, i.e. SSE. But it's the same thing: both are a pain to work with in Django. I find django-channels so cumbersome.

So, for my latest projects, I've ended up offloading this push functionality to a service like Centrifugo. Django talks to Centrifugo, and clients connect to it as well. It makes Django still be django, without worrying about django-channels, ASGI and all of that complicated setup. I've been very, very, very happy with this decision, even if I do have some additional complexity to articulate the two. It is nowhere near as confusing and convoluted as django-channels and uvicorn/daphne, though.

As for async python in general, fully agree. Every couple of years I decide to give it a go with some new tool or framework, thinking surely now it'll finally just click and I'll get with the times. Every time I feel disappointed with footguns and absolutely terrible ergonomics. No, thanks.

You don't need django-channels anymore for SSE: https://docs.djangoproject.com/en/6.0/ref/request-response/#...
For lots of applications, Mercure is a great replacement for websockets and it works wonderfully with django
Please go on. I also feel like asyncio is a big hack. Even just accidentally blocking the event loop is way too easy. And I couldn't believe it when I read that you need to store a reference to a task in a set to keep it from accidentally getting canceled. How did this become the main stack of AI backends? It's like node but slower AND much easier to mess up because of synchronous IO.
Well references in python are mutable by default, so you essentially combine an asynchronous model with mutable shared state. Combine that model with unknown caller exceptions in any subfunction and you're in for a world of hurt.
I'm not sure i see it as a hack, but i do feel unduly burdened as a developer by async in python. I feel like there's a lot that i have to think about and i'd appreciate more help from the language.

I don't want to be overly critical, there's languages that people complain about and then there are languages that no one uses... If i compare it to js/ts, some stuff is genuinely better in Python - e.g. if you missed an await. While both ecosystems have lint tools available for this, but the behaviour is just friendlier in python.

Structured concurrency is better in python, but even if TaskGroup is nicer to use than AbortController, it still has its own foibles which means i'll usually advocate for AnyIO.

But the js/ts ecosystem just generally benefits from being async from the get-go where python you're just a time.sleep() away from a bug that will slip through dev envs and ci pipelines undetected and only rear its head under load in prod.

If i have a tip to share its the debug flag for asyncio run:

    asyncio.run(f(), debug=True)  # find some more issues before prod
Or just env PYTHONASYNCIODEBUG=1.
I agree that there's plenty of footguns with async python, but I think the hype is a bit overblown. A few sensible defaults in the standard library would go a long way, e.g. requiring a maxsize argument to Queue (or None if you really need it to be unbounded). The "accidental blocking" thing seems like less of design issue - it has much more to do with python's super long history as a sync-first language. Taking a step back, though, async is popular because it is genuinely extremely useful! If you need to do a bunch of work and that work is mostly IO-bound, then it makes sense to live in an async world.
I think people in the Python world just don't yet have a good mental model of async either. I recently have had conversations with a number of people who have switched to FastAPI because "it's faster" even when they're using it to serve ML models which are compute bound and there's nothing you can await on.
I think the mental model is the right thing to focus on. I'm not denying there are cases where async at the edge of an app can make sense but there's no free lunch and i that doesn't appear to be well understood.

Maybe i will write a blog post, if nothing else than to get my own thoughts in order.

async python is always slower than just naively using threads. I don't think there is any application in production anywhere that would not prove me right on this. the complete lack of any kind of scheduling is a disaster. unless you know exactly what I mean by a scheduler please don't reply to this.
I don’t think there’s anything Python specific about that criticism. Most languages with cooperative concurrency have simple or no schedulers and run coroutines pretty much directly (or unpredictably, if they’re scheduled into parallel executors). Even Java and Tokio’s scheduling, such as it is, isn’t that directly controllable or observable and doesn’t magically fix loop-blocking or cache efficiency or whatever.

I think you might just not like cooperative async IO multiplexing as a programming model. Which is fine, but has nothing to do with Python.

I don't like it, but I don't like it because it is impossible to make it performant.

What if I told you there was one of the greatest engineering achievements of mankind ready to make sure your threads run whenever they can, but also fairly and carefully balancing context switching cost and throughput, and instead you use python async and get 1/10th the performance, weird impossible to avoid pauses an order of magnitude longer than the threaded version's median response time, and just constantly have to fix bugs about callbacks firing on canceled blah blah blah.

Those are all fair points! They apply more or less equally to, say, nodejs or Perl's AnyEvent.

I'm totally onboard with "use the OS thread scheduler when you can"; it's a brilliant piece of technology. And if you are in a situation where

> pauses [are] an order of magnitude longer than the threaded version's median response time

...then fully cooperative, single-thread async programming isn't for you!

But async programming was made for the inverse use-case of that: backend functions which spend the vast majority of their time waiting for I/O--slow databases, HTTP requests, and whatnot--and which needed to serve very high concurrency demands on very cheap servers. If, say, your web route handler wants to probe or parallel-fetch from 100 unique-per-request HTTP endpoints, and you expect to handle 100 web requests concurrency, using threads is going to cost a lot of resources (the portion stack space that's allocated eagerly, start/stop time, and so on). For some folks, those resources might be readily available, in which case threads may still be the way to go! But for other folks (tiny servers, or more orders of magnitude than 100/100--use cases like proxies or CDNs) cooperative async might be a better way to go. Similarly, if your concurrent code is sharing a lot of data between routines but is primarily waiting on I/O, single-thread async concurrency might be a less race-condition-bug-prone model to use.

I'm generally with you that it's a tool that's prioritized above threads in a lot of cases when it shouldn't be. But it is still definitely valuable for a lot of common challenges.

A lot of the above is rehashing the comments I made here: https://news.ycombinator.com/item?id=39247876

You clearly know what you are talking about and I agree with what you are saying 100%, if you are spending almost all your time waiting around, you should address that. Generally the OS has a better solution there as well, in fact linux has several. Python also has several, and solutions like Stackless or Twisted are just a joy compared to the cancer of python async.

Even if you buy into callback-style async style programming like node or python async, Python async is not just bad because the general idea of it is bad, it is also bad-at-what-it-is-trying-to-be. It's a bad, hack, incompetent implementation of async programming, which in this style (cooperative callback style) is a bad architecture. It's unforgivably bad. Look in the stdlib and read the code for it, it's full of gotchas and confusion and patch after patch after patch putting in exceptions (like the "we want to do W but if this task is x then actually don't do this but if it's y do this other thing and if it's z then do a third thing kind of exception) and checks and double checks every 3rd line because the design itself is broken. Whole features (like un-cancel task) were put in because one influential person demanded it because the library they wrote can't work without it, even though it breaks a fundamental invariant that should never be violated.

Fundamentally, when you choose a tool or architecture, it should not be the thing you spend 50% of your time debugging. Every Python async project I've ever worked on required me to spend half my time tracing around in the guts of the async library itself to figure out what weird edge case we were running into that made something completely broken. This is like the hallmark of bad software.

I wish Stackless/Twisted had succeeded, I really do, but they unfortunately lost to what I agree is a worse implementation. Like you, I've spent probably thousands of hours in my career debugging bizarre Python async internal machinery and writing tools that shave off some of the rough edges, and you're right that it's bad out there.

I think the main things that cripple the Python stdlib/asyncio design and the ecosystem that arose around it are:

1. Thread:loop locality. The design of event loop-per-thread is ... admirably flexible, but doomed to smack into the reality that many folks aren't in control of what threads their code is invoked on, and what may or may not have kept a reference to an event loop, which brings me to...

2. The fact that event loop objects can be replaced/started/stopped. That's a terribly confusing API with lots of inherently global state at the edges (open files/pipes used for self-polling, signal handlers), which trips up all sorts of code--including the stdlib itself at times.

A better API design would have, I think, had loops be utility objects without a notion of "running" or not, ideally with the ability to be addressed across threads (note that I'm not asking for coroutines to be auto-scheduled onto multiple threads, just for "asyncio.run"-style entry points and loop objects to work on first access in any thread context). That'd save on the wacky complexity needed for libraries like asgiref (which does a very good job at something that should not be this hard) and reduce "you can't run this coroutine because it's loop is stopped" and "you can't run this coroutine because it's owned by another loop"-type bugs.

Swapping in other loops like uvloop should be a stdlib call that has to happen before any async functionality has run anywhere in the program, and doing it after that should be an error. Or just ... don't support other loops. Realistically there are only two, and the internal "loop" abstraction is so low-level that making it swappable can only speed up certain other kinds of code; a lot of perf is left on the table behind those ugly and expensive "introspect the loop/task/passed-in-awaitable types and states on every single asyncio function in the stdlib" checks you correctly complain about.

The ability to lapse into/out of "blocks" of async code in a largely-synchronous codebase (or between tests) could have been provided with a few simple utilities that asserted things like "crash if any timers/sockets/etc. are still registered with the event loop" or "give me a weakref-tracked list of all live Future and Task handles that the loop has ever manipulated". A ten-liner I use in most async Python projects I touch is basically "write a wrapper class for concurrent.Future and asyncio.Future which stores a weakref from the Future to any async Task that was active when anything wrote to the Future", which makes "is there anything async-condvar shaped that hasn't been GC'd yet?"-type checks trivial.

3. Cancellation is a tarpit, as you pointed out. I'm a big fan of the ability to observe cancellations (the Nodejs model and the Rust model both leave a lot to be desired here), and it's useful to be able to intercept and take action in response to them within reason. However, modelling them as a regular exception runs afoul of too much "except:"-using code, and the idea of "sheild", uncanceling, and accidentally swallowing cancellations is a poorly designed API. Ideally, cancellation handlers would be used in the same way that signal handlers are used--constant-time, minimally-side-effectful code with a guaranteed jump back to resume cancellation propagation when they're done. I understand what making cancels preventable/handleable enables, and it's indeed valuable ... but it's not worth it compared to the pervasive bugs that emerge around the choice of making them a regular exception. Like, sure, it is technically the programmers' fault for misusing CancelledError, but past a point, it's easier to fix the tool than the users.

4. Future objects (especially concurrent.Future's ability to serve as a sync/async "bridge") are treated as low-level tools users should rarely reach for directly. The stdlib them wraps them with some pretty questionably behaving and poorly performing code. I wish the original APIs around asyncio had made the creation and use of Futures the main language things speak, rather than endless utility methods that end up "turducken wrapping" Futures/tasks internally. Relatedly, it's frankly insane the amount of hoops you have to jump through to customize exception propagation between a pair of Future-ish things when chaining them (if A fails with X, sometimes I want B to fail with the same thing, sometimes I don't. If X is CancelledError, the best-practices tools in the stdlib require creating no fewer than six nested/intertwined futures to express "if A fails with a real error, B should fail; if A is cancelled, B should return/do nothing/whatever").

5. I wish the asyncio stdlib had given us more tools to bridge thread/async computation early on (run_coroutine_threadsafe and and run_in_executor are simultaneously too high- and too low-level). Honestly, a ton of the asyncio stdlib feels like it was written as if some of the held-for-constant-time synchronization locks involved in e.g. "call_soon_threadsafe" evoked more fear in the maintainers than the 8 stack frames of chunky pure-python code involved in doing normal thread-local operations. Unconditionally taking out some usually-uncontended coordination locks whenever communicating between different sync stacks/async coroutines would not break the bank--hell, this is what they did for every data object in the program when they removed the GIL. It's fine. Again, modelling an "event loop" as something fixed and cross-thread without a running/stopped state would make this moot, but here we are.

6. There are a lot of preforking systems in Python, from multiprocessing to gunicorn to mod_python. Async code should have been fork-safe--at least as fork-safe as Python's threads, which are hellishly unsafe to fork in the presence of on paper, but which the GIL and interpreter behavior made fork-safe in practice in a huge number of situations (the evidence of which is that thousands of users didn't even know they were in those situations and things kept working fine).

On the other hand, some things that a lot of people rail about in Python async are, I believe, good ideas that should be emulated:

1. Python's got a giant legacy of synchronous code, and (at least when asyncio was created), didn't have the ability to effectively spread work across threads. Given that, the refusal to make async stuff automatically schedule onto multiple threads when a coroutine blocks was a good idea. Loop blocking was just always going to be more common and frustrating in Python async than in JavaScript (with no synchronous legacy), Java (where everyone's used to their code being plopped onto random thread pools anyway), or Rust (with compiler support for expressing that a chunk of code can be moved between threads or not).

2. The taxonomy of awaitables from regular "async def" (fast, no permanent tracked stack) to Futures to Tasks (promises) is a good design. A good async system needs something in all those niches. I just wish the situations where one was automatically promoted to another were handled better.

3. PYTHONASYNCIODEBUG is very useful.

4. Tricky as they are to work with, async generators are very powerful.

5. The decision to implement asyncio itself on top of the original interpreter's generator machinery was a good one (caveat the flaw in the original generator machinery that StopIteration is a regular exception that users can subclass and raise--just like CancelledError, that was not a good decision).

Things like Trio can definitely help shave off some of the rough edges of Python's async (and the stdlib has adopted structured concurrency too nowadays), but even using them inevitably causes some problems since it's too easy to mix and match asyncio and Trio.

At the end of the day, I think Python did pretty well here: it's a big synchronous language with a million deployment targets adopting an entirely new control flow primitive without changing any invariants. The fact that they did as well as they did without industry backing is due in large part both to careful design and the willingness to compromise/ship and improve on incomplete solutions by folks like Victor Stinner. They deserve props. There's still a lot that could be done better.

I would love a blog post on this, in case you ever considered writing one!
Yep, if you are going to make asynchronous programming, you need either a small, plain, and obvious layer that calls insulated synchronous code or some stack with high quality assurances.