← Back to context

Comment by fzeindl

5 hours ago

> Async is not I/O. It can be used for I/O, but it's just a way of writing pure, deterministic state machines that keeps a logical flow together instead of scattered over a bunch of transitions.

I argue that it is exactly opposite. Blocking threads are a much more natural way of thinking about concurrency.

Quote from the erlang creator Joe Armstrong's thesis. He argues that the only way to understand multi-threaded programs is writing strictly sequential code for every thread and not muddling all the code for all the threads in one place:

"The structure of the program should exactly follow the structure of the problem. Each real world concurrent activity should be mapped onto exactly one concurrent process in our programming language. If there is a 1:1 mapping of the problem onto the program we say that the program is isomorphic to the problem. It is extremely important that the mapping is exactly 1:1. The reason for this is that it minimizes the conceptual gap between the problem and the solution. If this mapping is not 1:1 the program will quickly degenerate, and become difficult to understand. This degeneration is often observed when non-CO languages ["non concurrency-oriented", looking at you JavaScript!] are used to solve concurrent problems. Often the only way to get the program to work is to force several independent activities to be controlled by the same language thread or process. This leads to a inevitable loss of clarity, and makes the programs subject to complex and irreproducible interference errors." [0]

[0] https://erlang.org/download/armstrong_thesis_2003.pdf

Your comment is phrased as a rebuttal, but I actually agree with it wholeheartedly, with one minor quibble.

> Blocking threads are a much more natural way of thinking about concurrency.

The (ideal) programming model for writing a thread vs an async task is identical, and indeed the most natural way of writing sequential flows that may incidentally be temporally separated. A blocking operation and an await are equivalent (in the absence of shared memory; in its presence they differ in that an async task on a single thread gets to assume atomicity of the code between awaits, but that's not too relevant here). I think what Joe is arguing against here is exactly splitting up your business flows into a bunch of explicit state machine transitions.

The difference is only in the caller. Callers of async tasks (à la Rust) have the right and responsibility of controlling how (and whether) the task they create resumes, while threads/BEAM processes (and ‘stackful’ futures à la Go, JavaScript, etc.) are answerable only to the great global event loop in the sky (sometimes known as the OS scheduler or the BEAM multiplexer, etc.). This makes it easier to avoid bugs that are about forgetting to drive a task, but harder to avoid bugs that are about forgetting to stop a task, or stopping a task wrongly; see structured concurrency. It also hurts composability a bit, because by construction there can be only one event loop and there's no way to decompose it.

You can see Erlang (or I suppose we should talk about BEAM more generally now people like Elixir) in terms of OS processes as an analogy, but it's just as valid and perhaps a little more accurate to think about it as a pool of async tasks with a global event loop, à la Go, where all blocking-looking code is actually async. It shares a common problem of such languages that because it can't interrupt real blocking code, e.g. FFI, you have to be very careful not to accidentally starve your other processes with such a call. This is made more obvious (but not really fixed) by languages that colour async functions differently, because the async colour acts as a promise (sadly not enforced by the language) that the function will be a good neighbour and not block for too long (whatever that means).

It's interesting that we've gone from cooperative to preemptive to cooperative multitasking again.

I agree that blocking threads is generally more natural / obvious. Cooperative multitasking was always done for performance reasons and more recently for simplicity (in single thread implementations with no locking) and/or scaling reasons.

Message passing concurrency is going even further though, and never really seems to have taken off wholistically. Again, primarily for performance reasons. When we do pass messages (across networks, too/from RAM or disk, etc) we usually add caching and other optimizations which make concurrency even harder to reason about.

  • Message-passing is popular in HPC (e.g. MPI) because it more closely models how hardware works. But at a higher level people mostly prefer to build abstractions on top of it like async/await because message-passing requires you to manually compile your flows into per-process state machines, and that's not how most people think about flows (even though business people do sometimes: https://link.springer.com/chapter/10.1007/978-3-540-72863-4_...).

  • Message passing on virtual threads on the JVM allows you to run millions of threads and it has no performance downside to async.

    • Every copy is a performance downside. There's plenty of applications where it is essential to use shared memory. You could not rewrite Linux to avoid shared memory between threads.

      The line is fuzzy though.

      You might say that each RAM access is really a message over a bus, and so is each cache access. Conversely, you could write a message passing style key value store and create race conditions by using like shared memory.

      Transactional memory is also 'message passing' like -> you bundle up access of shared memory into a single transaction 'message'.

      A lot of shared memory code uses constructions like FIFO/queues which are really not too distinct from message passing except when ownership is shared or goes back and forth. Perhaps that's just an 'implementation' of message passing.

      2 replies →

  • > wholistically

    You made me curious about the spelling of 'holistic', given that it really is based on the concept of wholeness. It turns out:

    - Holistic is formed from the Greek root hol-, meaning "whole". Like hologram.

    - It's even closer to the English than it looks; modern whole descends from Old English hal (cognate to the Greek word), which also gives us hale and health. The W in the modern spelling is a spurious addition unrelated to the etymology of the word. You'd expect "hole".

    - Who is different, and the W in that word is etymologically correct. (Old English hwa.)