OS threads, event loops, goroutines, actors and async/await compared on two measurable tests: a thousand 100 ms waits and four heavy computations. Which model to choose depends on how much you wait and how much you compute.
Concurrency and parallelism: two distinct problems
A cook preparing three dishes moves from the sauté to the oven to the chopping board without ever stopping to wait: that is concurrency, meaning several activities in progress during the same period. Three cooks each working on one dish at the same instant are parallelism, meaning several activities running simultaneously on several units of computation. Confusing the two leads you to pick the wrong tool, because the models in this article solve different problems: some make waiting cheap (network, disk, database), others exploit several cores for computation.
The question to ask before choosing a model is therefore a single one: does my program spend its time waiting or computing? The two tests below make it concrete. Every listing was run (OpenJDK 21.0.13, Node.js 24.11.0, Python 3.14.4) on a machine with 12 logical processors; timings change from one machine to another and should be read as orders of magnitude.
Test 1: a thousand requests that wait 100 ms
A request to an external service occupies the CPU for a few microseconds and then waits. I simulate a thousand requests that each wait 100 ms. If I run them one after the other it takes 100 seconds. The result depends on how many waits I can overlap.
OS threads (Java, C++)
In the most traditional model every activity is an operating system thread: the kernel schedules it and it can run on a different core. A pool of N threads runs at most N waits at the same time. The listing tests a pool of 10, a pool of 200 and, for comparison, one virtual thread per request (the virtual threads of Java 21, covered in the dedicated article).
import java.util.concurrent.*;
// 1000 simulated requests, each one "waits" 100 ms (a network call, a database).
public class Attese {
static long esegui(ExecutorService exec) throws Exception {
long t0 = System.nanoTime();
try (exec) {
for (int i = 0; i < 1000; i++) {
exec.submit(() -> { Thread.sleep(100); return null; });
}
}
return (System.nanoTime() - t0) / 1_000_000;
}
public static void main(String[] args) throws Exception {
System.out.println("pool of 10 OS threads: " + esegui(Executors.newFixedThreadPool(10)) + " ms");
System.out.println("pool of 200 OS threads: " + esegui(Executors.newFixedThreadPool(200)) + " ms");
System.out.println("one virtual thread per request: " + esegui(Executors.newVirtualThreadPerTaskExecutor()) + " ms");
}
}
// Output:
// pool of 10 OS threads: 10071 ms
// pool of 200 OS threads: 590 ms
// one virtual thread per request: 189 ms
With 10 threads the thousand waits take about 10 seconds (1000 divided by 10, times 100 ms), with 200 threads about 0.6 seconds (5 rounds of 100 ms, plus the cost of creating the threads), and with one virtual thread per request 189 ms, just above a single wait. The constraint is the number of OS threads: each has its own stack and its own scheduling cost in the kernel, so the pool has to be sized with care. It is the simplest model to read, because the code is sequential, and it remains the natural choice for computation, as test 2 shows.
Event loop on a single thread (JavaScript)
JavaScript has a single thread of execution and an event loop: network operations and timers are delegated to the operating system and their callbacks come back into the thread when they are ready (the mechanism is explained in event loop and microtasks). A thousand waits are a thousand timers in flight, not a thousand threads.
// The same 1000 requests, a single JavaScript thread: the waits overlap.
const attesa = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
const t0 = performance.now();
await Promise.all(Array.from({ length: 1000 }, () => attesa(100)));
console.log(`event loop, one thread: ${Math.round(performance.now() - t0)} ms`);
}
main();
// Output:
// event loop, one thread: 107 ms
Promise.all waits until all thousand promises resolve; since the timers run in parallel
in the kernel, the total is 107 ms, a few milliseconds above a single wait. The advantage is scalability on waiting
with one thread and no locks: no race on the program's variables, because each callback runs
to the end before the next one. The price is the other side of the same coin: a heavy computation
blocks everyone, as test 2 shows.
Async/await with a runtime (Python, Rust)
Python and Rust use the same idea with different syntax: async functions yield control at every
await and an explicit runtime decides who resumes. In Python that is asyncio; in Rust the language
includes no runtime and you pick one, for example Tokio (see
Rust async and Tokio).
import asyncio
import time
async def richiesta() -> None:
await asyncio.sleep(0.1)
async def main():
t0 = time.monotonic()
async with asyncio.TaskGroup() as tg:
for _ in range(1000):
tg.create_task(richiesta())
print(f"asyncio, one thread: {(time.monotonic() - t0) * 1000:.0f} ms")
asyncio.run(main())
# Output:
# asyncio, one thread: 113 ms
Same result as the JavaScript event loop: 113 ms for a thousand waits on one thread. The TaskGroup structure
guarantees that the thousand requests are finished or cancelled when the block exits (see
advanced asyncio). The consequence of a cooperative
model is that a task that does not await for a long time holds up everything else: the loop yields only where
you tell it to.
Test 2: four heavy computations
Now the work is pure computation: a loop of 1.5 billion iterations, repeated for four identical tasks. There is nothing to overlap here, only work to divide among cores. The first listing measures one task alone and then four tasks on four OS threads.
import java.util.concurrent.*;
// CPU work: 4 identical tasks. With 4 OS threads they can run on 4 cores together.
public class Calcolo {
static long lavoro() {
long s = 0;
for (long i = 0; i < 1_500_000_000L; i++) s += i % 7;
return s;
}
public static void main(String[] args) throws Exception {
long t0 = System.nanoTime();
lavoro();
long uno = (System.nanoTime() - t0) / 1_000_000;
System.out.println("1 task, 1 thread: " + uno + " ms");
t0 = System.nanoTime();
try (ExecutorService exec = Executors.newFixedThreadPool(4)) {
for (int i = 0; i < 4; i++) exec.submit(Calcolo::lavoro);
}
System.out.println("4 tasks, 4 threads: " + (System.nanoTime() - t0) / 1_000_000 + " ms");
}
}
// Output:
// 1 task, 1 thread: 2021 ms
// 4 tasks, 4 threads: 3282 ms
One task alone takes about 2.0 seconds, four tasks on four threads about 3.3 seconds: run in series they would have taken about 8.1 seconds (2.0 times four), so the parallelism worked, though less than perfectly. I cannot say for certain why it differs from the ideal case (2.0 seconds): it may depend on core frequency or on JIT compilation, and I did not investigate. The order of magnitude is clear: more OS threads, more cores used.
// The same 4 CPU tasks on the JavaScript thread alone: they run one after the other.
function lavoro() {
let s = 0;
for (let i = 0; i < 1_500_000_000; i++) s += i % 7;
return s;
}
let t0 = performance.now();
lavoro();
console.log(`1 task: ${Math.round(performance.now() - t0)} ms`);
t0 = performance.now();
for (let i = 0; i < 4; i++) lavoro();
console.log(`4 tasks, one thread: ${Math.round(performance.now() - t0)} ms`);
// Output:
// 1 task: 3250 ms
// 4 tasks, one thread: 12070 ms
On the single JavaScript thread, four tasks take about 12 seconds, almost four times one task alone (3.25
seconds): no parallelism. This is the limit behind the saying "Node.js is no good for computation", and it is true only
for the main thread: Node provides worker_threads to move computation onto other
threads. I did not try the workers: I only measured the limit.
With asyncio, which also runs on a single thread, the same reasoning applies.
Goroutines and actors: two models without a run example
I do not have the Go or Elixir toolchains on the machine, so I give no listings: what follows comes from the official documentation. In Go a goroutine is, according to Effective Go, a function that runs concurrently with others in the same address space; it is lightweight and costs little more than the space for its stack, which starts small and grows by allocating memory from the heap. The runtime multiplexes them onto OS threads: when one blocks, the others carry on. Goroutines communicate through channels (the CSP model), as explained in goroutines and channels.
In the actor model of Erlang and Elixir each actor is a process with private state that communicates only through messages, with no shared memory. The official Elixir guide says processes are extremely lightweight in memory and CPU, even compared to threads, so much so that having tens or hundreds of thousands of processes running together is not unusual. The Erlang documentation specifies that a newly created process uses 327 words of memory (OTP 27). The advantage is isolation: if one process breaks it does not drag the others down, and that is the basis of supervision. The cost is that data is copied between processes and that debugging many actors exchanging messages is harder.
Which model for which problem
| Type of work | Suitable models | Why |
|---|---|---|
| Many I/O waits (APIs, databases) | Event loop, async/await, goroutines, virtual threads | The waits overlap without one thread for each (test 1) |
| Heavy computation | OS threads, goroutines, processes | You need several cores (test 2); the event loop alone is not enough |
| Isolation and fault tolerance | Actors (Erlang/Elixir) | Each process has private state and a failure does not propagate |
| Team used to sequential code | OS threads, virtual threads | The code stays blocking and readable |
The table is not a hierarchy: each row answers the question "what does my program do all day?". The models combine: a Node service serves requests on an event loop and hands computation to workers; a Java service uses virtual threads for waits and a pool sized to the cores for computation. The first thing to do, before choosing, is to measure where the time of a typical request goes.
What you can do now
You can tell concurrency from parallelism, and you can recognise whether a workload is dominated by waiting or by computation. You have seen in numbers that a thousand 100 ms waits cost 10 seconds with 10 threads and about 0.1-0.2 seconds with an event loop or with virtual threads, and that four computations on a single JavaScript thread cost almost four times one. You also know that Go and Elixir offer alternative models, for which you find sources here but no test.
The next article opens up the event loop model in detail: how the JavaScript event loop really works. For the typical failure of all these models, see race conditions and deadlocks. For the opposite route, avoiding locks altogether, see lock-free data structures. For concurrency between machines instead of between threads, see distributed systems.
Sources
- Effective Go - the goroutine as a concurrent function in the same address space, cost a little more than the stack, which grows from the heap, accessed on 2026-11-12
- Processes (Elixir guide) - extremely lightweight processes, tens or hundreds of thousands together, accessed on 2026-11-12
- Erlang Efficiency Guide: Processes - a newly created process uses 327 words of memory (OTP 27), accessed on 2026-11-12
- The Node.js Event Loop - non-blocking I/O with a single JavaScript thread, accessed on 2026-11-12
- JEP 444: Virtual Threads - virtual threads scheduled by the JVM onto a small number of OS threads, accessed on 2026-11-12
The timings (10,071 ms, 590 ms, 189 ms, 107 ms, 113 ms, 2,021 ms, 3,282 ms, 12,070 ms) come from a single run on a machine with 12 logical processors; they are orders of magnitude, not benchmarks.
Commenti
Caricamento commenti...
Sign in to comment