Skip to content

Goroutines and Synchronization

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)
}

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 threads

Since Go 1.5, GOMAXPROCS defaults to runtime.NumCPU().

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:

  1. Add(delta) must be called before the goroutine starts.
  2. Done() decrements the counter. It is deferred.
  3. Wait() blocks until the counter reaches zero.

Never copy a WaitGroup by value. It contains internal state that must not be duplicated:

// Wrong: copies the WaitGroup
var wg sync.WaitGroup
go func(wg sync.WaitGroup) { // copy!
defer wg.Done()
}(wg)
// Correct: pass by pointer
var wg sync.WaitGroup
wg.Add(1)
go func(wg *sync.WaitGroup) {
defer wg.Done()
}(&wg)

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
}
  1. Always pair Lock() with Unlock(). Use defer to ensure Unlock() runs even on panic.
  2. Keep critical sections as short as possible. Long-held locks reduce concurrency.
  3. Never copy a Mutex after first use.
// Deadlock: reentrant lock attempt
mu.Lock()
mu.Lock() // deadlock -- same goroutine tries to lock again

Go’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 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.

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 ensures a function is executed exactly once, regardless of how many goroutines call it:

var instance *Singleton
var 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 is a concurrency-safe map. It is optimized for two patterns:

  1. Append-only (keys written once, read many times)
  2. 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.

Channels are a typed conduit for sending values between goroutines. They synchronize goroutines and Provide a safe communication mechanism without explicit locks.

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 ready
fmt.Println(v) // 42

A buffered channel does not block the sender until the buffer is full:

ch := make(chan int, 3)
ch <- 1 // does not block
ch <- 2 // does not block
ch <- 3 // does not block
// ch <- 4 // would block -- buffer is full
v := <-ch // 1

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 <- 1
ch <- 2
close(ch)
for v := range ch {
fmt.Println(v) // 1, 2
}
v, ok := <-ch
fmt.Println(v, ok) // 0 false

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.

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.”

  1. Forgetting to close channels. If the sender does not close the channel, receivers waiting with range will block forever. Use defer close(ch) or close explicitly when done.

  2. Closing a channel twice. Closing an already-closed channel panics. Track ownership: only the goroutine responsible for sending should close the channel.

  3. Sending on a closed channel. Sending a value to a closed channel panics. This is a fundamental rule of Go channels.

  4. Blocking on unbuffered channel with no receiver. An unbuffered send blocks until a receiver is ready. If no receiver exists, the goroutine blocks forever.

  5. Copying a Mutex after use. sync.Mutex must not be copied after first use. The same applies to sync.RWMutex``sync.WaitGroup``sync.CondAnd sync.Pool. Use pointers.

  6. 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.WaitGroup or channels to coordinate.

  7. Deadlock with RWMutex. A goroutine holding RLock cannot upgrade to Lock — it must release RLock first. Attempting to call Lock() while holding RLock() deadlocks.

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 demonstrating the application of key concepts are covered in the detailed sub-pages linked above.