Goroutines vs virtual threads
A goroutine starts at 2 KB of stack and grows on demand (up to 1 GB). You do not pool them, size them, or name
them. go f() is the whole API.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var wg sync.WaitGroup
done := make(chan struct{}, 1)
for i := 0; i < 10_000; i++ {
wg.Add(1)
go func(n int) { // pass args explicitly; never capture the loop var you might mutate
defer wg.Done()
if n == 9_999 {
done <- struct{}{}
}
}(i)
}
wg.Wait()
<-done
fmt.Println("10k goroutines done, live:", runtime.NumGoroutine())
}
// Java 21+ (JEP 444): same shape, different machinery
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
for (int i = 0; i < 10_000; i++) {
scope.fork(() -> work()); // virtual thread per task
}
scope.join().throwIfFailed();
}
// or: Thread.ofVirtual().start(() -> work());
// There are no threads. Concurrency is one event loop plus promises:
const results = await Promise.all(Array.from({ length: 10_000 }, () => work()));
// CPU-bound work blocks everything; use worker_threads for that.
What actually differs
| Go | Java virtual threads | |
|---|---|---|
| Unit | goroutine, 2 KB growable stack | virtual thread, heap-allocated stack chunks |
| Scheduling | M:N, own scheduler, GOMAXPROCS Ps |
M:N via ForkJoinPool carrier threads |
| Blocking I/O | parks the goroutine, frees the OS thread | unmounts the virtual thread (JEP 444) |
| Coordination | channels + context, WaitGroup, errgroup |
futures, StructuredTaskScope, executors, BlockingQueue |
| Cancellation | context.Context passed explicitly |
Future.cancel, interruption, scope shutdown |
| CPU-bound parallelism | just spawn N goroutines | needs a fixed pool / parallel streams — virtual threads don't help |
| Failure model | error values; panic kills the process | exceptions, per-task Future.get() |
| Leak detection | runtime.NumGoroutine, pprof goroutine/goroutineleak |
thread dumps, JFR |
Both are cheap-stack concurrency; the difference is the culture. Go makes concurrency the default primitive and forces you to pass cancellation through every call. Java makes it an executor decision and keeps structured concurrency opt-in.
The scheduler in one paragraph
The Go runtime runs G (goroutines) on M (OS threads) via P (logical processors, GOMAXPROCS, defaults to core
count). A goroutine that blocks on a channel or a network read yields its P to another goroutine — no thread is
parked. That is why 100k goroutines serving idle connections cost megabytes, not gigabytes. When a goroutine makes a
blocking syscall (a disk read, cgo call), the runtime can add an OS thread instead of stalling the P.
GOMAXPROCS=1 go test -race ./... # serialise the scheduler: turns races into deterministic reproductions
GODEBUG=schedtrace=1000 go run . # scheduler state every second while debugging stalls
The goroutine leak
The one failure mode that Java's executor model prevents for you. A goroutine blocked forever on a channel send keeps its stack, its captured variables, and any connection it holds:
// LEAK: nobody will ever receive; this goroutine lives until the process exits
func fetch(u string) {
go func() {
resp, _ := http.Get(u)
results <- resp.Body // blocks forever if the caller gave up
}()
}
// FIXED: caller's context decides, and the send is cancellable
func fetch(ctx context.Context, u string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req) // ctx cancels the request
if err != nil {
return err
}
defer resp.Body.Close()
select {
case results <- resp.Body:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
Detection, in increasing order of effort: runtime.NumGoroutine() in a test; go test -race; the goroutine
pprof profile; and go.uber.org/goleak (defer goleak.VerifyNone(t)) which fails the test if a goroutine outlives it.
Go 1.26+ also ships a goroutineleak profile that reports goroutines blocked on channels nobody can reach — a leak
detector built into the runtime.
Trap: capturing a loop variable by reference (
go func(){ use(i) }()) used to be a classic bug; since Go 1.22 each iteration has its own variable, so it is fixed for modules declaringgo 1.22or later. Old modules keep the old semantics — check thegoline ingo.modbefore you trust a snippet from a blog.
Proof checkpoint
- Spawn 100k goroutines that each wait on a channel; print
runtime.NumGoroutine()and the process RSS. - Convert the leaking
fetchinto the context-aware version and prove withgoleakthat nothing survives the test. - Explain why
GOMAXPROCS=1 go test -raceis a debugging tool, not a fix.