This is a really common comment to see on HN threads about async/await vs fibers/virtual threads.
What you're asking for is performance to be represented statically in the type system. "Blocking" is not a useful concept for this. As avodonosov is pointing out, nothing stops a syscall being incredibly fast and for a regular function that doesn't talk to the kernel at all being incredibly slow. The former won't matter for UI responsiveness, the latter will.
This isn't a theoretical concern. Historically a slow class of functions involved reading/writing to the file system, but in some cases now you'll find that doing so is basically free and you'll struggle to keep the storage device saturated without a lot of work on multi-threading. Fast NVMe SSDs like found in enterprise storage products or MacBooks are a good example of this.
There are no languages that reify performance in the type system, partly because it would mean that optimizing a function might break the callers, which doesn't make sense, and partly because the performance of a function can vary wildly depending on the parameters it's given.
Async/await is therefore basically a giant hack and confuses people about performance. It also makes maintenance difficult. If you start with a function that isn't async and suddenly realize that you need to read something from disk during its execution, even if it only happens sometimes or when the user passes in a specific option, you are forced by the runtime to mark the function async which is then viral throughout the codebase, making it a breaking change - and for what? The performance impact of reading the file could easily be lost in the noise on modern hardware and operating systems.
The right way to handle this is the Java approach (by pron, who is posting in this thread). You give the developer threads and make it cheap to have lots of them. Now break down tasks into these cheap threads and let the runtime/OS figure out if it's profitable to release the thread stack or not. They're the best placed to do it because it's a totally dynamic decision that can vary on a case-by-case basis.
> It also makes maintenance difficult. If you start with a function that isn't async and suddenly realize that you need to read something from disk during its execution, even if it only happens sometimes or when the user passes in a specific option, you are forced by the runtime to mark the function async which is then viral throughout the codebase, making it a breaking change - and for what? The performance impact of reading the file could easily be lost in the noise on modern hardware and operating systems.
You'll typically have an idea of whether or not a function performs IO from the start. Changing that after the fact violates the users' conceptual model and expectation of it, even if all existing code happens to keep working.
If you want to go full Haskell on the problem for purity-related reasons, by all means be my guest. I strongly approve.
However, unless you're in such a language, warping my entire architecture around that objection does not provide a good cost-benefit tradeoff. I've got a lot of fish to fry and a lot of them are bigger than this in practice. Heck, there's still plenty of programmers who will consider it as unambiguous feature that they can add IO to anything they want or need to and consider it a huge negative when they can't, and a lot of programmers who don't practice an IO isolation and don't even conceive of "this function is guaranteed to not do any IO/be impure" as a property a function can have.
In any system that uses mmap or swap it's a meaningless distinction anyway (which is obviously nearly all of them outside of embedded RTOS). Accessing even something like the stack can trigger implicit/automatic IO of arbitrary complexity, so the concept of a function that doesn't do IO is meaningless to begin with. Async/await isn't justified by any kind of interesting type theory, it exists to work around limitations in runtimes and language designs.
This argument is dull, nothing in programming can do anything perfectly: is catching exception is useless because the program can be killed by the OS? Is static typing pointless because cosmic rays can make your data ill-formed anyway?
All abstractions are leaky, and the OS and hardware's behaviors are always going to surface in ways that you cannot model in your programming language, no matter how low-level you want to go (asm itself is a poor abstraction on top of how the CPU actually works), but that doesn't make them useless.
Async/await is a way to communicate intent between developers on the same project, and between a dependency and its dependent. Exactly like static types, error as return values and non-nullable types. And like all of them while it doesn't prevent all bugs, it definitely helps.
The fact that it makes straightforward to implement the best possible performance is just the cherry on top.
> You'll typically have an idea of whether or not a function performs IO from the start.
I think GP's point is: why does that matter? Much writing on Async/Await roughly correlates IO with "slow". GP rightly points out that "slow" is imprecise, changes, means different things to different people and/or use cases.
I completely get the intuition: "there's lag in the [UI|server|...], what's slowing it down?". But the reality is that trying to formalise "slow" in the type system is nigh on impossible - because "slow" for one use case is perfectly acceptable for another.
While slow in absolute depends on lots of factors, the relative slowness of things doesn't so much. Whatever the app or the device, accessing a register is always going to be faster than random places in RAM, which is always going to be faster than fetching something on disk and even moreso if we talk about fetching stuff over the network. No matter how hardware progresses, latency hierarchy is doomed to stay.
That doesn't mean it's the only factor of slowness, and that async/await solves all issues, but it's a tool that helps, a lot, to fight against very common sources of performance bugs (like how the borrow checker is useful when it protects against the nastiest class of memory vulnerabilities, even if it cannot solve all security issues).
Because the situation where “my program is stupidly waiting for some IO even though I don't even need the result right now and I could do something in the meantime” is something that happens a lot.
> Whatever the app or the device, accessing a register is always going to be faster than random places in RAM, which is always going to be faster than fetching something on disk and even moreso if we talk about fetching stuff over the network.
The network is special: the time it takes to fetch something over the network can be arbitrarily large, or even infinite (this can also apply to disk when running over networked filesystems), while for registers/RAM/disk (as long as it's a local disk which is not failing) the time it takes is bounded. That's the reason why async/await is so popular when dealing with the network.
Don't assume your process is the only process. You have to share resources like storage and RAM with everything else on the system. Just a single, simple Java app can gobble up all available RAM if you don't tell it otherwise.
Exactly. Which means other processes running on the same server can cause latency on disk access but also on RAM, which was the point I was trying to make :)
There are plenty of language ecosystems where there's no particular expectations up front about whether or when a library will do IO. Consider any library that introduces some sort of config file or registry keys, or an OS where a function that was once purely in-process is extracted to a sandboxed extra process.
> There are plenty of language ecosystems where there's no particular expectations up front about whether or when a library will do IO.
There are languages that don't enforce the expectation on a type level, but that doesn't mean that people don't have expectations.
> Consider any library that introduces some sort of config file or registry keys
Yeah, please don't do this behind my back. Load during init, and ask for permission first (by making me call something like Config::load() if I want to respect it).
> or an OS where a function that was once purely in-process is extracted to a sandboxed extra process.
Slightly more reasonable, but this still introduces a lot of considerations that the application developer needs to be aware of (how should the library find its helper binary? what if the sandboxing mechanism fails or isn't available?).
For the sandbox example I was thinking of desktop operating systems where things like file IO can become brokered without apps being aware of it. So the API doesn't change, but the implementation introduces IPC where previously there wasn't any. In practice it works fine.
> There are no languages that reify performance in the type system,
Async/await is a way of partially doing... just that, but without a having to indicate what is "blocking", and if an async function blocks, well, you'll be unhappy, so don't do that. For a great deal of things this is plenty good enough, but from a computer science perspective it's deeply unsatisfying because one would want the type system to prevent making such mistakes.
At least with async/await an executor could start more threads when an async thread makes a known-blocking call, thus putting a band-aid on the problem.
Perhaps the compiler could reason about the complexity of code (e.g., recursion and nested loops w/ large or unclear bounds -> accidentally quadratic -> slow -> "blocking") and decide if a pure function is "blocking" by dint of being slow. File I/O libraries could check if the underlying devices are local and fast vs. remote and slow, and then file I/O could always be async but completing before returning when the I/O is thought to be fast. This all feels likely to cause more problems than it solves.
If green threads turn out not to be good enough then it's easier to accept the async/await compromise and reason correctly about blocking vs. not.
A function being marked async tells you nothing in particular about the performance of that operation. It could be anything from seconds to milliseconds and routinely is e.g. elliptic curve cryptography operations that are plenty fast enough to execute on a UI thread animating at 60fps are nonetheless marked async on the web, whilst attaching a giant document fragment to the live DOM - which might trigger very intensive rerendering calculations - isn't.
Nothing stops you from starting more threads when you run low in a scenario when there are only threads also. That's how the JVM ForkJoinPool works. If your threads end up all blocked, more are started automatically.
You can't encode everything about performance in the type system, but that doesn't mean you cannot do it at all: having a type system that allows you to control memory layout and allocation is what makes C++ and Rust faster than most languages. And regarding what you say about storage access: storage bandwidth is now high, but latency when accessing an SSD is still much higher than accessing RAM, and network is even worse. And it will always be the case no matter what progress hardware makes, because of the speed of light.
Saying that async/await doesn't help with all performance issues is like saying Rust doesn't prevent all bugs: the statement is technically correct, but that doesn't make it interesting.
> Async/await is therefore basically a giant hack and confuses people about performance. It also makes maintenance difficult.
Many developers have embraced the async/await model with delight, because it instead makes maintenance easier by making the intent of the code more explicit.
It's been trendy on HN to bash async/await, but you are missing the mist crucial point about software engineering: code is written for humans and is read much more than written. Async/await may be slightly more tedious to write (it's highly context dependent though, when you have concurrent tasks to execute or need cancellation, it becomes much easier with futures).
> The right way to handle this is the Java approach (by pron, who is posting in this thread)
No it's not, and Mr Pressler's has repeatedly shown that he misses the social and communication aspects, so it's not entirely surprising.
> It's been trendy on HN to bash async/await, but you are missing the mist crucial point about software engineering: code is written for humans and is read much more than written.
Readability can be in the eye of the beholder. Some of us find it easier to read concurrent code using goroutines and channels in Go, or processes and messages in Erlang, than async/await in Rust.
I don't believe you. And you know why? Because because what the async syntax permit is just a superset of the capabilities of goroutines: Any goroutine-based code can be rewritten into async/await, without changing anything of the structure: in practice all it would change is that it would add .await at yield points. Yes, async Rust code in fact resembles a lot the code you're used to, with “tasks” (which are functionally goroutines) and channels (just have a look at tokio's documentation[1] to see how it looks like).
But for some situations, in addition to the goroutine style, async/await allows you to have a much more straightforward implementation (cancellation, timeout, error handling, etc.). Even then, there's nothing stopping you from using channels and select for these use-case when using Rust async/await. (In practice people don't because nobody sane would use channels and select to implement a timeout instead of tokio::timeout, but you can).
Saying that goroutine and channel are easier to read than async/await Rust is like saying your car can drive slower than mine, and it just reveals that you haven't actually ever read async rust code and you're just imagining how bad it must be because of some prejudice of yours.
You're writing that "code is written for humans" (very true) and that async/await is more readable, and when you receive feedback from other humans they find async/await slightly less readable than fibers/green threads, you dismiss the feedback as unfaithful. That's quite often the problem with arguments about readability: they devolve into personal opinions. Perhaps you're right, perhaps if I'd spend much more time reading async/await code, perhaps I'll eventually find it as readable as fibers code. But that's not the case today. Others in this discussion have shared a similar feedback. To be clear, that readability "issue" is not a big deal for me, just something that I perceive as a small obstacle.
> You're writing that "code is written for humans" (very true) and that async/await is more readable, and when you receive feedback from other humans they find async/await slightly less readable than fibers/green threads, you dismiss the feedback as unfaithful.
Yes, because it is. Async await can express exactly the same thing as goroutines without any alteration in structures, so by definition it cannot be “less readable” because the code is exactly the same. But, at the same time, for specific topics where goroutines code is objectively[1] not optimal, async/await offers additional tools.
Literally Anything that is straightforward with goroutines, is going to be straightforward with async/await, because your are just going to write the exact same code (really, go ahead and paste any Go code here and you'll see that the async Rust translation is going to be identical to a thread based translation, with only a small amounts “async” and “await” keywords added here and there).
> That's quite often the problem with arguments about readability: they devolve into personal opinions.
The problem here is that you're having an opinion on something you don't know about, and you're talking purely out of prejudice. And of course it's a big “personal opinion” problem.
> Perhaps you're right, perhaps if I'd spend much more time reading async/await code, perhaps I'll eventually find it as readable as fibers code. But that's not the case today.
That's not my point at all! My point is that FOR PRETTY MUCH EVERYTHING IT'S GOING TO BE EXACTLY THE SAME CODE, just with materialized yield points! And only for some stuff that is hard and tedious to write with goroutines (think cancellation, timeouts), you'd be able to use a different syntax with async/await (that is, it's going to be a function call instead of having to use channels + select).
And that's why async/await can be “easier to read” while not being “harder to read”, simply because it has the expressing power to express the exact same things, and the power to express a few other things in a better way.
[1]: (yes I say “objectively”, because writing 20 lines of concurrent code with channels and select for something that can be a simple function call is arguably inferior).
> Async await can express exactly the same thing as goroutines without any alteration in structures, so by definition it cannot be “less readable” because the code is exactly the same.
I agree that in many cases the "structure" is exactly the same, but the code is not, otherwise we wouldn't have to add `async` and `await` and adjust types for async.
> with only a small amounts “async” and “await” keywords added here and there
It's not just about the async and await keywords. It's also about adjusting the types, thinking differently about parallelism (not concurrency), and a few other things.
> My point is that FOR PRETTY MUCH EVERYTHING IT'S GOING TO BE EXACTLY THE SAME CODE, just with materialized yield points!
Then that's not the same code. This is syntactically and semantically different. And that's exactly what makes async/await interesting and useful. The screaming case wasn't necessary.
> it has the expressing power
Increased expressive power doesn't always help with increased readability. To continue that discussion in a constructive way, we would need to agree on a definition of readability and how to assess it objectively, because I'm not sure we're talking about the same thing. But I lost interest in doing so considering the general tone of the comments.
> It's not just about the async and await keywords. It's also about adjusting the types, thinking differently about parallelism (not concurrency), and a few other things.
No, you're overthinking it. The way concurrency works with async await is exactly the same as the way it works with green thread[1]. It's a bit different from what happens with OS threads but at the same time the difference between OS threads and green threads doesn't seem particularly impactful for you since you are happy to ignore it when talking about goroutines.
> Then that's not the same code. This is syntactically and semantically different. And that's exactly what makes async/await interesting and useful.
It's syntactically a bit different, but not semantically, at least not in a meaningful way: Rust async vs JS async is more semantically different than old Go[2] and rust, and old Go was more different to Go now than it was to Rust. The syntax difference is what makes it the most different, because it allows for terser constructs in a few cases (this is were the readability benefits kick in, but it's limited in scope). But that's basically the same difference as Rust error handling vs Go's (it works functionally the same, but Rust's approach allow for the terser `?` syntax that was added a few years after 1.0).
> The screaming case wasn't necessary.
Sorry about that, it wasn't about screaming and more about working around the lack of bold emphasis in HN comment formatting but I see how it can be misinterpreted as aggressive.
> Increased expressive power doesn't always help with increased readability.
If language A has more expressing power than language B, it means that the same developers can express things in language A the same way he would for language B. That is, his code would be no less readable when written in language A than if it was written in language B. Of course that says nothing about cultural differences between subgroups of developers and how some people in one language could write code that is less readable than other people in another language, but this has nothing to do with the language itself. (Bad developers write unreadable code in any languages and Go is a good testament that limiting the expressing power of one language isn't a good way to improve readability over the board as Go's leadership eventually recognized).
> To continue that discussion in a constructive way, we would need to agree on a definition of readability and how to assess it objectively
That's not something we can easily define formally, but we could work from code samples, like I suggested above.
[1] at least for cooperatively scheduled green threads, which is what go did for almost a decade. If you go back to the time where go had segmented stacks, then it's literally the exact same semantics in every way including memory layout.
[2] by “old Go” I mean Go before they introduced a preemptive scheduler, which happened a few years ago.
What you're asking for is performance to be represented statically in the type system. "Blocking" is not a useful concept for this. As avodonosov is pointing out, nothing stops a syscall being incredibly fast and for a regular function that doesn't talk to the kernel at all being incredibly slow. The former won't matter for UI responsiveness, the latter will.
This isn't a theoretical concern. Historically a slow class of functions involved reading/writing to the file system, but in some cases now you'll find that doing so is basically free and you'll struggle to keep the storage device saturated without a lot of work on multi-threading. Fast NVMe SSDs like found in enterprise storage products or MacBooks are a good example of this.
There are no languages that reify performance in the type system, partly because it would mean that optimizing a function might break the callers, which doesn't make sense, and partly because the performance of a function can vary wildly depending on the parameters it's given.
Async/await is therefore basically a giant hack and confuses people about performance. It also makes maintenance difficult. If you start with a function that isn't async and suddenly realize that you need to read something from disk during its execution, even if it only happens sometimes or when the user passes in a specific option, you are forced by the runtime to mark the function async which is then viral throughout the codebase, making it a breaking change - and for what? The performance impact of reading the file could easily be lost in the noise on modern hardware and operating systems.
The right way to handle this is the Java approach (by pron, who is posting in this thread). You give the developer threads and make it cheap to have lots of them. Now break down tasks into these cheap threads and let the runtime/OS figure out if it's profitable to release the thread stack or not. They're the best placed to do it because it's a totally dynamic decision that can vary on a case-by-case basis.