Threadsstd::threadevent loopNode.jsbrowserWeb Workers

How C++, JavaScript and Python Map onto the OS

C++ exposes OS threads directly; JavaScript hides them behind an event loop per realm and reaches the OS through the runtime’s own threads; CPython wraps OS threads but serialises bytecode with the GIL in its default build — three different contracts over the same kernel.

C++BrowserNode.jsCPythonRuntime-specific
▶ InteractiveInterview question
Progress

The problem

The same kernel offers the same clone, epoll and futex to every language. Yet a C++ programmer thinks in threads and mutexes, a JavaScript programmer in promises and never sees a thread, and a Python programmer is told threads "do not use multiple cores". What does each runtime actually build on top of the OS, and where do the folk rules stop being true?

C++: the OS, thinly wrapped

C++

std::thread (and std::jthread) is a kernel thread: on Linux it is pthread_createclone(), on Windows CreateThread. std::mutex is a futex-based lock (uncontended lock/unlock never enters the kernel; a contended one sleeps the thread in the kernel), std::condition_variable is a futex wait queue, std::atomic compiles to lock-prefixed instructions and memory fences with the ordering you specify. There is no runtime scheduler between you and the OS: if you start 1,000 threads, the kernel has 1,000 tasks.

Asynchrony is opt-in and library-shaped. std::async returns a std::future and may run the task on a new thread. Real async I/O comes from libraries — Boost.Asio / standalone Asio (a reactor over epoll/kqueue/IOCP), libuv, or a direct io_uring ring — and C++20 coroutines give the language a way to express suspension without a thread, which those libraries use as the task representation. The blessing and the curse are the same: everything is visible, everything is yours to get wrong. See Mutexes and Atomic Operations.

Native threads, a mutex, an atomic and a future
1#include <thread>
2#include <mutex>
3#include <atomic>
4#include <future>
5#include <vector>
6
7std::mutex m; std::vector<int> results; // protected by m
8std::atomic<int> done{0}; // lock-free counter
9
10int main() {
11 auto fut = std::async(std::launch::async, [] { return 42; }); // maybe a new thread
12 std::vector<std::jthread> pool;
13 for (int i = 0; i < 8; ++i)
14 pool.emplace_back([i] { // 8 kernel threads, 8 cores if available
15 int r = i * i;
16 { std::lock_guard<std::mutex> lk(m); results.push_back(r); }
17 done.fetch_add(1, std::memory_order_release);
18 });
19 int answer = fut.get(); // blocks this thread until ready
20 // jthreads join in their destructors
21}

JavaScript in the browser

Browser

A browser tab’s JavaScript runs in a realm with one call stack and one event loop; within that realm only one piece of JavaScript executes at a time. That is the precise statement behind "JavaScript is single-threaded" — it describes the execution model of one realm, not the process. The browser process is heavily multi-threaded: network fetches, image decoding, layout, compositing, timers and IndexedDB run on other threads, and their completions are posted to the realm’s task queue, where the event loop picks them up between JavaScript runs.

To run JavaScript in parallel you create another realm: a Web Worker gets its own thread, its own event loop and its own global object, and communicates by postMessage (structured clone, i.e. a copy) or by SharedArrayBuffer + Atomics (genuine shared memory, with all the hazards of Race Conditions, which is why it is gated behind cross-origin isolation headers). The event loop’s exact ordering — tasks, microtasks, requestAnimationFrame, rendering — is specified by HTML, and The Event Loop goes through it.

JavaScript in Node.js

Node.js

Node runs one V8 isolate on the main thread with an event loop provided by libuv. Sockets, pipes and timers are multiplexed by libuv on that thread using epoll (Linux), kqueue (macOS/BSD) or IOCP (Windows) — no extra threads needed for 10,000 idle connections. But file system calls, dns.lookup (which calls getaddrinfo), zlib, and crypto.pbkdf2/randomBytes have no portable non-blocking API, so libuv runs them on a thread pool of 4 threads by default (UV_THREADPOOL_SIZE, up to 1024). Node is therefore a process with at least 5 kernel threads plus V8’s own GC and compiler helper threads; the *JavaScript* of one isolate is single-threaded.

worker_threads create additional isolates on additional kernel threads in the same process, each with its own loop and heap; they share memory only through SharedArrayBuffer. cluster forks whole processes that share a listening socket. The classic Node scaling story — one process per core — is Process versus Thread applied: isolates cannot share the JS heap, so processes lose little by being processes.

Where each line actually runs in Node
1import { readFile } from 'node:fs/promises'
2import { Worker } from 'node:worker_threads'
3import { createServer } from 'node:http'
4
5createServer((req, res) => res.end('ok')).listen(8080) // sockets: epoll/kqueue on the loop thread
6
7const cfg = await readFile('config.json', 'utf8') // fs: libuv thread pool (4 threads)
8
9const w = new Worker('./hash-worker.js') // new V8 isolate on a new kernel thread
10w.postMessage({ data: cfg }) // structured clone: a copy, not shared
11
12setTimeout(() => console.log('tick'), 0) // timer phase of the libuv loop
13await Promise.resolve() // microtask: runs before that timer

Python: threads, processes, asyncio and the GIL

CPython

threading.Thread is a real kernel thread. In CPython’s default build the interpreter holds a global interpreter lock while executing Python bytecode, so at most one thread runs bytecode at a time; the interpreter drops the lock around blocking system calls and inside many C extensions (NumPy, I/O, compression), and forces a switch every 5 ms (sys.setswitchinterval). The consequence is precise: Python threads give full concurrency and full parallelism for I/O and for extension code that releases the GIL, and no parallelism for pure-Python CPU work. "Python cannot use multiple cores" is false as stated — a threaded NumPy workload will happily peg 16 cores — and true only for bytecode in that build.

The picture is changing and varies by implementation. CPython 3.13 shipped an optional free-threaded build (PEP 703, python3.13t) with no GIL, and subsequent releases continue to stabilise it; 3.12 added per-interpreter GILs for sub-interpreters (PEP 684). PyPy has a GIL; Jython and IronPython never did; GraalPy and others differ again. When you read "the GIL", ask "which interpreter, which version, which build".

`multiprocessing` sidesteps all of it by running separate interpreter processes: on Linux by fork (fast, inherits state, dangerous with threads), on macOS and Windows by spawn (a fresh interpreter, arguments pickled). Each process has its own GIL, so pure-Python CPU work scales with cores; the price is IPC by pickling, no shared objects without shared_memory, and higher memory. `asyncio` is the event loop: one thread, coroutines suspended at await, sockets multiplexed by selectors (epoll/kqueue) — concurrency without threads for I/O-bound work, and run_in_executor to push blocking or CPU-bound calls to a thread or process pool.

Choosing by workload in CPython
1import asyncio, concurrent.futures as cf, hashlib, threading
2
3def cpu_work(n: int) -> str: # pure-Python loop + C hashing
4 return hashlib.sha256(b"x" * n).hexdigest() # sha256 releases the GIL in C
5
6async def main():
7 loop = asyncio.get_running_loop()
8 # I/O-bound: coroutines on one thread, thousands are fine
9 reader, writer = await asyncio.open_connection("example.org", 80)
10 # blocking library call: thread pool; the GIL is released while it blocks
11 data = await loop.run_in_executor(None, some_blocking_client.fetch, "key")
12 # pure-Python CPU-bound: process pool, one GIL per process
13 with cf.ProcessPoolExecutor() as pool:
14 digests = await asyncio.gather(*(loop.run_in_executor(pool, cpu_work, n) for n in range(8)))
15
16asyncio.run(main())

The three contracts side by side

Runtime-specific

The kernel primitives are identical; what differs is which ones the runtime exposes, which it hides, and what it promises about ordering. Use the table to translate between them and to spot which folk rule applies to which runtime.

Runtime → OS mapping
C++JavaScript (browser / Node)Python (CPython default build)
Unit of concurrencyKernel thread; coroutine with a libraryTask/microtask on a realm’s event loopThread, coroutine (asyncio), or process
Parallel bytecode/JS in one processYesOnly via workers (separate realms)No for bytecode (GIL); yes in the free-threaded build
Parallelism for native codeYesRuntime threads (libuv pool, V8 helpers) — not your JSYes when the extension releases the GIL
Async I/O mechanismLibrary: Asio/libuv/io_uring/IOCPlibuv: epoll/kqueue/IOCP; browser: its network stackselectors: epoll/kqueue; ProactorEventLoop (IOCP) on Windows
Blocking a loop threadNo loop unless you build oneStalls every task in that realmStalls every coroutine on that loop
Shared mutable memoryEverything; you synchroniseSharedArrayBuffer + Atomics onlyThreads share objects (GIL makes single ops atomic); processes need shared_memory
Multi-core recipeThreads up to core countWorkers or cluster processesProcesses, or C extensions/free-threaded build

Key points

  • C++ threads, mutexes and atomics are the OS primitives with a thin wrapper; async I/O is a library choice (Asio, libuv, io_uring, IOCP).
  • "JavaScript is single-threaded" is true of one realm’s execution; browsers and Node are multi-threaded processes that post completions to the realm’s task queue.
  • Node multiplexes sockets with epoll/kqueue/IOCP on the loop thread and runs file I/O, DNS lookup, zlib and crypto on a 4-thread libuv pool; workers are extra isolates on extra threads.
  • In CPython’s default build the GIL serialises bytecode, not I/O or GIL-releasing C code; the free-threaded build removes it, and other Python implementations differ.
  • multiprocessing gives CPU parallelism at the price of IPC; asyncio gives I/O concurrency on one thread; run_in_executor bridges to pools.
  • The kernel is the same underneath; the runtime decides which primitives you see and what blocking costs.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why did JavaScript choose an event loop instead of threads?

It was designed for a UI where the DOM must not be mutated by two threads at once; one loop per realm makes every DOM access race-free by construction, and workers give parallelism without sharing the DOM.

Why does CPython have a GIL?

Reference counting on every object would need an atomic operation per increment without it; one lock made single-threaded code fast and C extensions simple. Removing it (PEP 703) required biased reference counting and per-object locks to keep that speed.

Why does Node need a thread pool if it is asynchronous?

Because portable operating systems offer readiness notification for sockets but not for regular file reads; the only way to make readFile non-blocking for the loop is to block a different thread.

How runtimes use OS threads

How runtimes use OS threads
The kernel only knows threads. Every runtime decides how many to ask for and what to put on them.
Node.js runtimeepoll/kqueue/IOCP via libuvOS threadevent loopKernel scheduler → 1 core used
Node.js
100 network calls = 100 sockets registered with epoll (Linux) / kqueue (macOS) / IOCP (Windows) through libuv. One OS thread, no blocking — this is the C10k design.
"JavaScript is single-threaded" means your script runs on one thread; the runtime around it (V8 GC, libuv pool, browser network stack) uses many. "Python cannot use multiple cores" is false: CPython threads cannot run bytecode in parallel; processes and GIL-releasing extensions can.

How it fails

What the failure looks like from inside real software.

  • A Node service doing many fs calls or dns.lookups stalls at 4 concurrent operations: the libuv pool is the bottleneck, not the loop; raise UV_THREADPOOL_SIZE or use fewer blocking calls.
  • A CPython service parallelises pure-Python parsing with ThreadPoolExecutor and gets no speed-up; the same code with ProcessPoolExecutor scales — the GIL.
  • A multiprocessing script works on Linux and fails on macOS/Windows with a pickling error: fork inherited state that spawn must serialise.
  • A C++ program uses std::async in a loop expecting a pool and creates a thread per call; the process dies of EAGAIN at the thread limit.
  • A browser page runs a 2-second computation on the main realm: rendering, clicks and timers all freeze, because they share that realm’s loop.
  • A Python asyncio handler calls requests.get (blocking) and every other coroutine waits; the fix is an async client or run_in_executor.