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_THREADS inside.

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:

AspectPythonJavaGo
Unit of concurrencyThread (serialized by GIL)Platform thread / Virtual threadGoroutine
Cost per unitOS thread (MB)Platform: MB / Virtual: KBKB (starts at 2KB)
Multicore CPU-boundNeeds multiprocessingPlatform: yes / Virtual: yes (pinning aside)Native
Multicore I/O-boundasyncio or threads (GIL released)Virtual-thread friendlyGoroutine friendly
SynchronizationLock, asyncio primitivessynchronized, java.util.concurrentChannels (CSP) + sync
Operational practiceasyncio + multiprocessing mixJava 21+ virtual-thread migrationsThread-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
  • synchronized blocks 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