Hacker News new | ask | show | jobs
by ajross 237 days ago
Is it time now to say that async was a mistake, a-la C++ exceptions? The recent futurelock discussion[1] more or less solidified for me that this is all just a mess. Not just that one bug, but the coloring issue mentioned in the blog post (basically async "infects" project code requiring that you end up porting or duplicating almost everything -- this is especially true in Python). The general cognitive load of debugging inside out code is likewise really high, even if the top-level expression of the loop generator or whatever is clean.

And it's all for, what? A little memory for thread stacks (most of which ends up being a wash because of all the async contexts being tossed around anyway -- those are still stacks and still big!)? Some top-end performance for people chasing C10k numbers in a world that has scaled into datacenters for a decade anyway?

Not worth it. IMHO it's time to put this to bed.

[1] No one in that thread or post has a good summary, but it's "Rust futures consume wakeup events from fair locks that only emit one event, so can deadlock if they aren't currently being selected and will end up waiting for some other event before doing so."

5 comments

I really wish people would get over the coloring meme.

Knowing if a function will yield the thread is actually extremely relevant knowledge you want available.

What bothers me, for example in Python, with the function coloring is that it creeps everywhere and you need to rewrite your functions to accommodate async. I think being able to take and return futures or promises and handle them how you wish is better ergonomics.
> I think being able to take and return futures or promises and handle them how you wish is better ergonomics.

You can do that. If you don't await an async call, you have a future object that you can handle however you want.

Yeah but to be fair, that can have adverse effects if you, say, busy wait.

The sync code might be running in an async context. Your async context might only have one thread. The task you're waiting for can never start because the thread is waiting for it to finish. Boom, you're deadlocked.

Async/await runtimes will handle this because awaiting frees the thread. So, the obvious thing to do is to await but then it gets blamed for being viral.

Obviously busy waiting in a single threaded sync context will also explode tho...

This is like saying knowing if you're dealing with NEAR pointers or FAR pointers is extremely relevant. I reject the premise - a model that forces me to think about these things is a degenerate model.
That's fine but the alternatives are insufficient.
Obviously "insufficient" is always going to be subjective. But some technologies really do end up bad by consensus, and I'm getting that smell from async. There really aren't any world class software efforts that rely heavily on async code. Big projects that do end up complaining about maintenance and cognitive hassle, and (c.f. the futurelock thing) are starting to show the strains we saw with C++ exceptions back in the day.

Async looks great in a blog post full of clean examples. It... kinda doesn't in four year old code written by people who've left the project.

Funny you mention this.

Zig's colorless async was purely solving the unergonomic calling convention, at the cost of knowing if a function is async or not (compiler decides, does not give any hints and if you get it wrong then that's UB).

Arguably the main problem with async is that it is unergonomic. You always have to act like there were 2 types of functions, while, in practice, these 2 types are almost always self-evident and you can treat sync and async functions the same.

I don't really know Zig. How does it handle the common GUI thread pattern where you get lock free concurrency by funneling the async GUI code through the GUI thread?

When you know what functions and blocks are synchronous, you know the thread will not be yielded. If you direct async tasks to run on a single thread, you know they will never run concurrently. These together mean you can use that pattern to get lock free critical sections. You don't need to write thread-safe data structures.

If a function can yield implicitly, how do you have the control you need to pull this off?

It's a really common pattern in GUI dev so how does Zig handle that?

Of course it's useful, that's why function modifiers like 'const' or 'virtual' (thinking from a C++ perspective) are widely seen as useful, but making one function virtual doesn't force you to propagate that all the way up the call tree.
Const is similar, now that you mention it.
Const is the reverse.

Constness is infectious down the stack (the callee of a const function must be const) while asyncness is infectious up the stack (the caller of an async function must be async). So you can gradually add constness to subsections of a codebase while refactoring, only touching those local parts of the codebase. As opposed to async, where adding a single call to an async function requires you to touch all functions back up to main

Same. Colored functions are just effect systems, which are extremely useful.

Javascript's async as of ten years ago just happened to be an especially annoying implementation of a specific effect.

> Knowing if a function will yield the thread is actually extremely relevant knowledge you want available.

When is this relevant beyond pleasing the compiler/runtime? I work in C# and JS and I could not care less. Give me proper green threads and don't bother with async.

Knowing when execution will yield is useful when you want to hold onto a thread. If you run your GUI related async tasks on the GUI thread you don't have to worry about locks or multi threaded data structures. Only a single GUI operation will happen at a time.

If yields are implicit, you don't have enough control to really pull that off.

Maybe it's possible but I haven't seen a popular green threaded UI framework that let's you run tasks in background threads implicitly. If I need to call a bunch of code to explicitly parcel background work, that just ends up being async/await with less sugar.

Look at the node.js APIs: readFile, readFileSync, writeFile, writeFileSync ... and on and on. If that's not a meme then I don't know what is.
And the alternative without async-await is ? blocking the event loop or the callback pyramid.

Node is one place where async-await has zero counter arguments and every alternative is strictly worse.

The problem with Node is that the async decision is in the hand of the leaf node, which bubbles up to the parent where my code sits. Async/await is nice and a goal in most modern Node, but there are codebases (old and new) where async/await is just not an option for many reasons.

Node dictates that when faced with an async function the result is that I must either implement async myself so I can do await or go into callback rabbit holes by doing .then(). If the function author is nice, they will give me both async and sync versions: readFile() and readFileSync(). But that sucks.

The alternative would be that 1) the decision to go async were mine; 2) the language supports my decision with syntax/semantics.

Ie. if I call the one and only fs.readFile() and want to block I would then do

       sync fs.readFile()
Node would take care of performing a nice synchronous call that is beneficial to its event-loop logic and callback pyramid. End of the story. And not some JS an implementation such as deasync [1] but in core Node.

1. https://www.npmjs.com/package/deasync

> And the alternative without async-await is ? blocking the event loop or the callback pyramid.

No, just callbacks and event handlers (and an interface like select/poll/epoll/kqueue for the OS primitives on which you need to wait). People were writing threadless non-blocking code back in the 80's, and while no one loved the paradigm it was IMHO less bad than the mess we've created trying to avoid it.

One of the problems I'm trying to point out is that we're so far down the rabbit hole in this madness that we've forgotten the problems we're actually trying to solve. And in particular we've forgotten that they weren't that hard to begin with.

They could have added threads to Node as well? Granted, it would have been a lot of difficult work.
Losing threads and moving to the async I/O model was the motivation behind Node in the first place.

https://nodejs.org/en/about

If you use async I/O you can just use the Chrome JavaScript runtime as-is. I would claim it was the only low-effort model available to them and therefore not motivation.

The motivation for node was that users wanted to use JavaScript on the server.

You mean like with web workers or something?
With a shared interpreter/process state, like Python, Java, C, C++, ...

Node is not a web page, so no reason to limit it to the same patterns.

Then, the next issue would be thread safety. But that could be treated as a separate problem.

Except function colouring is a symptom of two languages masquerading as one. You have to choose async or sync. Mixing them is dangerous. It’s not possible to call an async function from sync. Calling sync functions from async code runs the risk of holding the run lock for extended periods of time and losing the benefit of async in the first place.

I don’t have anything against async, I see the value of event-oriented “concurrency”, but the complaint that async is a poison pill is valid, because the use of async fundamentally changes the execution model to co-operative multitasking, with possible runtime issues.

If a language chooses async, I wish they’d just bite the bullet and make it obvious that it’s a different language / execution model than the sync version.

I think this analogy is too extreme. That said, modern languages should probably consider the main function/threading context default to async.

Calling sync code from async is fine in and of itself, but once you're in a problem space where you care about async, you probably also care about task starvation. So naively, you might try to throw yeilds around the code base.

And your conclusion is you want the language to be explicit when you're async....so function coloring, then?

With Java 25 virtual threads, async definitely is no longer required and I hope it dies a slow and painful death. We have projects at work that have never more than 3 concurrent users that use rxjava and are a nightmare to work on.
Concur. I build my own tools in rust when I have to just to avoid it. It is splitting rust into 2 ecosystems, and I wish it didn't exist because it's a big compatibility barrier. We should be moving towards fewer of these; not more. Make code and applications easier to interop; Async makes it more difficult.
I can't stand this aversion to async/await. It's not a big deal.

I don't understand why async code is being treated as dangerous or as rocket science. You still maintain complete control, and it's straightforward.

Now that we know about the "futurelock" issue, it will be addressed.

I'm sure Rust and the cargo/crates ecosystem will even grow the ability to mark crates as using async so if you really care to avoid them in your search or blow up at compile time upon import, you can. I've been asking for that feature for unsafe code and transitive dependency depth limits.

Because async Rust is a lot harder to reason about than sync code. And I want my code to be as easy to reason about as possible.
What is wrong about C++ exceptions?
"Nothing", in principle. But they're bug factories in practice. It's really easy to throw "past" cleanup code in a language where manual resource management remains the norm.

It's not that they can't be used productively. It's that they probably do more harm than good on balance. And I think async mania is getting there. It was a revelation when node showed it to us 15 years ago. But it's evolved in bad directions, IMHO.

Yeah node showed that a native async single threaded runtime can be performant. Seems like this knowledge was lost to the world somewhere along windows vista; everyone who has had to ever develop in the cooperative world of early winapi could tell you that easily.
C# had async/await long before Javascript/node. Not that big a revelation ;-)
.NET wasn't the first either. Lisps were doing continuations in the 70's.

But "invented" and "revealed" are different verbs for a reason. The release of node.js and it's pervasively async architecture changed the way a lot of people thought about how to write code. For the better in a few ways. But the resulting attempt to shoe-horn the paradigm into legacy and emerging environments that demanded it live in a shared ecosystem with traditional "blocking" primitives and imperative paradigms has been a mess.

I think you're underestimating the role of .NET in this. It was .NET that popularized this concept for the masses, and from there it spread to other languages including JavaScript, which also borrowed the exact same async/await keywords from C#.
For one thing, they’re expensive and viral. “Zero overhead” implementations don’t take into account the need for unwind tables. For every function/method that might be thrown across. They’re disabled in a lot of production environments for this reason.
There was an interesting talk about C++ exceptions in smaller firmware at CppCon last year: https://youtu.be/bY2FlayomlE

Basically, the overhead of exceptions is probably less than handling the same errors manually in any non-trivial program.

Also, it's not like these table doesn't exist in other languages. Both Rust and Go have to unwind.

But if you explicitly handle exceptions using IF statements then that's overhead too, right?
yes but i think branch prediction essentialy makes them zero overhead
That's a different type of overhead than having unwind tables. With exceptions you wouldn't need a branch after each function call at all.
But a branch that is (almost) never taken has an overhead close to the overhead of a NOP instruction, which may be negligible on modern architectures.
There are cases in systems-y code where it is not safe to unwind the stack in the ordinary way and it is difficult to contain the side-effects. These can be non-obvious and subtle edge cases that are often difficult to see and tricky to handle correctly. C++ today is primarily used in code contexts where these kinds of issues can occur. This is why it is a standard practice to disable exceptions at build time i.e. -fno-exceptions.

With the benefit of hindsight, explicit handling and unwinding has proven to be safer and more reliable.

But you can implement exceptions by using the same IF statement approach you would use for manual error handling. No need for unwinding tables and such if that optimization is a bridge too far for your specific target platform.
Your comment is downvoted as I write this, but I kind of think Zig's new design agrees with you. It uses the terms "async" and "await", but the API design looks more similar to traditional threading (like Rust's thread::spawn and join() APIs). With the fun distinction that you can choose whether your program uses actual threads, or coroutines, or just runs everything synchronously without changing any of your code.