▲ | unscaled 6 days ago | |||||||
I think most of the arguments in this essay rely on this single premise: "The second thing I want you to take away is that imperative languages are not inferior to functional ones." There is an implied assumption that async/await is a "functional feature" that was pushed into a bunch of innocent imperative languages and polluted them. But there is one giant problem with this assumption: async/await is not a functional feature. If anything, it's the epitome of an imperative flow-control feature. There are many kinds of functional languages out there, but I think the best common denominator for a primarily functional language nowadays is exactly this: in functional languages control flow structures are first class citizens, and they can be customized by the programmer. In fact, most control flow structures are basically just functions, and the one that aren't (e.g. pattern matching in ML-like languages and monadic comprehensions in Haskell-inspired languages) are extremely generic, and their behavior depends on the types you feed into them. There are other emphasis points that you see in particular families of languages such as pattern matching, strict data immutability or lazy computation — but none of these is a core functional concept. The interesting point I want to point out is that no primarily functional language that I know actually has async/await. Some of them have monads and these monads could be used for something like async/await but that's not a very common use, and monad comprehensions can be used for other things. For instance, you could use do expressions in Haskell (or for expressions in Scala) to operate on multiple lists at once. The same behavior is possible with nested for-loops in virtually every modern imperative language, but nobody has blamed Algol for "polluting" the purity of our Fortran gotos and arithmetic ifs with this "fancy functional garbage monad from damned ivory tower Academia". That would be absurd, not only because no programming language with monadic comprehensions existed back then, but also because for loops are a very syntax for a very specific thing that can be done with monadic expression. They turn a very abstract functional concept into a highly specific — and highly imperative — feature. The same is true for await. It's an imperative construct that instructs the runtime to suspend (or the compiler to turn the current function into a state machine). So no, async/await does not have anything to do with functional-language envy and is, in fact, a feature that is quite antithetical to functional programming. If there is any theoretical paradigm behind async/await (vs. just using green threads), it's strong typing and especially the idea of representing effects by types. This is somewhat close to fully-fledged Effect Systems (in languages such as a Koka), but not as powerful. The general idea is that certain functions behave in a way that is "infective" — in other words, if foo() calls bar() which in-turn calls doStuff(), it might be impacted by some side-effect of doStuff(). In order to prevent unpleasant surprises, we want to mark this thing that doStuff does in the function signature (either using an extra argument, a return type wrapper or just an extra modifier like "async"). In a pure language like Haskell, everything from I/O to mutable memory requires specifying an effect and this is usually done through monadic return types. But even the very first version of Java (Ron Pressler's ideal untarnished "imperative" language) has effects (or "colors") which still remain in the language: checked exceptions. They are just as infective as async I/O. If you don't handle exceptions in place, a function marked with "throws IOException" (basically almost any function that deals with I/O) can only be called by another function marked with "throws IOException". What's worse, unlike JavaScript which only has two colors (async and non-async), Java has an infinite number colors! The description above sounds horrible, but it's not. Checked exceptions are widely believed to be a mistake[1], but they don't bother Java developers enough to make the language unusable. You can always just wrap them with another exception and rethrow. The ergonomics could have been made slightly better, but they're decent enough. But the same can be said for async/await. If you take a language with a similar feature that is close to Java (C# or Kotlin), you'll see the asynchronous functions can still run as blocking code from inside synchronous functions, while synchronous functions can be scheduled on another thread from a synchronous function. The ergonomics for doing that are not any harder than wrapping exceptions. In addition to that, the advantages of marking a function that runs asynchronous I/O (just like marking a function that throws an exception) are obvious, even if the move itself is controversial. These functions generally involve potentially slow network I/O and you don't want to call them by mistake. If you think that never happens, here is the standard Java API for constructing an InetAddress object from a string representing an IPv4 or IPv6 address: InetAddress.getByName()[2]. Unfortunately, if your IP address is invalid, this function may block while trying to resolve it as a domain name. That's plain bad API design, but APIs that can block in surprising ways are abundant, so you cannot argue that async/await doesn't introduce additional safety. But let's face it — in most cases choosing async/await vs. green threads for an imperative language is a matter of getting the right trade-off. Async/Await schedulers are easier to implement (they don't need to deal with segmented/relocatable/growable stacks) and do not require runtime support. Async/await also exhibits more efficient memory usage, and arguably better performance in scenarios that do not involve a long call-graph of async functions. Async/await schedulers also integrates more nicely with blocking native code that is used as a library (i.e. C/C++, Objective C or Rust code). With green threads, you just cannot run this code directly from the virtual thread and if the code is blocking, your life becomes even harder (especially if you don't have access to kernel threads). Even with full control of the runtime, you'd usually end up with a certain amount of overhead for native calls[3]. Considering these trade-offs, async/await is perfect in scenarios like below: 1. JavaScript had multiple implementations. Not only were most of them single-threaded, they would also need a major overhaul to support virtual threads even if a thread API was specified. 2. Rust actually tried green threads and abandoned them. The performance was abysmal for a language that seeks zero-cost abstraction and the system programming requirements for Rust made them a deal breaker even if this wasn't the case. Rust just had to support pluggable runtimes and mandating dynamic stacks just won't work inside the Kernel or in soft real-time systems. 3. Swift had to interoperate with a large amount of Objective C called that was already using callbacks for asynchronous I/O (this is what they had). In addition, it is not garbage-collected language, and it still needed to call a lot of C and Objective C APIs, even if that was wrapped by nice Swift classes. 4. C# already had a Promise-like Task mechanism that evolved around wrapping native windows asynchronous I/O. If .Net was redesigned from scratch nowadays, they could have very well went with green threads, but the way .Net developed, this would have just introduced a lot of compatibility issues for almost no gains. 5. Python had the GIL, as the article already mentioned. But even with patching runtime I/O functions (like greenlet — or more accurately, gevent[4] — did), there were many third party libraries relying on native code. Python just went with the more compatible approach. 6. Java did not have any established standard for asynchronous I/O. CompletableFuture was introuced in Java 8, but it wasn't as widely adopted (especially in the standard library) as the C# Task was. Java also had gauranteed full control of the runtime (unlike JavaScript and Rust), it was garbage collected (unlike Rust and Swift) and it had less reliance on native code than Swift, Pre-.NET Core C# or Python. On the other hand, Java had a lot of crucial blocking APIs that haven't been updated to use CompletableFuture, like JDBC and Servlet (Async Servlets were cumbersome and never caught on). Introducing async/await to Java would mean having to rewrite or significantly refactor all existing frameworks in order to support them. That was not a very palatable choice, so again, Java did the correct thing and went with virtual threads. If you look at all of these use cases, you'd see all of these languages seem to have made the right pragmatic choice. Unless you are designing a new language from scratch (and that language is garbage collected and doesn't need to be compatible with another language or deal with a lot of existing native code), you can go with the ideological argument of "I want my function to be colorless" (or, inversely, you can go with the ideological argument of "I want all suspending functions to be marked explicitly"). In all other cases, pragmatism should win. --- [1] Although it mostly comes to bad composability — checked result types work very well in Rust. [2] https://docs.oracle.com/en/java/javase/17/docs/api/java.base... [3] See the article blelow for the overhead in Go. Keep in mind that the Go team has put a lot of effort into optimizing Cgo calls and reducing this overhead, but they still cannot eliminate it entirely. https://shane.ai/posts/cgo-performance-in-go1.21/ | ||||||||
▲ | brabel 2 days ago | parent | next [-] | |||||||
> What's worse, unlike JavaScript which only has two colors (async and non-async), Java has an infinite number colors! Your comment is great, but I need to point out that the above sentence is misrepresenting Java. You can call any function from a Java function. The fact that you may need to handle an Exception when calling some doesn't make it a "colored" function because you can easily handle the Exception and forget about the color, and if you remember the color problem, it was problematic that colors are infectious, i.e. you just can't get rid of the color, which is not the case in Java. Some claim that's actually bad because it prevents things like structured concurrency (because Java can start a Thread anywhere and there's no way for you to know that a function won't... if there was a "color", or better said, effect, for starting a Thread, you could guarantee that no Thread would be started by a function lacking that effect). | ||||||||
▲ | solidninja 4 days ago | parent | prev | next [-] | |||||||
Thank you for writing this - it is more detailed that I could come up with! I would like to add that I feel like functional approaches are more the "future" of programming than trying to iterate over imperative ones to make them as "nice" to use. So I don't really see the big deal of trying to add-on features to existing languages when you can adopt new ones (or experiment with existing ones e.g. https://github.com/getkyo/kyo for a new take on effects in Scala). | ||||||||
▲ | the_mitsuhiko 6 days ago | parent | prev | next [-] | |||||||
> There is an implied assumption that async/await is a "functional feature" that was pushed into a bunch of innocent imperative languages and polluted them. But there is one giant problem with this assumption: async/await is not a functional feature. If anything, it's the epitome of an imperative flow-control feature. async/await comes from C# and C# got this as an "appoximation" of what was possible with F#. You can go back to 2011 where there are a series of videos on Channel 9 by Anders Hejlsberg where he goes into that. That said, I don't think my post relies on the premise that this is a fight about imperative to functional programming. If anything the core premise is that there is value in being able to yield anywhere, and not just at await points. > If you look at all of these use cases, you'd see all of these languages seem to have made the right pragmatic choice. Potentially, who am I to judge. However that choice was made at a certain point in time and the consequences are here to stay. Other than in JavaScript where it's self evident that this is a great improvement over promise chaining (sans the challenge of unresolved promises), I'm not sure the benefits are all that evident in all languages. I do a fair amount of async programming in JavaScript, Python and Rust and the interplay between threads and async code is very complex and hard to understand, and a lot of the challenges on a day to day would really feel like they are better solved in the scheduler and virtual threads. > Unless you are designing a new language from scratch (and that language is garbage collected and doesn't need to be compatible with another language or deal with a lot of existing native code), you can go with the ideological argument of "I want my function to be colorless" (or, inversely, you can go with the ideological argument of "I want all suspending functions to be marked explicitly"). In all other cases, pragmatism should win. I will make the counter argument: even in some languages with async/await like Python, you could very pragmatically implement virtual threads. At the end of the day in Python for instance, async/await is already implemented on top of coroutines anyways. The "only" thing that this would require, is to come to terms with the idea that the event loop/reactor would have to move closer to the core of the language. I think on a long enough time horizon Python would actually start moving towards that, particularly now that the GIL is going and that the language is quite suffering from the complexities of having two entirely incompatible ecosystems in one place (two sets of future systems, two sets of synchronization directives, two independent ways to spawn real threads etc.). | ||||||||
| ||||||||
▲ | dfawcus 2 days ago | parent | prev [-] | |||||||
On point [3], using the Shane's code, and with an additional one for gccgo, on my laptop I see:
Hence the GccGo version of calling the C function is in the same ballpark as for a native Go function call. This is as to be expected when using that mechanism.So using various C libraries does not necessarily have to involve the overhead from Cgo.
|