Go for
Java Developers
Section 10

Goroutines

A goroutine is a function running concurrently with others in the same process. Start one with go functionCall(). The Go runtime schedules goroutines across OS threads automatically.

Thread vs Goroutine

Java ThreadGo Goroutine
~1MB initial stack~2KB initial stack, grows on demand
Managed by OSManaged by Go runtime scheduler
~10K threads before OOMMillions of goroutines in production
new Thread(() -> ...).start()go func() { ... }()
Future/CompletableFuture for resultsChannels for results (next section)
Thread pool (ExecutorService)No pool needed — goroutines are cheap

Starting a goroutine

Java — Thread or ExecutorService
// Java — create a thread explicitly
Thread t = new Thread(() -> {
    processMessage(msg);
});
t.start();

// Or use an executor pool:
ExecutorService pool = Executors.newFixedThreadPool(8);
pool.submit(() -> processMessage(msg));
Go — go keyword
// Go — prefix any function call with 'go'
// That's it. No thread object, no pool.
go processMessage(msg)

// With an anonymous function:
go func() {
    processMessage(msg)
}()
// The () at the end calls the function immediately
// 'go' starts it in a new goroutine

// Current goroutine continues IMMEDIATELY
// Does not wait for processMessage to finish

Goroutine lifecycle

Four states
Runnable — created with go, waiting for a thread slot
Running — executing on an OS thread right now
Waiting — blocked on I/O, sleep, or synchronisation. Its OS thread is freed for other goroutines
Dead — function returned. Stack reclaimed. No explicit join.

The scheduler runs GOMAXPROCS OS threads (= CPU cores by default). When a goroutine blocks, its thread immediately picks up another runnable goroutine. This is why Go can handle thousands of concurrent I/O operations with only a handful of threads.

go func() — anonymous goroutine, fully explained

every part of 'go func() { ... }()' explained
// Break down:  go  func()  {  ...  }  ()
//              ^   ^            ^   ^
//              |   |            |   +-- () calls the function immediately
//              |   |            +------ function body
//              |   +------------------- anonymous function literal
//              +----------------------- start in a new goroutine

counter := 0

go func() {
    // This runs in a NEW goroutine concurrently
    // It CAN access 'counter' from outer scope (closure)
    // But concurrent access without synchronisation is a data race!
    counter++
}()

// The current goroutine continues here IMMEDIATELY
// counter++ above may or may not have run yet

The closure capture bug — very common mistake

loop variable capture — wrong vs right
messages := []Message{{Topic: "a"}, {Topic: "b"}, {Topic: "c"}}

// ─── WRONG ───────────────────────────────────────────────────
for _, msg := range messages {
    go func() {
        // BUG: msg is the LOOP VARIABLE — shared across all iterations
        // By the time this goroutine runs, the loop may have advanced
        // All three goroutines may print the SAME last message
        fmt.Println(msg.Topic)
    }()
}

// ─── CORRECT — pass as argument ──────────────────────────────
for _, msg := range messages {
    go func(m Message) {  // m is a new parameter, scoped to THIS goroutine
        // m is a copy of msg at the moment this goroutine was launched
        // Safe — no sharing with other goroutines or loop iterations
        fmt.Println(m.Topic)
    }(msg)  // msg evaluated HERE, copied into m
}
Go 1.22+ fix: Starting with Go 1.22 (Feb 2024), loop variables are scoped per-iteration, so the closure capture bug above no longer occurs in new code. The pass-as-argument pattern is still valid and worth knowing — you will encounter it in pre-1.22 codebases and it makes the intent explicit.

WaitGroup — waiting for goroutines to finish

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

for (Message msg : messages) {
    executor.submit(() -> {
        try {
            process(msg);
        } finally {
            latch.countDown();
        }
    });
}
latch.await();  // block until count = 0
Go — sync.WaitGroup
var wg sync.WaitGroup
// WaitGroup is a counter:
// Add(n)  ->  counter += n
// Done()  ->  counter -= 1   (call when goroutine finishes)
// Wait()  ->  block until counter == 0

for _, msg := range messages {
    wg.Add(1)               // counter++ before launching
    go func(m Message) {
        defer wg.Done()     // counter-- guaranteed, even on panic
        process(m)
    }(msg)
}

wg.Wait()  // blocks until all goroutines call Done()
fmt.Println("all done")
!wg.Add(1) must be called BEFORE launching the goroutine, in the parent goroutine. If you call it inside the goroutine, wg.Wait() may return before Add() even executes.

Debugging goroutines

runtime inspection — how many are running?
import "runtime"

// Count goroutines right now
n := runtime.NumGoroutine()
fmt.Printf("goroutines running: %d\n", n)

// Print full stack trace of EVERY goroutine — invaluable for debugging hangs
buf := make([]byte, 1<<20)              // 1MB buffer
n = runtime.Stack(buf, true)             // true = include all goroutines
fmt.Printf("%s", buf[:n])

// Expose count as an HTTP endpoint (add to your server):
http.HandleFunc("/debug/goroutines", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "goroutines: %d", runtime.NumGoroutine())
})

// pprof gives you goroutine dumps, CPU profiles, heap profiles:
import _ "net/http/pprof"   // registers /debug/pprof/ routes in init()
// curl localhost:8080/debug/pprof/goroutine?debug=2