I stopped paying attention to the language around 2012-2013 or so when the direction clearly steered towards the latter.
You may want to check https://austral-lang.org/ out.
OCaml is / was short for Objective Caml. Hence the name used to have an apostrophe. At some point that was dropped.
It's even hard real time capable, which makes it potentially viable in scenarios like games where GC traditionally hasn't been.
Rust's async doesn't have the limitation author described – you can spawn and block both (Tokio has some limits there, but that's Tokyo's choice, not language limitation), making "color" largely irrelevant.
The second point was that both should have identical syntax, which Rust deliberately chose not to, because from Rusts perspective that would be too much implicit magic.
Async without function coloring means being able to call `async add(a, b: usize) -> usize` and having an usize back whether you're calling from a regular or async function. If you need slightly different logic because of async, it is indeed function coloring.
In fact, a hypothetical async without coloring would not even need the `async` keyword at all, as all function would be effectively the same so could be called asynchronously or not. I'm not sure that is even possible with this abstraction.
So the two camps I've seen on solving it is either
a) blackbox it and set some rules for implementers, inevitably leaving to some hidden and very nasty bugs on the user end or bottlnecks for advanced users, or
b) give implementers the full tools to setup, launch, and synchronize themselves, which may end up being slower than a single threaded solution if the user isn't adept already with parallel programming.
You inevitably always have bits of a) creep in, even for the most explicit solutions
The insight that function coloring gives you is terribly important. Roman Elizarov (of Jetbrains, author of Kotlin and lead on Kotlin Coroutines) agrees too: https://elizarov.medium.com/how-do-you-color-your-functions-...
async functions without coloring means that the only warning you'll ever get that `calculate2Plus2()` actually ends up running a distributed BigQuery and writing the end result to disk, printing it to stdout() and parsing that result to give it back to you is... hopefully, documentation is up to date and you read it?
Async function coloring is not a problem. Async function coloring is a solution to "software developers are awful and will do awful things without any warning". If `calculate2Plus2` did not exhibit the write to disk behaviour in v1.0.0, but bumping to v1.0.1 does, I'd really want a warning that it does at least, and ideally a compiler error. The proper solution to function coloring is to have a pleasant API to interop between both worlds so that, at worst it's just a dozen characters more to say "yep, I really want to block here".
So the whole sync/async distinction doesn't really make much sense outside the context of single threaded UI toolkits, where indeed you have to read the docs anyway because doing some very CPU intensive work on the UI thread will block it even if no "blocking" APIs are ever invoked (and what is or is not blocking is somewhat arbitrary anyway in many cases).
Alternately, an effect system can communicate "touches disk", can make functions which are polymorphic to effects (e.g. map touches the disk if the mapping function touches the disk), and can distinguish different effects, unlike async/sync being anything-or-nothing. IMO async is more of an implementation detail (as "may suspend/yield" is not a very precise or interesting effect), whereas effects would communicate the properties that ohgodplsno wants to know.
https://dev.epicgames.com/documentation/en-us/uefn/specifier...
converges/diverges, computes, varies, reads, writes, transacts, no_rollback.
Blocking has meaning in a lot more contexts, and being a consultant on JVM related topics, you should know that: it's the entire purpose of Project Loom, and Loom doesn't entirely get rid of colour coding either explicitly for that purpose. Loom wasn't made because the guys at Oracle have a deep love for JavaFX, but rather takes into account the server world, where you really want to know that you're going into another context, another computer, etc. The only time where the existence of async doesn't make sense is if your entire language and ecosystem expects everything to already be distributed. In which case, you've just switched the default color to async.
Finally, you chose to read async as the current JS/C# abomination implementation, but most of the sensible languages have implemented it as an effect: Kotlin has suspend funs, they don't return a Promise, but they tell you two things: they're going to touch something like the disk or the network, and if you really want to have them in a non-suspend context, you can either get a Deferred<T> out of them (and find another thread to run it on, and handle synchronization yourself), or run them on the current thread (and block everything).
Kotlin suspend funs do not tell you they're about to touch network disk, that's the reason they use "suspend" and not "async". Suspend funs can also use generators (with yield) in which case no I/O is happening but they are nonetheless suspending.
So this is why blocking as a concept isn't a great one, IMO. The Kotlin designers were right to not use the word in their implementation of coroutines. Where Loom discusses blocking a bit, it's not defined as being about blocking, it's about being able to scale up threads to way beyond what was previously possible. It just so happens that the primary reason you'd want to do that is if you have lots of threads that spend lots of time waiting for things, but that doesn't automatically require network or disk access. For example you can use threads that spend all their time in Thread.sleep if you were writing an agent simulation.
Wasn’t basically all of the JDK’s file APIs rewritten to io_uring-like calls to support Loom? I have thought that IO was definitely something that Loom handled.
You're conflating too many orthogonal things.
It's not merely the performance of touches disk/network that async was used for those, it's because that waiting is not because you're held up by the language doing calculations. That isn't the case with a function like you describe.
Marking functions async when they aren't yielding just to signal that they might be slow is a bizzare idea. That's not what async is and it doesn't bring any real benefit, it's just abusing the notion (and limiting the contexts where you can run those functions). You'll still be using libraries which wont follow this strange idea, and you should know their performance characteristics.
Blocking code exists in all major languages, including JS. In a single thread context, having something "async" wont help you at all, if it calls anything blocking, which can be something as common as JSON.parse
>If it suddenly expects a disk write and I'm running on a ROM, I'm going to have a hard time. If it suddenly tries to contact google.com and I'm on an airgapped machine, I'm going to have a hard time.
All of those have nothing to do with async, and what async is created and used for.
What you want is something like Haskell's IO "tainting", a (side) effects system, or something to that (no pun intended) effect.
As a design choice of OCaml’s eio - an async runtime, you must pass around a dependency to all your IO functions. With the “net” object passed visible in my function type signature, I can tell some network work may occur. The first benefit is we get synchronous Go-like code.
In case of java there really is no compiler magic even, “just” runtime magic.
With Rust's async you have to worry about a different worker thread picking up the work after a context switch, which makes things complicated. Not so with Java.
Its a lovely design though.
To my mind it's very much a balancing act between "low power to the developer, high power to the language" and "high power to the developer, low power to the language" all up and down the stack from software / hardware to "consumer / framework".
TL;DR - he says that "doParallel" and "doConcurrently" are separate operations with distinct semantics that designers of programs must care about and that conflating the two (especially the common "doConcurrently-and-often-but-not-always-in-parallel") is one of the most common causes of bugs in programs that need to make progress in multiple threads of execution.
To schedule tasks properly, you need to know who is waiting on them as early as possible. With a promise, you only know when you get to the "wait()" call, which is too late.
The correct solution is called structured concurrency.
The issue is that you want to be able to save the entire thread stack cheaply so that you can switch to another task at an async yield point, and then go back to your previous stack frame. You want to do this without spawning system threads.
Say you have an f -> g -> h call stack that blocks on IO. If all the functions are in fact async state machines (or some other kind of callbacks) your thread stack will look like this:
Executor.loop() -> Executor.run(h) -> h.await()
That is, your functions are really objects that are queued up and taken as needed by the executor. If h has a yield point, you can put it back on the queue, go back to the loop and pick up some other work. Later you can do the above again and await h to again up to the next yield point.
Now consider the case where the middle function g is NOT async. In that case the call from f to g will be a normal function call that gets put on your call stack. In a language where this is allowed, you'll still have to wait or execute h and you'll get a call stack that looks like,
Executor.loop() -> Executor.run(f) -> f.await() -> g() -> h.awaitUntilCompletion()
(where our awaitUntilCompletion is minimally just calling h.await in a loop until h is finished). At this point, you are stuck: you can't yield from g() because it is a normal function with a normal stack frame, so your thread has to wait till g() finishes before it can be used for anything else. You are basically blocking a thread on h. At this point you might spawn a new thread to keep your thread pool count up (which is expensive and memory hungry) or just accept the forced synchronous blocking (which reduces throughput).
If this happens over and over again, you either end up running out of memory or end up running code in a synchronous fashion but with worse performance (because of all the state machines). This is why functions are usually coloured, so that you don't get yourself in this accursed state by accident. AFAIK the alternative is to allocate all of your call stacks in the heap so that you can switch them in and out of kernel threads, which is what fibers are. This requires a complicated runtime with a scheduler such as in Loom,
https://github.com/mirage/ocaml-cohttp/blob/16e991ec1f7e5f0c...
Performing these effects is similar to throwing exceptions up the callstack where whichever ancestor handles the IO work, then resumes the child with IO work done in hand.
- what the type of res is (is it a string? A buffer? Async string? Something else?)
- how this code does not block: what is happening in parallel? At which point will it block?
I would like to be excited about OCaml’s algebraic effects, but right now I don’t really get it.
- It's implementing non-blocking I/O using effect handlers. The complexity is not exposed to the library user, which is actually the whole point. If you want to dive into the concurrency library (Eio) and study how it works, that's very doable, just like any concurrency library in any other language.
There's really not that much to get–OCaml will do async I/O without having to colour functions, just like Go, Erlang/Elixir, and soon Java. It's not something sexy like monads that excite people with mysterious FP concepts, it's just a lot of hard work that went into simplifying how developers code at scale.
When reading ocaml on _text_ like from GitHub, I personally accept that I won’t need to know the type and underlying data structures just yet unless I read and have open the .mli file.
I’m excited first and foremost because we can have async code feel synchronous like Go.
How does the code run? IO runtime is defined in userland, like Rust’s Tokio. That’s the “Eio_main.run” part. Underneath, eio spins up and manages processes in a non blocking manner, even scheduling work.
Since effects are essentially resumable exceptions, this particular http request throws an effect to the runtime. The logic and this particular process pauses (okay I may be wrong here). Logic flow now continues somewhere in our runtime level (the effect handler), where all the async IO work happens. The runtime resumes the child process and returns the http result back to the child via a “delimited continuation”.
IMO, this is even worse than function coloring.
Rust has basically 2 executor libraries, tokio and async-std. It seems to me like tokio is solidifying as the executor of choice and it's only a matter of time before that design is baked into the std library.
std::async is mostly "good enough" for ordinary cases where you only want to fire-and-forget and have no control over the underlying dispatching and scheduling algorithms. If you want something tweaked towards your use-case, and hence actual high-performance executor library, std::async is not gonna cut it. And that's where the "C++ executors" proposal come in.
I think designing and evolving any living programming language is just one of the hardest problems out there.
Incredible blog post indeed, was awesome to read it.
His dislike for functional constructs didn't do Python any favour, but now that he's gone I see Python is adding the kitchen sink as well. I haven't kept up with the language since that pattern matching proposal.
If they're all terrible, then the language won't appeal to anyone and will die off naturally.
On the other hand if everyone gets to add their favorite terrible idea, there may still be enough non-terrible parts to appeal to enough people to keep momentum.
One of the most popular and succesful languages, and a major force in AI innovation?
The transition was slow (Python 2 was supported in parallel to 3 for a long period of time so there was more than enough time to migrate), relatively easy to do, and brought huge benefits to the ecosystem.
It could have been done and forgotten in 2 years if some community members had not been dicks about it.
JS devs have been transpiling for years now and it works.
Python should have released a conversion tool that could automatically convert most stuff and identify the stuff it couldn't convert. This kind of directed upgrade would have made the process much easier for developers to actually accomplish.
Sure. The point is that a community can make things go poorly, with or without a BDFL.
Here's the timeline the way I think about it:
- 2008-2012 Python 3 becoming usable (byte formatting, six library)
- 2012-2016 Libraries (Django, etc) becoming compatible
- 2016-today Applications (Trac, Ansible, Chrome, etc) becoming compatible.
It took a lot of time for the ecosystem to be ready. Yes, there was a lot of stubbornness, but that's reality.
There are many things that could have been done to make the transition go smoother.
There's a lesson in there to learn. Its unfortunate that the people who need to learn it most likely won't.
(That said, JS is actually a very versatile and almost-great language, getting better all the time)
https://peps.python.org/pep-0020/
Things like "There should be one-- and preferably only one --obvious way to do it." just was such a breath of fresh air in language design.
Function coloring seems to come up a lot in these discussions, but I don't see a better way without providing a runtime. Could you propose an alternative approach to async, without sacrificing ability to write zero-overhead, high-performance bare metal systems?
template<template<typename> class MyContainer>
int getFirstInt(const MyContainer<int>& myContainer) {
return myContainer[0];
}
(In Rust it's not possible to write something like MyContainer<int>; only the int is allowed to be generic).They're not expressive enough to implement a monadic async library, as far as I'm aware, so they don't fulfill the role of proper HKTs in this context.
>minus `template`’s duck-typiness so you’d have to spell out what contract the container must adhere to.
Do you think it's consistent that the T in MyContainer<T> can be "duck typed" but not the MyContainer?
AIUI you need GC in order to do that. There's a reason why Rust's async/await support needs compiler magic.
(I'd like to see standard interfaces for "pluggable" garbage-collected arenas in Rust, but this will need to wait until after Allocators/Storages get fully stabilized.)
I really wish people stopped using this concept, especially in the context of Rust because the `async fn`/“regular function” split is strictly equivalent to the `try_something()`/`something()` split (the first one being failible and returning a `Result` in case of failure). `Result`s and `Option`s are coloring the stack in exactly the same way a `Future` does (and `async` is pure syntactic sugar on top of future).
So, someone may like exceptions and green threads more than `Result` and `async` (and this is a completely valid PoV, even though I personnaly like the explicitness better), but thinking `async` is somehow special is just a conceptual mistake.
BTW, in the original blog post the “red” functions were actually functions with a callback parameter, which is actually very different.
Tons of stuff in std and many other crates don't use async. A lot of stuff is harder and more code to do async. A lot of it is much harder to read.
That's the difference.
Well, there are dozens[0] of incompatible error types in std, two of them being just called `Error`[1][2].
Everytime you create a function that use more one of the different error types from std (for instance, an I/O error and a TryFrom-related error) you have to create a custom Error enum to accommodate the different kinds of errors from std…
> community agreement to do things that way
And the community agreement is e̶r̶r̶o̶r̶-̶c̶h̶a̶i̶n̶, ̶f̶a̶i̶l̶u̶r̶e̶, ̶ s̶n̶a̶f̶u̶, this_error, unless it's for a binary crate and then it's ̶a̶n̶y̶h̶o̶w̶, ̶e̶y̶r̶e̶, color-eyre.
The error ecosystem is much more fragmented than the async one (even though I think this_error is here to last, so I don't have to migrate once again…). In the async world, there's pretty much tokio (but I think it would actually be better if it interoperated better with other runtimes so alternatives could emerge)
> A lot of it is much harder to read.
Ah yes, fut.await is fundamentally harder to read than res?, right?
Overall, you vastly underestimate the friction that the `Result`-based error handling adds. I still think it's worth it, and it's been slowly getting better (did I say I loved this_error?), but it remained one of the biggest point of friction in Rust over the 8 years (time flies) I've been using it. Async is comparatively less disrupting (mostly because it only appears in the call stacks where you're doing I/O, whereas errors are ubiquitous).
[0]: https://doc.rust-lang.org/std/error/trait.Error.html?search=...
A nice talk about this: https://www.reddit.com/r/programming/comments/da141r/ron_pre...
More concretely, threaded code has nothing to say about whether or not its synchronous or asynchronous, and asynchronous code has nothing to say about whether or not it's evaluated in parallel.
That is an artificial distinction you made up. I have written many apps and desktop applications, all of which where using 3-10 threads, some of them doing "parallel" work meaning the parallel computation of divide and conquer algorithms, some were doing concurrent work.
I tried async often. I threw it away every single time, because it "infects" the entire codebase. I suspect the only reason async exists is because the Javascript event-loop is single-threaded and there are no blocking primitives in JS. If you don't have access to blocking data-handoff between threads, you can choose between callback hell or async/await.
Please take a look at the video I posted earlier.
2, 4, even 256 sockets exchanging data can be worked with concurrently on a single thread and gain performance vs blocking and waiting for the first socket to finish. There's no parallelism, since they're never actively reading/writing at the same time, but they're concurrent because they exist and operate in overlapping timeframes.
Running two independent algorithms could mimic this - run part of algo A, then part of B, then A, etc. It's not useful for performance, though. To be useful you require parallelism - you have to have the algorithms executing at the same time, using multiple threads.
On async itself - it's not perfect, but honestly, there are contexts where it makes a lot of sense. I work on a lot of non-blocking C code - the entire programs are basically epoll and timer driven. As a result, the high level coordination is just callback hell. Async is very nice in comparison.
Yes, you can spin up threads to do everything with blocking, but non-blocking I/O came around specifically because the threads add overhead and kind of suck. It's worth noting that having threads can also infect the codebase. You either have to carefully manage mutexes, or you only communicate with channels and have to worry about keeping things updated and in sync. Sometimes this works great with minimal communication between threads. However, if you have one socket per thread and the sockets are all triggering actions that mess with the same data... it's not so great.
Besides IO Completion Ports for async IO has been the fastest way to do heavy IO on Windows, and it was introduced years before JavaScript even was a thing.
And that's just one example. So hardly think JavaScript was the main driver there.
Rust Channel have the nice additional that they don't panic, and don't have all the weird Go Channel Axioms[1] that you have to wrap your head around to get things to work correctly[2].
For what it's worth, I don't think Java Virtual Threads (a.k.a. project loom) has implemented an explicit concept of channels, although I guess BlockingQueue could work as a channel.
There is a big issue with bringing CSP to languages like Java 21 or Go, that use so-called "colorless concurrency" or "stackful coroutines". CSP losses a lot of its modeling power when you combin shared mutable state and pre-emptive (or invisible) parallelism. And this is exactly what both of these languages do.
"Colored" abstractions like async/await are more cumbersome (especially in Rust), but they let you know exactly at which points of execution your stackless coroutine may suspend, and you can handle synchronization accordingly.
Those functional concepts are cumbersome, but threads ("green", "kernel", "virtual", "goroutines" or otherwise) are not the panacea their proponents claim them to be. I had to debug way too many data races in my life to consider them perfect. Rust still has a lot to improve on its async front (no async traits, async closures or async iterators and the async runtime confusion), it can at least promise me that I won't get any data races.
[1] https://dave.cheney.net/2014/03/19/channel-axioms [2] https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-s...
But not any other kind of race condition.
Also, data races are “safe” in java. Plus you can rely on immutable data for those parts. Parallelism will always be hard, there is no model for shared mutability that would be easy.
Being sync or async is essentially a property of the attention of the caller, not of the action itself. Is "eating a donut" a sync or async action? If I'm focusing all my attention on it, essentially putting all tasks aside (after) - then it's a synchronous action. If I'm reading a book/watching a video/walking/etc, while eating – it's an async action.
How does "func EatADonut() async {}" aka "eating a donut is an inherently async action" even make sense to people?
But consider a network request. The vast majority of the time is not spent in the CPU.
Right?
s/eat a donut/make a network request/
Is "network request" a synchronous or asynchronous activity? It depends whether your code blocks and wait's until response (or timeout) or continues executing and handling it when it comes. It's property of the "attention" of the caller, not of activity itself.
Something that pegs the CPU to 100% because it's doing intense processing isn't a good candidate for async. Similarly, some code can cause issues when written in a non-streaming fashion. Take the following example (in python):
x = [x for x in range(1_000_000_000)]
y = (x for x in range(1_000_000_000))
If you spawn a bunch of async workers doing the first one concurrently, you'll OOM your system. If you spawn a bunch of async workers doing the second one concurrently, your system will be fine.In other words, "async" is a label on a box of donuts that implies (though doesn't ensure, people can of course still do bad things) that the donut won't explode if you look away from it.
Even if this is an original logic, why language is deciding for me what is considered heavy or not? What if I'm fully aware that I'm doing heavy processing and I want it to be happening in the background. What if I'm writing HFT software and every call is heavy for me. Language is not a right level of abstraction of marking "heaviness" of the code.
It really just doesn't make sense. Why stop there and not start marking functions with how many times per second they can be called? Like you can call "light" function 100 trillion times per second and OOM the system, so let's mark it "func light() async 2_times_per_second {}". It only can be called from functions that have lower per second label.
Plus, if Python cares so much about not OOM-ing the system I would start by not requiring 32-bit int to occupy 28 bytes in the first place.
Well, I'm not that deeply versed into any language's history, but I imagine any 15+ year old language had a point where they needed asyncrhonous functionality but had to write around legacy code. I don't think Javascript would have been written the way it was in the 90's if asynchronous operations were a mandatory priority.
So it's probably a lot more "this is how we hack this in without creating Python 3.0". Relatively elegant to make it an optional part of the language that you explicitly choose to delve into, instead of a core feature that would have broken thousands of sites behind the scenes if you made it "right".
>why language is deciding for me what is considered heavy or not?
because we decided decades ago that we didn't like using fork(), nor creating/destroying processes ourselves. problems that would inevitably need multiple solutions when supporting multiple platforms because these aren't language features so much as OS calls made in a language that you may or may not be writing in.
Remember at the end of the day that languages are abstractions on top of an OS. Any interesting ideas for problems involving more than a single process space that your OS makes for you is bound by what the OS's API lets you do. It's arbituary but also based on historical problems to wrangle.
So we haven't had enough problems where we need to bound a function by how much it is called. We have for how to interact with all the above OS/language problems.
The question is how you translate mental models from real world to the code, and async/await fails here spectacularly. It's just weirdly unnatural thinking about sequencial processes. It requires tremendous amount of cognitive gymnastics just to reason about simple things.
Of course it does, read it as "beware, something blocking down the road".
If you can EatADonout without blocking, please do, but want it or not that's a different implementation, one that doesn't block and the signature it's telling you so.
We're so used to sync and having hidden blocking operations. I wonder if in an alternate universe the first languages considered the blocking/async nature of operations and then some newer languages considered hiding this information into seemingly sync functions would produce similar but opposite outrage against it.
Implementation is the same. In both cases it's the same set of CPU instructions, but async/await languages create artifical division, forcing developer to think othervise.
So, let me explian my reasoning. Code starts with a developer's mental model of a problem and behavior of the system and then translating it into the code. The more straightforward this translation, the more readable the code. Code is a second degree map of problem domain so to speak ("real" world -> mental model -> code).
Like if you want to add two values, the simplest form of code would be "add(2,2)" which is pretty straightforward. If the code forces you to do some mental gymnastics (i.e. "2 2 addOnlyEvenNumbers") - that's less straightforward, less clear and less readable.
In the same vein, if you want to execute some function ("EatADonut" or "MakeHTTPCall") – you may care or not care about blocking and waiting for results. But it's your call. So it makes sense to give you two options to run this function. Go has simplest possible solution – "eatADonut()" vs "go eatADonut()". It doesn't matter what is a "default" here – it could be "eatADonut()" (go to background) vs "sync eatADount()". What matters that "eating a donut" is just a set of instructions to the CPU, and it's up to caller to decide how you want to execute it in terms of concurrency.
Now, "async/await" approach turned this ownership of "synchronicity" around. Now function is deciding how it should be called. Mental model of "actions" now needs to be translated into "actions being async or sync for the purpose of fitting into this language concept". Which is cognitively expensive for no added benefit.
Sure you can rationalize it, and get used to it as to any other absurd design, but it still adds unneeded complexity to the code, makes it less readable and less clear.
You have to think of it from a higher level.
Nobody knows if eatdonut is strictly async but everyone knows all IO calls are async. So something like socket.send would be async, this is obvious.
Then anything that calls socket.send would then in turn become async itself. The async sort of spreads around like a virus. Any function that uses other async functions gets polluted with the async marker.
So in this sense, you should think of it like this. If eatADonut is async it means it touches IO. It sends data somewhere else to some other thing. It also means it's not unit testable.
In a nut shell This is the intuition you should have about async:
Async Tells You about the properties of eatADonut. eatADonut doesn't tell you anything about whether or not it's async.
This is largely identical to how the IO monad works in Haskell. Unless you want all your logic polluted with the IO monadic wrapper or the async marker you have to learn to segregate your functions into async functions and non-async functions.Under this intuition, EatADonut, by good design principles should NOT be async. This is what it should be:
fn eatAdonut() -> ResultOfEatADonut
async fn sendDataToIO(data: Data) -> ()
async parentCaller() -> () {
let data = eatAdonut();
sendDataToIO(data.serialize()).await;
()
}
The async marker forces you to organize your code in this way, You have to do this unless you want all your functions to be polluted by the async marker. This is the proper way to organize code Anyway, as it allows your code to be unit testable. Async calls are, in general, not unit testable because the functions are not pure. So if you look at what I did above, I segregated eatAdonut Away from IO and made part of the logic of the overall code testable via unit tests.IO calls should be very general and small functions while your other functions should be pure and unit testable. This is the overall intuition you should have about async.
Believe it or not, this specific aspect of async is actually an improvement over golang style concurrency. Not an overall improvement, it's still debatable which style is better, but I'm saying the async marker is actually a feature that other styles of implementing concurrency don't offer.
Basically, async encodes the concept of IO and function purity into the type system. It allows the type checker and You to tell which functions touch IO and which functions are pure and unit testable.
People think unit testability is some kind of marginal benefit because you can cover testing with integration tests which are basically tests that happen across IO. It's more than that. The ultimate dream of programming something as if it's a bunch of modular lego blocks that you can elegantly place together is most fully realized with pure functions. Async enables more of this style of modularity in Rust (but not without some cost which is why it's debatable whether or not it's better than go style concurrency).
Do you mean on a hardware level? Because otherwise, hell no.
If I’m writing a binary file parser that buffers some data and processes it, I sure as hell want to wait for IO, there is no other meaningful way forward in most cases. It is also not a small isolated part, but a tight loop bouncing between CPU and IO.
Async is a function of the caller.
I mean in the context of async await. The smallest primitive that is properly async is an IO function.
There are functions, you can call them sync or async if they handle IO or UI or they will get a necessary data later.
I still don't understand why I can't fetch a URL from top level javascript. Also I don't understand why zig async and await passes the control flow that seemingly total arbitrary way. The naive approach (put async calls in a queue, and periodically check if they are completed or can be executed) seems fast, deterministic, and good enough in every way?
Okay, maybe zig needs the speed, and can't just stop the execution of the sync code time to time to do something else, but why javascript? Maybe javascript async is build upon regular promises and regular objects, instead as a proper language element with proper support in the javascript engines? I don't know. Anyway async as is used with colors is totally against the picture that the words "async" and "await" suggest.
> Maybe javascript async is build upon regular promises and regular objects, instead as a proper language element with proper support in the javascript engines?
Yes, they are promises.
> The async and await keywords enable asynchronous, promise-based behavior to be written in a cleaner style, avoiding the need to explicitly configure promise chains.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
> The naive approach (put async calls in a queue, and periodically check if they are completed or can be executed) seems fast, deterministic, and good enough in every way? Okay, maybe zig needs the speed, and can't just stop the execution of the sync code time to time to do something else, but why javascript?
You're describing preemptive multitasking. You can create separate execution threads pretty easily in most languages (even super old C code), but that's not why Javascript and Zig have the 'async' keyword. The difficult part isn't the asynchronous execution; the hard part is handling the result from asynchronous code (i.e. the 'await' part). Promises are a simple mental model for organizing how pieces of code depend on the results of other pieces of code. The C# documentation does a good job explaining this:
https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous...
Note: From Zig's documentation it sounds like 'async' is cooperative multitasking. This is single-threaded execution. It is "concurrent" code, but it isn't executing in "parallel".
Concurrency is difficult because it is explicitly resource management. You don't need concurrency for calculating the correct answer, you only need it for managing time.
Sending a letter and getting a reply is an inherently async action; you can stare at the mailbox all day but you probably don't want to. Waiting in line at the bank is inherently sync; if you try to do something else and then come back they'll make you start over again.
Yet, it's up to me to decide, not to the mailbox.
Caller can use this information to invoke 10000 IO bound tasks in parallel, whereas cpu bound tasks are limited to number of cores and may want to be throttled. In your analogy, I can eat a donut while simultaneously listening to music, but I can not eat a donut while simultaneously eating fish soup.
Now where it get complicated is when a function does both io wait and heavy cpu processing. Now neither color of the function match.
Forcing this upon the caller to invoke such functions in special ways is the strange part. Taken to the absurd you ought to have special syntax for every resource constraint in the computer, memory bound, disk bound, wifi bound, and so on. It would be better if the runtime could figure this out by itself and parallelize accordingly.
The single threaded aspect of async functions does have certain other benefits, like not having to care as much about muxes when synchronizing state.
Other possible semantics would be to start multiple tasks and wait for them all — this is where structured concurrency comes in, giving a good analogue with gotos.
Async-await is fundamentally an optimization, not about semantics only. Languages without runtimes simply can’t reason about it without language support, but managed ones could do so as every IO call goes through it — scheduling multiple tasks to a single core is now possible.
Without it when you want to do asynchronous stuff you either block on it (not good since JS is single-threaded), eg "let toto = asynchronousFunction(); doSomething(toto)" and nothing can be done during the time asynchronousFunction is waiting on IO or something (in a browser environment that means part of your website stops responding to user input); or pass as an argument to the asynchronous function a function that will be executed by the asynchronous function once it "wakes up" (usually called a "callback"), and while it's waiting, the JS runtime can execute other stuff, eg "asynchronousFunction((toto => doSomething(toto)))".
Callbacks were I think from the start in JS. After a while promises were introduced, which compared to callbacks avoids nesting, and probably have some other advantages that I don't know about. Still, they are method chaining and not "regular code". For example regular try/catch won't work as usual, you have to use .catch(). Even later async/await got introduced, which allow you to write asynchronous code as if it was regular code, with a few exceptions (for example you can only use await in an async function, top-level await took a while to land on Node, stuff like that).
I don't think using real world human actions helps with understanding any of that. Async functions make sense in JS because again, JS is single threaded and if you do a blocking call (like asking the kernel to read a file, or making an HTTP request and waiting for the response), the event loop is blocked. From Node.js documentation's on the synchronous function in the fs module:
> The synchronous APIs perform all operations synchronously, blocking the event loop until the operation completes or fails.
I feel like I need to point out that Donald “Structured programming with GO TO statements” Knuth included an example of coroutines in the first volume of The art of computer programming, the first edition, dated 1968. In assembly language for an accumulator machine. With a box of scraps!^W^W^W^W^WThat is to say, that C and most other languages have made coroutines awkward and thus virtually unused in the past three decades or so does not mean they are particularly novel or high-level.
Granted, Knuth used coroutines in a simulation and not for I/O, so he did not need that much of a scheduler, but still.
It's just an abstraction, it's not zero-(runtime)-cost. It might be the "lowest possible cost", still nonzero.
I continue to find the "function coloring" argument misses the point unless you're arguing from a developer experience perspective. Why should two fundamentally different things look and function the same? Want this the ultimate pitfall of early RPC implementations where everything looks synchronous?
In Rust, a lot of the friction is due to how async functions/futures fundamentally differ in model of execution and how that interplays with the rest of the language. Other languages get to hand-wave a lot of these problems away with a GC. (It certainly could be less of a pain nonetheless.)
- Futures don't execute until polled, can partially execute if something isn't ready (and would block), can partially execute and be arbitrary cancelled and dropped. There is no equivalent in sync functions unless you make them co-routines.
- Since you can "pause" futures if something would block, you need a place to store the function's state for when it's resumed. Now you must be consider if the async function's state is `Send` if the future wants to move to a different thread to continue executing -- which is why you see `pin!` used. Sync functions don't care about this since you always run to completion on the same thread, and the stack is ephemeral.
- Likewise, the `Future` returned by the async function is going to need to encapsulate it's state that it saves. In the general case, this is a compiler generated anonymous struct that changes if any state saved across `.await` changes, hence the opaque `impl Future`. This is why you see `BoxedFuture` a lot to abstract this away at the expense of an allocation. Ideally, the new associated types with lifetimes can avoid this with traits.
So if all functions were co-routines (i.e. functions that can be resumed and re-entered) they would all have the same "color". But all you really did was "lift" all sync functions to be "async" functions with no internal await points.
(IMHO, if the C# team back in the day decided to implement full blown co-routines into the language instead of just `async/await` as a compiler trick, I think many other projects would have followed suit with a more general co-routine solution instead of treating `async/await` as this special thing which is just a specific compiler generated implementation of co-routines.)
async in Rust is a popularity issue even more than a technical issue.
A whopping amount of people who are coming to Rust are doing so because they want a good ecosystem for implementing network service servers. Rust/cargo/crates hits the sweet spot for them.
I am with you that I loathe async/await in Rust. However, I also have to acknowledge that without async/await, Rust is a vastly less popular language.
All the other uses of systems programming are simply dwarfed by number of people building network services. That's just the sad reality.
That said, I still hate async with a passion, it makes the language more complex and not very elegant (i.e. function coloring). And now that I know how it works behind the scene (thanks to Jon Gjengset [1]), it feels so complicated and hacky, a mediocre very high level concept that someone managed to implement as a zero-cost abstraction. Impressive, but still a bad idea.
I'm sure the pro of having a BDFL instead of a committee is being able to follow a singular vision, instead of trying to appease members by adding the fad du jour which might stray a little too far from the original vision. Too many chefs in the kitchen and all.
1: https://www.youtube.com/watch?v=ThjvMReOXYM