gopherpath
0%

Goroutines vs virtual threads

50 min · lesson 1 of 4 · #concurrency · #goroutines

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.

Go
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
// 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());
TypeScript
// 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.

Shell
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:

Go
// 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 declaring go 1.22 or later. Old modules keep the old semantics — check the go line in go.mod before 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 fetch into the context-aware version and prove with goleak that nothing survives the test.
  • Explain why GOMAXPROCS=1 go test -race is a debugging tool, not a fix.
stored in this browser only