Goroutines vs Threads vs Async Await Compared Fairly
Run the same 10,000 concurrent sleepers in Go, Python asyncio, Node and Java threads, then compare time, memory and what each model does to your code.

"Which concurrency model is best" is a question that attracts strong opinions and weak measurements. The models are tools with different costs, and the costs are easier to see in a single experiment than in a debate. This article runs one small task through four runtimes, reads the numbers, and then spends most of its length on the thing numbers don't capture: what each model does to the code you write.
Tested with Go 1.25.6, Python 3.14, Node 24, Java 21 (Temurin in Docker), macOS on Apple Silicon (arm64)
The task: start 10,000 concurrent tasks that each wait one second and then finish, and measure how long the whole thing takes. A runtime that handles waiting cheaply finishes in about one second; one that pays heavily per task takes longer. This article connects to goroutines, the scheduler, concurrency for Python developers and for Node developers, and belongs to the concurrency patterns cluster. Everything ran on an Apple Silicon Mac, with the Java runs inside Docker.
Four ways to wait 10,000 times
Go. One goroutine per task, time.Sleep for the wait:
package main
import (
"fmt"
"sync"
"syscall"
"time"
)
func main() {
start := time.Now()
var wg sync.WaitGroup
for range 10000 {
wg.Go(func() { time.Sleep(time.Second) })
}
wg.Wait()
var ru syscall.Rusage
syscall.Getrusage(syscall.RUSAGE_SELF, &ru)
fmt.Printf("go goroutines: %.2fs, peak RSS %.0f MiB\n", time.Since(start).Seconds(), float64(ru.Maxrss)/1048576)
}go goroutines: 1.02s, peak RSS 31 MiBPython asyncio. One task per wait, all gathered:
import asyncio, time, resource
async def task():
await asyncio.sleep(1)
async def main():
start = time.time()
await asyncio.gather(*(task() for _ in range(10000)))
print(f"python asyncio: {time.time() - start:.2f}s, peak RSS {resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1048576:.0f} MiB")
asyncio.run(main())python asyncio: 1.05s, peak RSS 38 MiBNode.js. The same with promises and timers:
const start = Date.now();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
Promise.all(Array.from({ length: 10000 }, () => sleep(1000))).then(() => {
const rss = process.resourceUsage().maxRSS / 1024; // kilobytes to MiB
console.log(`node promises: ${((Date.now() - start) / 1000).toFixed(2)}s, peak RSS ${rss.toFixed(0)} MiB`);
});node promises: 1.01s, peak RSS 53 MiBJava 21. Two runs of the same job, one with platform threads (each backed by an OS thread) and one with virtual threads (Java's version of the goroutine, new in 21):
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
for (String kind : new String[] {"platform", "virtual"}) {
long start = System.nanoTime();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
Runnable r = () -> { try { Thread.sleep(1000); } catch (InterruptedException e) {} };
threads.add(kind.equals("virtual") ? Thread.ofVirtual().start(r) : Thread.ofPlatform().start(r));
}
for (Thread t : threads) t.join();
System.out.printf("java %s threads: %.2fs%n", kind, (System.nanoTime() - start) / 1e9);
}
}
}java platform threads: 3.12s
java virtual threads: 1.04sEach block prints its own result right after the code.
Reading the numbers
Four of the five runs finished in about a second: Go, asyncio, Node, and Java's virtual threads. Waiting is free in all of them, because none of them parks an operating system thread per wait; a timer wheel or a heap of deadlines wakes tasks when their second is up. The fifth, Java with 10,000 platform threads, took over three seconds, and that is the OS-thread cost showing up: the kernel creates each thread, gives it a stack and schedules it, and creation alone is on the order of hundreds of microseconds. Ten thousand threads also fit on this machine only because the sleeping threads use little memory; a thread that has touched its stack, or ten times as many threads, runs into kernel limits.
On memory, a goroutine starts with a stack of about 2 KiB (the runtime grows it as needed) plus bookkeeping, which comes to roughly 2.5 KiB, so a hundred thousand sleeping goroutines are a quarter of a gigabyte at worst and 10,000 are about 25 MiB. An asyncio task and a promise with its timer are in the same range or a bit larger, since each is a heap object with a coroutine frame or closure. An OS thread reserves a large stack, 8 MiB of address space by default on Linux, though only touched pages count against real memory. The peak RSS printed in the first three runs (31 MiB for Go, 38 for Python, 53 for Node) is dominated by each language runtime itself, so the per-task difference is smaller than the gap between interpreters.
So on the question "how many mostly idle tasks can I hold", goroutines, async tasks and virtual threads are equivalent for practical purposes, and OS threads are one to two orders of magnitude more expensive. The interesting differences are elsewhere.
What each model does to your code
Function coloring. In async await, a function that waits must be async, and so must everything that calls it, up to the entry point. This is the "colored functions" problem: the language splits into async and sync worlds, a sync function cannot call an async one without a bridge, and libraries ship twice (an HTTP client and an async HTTP client). Go has no such split. A function that blocks on a channel looks identical to one that adds two numbers, and the caller decides whether to run it in a goroutine. Java's virtual threads take the same approach, which is a large part of why they were built: ordinary blocking code, run cheaply.
Where the code runs. Python's asyncio and Node's event loop run your code on one thread. They interleave tasks at await points, which makes shared-state reasoning easy (no two tasks run at the same instant) and means that CPU-bound code anywhere blocks everything. A 200 ms JSON parse in a handler stalls every other request for 200 ms. The standard answers are worker threads or processes, which bring their own complexity. Go schedules goroutines over all cores and preempts long-running ones (async preemption), so CPU-bound work in one goroutine slows only its own core. That also means Go code has real data races, which single-threaded event loops are spared, and the discipline in data races applies.
Cancellation. Go passes a context.Context explicitly through the call chain (context). Python has task cancellation that raises an exception at the next await. JavaScript uses AbortController and AbortSignal, which each API must opt into. Java uses interrupts. Go's is the most explicit and the most verbose, and it is the one in which the cancellation path is visible in every function signature.
Errors. Go returns them, so each concurrent task's failure is a value you handle (error handling). Async code uses exceptions that propagate through await, with gather and TaskGroup choosing between first-error and all-errors behavior. An exception in a fire-and-forget task can vanish unless someone awaits it; a panic in a goroutine, by contrast, takes the process down, which is loud and sometimes unwelcome (recovering in goroutines).
Debugging. A goroutine has a real stack, and a dump shows what every one is doing (goroutine dumps). Async stack traces are reconstructed from the chain of awaits, which is better than it was and still stitches things together after the fact.
The ecosystem. This is the practical argument for async await in Python and JavaScript: the language and the libraries are built around it, and the choice is not the programmer's. Go's equivalent is that the standard library is built around goroutines, and a blocking call is the norm.
Choosing
If the task is "wait on many things", all four models work, and the right one is whatever your language ships with. The reasons to prefer the goroutine model are not about waiting. They are the absence of function coloring, true multicore execution without extra machinery, and a single mental model for I/O-bound and CPU-bound work. The reasons to prefer async await are ecosystem fit and the single-threaded simplicity of reasoning about shared state, which is worth something when the workload is almost entirely I/O. Platform threads still make sense when the number is small and each does substantial work, and for blocking libraries that cannot be made async.
What I would not do is choose on the headline numbers above. They show that the three cheap models are equally cheap at this size. A fairer follow-up experiment mixes waiting with CPU work and looks at tail latency under load, and there the differences in scheduling (single thread versus multicore, cooperative versus preemptive) decide the result. The Java documentation on virtual threads and the Python asyncio documentation are the sources for the other sides of the comparison, and the Go FAQ on goroutines states the goroutine design goals.


