> One comment, though: it's not an apples-to-apples comparison! (I'm talking about your gist.) Specifically, in Elixir, you should create the state machine yourself (very easy in Elixir, thanks to pattern-matching definitions of functions) and schedule it across a few processes manually. You'd need ~100loc for this, and you'd get results much closer to C# and Rust.
Hmm, I can't see myself agreeing to this. Each state machine would have to be hand-rolled manually, or another abstraction for that would need to be introduced. It becomes a lot of manual work very quickly. Or it becomes a callback hell. Whichever you prefer, both are among reasons behind .NET pioneering async/await and its later adoption across other languages. I am unsure about scheduling cost too - significant amounts of application logic implemented in pure Erlang or Elixir will be subject to interpreter-tier performance which is unlikely to match the performance of JIT/AOT-compiled languages. BEAM has excellent allocation throughput thanks to per-process GCs, but not the raw speed of code execution.
The comparison was linked as an addendum to the discussion, didn't intend to make it the main focus :) But if you're interested in the context - the initial idea behind the comparison was that I got tired of hearing a colleague giving unjustified praise to Go. Especially when it comes to highly-granular interleaving of concurrent operations (for the lack of better term).
And while yes, BEAM processes and Goroutines belong to category of stackful coroutines with preemptive userspace scheduling, which has very different cost model when compared to how task continuations are handled by .NET's threadpool and task scheduler implementations, one of the most common ways of using `go func(...` in Golang is as if it was a task/future, where one or multiple goroutines are fired once to yield one or multiple results collected via channel/slice/some other collection + WaitGroup. In Go, the users do not have an alternative to this for highly granular ad-hoc concurrency save for re-implementing an async/await or fork/join-like API themselves that leverages a custom pool of goroutines and yield/join primitives.
So for all intents and purposes the numbers indicate the user experience when someone wants to spawn a lot of independently ran operations and then wait for them all to complete. This overhead is real, albeit pushing the task/goroutines/process count to 1 million is going to be very uncommon.
It's probably on the opposite end to what would idiomatic Erlang or Elixir code look like, but below is a popular pattern that relies on this:
using var http = new HttpClient {
BaseAddress = new("https://news.ycombinator.com")
};
// Tasks are hot-started, making the requests parallel
var page1 = http.GetStringAsync("?p=1");
var page2 = http.GetStringAsync("?p=2");
Console.WriteLine(await page1 + await page2);
This way, you can also map e.g. an array of elements into a sequence of tasks, which is then fed into Task.WhenAll which awaits all of them and returns an array of results. This is what the linked comparison measures. You can easily spawn thousands of requests awaited concurrently and the runtime will scale with this very well (also because Socket has efficient epoll/kqueue integration underneath).
> On the other hand, BEAM processes do the "preemptive userspace scheduling,"
Perhaps you meant "processes are scheduled preemptively"? My understanding is that processes are the main scheduling unit of BEAM, much like Goroutines are in Go (which Go has an explicit mechanism for suspension mid-execution).
> It would be nice to expand the comparison: Java got virtual threads lately, I'm curious how that would fare - closer to C# or BEAM? Throw in Kotlin's coroutines (probably closer to C#) and Python's asyncio (or Lua native coroutines, but then you need to write the scheduler) for a good measure... We could see how concurrency primitives differ in overhead across more languages.
The Java way of solving this will be using its Structured Concurrency which is currently in an incubator. I did not add Python because Python example was attempted by a colleague and it was impossibly slow, and there are multiple ways of going about it. If you care about performance, especially in multi-tasking, then using Python at all is a terrible idea. I mostly focused on the languages that put concurrency and parallelism at their forefront, and without making it a proper automated comparison with controlled environment I don't think the numbers will be very useful, it's also more tedious than it looks unfortunately.
> An interesting note about F# `async { }` - I thought this one also builds coroutines through CPS transform; it should be on par with Task regarding overhead.
F#'s Async (and async { } blocks that express it) is the original source of where async/await comes from. It is implemented via F#'s "Computation Expressions"[0] as a form of do-notation. It is only afterwards that C# got its own async/await implementation. For all intents and purposes both achieve the same goal and both implement a stackless coroutine. F#, however, keeps its core netstandard2.0-compatible and overall runtime-agnostic making it unable to use the APIs that are available for newer targets.
As measured, it appears to be much preferable to use task { } blocks instead, which have significantly lower heap size impact, and are similar in their performance to C#'s async methods. It's important to note that async { } and task { } blocks have different semantics w.r.t. hot vs cold start, multiple awaits/result caching and explicit vs implicit cancellation propagation.
[0]: https://learn.microsoft.com/en-us/dotnet/fsharp/language-ref...