The three languages handle concurrency in fundamentally different ways. CPython serializes multithreaded execution behind an interpreter-wide lock (the GIL). Java mapped its threads 1:1 to the OS for years and only recently added virtual threads to multiplex them. Go shipped from day one with an M:N scheduler running goroutines on top of OS threads. On the same backend request-handling workload, the model decides whether you build a thread-per-request service, a reactive one, or a multiprocess one.
Python GIL
CPython’s GIL (Global Interpreter Lock) is a lock that protects the interpreter itself. At any moment, only the thread holding the GIL is executing Python bytecode.
sequenceDiagram
participant T1 as Thread 1
participant GIL as GIL
participant T2 as Thread 2
T1->>GIL: acquire
Note over T1: running bytecode
T1->>GIL: release (interval hits)
T2->>GIL: acquire
Note over T2: running bytecode
T2->>GIL: release
T1->>GIL: acquire
The implication is plain: pure-Python CPU-bound work multithreaded across cores only uses one core. The GIL is released during I/O calls (socket reads, DB queries, file I/O), so multithreading still helps I/O-bound workloads. NumPy and many C extensions also drop the GIL inside their compute routines.
For real CPU parallelism, you need a workaround.
- multiprocessing — Spawn separate processes. Each has its own GIL. IPC (Inter-Process Communication) has a cost; memory is isolated.
- asyncio — Single-threaded event loop with cooperative scheduling. Handles thousands of concurrent I/O operations without threads. The GIL is irrelevant.
- Release the GIL in C extensions — Push CPU-heavy parts into C and use
Py_BEGIN_ALLOW_THREADSinside.
Python 3.13 introduced a free-threaded build as an experiment (PEP 703). Build the interpreter with --disable-gil and the GIL is gone. PEP 779 (2025) moved it to a supported phase, but the default build still ships the GIL. The library ecosystem needs time to be validated as free-thread-safe.
Java Virtual Threads
Traditional Java threads map 1:1 to OS threads. Each Thread object holds a kernel thread, with a default stack of 1MB (tunable via -Xss) and kernel-scheduled context switches. Spinning up thousands of them eats memory and scheduling time quickly.
That cost is why thread-per-request lost ground to reactive frameworks (Spring WebFlux, Vert.x) for a while. The simple “one thread per request” model collapsed under traffic.
Java 21 made virtual threads GA (JEP 444). A virtual thread is JVM-managed and lightweight; many virtual threads multiplex onto a small set of carrier threads (the actual OS threads).
block-beta
columns 4
v1["VT 1"]
v2["VT 2"]
v3["VT 3"]
vn["VT N (10⁴+)"]
space:4
c1["Carrier
(OS thread)"]
c2["Carrier"]
c3["Carrier"]
c4["Carrier
(usually # of cores)"]
style v1 fill:#C8E6C9
style v2 fill:#C8E6C9
style v3 fill:#C8E6C9
style vn fill:#C8E6C9
style c1 fill:#90CAF9
style c2 fill:#90CAF9
style c3 fill:#90CAF9
style c4 fill:#90CAF9
The heart of the design is how blocking I/O is handled. When a virtual thread hits a blocking call like Socket.read, the JVM unmounts it from its carrier so another virtual thread can use that carrier. Once the I/O completes, the virtual thread is mounted onto any available carrier and resumes. The mechanism is called a continuation.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
// blocking I/O — the carrier is freed during the wait
response = httpClient.send(request, BodyHandlers.ofString());
return response.body();
});
});
}
Tens of thousands of virtual threads run naturally. The code model is the familiar thread-per-request shape, but the cost profile is different. The catch: CPU-heavy work and synchronized blocks pin the carrier, blocking other virtual threads from using it. Java 24’s JEP 491 lifts some of that restriction.
Go Goroutines
Go was designed for M:N from the start. A goroutine is a runtime-managed lightweight thread with a 2KB initial stack that grows as needed. The go keyword launches one, and hundreds of thousands can coexist in one address space.
for i := 0; i < 100_000; i++ {
go func(id int) {
// I/O or CPU work
}(i)
}
The Go runtime multiplexes goroutines onto a small set of OS threads via the GMP scheduler. G is a goroutine, M is an OS thread, P is a logical processor (controlled by GOMAXPROCS, defaulting to the core count). When a goroutine blocks in a system call, the runtime moves the rest of its P’s queue onto another M to keep work flowing.
flowchart TB
subgraph runtime["Go Runtime"]
P1["P
(local queue)"]
P2["P
(local queue)"]
GRQ["Global queue"]
end
subgraph os["OS"]
M1["M (OS thread)"]
M2["M (OS thread)"]
end
P1 --> M1
P2 --> M2
GRQ -.-> P1
GRQ -.-> P2
The other half of Go’s concurrency story is CSP(Communicating Sequential Processes). “Do not communicate by sharing memory; instead, share memory by communicating.” Channels are the preferred primitive; mutexes from sync exist but channels are idiomatic.
Lightweight goroutines plus automatic scheduling make a thread-per-request shape comfortable at huge concurrency. Pitfalls like carrier-thread pinning are rare, which keeps the operational model simple. The deeper mechanics of GMP and channels live in a separate post.
Three Models Compared
Where the three models lead on the same backend request-handling workload:
| Aspect | Python | Java | Go |
|---|---|---|---|
| Unit of concurrency | Thread (serialized by GIL) | Platform thread / Virtual thread | Goroutine |
| Cost per unit | OS thread (MB) | Platform: MB / Virtual: KB | KB (starts at 2KB) |
| Multicore CPU-bound | Needs multiprocessing | Platform: yes / Virtual: yes (pinning aside) | Native |
| Multicore I/O-bound | asyncio or threads (GIL released) | Virtual-thread friendly | Goroutine friendly |
| Synchronization | Lock, asyncio primitives | synchronized, java.util.concurrent | Channels (CSP) + sync |
| Operational practice | asyncio + multiprocessing mix | Java 21+ virtual-thread migrations | Thread-per-request by default |
Python concurrency fits when:
- The workload is I/O bound and asyncio’s single-thread event loop is enough
- CPU-heavy work is well isolated and can run in multiprocessing or C extensions
- The service is small or script-shaped
Java virtual threads fit when:
- You want the familiar thread-per-request model at tens of thousands of in-flight requests
- You’re reusing the JVM ecosystem (JDBC drivers, HTTP clients) as is
synchronizedblocks and CPU-heavy regions are a small share of total time
Go goroutines fit when:
- You’re starting a new service with concurrency as a first-class assumption
- You want a single algorithm and few tuning decisions
- Channel-based communication matches the domain
The three models stand on different foundational decisions for the same multicore environment. Python kept the GIL’s simplicity and standardized the workarounds. Java carried OS-thread cost for years before adding virtual threads. Go went lightweight from the start. The same request-handling pattern lands as thread-per-request, reactive, or multiprocess depending on which foundation you start from.
References
- Everything Is Pass by Value — Memory Transfer in Go, Java, Python — The shared memory model that decides how data moves between concurrent units.
- Three Garbage Collectors — How GC differences show up as STW pauses in multithreaded workloads.
- Go Concurrency Model — Deeper coverage of goroutines, the GMP scheduler, and channels.