Go, Rust, Python and Node concurrency: what I measured on 16 cores
By Raphael Discky
What I measured, and on what
I had opinions about concurrency in Go, Rust, Python and Node, but I had not measured any of them. So I built the same four problems in each language: counting primes on 16 cores, fanning out to hundreds of network calls open, slowing down a producer that outruns its consumer, and sharing a counter between workers.
Those four problems ran on one machine: 16 cores, Linux 7.0, Go 1.26.4, rustc 1.97.1,
Node 24.13.0, and CPython 3.14.5. Python 3.14 is the first release that treats the
free-threaded build as a supported feature rather than an experiment
(PEP 779), so every Python variant ran on both
builds: the default one, which keeps
the GIL, and
python3.14t, which removes it. The tables tag them GIL and no-GIL. I ran each variant
five times and report the median, and every snippet is trimmed from the code that
produced the numbers.
Burning CPU on 16 cores
This test counts primes below four million by trial division, split into 16 chunks. The algorithm is the same in all four languages, and every variant returns 283,146.
1var wg sync.WaitGroup2results := make([]int, workers)3
4for i := range workers {5 wg.Add(1)6 go func() {7 defer wg.Done()8 lo, hi := bounds(i)9 results[i] = countPrimes(lo, hi)10 }()11}12wg.Wait()| Variant | Median |
|---|---|
| Rust, 16 threads | 0.051s |
| Go, 16 goroutines | 0.057s |
| Node, 16 workers | 0.164s |
| Node, single thread | 0.337s |
| Python GIL, 16 interpreters | 1.346s |
| Python GIL, 16 processes | 1.365s |
| Python no-GIL, 16 threads | 1.469s |
| Python no-GIL, 16 processes | 1.514s |
| Python no-GIL, 16 interpreters | 1.956s |
| Python GIL, no threads | 9.774s |
| Python GIL, 16 threads | 9.992s |
| Python no-GIL, no threads | 10.731s |
Sixteen threads on sixteen cores were slower than one thread, as long as the GIL stayed in the build. The GIL build's 16 threads took 9.992s against 9.774s for the serial loop, because all 16 still take turns holding one interpreter lock while adding coordination overhead.
That verdict flipped on the no-GIL build: the same sixteen threads finished in 1.469s on
python3.14t, 7.3x faster than that build's own serial run of 10.731s, with no pool and
no second process. The overhead lands on the serial side instead: the no-GIL serial loop
ran 9.8% behind the GIL build's (10.731s against 9.774s), and single-threaded code on
that build pays the cost whether or not it ever starts a thread.
Rust took the fastest row, 0.051s from sixteen threads against Go's 0.057s, which ran the same chunking. The two compiled languages sit more than 20x in front of every Python variant on this table.
The main limitation appears to be the workload distribution rather than the Python runtime. The program divides work into contiguous ranges, which makes the final chunk the most expensive because larger numbers require more trial divisions. As a result, workers processing smaller ranges finish earlier and remain idle while the last worker completes its heavier workload. This behavior was consistent across all three parallelization approaches: no-GIL threads, GIL-build processes, and GIL-build interpreters. Since they all reached a similar limit, the chunking strategy is likely the primary bottleneck rather than the execution model.
The interpreter results use another feature introduced in Python 3.14.
PEP 734 added subinterpreters to the standard library, and concurrent.futures now includes InterpreterPoolExecutor,
which runs one interpreter per worker thread.
1def run_interpreters() -> int:2 # Sixteen interpreters in one process, one per worker thread.3 with InterpreterPoolExecutor(max_workers=WORKERS) as pool:4 return sum(pool.map(count_primes, *zip(*chunks())))
On the standard GIL build, the interpreter pool performed almost the same as the process pool (1.346s vs. 1.365s). Unlike the process pool, however, it did not need to start sixteen separate operating system processes. Instead, each interpreter has its own GIL, allowing the worker threads to run in parallel without competing for a single global lock.
On the no-GIL build, the interpreter pool was the slowest of the parallel approaches at 1.956s. This is expected because both free threading and subinterpreters are designed to remove the same bottleneck: the GIL. Once the GIL is already gone, using multiple interpreters provides little additional benefit while adding some overhead.
Node's 16 workers completed the benchmark in 0.164s, compared to 0.337s for the single-threaded version. However, each worker loads its own copy of the module before it begins counting primes. For a workload this small, much of the potential performance gain is offset by the cost of starting the workers. A worker pool becomes more effective when the same workers stay alive and handle many requests instead of being created for each task.
Fanning out to 500 sockets
For this test, 500 requests go to a local TCP server that waits 50ms before responding, with no more than 64 requests in flight. Each runtime enforces that limit its own way: only Python ships a semaphore in its standard library; Go builds one from a buffered channel, Rust sizes a thread pool to the cap, and Node hand-rolls the class below.
1// A buffered channel IS the semaphore, straight from the language.2sem := make(chan struct{}, limit)3var wg sync.WaitGroup4
5for range requests {6 wg.Add(1)7 go func() {8 defer wg.Done()9 sem <- struct{}{}10 defer func() { <-sem }()11 fetch()12 }()13}14wg.Wait()| Variant | Median | Peak RSS |
|---|---|---|
| Rust | 0.408s | 2.6 MB |
| Go | 0.409s | 8.0 MB |
| Python GIL, threads | 0.415s | 21.5 MB |
| Python no-GIL, threads | 0.420s | 44.3 MB |
| Python GIL, asyncio | 0.451s | 21.4 MB |
| Python no-GIL, asyncio | 0.455s | 26.2 MB |
| Node | 0.461s | 71.7 MB |
The difference between the fastest and slowest results is only 53 ms across 500 network requests. Both Python builds fall within that range, and Rust finishes just 1 ms ahead of Go, a difference small enough to be considered noise. For this type of IO-bound workload, the choice of runtime has little impact on performance because the server spends about 50 ms processing each request. In practice, I would choose the runtime the team is already familiar with rather than optimize for such a small difference.
The previous benchmark limited concurrency to 64 requests. I expected increasing the limit to 500 to expose the cost of creating one OS thread per request, but that was not the case on the standard GIL build. The threaded version completed in 0.115s and used 30.9 MB of memory, while the asyncio version finished in 0.118s using 24.2 MB. After subtracting each runtime's baseline memory usage, the threaded version consumed 20.2 MB compared to 13.5 MB for asyncio, only about 1.5 times more.
The no-GIL build told a different story. Its 500-thread version peaked at 121.5 MB, or 108.0 MB above baseline, which works out to roughly 221 KB per thread. In contrast, the asyncio version used only 15.5 MB above baseline. The memory gap therefore increased from about 1.5x on the standard GIL build to 7.0x on the no-GIL build.
Rust had the smallest memory footprint of all the runtimes tested. Its 500-thread pool used only 4.5 MB more memory than its baseline while delivering similar performance.
The baseline matters for Node as well. Its 74.6 MB at a cap of 500 looks alarming until you compare it with an empty Node process, which used 41.2 MB on this machine. This means that more than half of the reported memory usage comes from the V8 runtime itself, before any application code is executed.
When the producer outruns the consumer
In this benchmark, a producer generates 200,000 buffers of 256 bytes each faster than the consumer can process them. The goal is to measure how each runtime handles backpressure when the producer outpaces the consumer. Three of the four standard libraries provide a built-in primitive for this pattern.
1// Capacity 100. The send blocks when the buffer is full, which throttles2// the producer without a line of code asking it to.3queue := make(chan []byte, 100)4
5go func() {6 for b := range queue {7 total += consume(b)8 }9}()10
11for range items {12 queue <- make([]byte, 256)13}14close(queue)| Variant | Median | Peak RSS |
|---|---|---|
| Go, bounded at 100 | 0.056s | 7.7 MB |
| Go, unbounded | 0.051s | 30.8 MB |
| Rust, bounded at 100 | 0.052s | 2.4 MB |
| Rust, unbounded | 0.056s | 10.9 MB |
Python GIL, maxsize=100 | 1.753s | 12.5 MB |
| Python GIL, unbounded | 1.737s | 74.8 MB |
Python no-GIL, maxsize=100 | 2.263s | 15.1 MB |
| Python no-GIL, unbounded | 2.185s | 88.7 MB |
| Node, bounded | 0.262s | 69.4 MB |
| Node, array backlog | 0.239s | 227.1 MB |
Adding a queue size limit had almost no effect on execution time, but it significantly reduced peak memory usage, by between 3.3x and 6.0x depending on the runtime. In fact, the unbounded versions were slightly faster in every runtime except Rust.
Go, Rust, and Python all support bounded queues directly in their standard libraries.
In Go, make(chan T, 100), Rust's sync_channel(100), and Python's Queue(maxsize=100) all express the same idea:
when the queue reaches its capacity, the producer blocks until the consumer removes an item. This prevents memory usage from growing without bound.
The no-GIL build behaved the same way. A bounded queue reduced its peak memory by 5.9x, although the entire pipeline ran about 29% slower than the standard GIL build (2.263s versus 1.753s for the bounded runs).
Node's standard library does not provide a bounded queue. My hand-written queue simply kept expanding its backing array, reaching 227.1 MB because the producer was never forced to wait. An array works as a queue only when the consumer keeps up with the producer. In a real application, that assumption often does not hold, making backpressure essential to prevent unbounded memory growth.
Sharing one counter
Eight workers, one million increments each, one shared total. The correct answer is 8,000,000.
1// Go has true low-level atomics in the standard library. No lock needed.2type AtomicCounter struct {3 value atomic.Int644}5
6func (c *AtomicCounter) Increment() int64 {7 return c.value.Add(1)8}9
10func (c *AtomicCounter) Get() int64 {11 return c.value.Load()12}| Variant | Median | Result |
|---|---|---|
Go, sync/atomic | 0.077s | 8,000,000 |
Go, sync.Mutex | 0.311s | 8,000,000 |
Rust, AtomicI64 | 0.093s | 8,000,000 |
Rust, Mutex | 0.192s | 8,000,000 |
Node, Atomics.add | 0.341s | 8,000,000 |
Python GIL, Lock | 0.936s | 8,000,000 |
| Python GIL, no lock | 0.332s | 8,000,000 |
Python no-GIL, Lock | 1.325s | 8,000,000 |
| Python no-GIL, no lock | 0.777s | 1,980,595 |
Node, plain view[0]++ | 0.126s | 1,257,537 |
Replacing Atomics.add with a plain read-modify-write on the same shared memory produced incorrect results in every run.
Instead of 8,000,000, the final count ranged from 1,130,611 to 1,788,195.
Node lost 78% to 86% of the increments without reporting any error or warning.
The idea that Node is "single-threaded" no longer holds once multiple workers share a SharedArrayBuffer.
The GIL build behaved differently. The unlocked counter returned 8,000,000 in all fourteen runs.
This appears to work because of CPython's current implementation, not because Python guarantees it is thread-safe.
CPython's 5 ms switch interval and its limited thread switch points may prevent this particular += from being interrupted.
That behavior disappeared on the no-GIL build. The same unlocked code on python3.14t returned between 1,930,440 and 2,042,864 in all fourteen runs.
dis shows that self.value += 1 compiles into a load, an add, and a store.
Without the GIL, threads can interleave between those steps and lose updates.
One difference I did not investigate is that Node's incorrect results varied much more, while the no-GIL results stayed close to two million.
Rust has no unlocked version because the equivalent code does not compile: the borrow checker rejects a second mutable borrow of the counter (error E0499).
Locks also have a cost. The GIL build's Lock took 2.8x longer than the unlocked version (0.936s vs. 0.332s),
Go's mutex took 4.0x longer than its atomic counter (0.311s vs. 0.077s),
and Rust's Mutex took 2.1x longer than AtomicI64 (0.192s vs. 0.093s).
The no-GIL Lock was 42% slower than the GIL build (1.325s vs. 0.936s),
but on that build locking is no longer optional because the unlocked version loses updates.
For simple counters in Go and Rust, atomics are the better choice.
Where Node wins
None of this stopped me from deploying this blog on Cloudflare Workers. For request-shaped work that spends its time waiting on other machines, the event loop fits well. Node came within 53ms of Rust and Go on the fan-out test, and I did not have to write concurrency code. One language for browser and server is more valuable to me day to day than 3x on a benchmark I ran once. Workers also use isolates, so there is no shared memory or counter to mishandle.
The event-loop model has a limit. Node ships neither a semaphore nor a bounded queue. As soon as a workload needs backpressure, you have to maintain that primitive yourself. Both classes in this post exist because I could not find one in the standard library.
The table I use
| Shape of the work | Reach for | Steer clear of |
|---|---|---|
| CPU-bound, want all cores | Go or Rust; in Python, no-GIL threads | GIL-build threads (measured slower than serial) |
| IO fan-out, hundreds in flight | Whichever you know best | Nothing, all seven variants land within 53ms |
| Bounded pipeline, needs backpressure | Go, Rust, or Python's Queue(maxsize=) | Node's default array-as-queue |
| Shared mutable state across cores | Go sync/atomic, Rust AtomicI64 | No-GIL Python without a Lock, SharedArrayBuffer without Atomics |
| One language, client and server | Node | Go or Python for the browser half |
| Data, ML, glue | Python: processes, or no-GIL threads | Threads on the GIL build |
Go counted primes 171x faster than the GIL build's serial loop and 24x faster than its process pool. Rust and Go traded wins throughout the benchmarks: Rust led on the prime-counting test, tied on the IO fan-out, and trailed on the atomic counter. Those results matched my expectations, so they did not change how I work.
The no-GIL results did. Sixteen threads, which were slower than a single thread on the GIL build, became the fastest way I measured to run this workload in Python. Even more surprising, an unlocked counter that returned the correct result in every run on the GIL build lost about three quarters of its increments on the no-GIL build. The same file, the same eight threads, and the same code produced 8,000,000 on one interpreter and 1,980,595 on the other. That difference now depends on the Python build, and nothing in the source code makes that difference obvious.