Hacker News new | ask | show | jobs
by ltbarcly3 2 days ago
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.

1 comments

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 far too easy to mix and match asyncio e.g. 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.