There are serious drawbacks to async/await, and the red/blue blog post manages to list none of them.
I've wrote a blog in response to OP containing a more detailed version of the above:
https://wpbindt.github.io/async/opinions/programming/2024/01...
There are serious drawbacks to async/await, and the red/blue blog post manages to list none of them.
I've wrote a blog in response to OP containing a more detailed version of the above:
https://wpbindt.github.io/async/opinions/programming/2024/01...
My reading of the article is very different. I think the main thrust is function color is infectious, and that's an issue because adding "async" to a single function can have cascading effects through your codebase, which makes refactoring difficult and slows down development speed. This is true even if "async" conveys important information.
As an example, a project I work on provides functionality implementing arbitrary computation graphs, with an explicit guarantee that all computations are synchronous, consistent and complete on return from every state change call. This guarantee is a conscious design decision that characterizes both the project itself (and its API boundaries) as well as many of its internals predicated on the same principles. It does explicitly account for asynchronous behavior at the entry point, and at very specific exit points. But introducing async anywhere else in between would mean building a fundamentally different project, and probably revisiting business goals in the process.
The function color problem isn’t just annoying or tedious, it can be fundamental to whether certain projects are even viable.
For a language with async/await yes, you need to bifurcate your program into synchronous and asynchronous pieces, and be very careful to maintain that split.
For a language with a synchronous thread abstraction like go, you simply don't need to create this artificial bifurcation in the first place
That would be broadly unappealing where single threaded, cooperative concurrency is a baseline assumption! But it’s more fair to say it’s a trade off, and it’s also fair to recognize the things made impossible by a trade off.
Yes: because the language has distinct semantics for asynchrony which fundamentally* cannot be made synchronous, it is good that the language also makes that fundamental inability explicit, and inviolable.
No: the above reasoning is inherently tautological! It’s effectively arguing that it’s good that the language doesn’t have a massive inconsistency between two deeply coupled aspects of the same semantics. That is good! But it’s the kind of good you kind of have to expect, or treat as a tragic design flaw in its absence.
* There are some exceptions, but they’re generally esoteric or niche or have other important caveats.
Being real vague here, in order to support a new capability, we need to insert an asynchronous operation in the middle of what has been a synchronous operation. The synchronous operations is done implicitly and explicitly in many, many places in the app (yes I know this sucks, big old codebases are what they are, and you can only refactor so much at one time while continuing to deliver new features).
We have a hard ordering requirement where the new async behavior must be completed before the work can continue.
And it's killing us. What would be a simple case of "call this new function" is spiraling into weeks of work and thrown our planning into question.
Most of the time it's pretty easy to accommodate the contagion. It's a little more work than is ideal, but it is fine in practice because you hit a natural boundary that stops the spread.
But in this case, due partly to tech debt and partly due to deep architectural decisions, it's blocking our ability to move forward in a reasonable amount of time.
We will certainly find a solution, but it's going to be much more expensive to get there than it needs to be.
My response to this would be that this infectiousness is not a feature of async/await but an intrinsic property of the function. A function calling an expensive function is itself expensive. This is not a bug, it's just reality, and async forces you to be upfront with that.
Another part of my response would be that it doesn't need to infect your entire code base, and that it actually pushes you in the direction of keeping your business logic (which can be sync) separated from your infra logic (which is almost guaranteed to be async). This too I see as a good thing, mixing business logic with infra logic tends to push you in the direction of death by a thousand cuts bad performance. You can achieve this separation as follows:
async def your_endpoint():
business_object = await db.get()
business_object.do_business()
await db.save(business_object)
The blog post I linked to goes into more detail.You're right, the async keyword describes a reality about a function, and any attempt to syntax sugar your way out of that reality will leave you with a leaky abstraction. Async is async whether you have colored functions or colorless functions.
On the other hand, should changing a single function from synchronous to async require you to touch up your code in 30 different places across 10 different files? If you know what you're doing and you're okay with the async call, why do you have redeclare your intentions over and over again like you're punching in nuclear launch codes?
It's an opinionated question. You can see a similar design tradeoff in Zig's approach to generics (implicit type constraints) versus Rust's approach to generics (explicit type constraints) Rust's explicitness removes potential footguns, but it's also an absolute PITA to refactor your type system when you have to.
[1] keeping business logic separate from infra logic is typically a good idea, see hexagonal architecture, or imperative shell functional core. Async/await is a convenient mechanism to guarantee maintaining this architectural choice, even for developers less familiar with this architecture.
The main issue here is that whether or not a function is expensive is known upfront. In practice, this bites you when (a) you refactor (the impl changes), and (b) when you’re writing interfaces (the impl is unknown). This causes cumbersome cascading refactors and even complete loss of interop (in case where a 3p interface is sync and your impl is not).
In order to argue against that point, you have to argue that async is upfront-predictable and obviously part of the interface. And that it should be considered a breaking change - or semantically different if you will.
Regular functions always return (or crash the program).
Regular functions run to completion before returning control to the caller.
These are two key properties async functions lack, so the caller has to care. It's analogous to the notion that checked exceptions are challenging because they also become part of the interface and so the caller is impacted by implementation details.
(This does raise the question of whether there's a form of async hiding just out of sight of current language designs that's analogous to "unchecked exceptions": can one construct a model of function calling that makes one not have to care about async vs. sync functions at the cost of something else? I suspect, without exploring too deeply, that Orc (https://orc.csres.utexas.edu/) has gone that road).
Absolutely. Async is sugar for state machines. There is nothing wrong with that per se.
> Regular functions run to completion before returning control to the caller.
Yep. However it's important to note that it’s very possible to cancel regular functions IO from a language perspective, using a cooperative signal. (I always squirm a little every time someone says cancelation is easier in async - you shouldn’t imo cancel by dropping a state machine on the floor).
The best example of this is context.Context in Golang. But even Tokio has CancellationToken.
That said, the real benefit of the state machine is known size. This is the main (and I would say only) meaningful advantage compared to a regular stack. In Golang, you have dynamic per-goroutine (green thread) stack, which is wasting memory compared to rust async.
If I understand it correctly this is technologically possible in Python and there are two main tradeoffs depending on how the event loop is modified. given the following:
x = 0
y = 0
async def set_x():
global x
x = await request_x() # returns 5
async def set_y()
global y
y = await request_y() # returns 10
def business_logic():
if x == 0:
set_y() # magic synchronous call to async func
print(x + y)
async def do_business():
business_logic()
async def main():
await asyncio.gather(
set_x(),
do_business())
What is the value that's printed? If all coroutines share the same event loop then we don't know because set_y() could yield execution and give it to set_x(). The author of business_logic() may not even know they are writing async code because it just works, and the author of main() assumes that do_business() is concurrency safe because it's async.The alternative is nested event loops, so everything in business_logic() gets its own event loop that must fully complete before anything else in the parent event loop can run. This solves the concurrency bug but we've introduced blocking into code that was obviously intended to be async. This approach works (see nest-asyncio package) but the core devs have opted against it because they feel it makes it too easy to break the value of async on accident or out of laziness.
IMO something that could work is a `sync def` declaration where the function is written in async style but gets its own event loop and can be called synchronously. It solves the tainting issue while still being explicit, easy to convert to full async, and easy to lint for in projects where it would be a net negative.
No, you can't say that; claiming they do is equivalent to claiming the halting problem is trivially solvable since any program it applies to will definitively halt.
Trivial counterexample:
def forever():
while True:
pass
> Regular functions run to completion before returning control to the caller.This is true, but it need not be an inherent distinction of the function, but could be one made between the function, caller, and context: that is, the function can have points at which it can yield control, if it is called in the context of an event loop, it will yield at those points, and the caller can choose either to directly call the function and get sync behavior relative to itself or call it in the context of the same event loop as the caller is running, getting async behavior if the function yields early and the call returning an awaitable value (whether or not there is an early yield.)
For the way many “scripting” languages are implemented, where there is already a kind of yield to the runtime at IO, this would be a pretty natural implementation and lets a caller treat any function as async or not without caring if it was specifically implemented as async or not, though you’ll grt no real concurrency in a single-threaded runtime without the function having yield points. (This is, AIUI, basically what the async implementation in Ruby does, which is why while there are libraries specifically designed for async use, existing Ruby async-unaware code that does IO can be called as async and works concurrently out of the box.)
A more precise way to say it is "regular functions either return or we're in a bad place (or we're in a very special circumstance, like 'this program burns cycles on purpose because the scheduler requires something to be running')." For async functions, failure to progress to a returnable state is expected behavior, so the whole system needs to be built around allowing computational progress while waiting for the state to change to where the return can occur.
My issue is that in some contexts the cost of the function is effectively meaningless. A lot of python scripting falls into this category, where performance is not a concern or there is nothing useful to accomplish in the process while waiting for I/O. At that point any async up the stack is just forcing the user to deal with implementation details they shouldn't need to care about.
I can appreciate that it enforces good design decisions in some contexts, but marking that up as a pro feels like tasking the interpreter with doing the job of a linter.
If I could design a language from scratch, I'd argue for async/await OOTB. For example, the Microsoft Midori team did "async everything" in their C# derivative.[0]
[0]: https://joeduffyblog.com/2015/11/19/asynchronous-everything/
I found your post helpful and well written, but in the end I still find myself supporting Bob's blue/red delineation as a helpful articulation of the key limitations with async/await. Why?
Because the only thing that the async keyword objectively says is "someone, somewhere down the call stack, decided to make their function async". You mention I/O in your post and it might indeed be because of that. You also mention cheap vs expensive and, again, it could be that. But those are heuristics: not objective or definitive. The cheap vs expensive point is particularly problematic (a) because there's no delineation between the two, and (b) because it's different for different use cases.
Is reading a file expensive? It might be. If I need to read a 1GB file just to pick out 10 characters, then the read is expensive. But if I'm reading that same file to run a stochastic simulation, then the time to read the file might well be insignificant in comparison to the computation. And, besides, I can't run the computation until I've read the input data anyway - so it's not helpful to me that someone else decided that reading files is "expensive".
The other argument I've seen for marking IO as async is partial failure: IO can fail in ways that local computation doesn't (modulo hardware failures - which can occur, though are much less common). That would be legitimate - except, per above, "async" doesn't definitely mean "does I/O". It only means someone, somewhere in the chain, decided to make their function async. And now I'm constrained to make my functions async too because of its transitive nature.
I'm not against cooperative multi-tasking per se. I do prefer the developer experience of runtime-supported fine threading such as Erlang processes or goroutines. That's a preference though, and others will feel differently - ref comment on static vs dynamic typing.
Well it specifically means that a function can yield its execution context. That property is taken on when one function calls an async function.
It has clear implications for very common threading patterns, I think it's right to argue that using blue/red obfuscates that.
Some type systems allow for validated vs. unvalidated data. And then all of a sudden you have a creeping concern that half your functions talk about char* and half your functions talk about String (or, perhaps, a heterogeneous collection of strings: userid and telephone number and the like). Suddenly the code on the outside of the validation boundary can't use the code on the inside without addressing the question of whether their data is valid.
- The annotation doesn't guarantee any particularly useful property: in many languages async functions can still block or take arbitrary time, while on the other hand non-async functions can still lead to reentrancy issues.
- Most languages do not allow abstracting over async functions: you end up having to write the same higher order function twice even if it would be agnostic over the effectfulness of its parameter.
Some languages, for example some of those with first class effects and no implicit side effects, you can both enforce the property and abstract over the effectfulness.
But for most languages the real reason the async annotations exists is because it is a way to syntactically enforce that only a single stack frame can be suspended as opposed to a whole stack, which is tradeoff between usability, and performance and space usage. The supposed benefit is a post-hoc rationalization.
The peer replies here are dinging you on the vagueness of "expensive" but it's not really about that. The explicit information given by async is whether a function will yield its execution context or retain control of it.
If you're leveraging "has control of the thread" as a form of concurrency (eg the UI thread) then knowing a function is sync or async is extremely important.