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.
Intuition
Section titled “Intuition”Goroutines are lightweight workers in a factory: Imagine a factory where you can hire workers for almost free (2-8 KB of stack each). Workers communicate through message pipes (channels) — you put a part in one end, someone picks it up the other end. The sync primitives are like locks on toolboxes: only one worker can grab a wrench at a time. The M:N scheduler is the factory floor manager, juggling thousands of workers across a fixed number of actual workstations.
Why it matters: Goroutines make concurrent programming practical in everyday code. You don’t need thread pools or callback pyramids — just go func() and a channel. The scheduler handles the hard part of mapping many logical threads onto few OS threads.
The key insight: Go concurrency is about communication (channels) rather than coordination (locks) — “Don’t communicate by sharing memory; share memory by communicating.”
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.
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.