Skip to content
JavaGo
Part IV · Concurrency

Goroutines

Goroutines vs platform and virtual threads, the go keyword, the loop-variable change in Go 1.22, WaitGroup, and how to see what is running.

Section 14 of 31 6 min read

A goroutine is a function running concurrently with the rest of the program. You start one by putting go in front of a function call. The Go runtime multiplexes goroutines onto a small pool of OS threads, parks them when they block, and resumes them when they can continue.

Threads, virtual threads and goroutines

JavaGo
Platform thread: one OS thread, ~1 MB of reserved stack by defaultGoroutine: a few KB of stack that grows and shrinks as needed
Virtual thread (Java 21+): JVM-scheduled on carrier threadsGoroutine: runtime-scheduled onto OS threads (M:N)
Thousands of platform threads is a lot; virtual threads scale to millionsHundreds of thousands of goroutines is routine
Thread.ofVirtual().start(task)go task()
Executors.newVirtualThreadPerTaskExecutor()No executor: go is the executor
Future / CompletableFuture for resultsChannels, or shared state + sync.WaitGroup
Thread.interrupt() for cancellationcontext.Context (see Context)

Virtual threads narrowed the gap, they did not close it

Java 21’s virtual threads bring the same idea to the JVM: cheap threads, blocking code, a scheduler underneath. The differences are ecosystem-wide rather than per-thread. In Go every library has always been written for this model, so there is no synchronized pinning to audit (Java 24 fixed most pinning), no thread-local habits to unlearn, and no reactive stack to choose instead. Cancellation through context.Context is also universal, where Java’s interruption is best-effort.

Java platform threads: 1:1

Java virtual threads: M:N

Go goroutines: M:N

Fig. 14.1A platform thread is an OS thread. Virtual threads and goroutines are both M:N: many cheap threads share a few OS threads, and the scheduler parks whichever ones are waiting so the threads keep running work.

Starting a goroutine

Java — a thread or an executor
// A virtual thread (Java 21+)
Thread t = Thread.ofVirtual().start(() -> process(msg));

// Or an executor
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    pool.submit(() -> process(msg));
} // close() waits for submitted tasks
Go — the go keyword
// Prefix any function call with go
go process(msg)

// Or an anonymous function, called immediately
go func() {
    process(msg)
}() // <- these parentheses call it

// The current goroutine continues IMMEDIATELY.
// Nothing waits for process() unless you arrange it.

How the scheduler works

Parked, not blocked

The runtime lets up to GOMAXPROCS goroutines execute Go code at once. By default that is the number of CPU cores, and since Go 1.25 it also respects a container’s CPU limit (for example a Kubernetes CPU limit), so Go services no longer need a library to set it.

When a goroutine waits on a network read, a channel, a lock or a timer, the runtime parks it and runs another goroutine on that thread. Network I/O goes through the runtime’s poller (epoll/kqueue), so thousands of waiting connections cost memory, not threads. Blocking system calls such as file I/O do occupy a thread; the runtime simply starts another one to keep the other goroutines running.

Closures and loop variables

the classic bug, fixed in Go 1.22
for _, msg := range messages {
    go func() {
        fmt.Println(msg.Topic)
    }()
}

// Go 1.22+: each iteration has its OWN msg variable, so this
// prints every topic. Before 1.22 there was one msg shared by
// all iterations, and the goroutines could all print the last one.
//
// The semantics follow the "go" line in go.mod, not the compiler:
// a module that still says "go 1.21" keeps the old behaviour.

// The old workaround, still common in existing code:
for _, msg := range messages {
    go func(m Message) {
        fmt.Println(m.Topic)
    }(msg) // evaluated now, copied into m
}

Waiting for goroutines: sync.WaitGroup

Java — CountDownLatch
CountDownLatch latch = new CountDownLatch(messages.size());

for (Message msg : messages) {
    executor.submit(() -> {
        try {
            process(msg);
        } finally {
            latch.countDown();
        }
    });
}
latch.await(); // block until the count reaches zero
Go — WaitGroup.Go (Go 1.25+)
var wg sync.WaitGroup

for _, msg := range messages {
    wg.Go(func() { // Add(1), go, and Done() in one call
        process(msg)
    })
}

wg.Wait() // block until every function has returned
fmt.Println("all done")
the pre-1.25 form you will see everywhere
var wg sync.WaitGroup
for _, msg := range messages {
    wg.Add(1)          // BEFORE starting the goroutine
    go func() {
        defer wg.Done() // runs even if process returns early
        process(msg)
    }()
}
wg.Wait()

// Calling wg.Add(1) inside the goroutine is a race: Wait() can
// run first and return early. go vet (Go 1.25+) reports it.

Seeing what is running

goroutine counts, dumps and profiles
// How many goroutines exist right now?
slog.Info("runtime", "goroutines", runtime.NumGoroutine())

// pprof: import for side effects, serve on an internal port
import _ "net/http/pprof"

go func() {
    // localhost only: profiles expose internals
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

// Full stack of every goroutine, grouped (like jstack):
//   curl 'localhost:6060/debug/pprof/goroutine?debug=2'
//
// Go 1.27+: goroutines that can provably never wake up:
//   curl 'localhost:6060/debug/pprof/goroutineleak?debug=1'
↑ ↓ to navigateEnter to openEsc to close