Goroutines and Synchronization
Goroutines
Section titled “Goroutines”A goroutine is a lightweight thread managed by the Go runtime. Goroutines are multiplexed onto a Small number of OS threads (default: GOMAXPROCS, equal to the number of CPU cores). The Initial stack size is small (2-8 KB) and grows/shrinks as needed.
Starting a goroutine:
go func() { fmt.Println("running in a goroutine")}()Goroutines are cheap. Creating thousands or millions of goroutines is routine in Go programs:
for i := 0; i < 100000; i++ { go func(id int) { doWork(id) }(i)}The Goroutine Scheduler
Section titled “The Goroutine Scheduler”Go uses an M:N scheduler: M goroutines are multiplexed onto N OS threads (called “M” in Go”s runtime Terminology, with “P” as logical processors). The scheduler uses work-stealing to balance load Across Ps.
Key components:
- G (goroutine): the user-level thread
- M (machine): the OS thread
- P (processor): the context for scheduling, holds the run queue
GOMAXPROCS controls the number of Ps (and thus the maximum parallelism):
runtime.GOMAXPROCS(4) // use 4 OS threadsSince Go 1.5, GOMAXPROCS defaults to runtime.NumCPU().
sync.WaitGroup
Section titled “sync.WaitGroup”sync.WaitGroup waits for a collection of goroutines to finish:
func main() { var wg sync.WaitGroup
for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("worker %d\n", id) }(i) }
wg.Wait() fmt.Println("all workers done")}Rules:
Add(delta)must be called before the goroutine starts.Done()decrements the counter. It is deferred.Wait()blocks until the counter reaches zero.
WaitGroup Gotcha
Section titled “WaitGroup Gotcha”Never copy a WaitGroup by value. It contains internal state that must not be duplicated:
// Wrong: copies the WaitGroupvar wg sync.WaitGroupgo func(wg sync.WaitGroup) { // copy! defer wg.Done()}(wg)
// Correct: pass by pointervar wg sync.WaitGroupwg.Add(1)go func(wg *sync.WaitGroup) { defer wg.Done()}(&wg)sync.Mutex
Section titled “sync.Mutex”sync.Mutex provides mutual exclusion. Only one goroutine can hold the lock at a time:
type SafeCounter struct { mu sync.Mutex value int}
func (c *SafeCounter) Incr() { c.mu.Lock() c.value++ c.mu.Unlock()}
func (c *SafeCounter) Value() int { c.mu.Lock() defer c.mu.Unlock() return c.value}Critical Section Rules
Section titled “Critical Section Rules”- Always pair
Lock()withUnlock(). Usedeferto ensureUnlock()runs even on panic. - Keep critical sections as short as possible. Long-held locks reduce concurrency.
- Never copy a
Mutexafter first use.
Locking Gotchas
Section titled “Locking Gotchas”// Deadlock: reentrant lock attemptmu.Lock()mu.Lock() // deadlock -- same goroutine tries to lock againGo’s Mutex is not reentrant. If the same goroutine calls Lock() twice without unlocking, it Deadlocks. This is a deliberate design choice — reentrant locks hide locking discipline bugs.
sync.RWMutex
Section titled “sync.RWMutex”sync.RWMutex allows multiple readers or a single writer:
type Cache struct { mu sync.RWMutex items map[string]string}
func (c *Cache) Get(key string) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() v, ok := c.items[key] return v, ok}
func (c *Cache) Set(key, value string) { c.mu.Lock() defer c.mu.Unlock() c.items[key] = value}Use RLock/RUnlock for read-only access. This allows concurrent readers but blocks writers (and Vice versa). Use Lock/Unlock for writes.
When to Use RWMutex vs Mutex
Section titled “When to Use RWMutex vs Mutex”Use RWMutex when reads significantly outnumber writes and the read critical section is not short. The overhead of RWMutex is higher than MutexSo if reads are rare or the Critical section is a single field read, Mutex may be faster.
sync.Once
Section titled “sync.Once”sync.Once ensures a function is executed exactly once, regardless of how many goroutines call it:
var instance *Singletonvar once sync.Once
func GetInstance() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance}sync.Once is safe for concurrent use and is the idiomatic way to implement lazy initialization in Go.
sync.Map
Section titled “sync.Map”sync.Map is a concurrency-safe map. It is optimized for two patterns:
- Append-only (keys written once, read many times)
- Disjoint (goroutines read/write distinct keys)
var m sync.Map
m.Store("key", "value")v, ok := m.Load("key")m.Delete("key")m.LoadOrStore("key", "default")Use sync.Map only when the standard map with sync.RWMutex is a proven bottleneck. For most use Cases, map + Mutex/RWMutex is simpler and performs better.
Channel Basics
Section titled “Channel Basics”Channels are a typed conduit for sending values between goroutines. They synchronize goroutines and Provide a safe communication mechanism without explicit locks.
Unbuffered Channels
Section titled “Unbuffered Channels”An unbuffered channel blocks the sender until a receiver is ready:
ch := make(chan int)
go func() { ch <- 42 // blocks until receiver is ready}()
v := <-ch // blocks until sender is readyfmt.Println(v) // 42Buffered Channels
Section titled “Buffered Channels”A buffered channel does not block the sender until the buffer is full:
ch := make(chan int, 3)
ch <- 1 // does not blockch <- 2 // does not blockch <- 3 // does not block// ch <- 4 // would block -- buffer is full
v := <-ch // 1Closing Channels
Section titled “Closing Channels”Only the sender should close a channel. Closing a closed channel panics. Receiving from a closed Channel returns the zero value and false:
ch := make(chan int, 2)ch <- 1ch <- 2close(ch)
for v := range ch { fmt.Println(v) // 1, 2}
v, ok := <-chfmt.Println(v, ok) // 0 falseChannel Direction
Section titled “Channel Direction”Channels can be restricted to send-only or receive-only:
func sender(ch chan<- int) { ch <- 42 // <-ch // compile error: receive from send-only channel}
func receiver(ch <-chan int) { v := <-ch fmt.Println(v) // ch <- 1 // compile error: send to receive-only channel}Use directional channels in function signatures to make the intended usage explicit.
Common Pitfalls
Section titled “Common Pitfalls”Forgetting to close channels. If the sender does not close the channel, receivers waiting with
rangewill block forever. Usedefer close(ch)or close explicitly when done.Closing a channel twice. Closing an already-closed channel panics. Track ownership: only the goroutine responsible for sending should close the channel.
Sending on a closed channel. Sending a value to a closed channel panics. This is a fundamental rule of Go channels.
Blocking on unbuffered channel with no receiver. An unbuffered send blocks until a receiver is ready. If no receiver exists, the goroutine blocks forever.
Copying a Mutex after use.
sync.Mutexmust not be copied after first use. The same applies tosync.RWMutex``sync.WaitGroup``sync.CondAndsync.Pool. Use pointers.Using goroutines without synchronization. Starting a goroutine and not waiting for it or communicating with it via a channel leads to data races. Use
sync.WaitGroupor channels to coordinate.Deadlock with RWMutex. A goroutine holding
RLockcannot upgrade toLock— it must releaseRLockfirst. Attempting to callLock()while holdingRLock()deadlocks.
flowchart TD
A[Goroutines] --> B[Key Concepts]
A --> C[Core Principles]
A --> D[Practical Applications]
B --> E[Fundamental definitions]
C --> F[Design patterns]
D --> G[Real-world usage]Summary
Section titled “Summary”This topic covers the core concepts of goroutines and synchronization, including underlying theory, practical implementation, and key applications.
Key concepts include:
- core concepts and terminology
- algorithms and computational thinking
- practical implementation
- security and ethical considerations
- applications in the real world
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Intuition
Section titled “Intuition”Goroutines are lightweight threads managed by the Go runtime, not the operating system. Starting a goroutine costs almost nothing — a few kilobytes of stack that grows on demand — so you can run millions simultaneously. The Go scheduler multiplexes many goroutines onto a small number of OS threads, using work-stealing to balance load. Channels are the primary way goroutines communicate: they are typed pipes that synchronize sender and receiver. Mutexes protect shared state when channels are not the right fit. sync.Once ensures lazy initialization happens exactly once even with many concurrent callers. The key rule is: never share memory by communicating; communicate by sharing memory through channels.
Cross-References
Section titled “Cross-References”- Channels and Concurrency Patterns — select, pipelines, and fan-out
- Functions — goroutines as anonymous function launches
- Testing — testing concurrent code