Goroutines
The go keyword, scheduling, WaitGroup, context cancellation
Starting a goroutine
The go keyword runs a function concurrently in a new goroutine. A goroutine is a "user-space thread" scheduled by the Go runtime: its initial stack is just 2 KB and grows on demand, so a single process can run hundreds of thousands of them — far cheaper than OS threads.
package main
import (
"fmt"
"time"
)
func handle(id int) {
fmt.Println("handling", id)
}
func main() {
go func() {
fmt.Println("running in another goroutine")
}()
go handle(42)
// give the goroutines a moment to run; replaced by WaitGroup next
time.Sleep(100 * time.Millisecond)
}The GMP Scheduling Model in One Sentence
A G (goroutine) is executed by an M (OS thread) and held in a runnable queue owned by a P (logical processor). GOMAXPROCS controls the number of Ps and defaults to the CPU core count; when a G blocks in a system call, Go automatically hands the P to another M to keep running the remaining Gs, so other goroutines aren't held back.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println(runtime.NumCPU(), runtime.GOMAXPROCS(0))
runtime.GOMAXPROCS(4) // usually no need to set manually
}Waiting for Completion: sync.WaitGroup
After main exits, all goroutines are forcibly terminated. To wait for subtasks, use a WaitGroup: call Add before launching, Done via defer, and Wait blocks until the counter reaches zero.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println("task", i)
}(i)
}
wg.Wait()
}errgroup: a Concurrency Group with Error Propagation
golang.org/x/sync/errgroup is a "can return error" upgrade of WaitGroup: if any goroutine fails, the whole group is automatically cancelled. Common for concurrent RPCs and bulk crawling.
package main
import (
"context"
"log"
"net/http"
"golang.org/x/sync/errgroup"
)
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
})
}
return g.Wait()
}
func main() {
err := fetchAll(context.Background(), []string{
"https://example.com",
"https://example.org",
})
if err != nil {
log.Printf("group failed: %v", err)
}
}Cancelling a goroutine with context
Go has no "kill a goroutine" API. The standard way to stop a subtask is to pass in a context.Context and have the subtask watch ctx.Done() to exit. This cooperative cancellation keeps resource cleanup controllable.
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d stop: %v\n", id, ctx.Err())
return
case <-time.After(200 * time.Millisecond):
fmt.Printf("worker %d tick\n", id)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
go worker(ctx, 1)
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // let the worker print its last line
}panic Does Not Cross goroutines
A panic inside a goroutine that isn't caught by recover crashes the entire process, not just that goroutine. In production, add a fallback recover at the entry of every long-running goroutine.
package main
import (
"log"
"runtime/debug"
"time"
)
func safeGo(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine panic: %v\n%s", r, debug.Stack())
}
}()
fn()
}()
}
func main() {
safeGo(func() {
panic("boom in goroutine")
})
time.Sleep(100 * time.Millisecond)
}Race Detection
Concurrent writes to a shared variable must be guarded by a lock or a channel. Always enable the race detector during development — it records every memory access at runtime and finds reads/writes that lack a happens-before relationship.
$ go run -race main.go
$ go test -race ./...
$ go build -race -o app # a race-enabled binary can also run in a canary envsync.Mutex / RWMutex
Mutex is a mutual-exclusion lock; RWMutex allows multiple concurrent readers but an exclusive writer. Reach for RWMutex only when reads dominate and the critical section isn't tiny; otherwise a plain Mutex is faster.
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.RWMutex
m map[string]int
}
func NewCounter() *Counter { return &Counter{m: map[string]int{}} }
func (c *Counter) Get(k string) int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.m[k]
}
func (c *Counter) Inc(k string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k]++
}
func main() {
c := NewCounter()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc("hits")
}()
}
wg.Wait()
fmt.Println(c.Get("hits")) // 100
}sync.Once and atomic
sync.Once guarantees a piece of initialization code runs only once (even if triggered by multiple goroutines simultaneously); sync/atomic provides lock-free integer/pointer operations for hot paths like counters and status bits.
package main
import (
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
)
var (
once sync.Once
client *http.Client
)
func Client() *http.Client {
once.Do(func() {
client = &http.Client{Timeout: 5 * time.Second}
})
return client
}
func main() {
fmt.Println(Client() == Client()) // true, the same instance
var hit atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
hit.Add(1)
}()
}
wg.Wait()
fmt.Println(hit.Load()) // 1000
}