Skip to content

Comment on A Design Space Exploration of Async/Awaitparent

Comments

In the 1990s, threads were programmed with extensive use of semaphores and threads arbitrarily running around shared data structures. This is a disastrous approach to threading, and I agree with pretty much every scathing condemnation written about it.

The problem is, the community collectively decided the problem was "threading" in general rather than "trying to have tons of threads running around shared data structures controlled via piles of simultaneously-held semaphores" specifically.

If you don't structure your threads on that basis, but instead default to something that looks more like actors and message passing, even if it isn't strictly speaking actors and message passing, the complexity comes down. Add some later elaborations like structured concurrency and a few other pre-canned design patterns for threading like a parallel map or worker pools being issued work items and it becomes merely something difficult rather than insane. When you program with threads sanely, it takes very little for async/await to actually be the substantially more complicated and difficult-to-understand choice when you have a workflow more interesting than "always await everything immediately" to implement, to say nothing of how nice it is to have things actually running on multiple cores simultaneously without having to carefully arrange for it.

Linux has 8MB thread stacks by default, Windows apparently has 1MB ones, and that space is not going anywhere. As long as people are worried about memory, they will need something lighter than threads. (Yes, golang managed to create dynamic stacks, but this required major support from compiler and so unlikely to appear in existing languages).

Also, I think that single-threaded programs, even with co-routines, are just so much nicer than multi-threaded ones. You write "index = last_index++;" and it Just Works (tm), no need to worry about locks or atomics or other thread access.

More people think they need a web framework that serves a million requests per second than actually do.

More people think they need the latest and hottest in manual memory management than actually do.

More people think they need a hundred thousand threads than actually do.

If you do have one of those cases, by all means prepare for it and deal with it. But be sure you have one first. The program that exceeds so much as a 100 threads is not only exceptional, but very exceptional. The exceptions are cognitively available and leap to mind, but are nevertheless the exceptions. And, again, if you have one, deal with it, but be sure you have one.

If you're sitting there in TypeScript land writing "async" code you've already surrendered on Ultimate Efficiency anyhow. Deciding what is more efficient between a threaded program in a runtime that doesn't box everything and JIT-optimized JS code is difficult but it isn't that hard for the threaded program that isn't boxing to win out on all runtime measurements, including consumed RAM.

"You write "index = last_index++;" and it Just Works (tm), no need to worry about locks or atomics or other thread access."

That goes back to my comment about using better threading techniques. I write a lot of "index = last_index + 1" (mutably incrementing like that in a single expression is just bad style anywhere you see it) in my threaded code all the time without thinking much about it, because I use the model where by default a value belongs to the one actor process that has access to it at all. The problem isn't that mutation is dangerous in threaded code, the problem was people writing threaded code based on a ton of threads running around shared data structures with locking. Not only is that not the only way to write threaded code, it is literally the worst. There are many other options, all of them better in some way, many of them much better.

Contrasting the difficulty of writing threaded code to something else based on the assumption that "lots of shared state locked by semaphores" is the only way to write code is like a Haskell advocate talking about the amazing benefits of functional programming while writing as if literally every imperative program is just one big pile of unmitigated, pure spaghetti code where everything is linked together with gotos and every variable in the program is a global variable. That's not the relevant comparison any more. It hasn't been for a long time. If anyone's program is scrambled because they did write a big pile of gotos and global variables or they did write a big pile of shared state with semaphores everywhere, that's on them. A vast array of better techniques of all shapes and sizes was available to them.

Both windows and Linux let you configure the thread stack size

Just to nitpick, it's a 8MB-sized mapping, not 8MB of RAM.

Likewise, on Windows, it's 1 MB of reserved memory but only 4 KB of initially committed memory.

https://learn.microsoft.com/en-us/cpp/build/reference/stack-...

Yes, golang managed to create dynamic stacks, but this required major support from compiler and so unlikely to appear in existing languages

That's true but I'm puzzled by the decision rationale. It's undeniably a major undertaking to add first class, fine-grained processes to a language and its runtime. But time invested there gets the multiplicative upside that all language users benefit from the investment. Instead, Async/Await transfers the complexity to users of the language, as TFA describes.

As an Erlang and now gleam developer, I'm continuously grateful for the BEAM's support for fine-grained processes (note these are VM processes, not OS level). If I want to do things in parallel, I spawn a new process to do it. Do I want that concurrency because of io latency or parallel computation? Doesn't matter. Processes handle both. If I want an actor - a long(ish) lived "object" that responds to messages sent to it - I spawn it as a process. If I want to communicate between processes, I send a message. That's the only choice. No shared memory so no semaphores, locks and whatnot.

I never have to think "hmm, should this function be sync or async?" and reason about the transitive implications through the entire call stack. I write functions to calculate values. If I want function A to be called after function B in program 1, I write them sequentially. If I want to run them concurrently in program 2, I spawn them in separate processes. Concurrency is a decision at the calling site, not when writing the function being called.

One concurrency primitive that meets all the needs. The reduction in cognitive load is palpable compared to Python (the other language I use regularly).

The usual reaction is "yeah but performance". I've never found this to be an issue in real life. Sure there are benchmarks that show C/Rust/C#/whatever is faster, often meaningfully so. In practice, for my needs: never been a problem.

I'm ever more grateful for the elegance and consistency of the BEAM concurrency model. From an ergonomic perspective, Async/Await feels like a poor abstraction by comparison.

That's not to say the BEAM (or its languages) is the final word in concurrency. The strong encapsulation boundaries from Structured Concurrency[0] would be a useful addition. Though even there, Erlang's supervisor hierarchies provide a a similar mechanism. Dataflow is another interesting area (many task-concurrent design questions are essentially dataflow problems).

Even without improvement though I'd still take Erlang's approach over Async/Await every day.

[0] https://en.wikipedia.org/wiki/Structured_concurrency

The principal difference between dispatching in a thread-based framework and async/await is that async/await allows you to program sequences of asynchronous operations much more easily. No more separation of code that initiates an async operation and the code that handles the result!

These higher level primitives that they mention provide the primitives for exactly that. This can be found in other paradigms besides async/await.

I do find that the ergonomics of this are highly dependent on a few features of a language runtime, without which it all falls apart. Or you need language specific syntax and typically a single standard implementation.

Of course. I don't think a language can support async/await without a library implementation, or the language features that support it.

And I can't honestly think of another paradigm that doesn't require callback functions or lambdas that, ergonomically, end up producing function implementations that end up drifting off the right side of the screen for anything more than a couple of sequential asynchronous operations.

The principal difference between threads and async/await has to be specified in at least 9 dimensions...

Very similar dimensions also apply to threads, the async/await is not really that different there.

No, they don't. With threads almost all of those properties are explicit choices that the developer has to implement.

At most you get hidden behavior on exception propagation and end-of-life extent.

You get a few dimensions. Some of the dimensions listed in the article for async/await become user library decisions rather than being baked into the threading. But you could still get things like, is each thread memory-isolated (Erlang, Pony?) or not, can you cancel them (imperative languages no, but Erlang and Haskell yes), is the concurrency structured or not, details around how and when the threads clean up (though perhaps arguably more related to memory management then thread management), can a thread be pre-emptively descheduled (though I'm not sure if there is any current system where the answer is "no", Go was "no" for a while).

If I sat down and made a careful study of all the threading implementations we might get up to a similar number of quirks.

I would suggest though that the dimensions are generally more likely to be corner cases. Some of the dimensions mentioned in that article are fairly in-your-face for an async/await implementation and can cause serious difficulties migrating between systems fairly quickly if you make the wrong assumptions, and writing correct async/await code that isn't just straightline "await everything immediately" code has to start taking some of those things into account very quickly. The equivalent for threading is more likely to only come up rarely and in more cases the correct answer is really "don't depend on that anyhow", e.g., rather than depending on exact details of how a thread is terminated to accomplish something, just cleanly send a message with your results to whoever it is waiting for it directly and let the runtime do the cleanup without your code witnessing any effects of it. Depending on these quirks in threading code is much more likely to be bad engineering practice, rather than necessary engineering practice in the async/await case.

Very much so and it can be argued that the difference between threads and ssync is just another dimension to compare on. For example, essentially everything that is involved in Structured Concurrency is as relevant to parallel scenarios as well.

AboutSource Built by g1lg1l

Hackerly is an independent reader for Hacker News, built on the public HN API. Not affiliated with Y Combinator.