Goroutines in Go
Launch goroutines with the go keyword, understand goroutine lifecycle, coordinate with sync.WaitGroup, and tune concurrency with GOMAXPROCS.
Launching Goroutines
The go keyword is the simplest concurrency primitive in Go. It launches any function call as a goroutine — a lightweight unit of execution that runs concurrently with the rest of the program. Unlike spawning OS threads, goroutines are cheap to create (starting at ~2 KB of stack) and managed entirely by the Go runtime scheduler, so you can launch thousands of them without running out of memory or bogging down the OS.
func sayHello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
func main() {
go sayHello("Alice") // launched concurrently — does not block
go sayHello("Bob")
go sayHello("Charlie")
time.Sleep(100 * time.Millisecond) // wait for goroutines to finish
// Output order is non-deterministic — goroutines run in parallel
}
Using time.Sleep to wait for goroutines is a bad practice — it’s a guess, not a guarantee. Use sync.WaitGroup instead.
Anonymous Goroutines
You can launch a goroutine from an anonymous function (closure) defined inline. This is convenient, but closures capture variables by reference — a common source of bugs in loops. Always pass loop variables explicitly as arguments to avoid all goroutines sharing the same final value.
go func() {
fmt.Println("from anonymous goroutine")
}()
// Correct: pass i as an argument — each goroutine gets its own copy
for i := 0; i < 3; i++ {
go func(n int) {
fmt.Println(n) // prints 0, 1, 2 in some order
}(i)
}
// Bug: all goroutines capture the same variable i
// By the time they run, the loop has finished and i == 3
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // likely prints 3, 3, 3
}()
}
sync.WaitGroup
WaitGroup solves the core coordination problem: how does the main goroutine know when all worker goroutines are done? It works like a counter — you increment it before launching each goroutine, decrement it when each finishes, and block until the counter reaches zero. This gives you a precise, race-free way to wait for a dynamic number of concurrent tasks.
import "sync"
func main() {
var wg sync.WaitGroup
names := []string{"Alice", "Bob", "Charlie", "Diana"}
for _, name := range names {
wg.Add(1) // increment BEFORE launching the goroutine
go func(n string) {
defer wg.Done() // decrement when this goroutine exits
// simulate work
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
fmt.Printf("Processed: %s\n", n)
}(name) // pass name as argument — avoids closure capture bug
}
wg.Wait() // blocks until all goroutines have called Done()
fmt.Println("All done")
}
Rules for WaitGroup:
- Call
Addbefore launching the goroutine, not inside it - Call
Doneexactly once per goroutine — usedefer wg.Done()at the start - Never copy a WaitGroup after first use
Goroutine Lifecycle
A goroutine lives until its function returns. The Go runtime will garbage collect a finished goroutine automatically. However, if a goroutine blocks permanently — waiting on a channel that never sends, or a lock that never releases — it is never reclaimed. This is a goroutine leak: memory and scheduler resources consumed forever. The fix is to always give goroutines a way out via context.Context or a done channel.
// Goroutine leak — goroutine blocks forever waiting on an unwritten channel
func leaky() {
ch := make(chan int) // unbuffered, nobody ever sends to it
go func() {
val := <-ch // blocks forever — this goroutine leaks
fmt.Println(val)
}()
// ch goes out of scope, but the goroutine is still alive
}
// Fix: give the goroutine a cancellation path via context
func nonLeaky(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done(): // exits cleanly when context is cancelled
fmt.Println("goroutine cancelled")
return
}
}()
}
runtime.GOMAXPROCS
By default, Go uses all available CPU cores to run goroutines in parallel. GOMAXPROCS controls exactly how many OS threads execute Go code simultaneously. In the vast majority of programs you should leave it at the default — the scheduler is highly optimised. Knowing it exists matters when profiling CPU-bound workloads or running inside containers where the core count may be miscounted.
import "runtime"
func main() {
fmt.Println(runtime.NumCPU()) // number of logical CPUs, e.g. 8
fmt.Println(runtime.GOMAXPROCS(0)) // 0 = query current value without changing it
// Explicitly set to 4 OS threads (rarely needed)
runtime.GOMAXPROCS(4)
// In most programs, leave it at the default — the Go scheduler handles this well
}
Detecting Race Conditions
A race condition occurs when two goroutines access the same variable concurrently and at least one access is a write. The result is undefined — you might get wrong values, crashes, or seemingly correct output that breaks under load. Go ships a built-in race detector: compile with -race and any data race is reported with the exact goroutine stack traces involved.
// counter.go — has a data race
var counter int
func increment() {
counter++ // NOT atomic — expands to: read, add 1, write (three separate ops)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment() // concurrent reads and writes to counter
}()
}
wg.Wait()
fmt.Println(counter) // result is unpredictable — could be anything < 1000
}
go run -race main.go
# DATA RACE
# Write at 0x... by goroutine 7:
# main.increment()
# Previous write at 0x... by goroutine 6:
# main.increment()
Fix it with sync/atomic or a mutex:
import "sync/atomic"
var counter int64
// atomic.AddInt64 is a single indivisible CPU instruction — no race possible
func increment() {
atomic.AddInt64(&counter, 1)
}
Practical Example — Parallel Web Fetcher
This example shows the core goroutine + WaitGroup pattern applied to a real task: fetching multiple URLs concurrently. Without goroutines, three 200ms requests would take ~600ms sequentially. With goroutines, all three run in parallel and the total time is ~200ms — the time of the slowest single request.
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
type Result struct {
URL string
StatusCode int
Duration time.Duration
Err error
}
func fetch(url string) Result {
start := time.Now()
resp, err := http.Get(url)
if err != nil {
return Result{URL: url, Err: err, Duration: time.Since(start)}
}
defer resp.Body.Close()
return Result{
URL: url,
StatusCode: resp.StatusCode,
Duration: time.Since(start),
}
}
func fetchAll(urls []string) []Result {
results := make([]Result, len(urls)) // pre-allocated — each goroutine writes its own index
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1)
go func(idx int, u string) {
defer wg.Done()
results[idx] = fetch(u) // safe: each goroutine writes a different index
}(i, url)
}
wg.Wait() // block until all fetches complete
return results
}
func main() {
urls := []string{
"https://go.dev",
"https://pkg.go.dev",
"https://golang.org",
}
results := fetchAll(urls)
for _, r := range results {
if r.Err != nil {
fmt.Printf("ERROR %s: %v\n", r.URL, r.Err)
} else {
fmt.Printf("%d %s (%v)\n", r.StatusCode, r.URL, r.Duration)
}
}
}