Every language on this manual answers the same question differently: what is the unit of concurrent execution, who schedules it, and what does it cost to create a thousand of them? Get this mental model right first — it explains why a Go service casually spawns 50,000 goroutines while a naive Java thread-per-request server used to fall over at a few thousand OS threads (before virtual threads), and why Node.js needs none of that machinery for I/O-bound work but hits a wall the moment the work is CPU-bound.
Language Verdict: Pros, Cons & Recommendation
- Goroutines are the single, simple, first-class concurrency primitive -- no second model to pick
- True multi-core parallelism by default, sized to GOMAXPROCS
- Cheapest concurrent unit of the five (~2KB growable stack)
- No built-in join/await -- coordinating completion is entirely manual (WaitGroup, channels)
- Easy to leak goroutines that are never coordinated back
- Virtual threads (JDK 21+) give millions of cheap concurrent units while keeping ordinary blocking-style code
- True multi-core CPU parallelism with no GIL-equivalent
- Structured concurrency (
StructuredTaskScope) closes the classic 'forgot to join' leak
- Platform threads are still expensive if you don't opt into virtual threads
- Two threading models (platform + virtual) to reason about during a transition period
- Coroutines are cheap, structured-by-default, and mature well before virtual threads existed
- Same JVM, same true multi-core parallelism as Java
Dispatchers.Default/Dispatchers.IO split cleanly separates CPU-bound from blocking work
- A second concurrency vocabulary (suspend functions, dispatchers) on top of the JVM's own threads to learn
- Blocking calls inside a coroutine without switching dispatcher is an easy, silent mistake
asyncio gives cheap, high-fan-out I/O concurrency comparable to Node or Gomultiprocessing provides a clear, standard escape hatch to real parallelism
- The GIL means
threading gives zero CPU parallelism, a frequent interview trap - Two separate concurrency stories (threading for I/O, multiprocessing for CPU) instead of one unified model
- Excellent, cheap I/O concurrency with a simple single-threaded mental model
async/await reads like synchronous code with no callback pyramidsworker_threads/cluster provide clear, explicit escape hatches to parallelism
- Zero CPU parallelism on the main thread, full stop -- no GIL to reason about, just one thread
- Workers share no memory by default, unlike Java/Kotlin/Go threads/goroutines
Recommendation: For interviews: state plainly whether the language's default concurrency unit gives real CPU parallelism (Java, Kotlin, Go: yes; Python threading, JS main thread: no) before reaching for any pattern -- it's the fastest way to signal you understand what you're actually being asked to design.
Concurrency Mechanics, Side by Side
Every model below eventually bottoms out on OS threads somewhere — the question is only how many user-space concurrent units share each one, and who decides when to switch between them.
// A goroutine is a few KB of stack that grows/shrinks as needed,
// scheduled M:N onto OS threads by the Go runtime itself (no OS involvement per goroutine).
go handleRequest()
// GOMAXPROCS caps how many goroutines can run truly in parallel (default: number of CPUs).
runtime.GOMAXPROCS(4)
The goroutine is Go's only concurrency primitive at the language level -- there is no separate 'thread' type you reach for instead. The Go runtime's scheduler (the G-M-P model) multiplexes an arbitrary number of goroutines onto GOMAXPROCS OS threads, growing each goroutine's stack from about 2KB as needed. This is the same idea as Java's virtual threads, but it has been Go's only model since day one rather than an alternative bolted on later.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
doWork()
}()
wg.Wait() // blocks until Done() has been called Add()'s worth of times
Go has no built-in 'join a goroutine' handle at all -- a goroutine is fire-and-forget by default, and you coordinate completion yourself, almost always with a sync.WaitGroup (a counting latch) or by receiving on a channel the goroutine closes when done. Forgetting to Wait() (or forgetting the corresponding Add/Done) is the single most common goroutine-leak bug in Go code.
This is the single most-tested conceptual question in this section: 'can this language's concurrency model use N cores in parallel for CPU-bound work, out of the box?'
// Yes -- GOMAXPROCS goroutines can run truly in parallel (default = NumCPU).
fmt.Println(runtime.NumCPU(), runtime.GOMAXPROCS(0))
Go goroutines run in true parallel across up to GOMAXPROCS OS threads (defaulting to the number of logical CPUs since Go 1.5) -- there is no GIL-style global lock. This is the headline reason Go is a common choice for CPU-bound concurrent services without reaching for a separate process model.
// Goroutine: starts at ~2KB, grows/shrinks dynamically -- cheap enough
// that 'go func()' in a loop is idiomatic, not a red flag.
for i := 0; i < 1_000_000; i++ {
go func() { time.Sleep(time.Second) }()
}
Goroutines starting at roughly 2KB (vs. ~1MB for an OS thread) and growing on demand is the foundational fact that makes Go's 'just spawn a goroutine' culture viable -- a million idle goroutines is a normal load test, not a stress test.
The five languages converge on the same underlying trade-off — cheap, massively-scalable concurrent units vs. simple, real-OS-thread parallelism — they just default to different points on it:
- I/O-bound, high fan-out (many concurrent network calls, few CPU cycles each): every language here handles this well once you reach for its cheap unit — Java virtual threads, Kotlin coroutines on
Dispatchers.IO, Go goroutines, Python asyncio, or plain JS async/await. This is the case all five were optimized for.
- CPU-bound, parallel compute: Go and the JVM (Java/Kotlin) give this to you for free with their default concurrency primitive. Python needs
multiprocessing (or releasing the GIL in a C extension); Node needs worker_threads or cluster. Forgetting this distinction — e.g. running CPU-heavy work inside a Python thread or a Node async function expecting parallelism — is the most common cross-language interview trap.
- Simplicity of the mental model: Go's 'just start a goroutine' and JS's 'just await a Promise' are the two simplest models to reason about locally, at the cost of Go needing explicit
WaitGroup/channel coordination and JS being fundamentally single-threaded for compute. Kotlin's structured concurrency (coroutineScope) is arguably the safest default for avoiding leaked/orphaned work.
- Interop with existing blocking code: Java virtual threads are unique in this group for letting you keep writing ordinary blocking-style code (JDBC calls, blocking HTTP clients) and get async-like scalability for free, without rewriting to
async/await or channels.