Concurrency
Go's concurrency model β goroutines, channels, and the GMP scheduler β is one of its greatest strengths. The mantra: "Do not communicate by sharing memory; instead, share memory by communicating."
π― Learning Objectives
- Launch goroutines and understand the GMP scheduler
- Use buffered and unbuffered channels
- Multiplex channels with
select - Understand and prevent data races
- Use
sync.WaitGroup,sync.Mutex,sync.Once - Implement the worker pool and fan-out/fan-in patterns
- Use context for cancellation and timeouts
π¬ The GMP Scheduler
G (Goroutines) β lightweight user-space threads (~2KB initial stack)
P (Processors) β logical CPUs (GOMAXPROCS, defaults to physical core count)
M (Machines) β OS threads (created/destroyed dynamically by the runtime)
G G G G G G G G G (goroutines β millions possible)
β β β β β β β β β
βββββββββββ βββββββββββ
β P (run β β P (run β (P holds a local run queue of goroutines)
β queue) β β queue) β
ββββββ¬βββββ ββββββ¬βββββ
β β
M M (M is an OS thread, pinned to a P)
β β
[OS kernel] [OS kernel]
When a goroutine blocks (syscall/channel):
β The P detaches from M and attaches to another M
β Unblocked goroutine rejoins a P's run queue
β Result: no wasted OS threads while waiting for I/O!
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
fmt.Println("CPUs:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0 = query only
// Goroutine β lightweight concurrency primitive
// Stack starts at 2KB and grows/shrinks dynamically (up to 1GB by default)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) { // capture id by value β avoid closure variable capture bug!
defer wg.Done()
time.Sleep(time.Duration(id) * 10 * time.Millisecond)
fmt.Printf("goroutine %d done\n", id)
}(i)
}
wg.Wait() // blocks until all goroutines call Done()
fmt.Println("all goroutines finished")
// Current goroutine count
fmt.Println("goroutines:", runtime.NumGoroutine())
}π‘ Channels
Channels are typed conduits for communicating between goroutines. They provide synchronisation with communication.
package main
import (
"fmt"
"time"
)
func producer(ch chan<- int) { // send-only channel
for i := 1; i <= 5; i++ {
fmt.Println("sending", i)
ch <- i // send blocks until receiver is ready (unbuffered)
time.Sleep(50 * time.Millisecond)
}
close(ch) // signal no more values β receivers see zero value after close
}
func consumer(ch <-chan int) { // receive-only channel
for v := range ch { // range receives until channel is closed
fmt.Println("received", v)
}
}
func main() {
// Unbuffered channel β every send blocks until there is a receive
ch := make(chan int) // capacity = 0
go producer(ch)
consumer(ch)
fmt.Println("--- buffered channel ---")
// Buffered channel β sends block only when buffer is full
bch := make(chan string, 3) // capacity = 3
bch <- "a" // doesn't block
bch <- "b" // doesn't block
bch <- "c" // doesn't block
// bch <- "d" // β would block! buffer full
fmt.Println("buffered:", len(bch), "/", cap(bch))
fmt.Println(<-bch) // a
fmt.Println(<-bch) // b
fmt.Println(<-bch) // c
// Done channel pattern β signal completion
done := make(chan struct{})
go func() {
time.Sleep(100 * time.Millisecond)
fmt.Println("work complete")
close(done) // broadcast to all receivers
}()
<-done // wait for signal
}π Select β Multiplexing Channels
package main
import (
"context"
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() { time.Sleep(100*time.Millisecond); ch1 <- "one" }()
go func() { time.Sleep( 50*time.Millisecond); ch2 <- "two" }()
// select waits for any case to be ready
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
}
}
// select with timeout
ch := make(chan int)
select {
case v := <-ch:
fmt.Println("received:", v)
case <-time.After(200 * time.Millisecond):
fmt.Println("timeout β no message received")
}
// select with context cancellation (production pattern)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
workCh := make(chan string)
go func() {
time.Sleep(300 * time.Millisecond)
workCh <- "result"
}()
select {
case result := <-workCh:
fmt.Println("got result:", result)
case <-ctx.Done():
fmt.Println("context cancelled:", ctx.Err())
}
// Non-blocking select with default
ch3 := make(chan int, 1)
select {
case v := <-ch3:
fmt.Println("received:", v)
default:
fmt.Println("no message ready β non-blocking")
}
}β οΈ Data Races & Synchronisation
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
// β DATA RACE β counter is shared without synchronisation
// Run with: go run -race main.go
// counter := 0
// var wg sync.WaitGroup
// for i := 0; i < 1000; i++ {
// wg.Add(1)
// go func() { defer wg.Done(); counter++ }() // RACE!
// }
// wg.Wait()
// β
Fix 1: sync.Mutex
var mu sync.Mutex
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("mutex counter:", counter) // always 1000
// β
Fix 2: atomic (fastest for simple counters)
var atomicCounter int64
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&atomicCounter, 1) // lock-free, CPU instruction
}()
}
wg.Wait()
fmt.Println("atomic counter:", atomicCounter) // always 1000
// sync.Once β initialise exactly once (thread-safe singleton)
var once sync.Once
for i := 0; i < 5; i++ {
once.Do(func() {
fmt.Println("initialised exactly once")
})
}
}π Worker Pool Pattern
package main
import (
"fmt"
"sync"
"time"
)
type Job struct{ ID int }
type Result struct{ JobID, Value int }
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
// Simulate work
time.Sleep(10 * time.Millisecond)
results <- Result{JobID: job.ID, Value: job.ID * job.ID}
fmt.Printf("worker %d processed job %d\n", id, job.ID)
}
}
func main() {
const numWorkers = 3
const numJobs = 9
jobs := make(chan Job, numJobs)
results := make(chan Result, numJobs)
// Start worker pool
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Send all jobs
for j := 1; j <= numJobs; j++ {
jobs <- Job{ID: j}
}
close(jobs) // signal workers: no more jobs
// Close results channel when all workers are done
go func() {
wg.Wait()
close(results)
}()
// Collect results
total := 0
for r := range results {
total += r.Value
}
fmt.Printf("Sum of squares 1..9 = %d\n", total) // 285
}π€ Interview Questions
A goroutine is a lightweight concurrently executing function managed by the Go runtime. Key differences from OS threads:
- Memory: Goroutine starts at ~2KB stack (grows dynamically up to 1GB); OS thread typically uses 1β8MB fixed stack.
- Creation cost: Goroutines are cheap (~microseconds); OS threads are expensive (~milliseconds).
- Scheduling: Goroutines are scheduled by Go's GMP scheduler in user space; OS threads are scheduled by the kernel.
- Multiplexing: Thousands of goroutines run on a handful of OS threads via the GMP model.
- Blocking: When a goroutine blocks on I/O, the runtime parks it and runs another goroutine on the same OS thread. OS threads block and waste the kernel thread.
A typical Go server can handle 100,000+ concurrent goroutines on commodity hardware.
A data race occurs when two goroutines access the same memory location concurrently AND at least one access is a write, without synchronisation. The Go memory model guarantees that reads and writes to shared memory must be synchronised β otherwise results are undefined.
Detection: Run with go run -race or go test -race. The built-in race detector instruments memory accesses at compile time and reports races at runtime with the full goroutine stack trace.
Fixes: (1) sync.Mutex: protect shared data with Lock/Unlock; (2) sync/atomic: for simple counters and flags; (3) Channels: communicate rather than share β pass ownership of data through a channel; (4) sync.RWMutex: multiple readers, exclusive writer.
Send to closed channel: Panics immediately β "send on closed channel". Never send to a channel you don't own or that may be closed by another goroutine. The producer should be the one who closes channels.
Receive from closed channel: Always succeeds immediately, returning the zero value of the element type and false for the ok flag: v, ok := <-ch. With range, the loop terminates cleanly when the channel is closed. This is the primary mechanism for signalling that a producer is done.
Rule: Only close a channel from the sender side. Never close from the receiver side. Use a done channel or context to signal cancellation in the opposite direction.
π§ͺ Quiz
make(chan int) create?select cases are ready simultaneously, Go:π Cheat Sheet
go func() { ... }()ch := make(chan T)ch := make(chan T, cap)ch <- valv := <-chclose(ch) // from sender onlyfor v := range ch { }select { case v:=<-ch: ... }wg.Add(1); go...; wg.Done(); wg.Wait()mu.Lock(); defer mu.Unlock()atomic.AddInt64(&n, 1)go run -race / go test -race