Go’s concurrency model builds on CSP (Communicating Sequential Processes). The core philosophy is one line:
“Do not communicate by sharing memory; instead, share memory by communicating.”
Instead of locking shared memory, pass data through channels. Goroutines handle execution, Channels handle communication, and the sync/atomic packages provide auxiliary synchronization.
Goroutine
A goroutine is Go’s lightweight execution unit. It is not an OS thread. The Go runtime multiplexes many goroutines onto a small number of OS threads.
go func() {
// This function runs in a new goroutine
}()
A single go keyword creates one. The initial stack is only a few kilobytes, and the runtime grows and shrinks it automatically as needed. OS threads become impractical at a few thousand; goroutines scale to hundreds of thousands in the same address space.
GMP Scheduler
The Go runtime uses an M:N scheduling model, known as the GMP model.
flowchart TB
subgraph Runtime["Go Runtime"]
subgraph P1["P (Processor)"]
LRQ1["Local Queue: G1, G2, G3"]
end
subgraph P2["P (Processor)"]
LRQ2["Local Queue: G4, G5"]
end
GRQ["Global Queue: G6, G7..."]
end
subgraph OS["OS"]
M1["M (OS Thread)"]
M2["M (OS Thread)"]
M3["M (OS Thread)"]
end
P1 --> M1
P2 --> M2
GRQ -.->|"Stolen when P's local queue is empty"| P1
G (Goroutine). A lightweight execution unit carrying a function and its stack.
M (Machine). An OS thread. Executes instructions on the actual CPU.
P (Processor). A logical processor. Provides the context needed to run goroutines. GOMAXPROCS controls the number of Ps, defaulting to the CPU core count.
Each P has a local queue. When a goroutine is created, it enters the current P’s local queue. An M attaches to a P and executes goroutines from its local queue one by one. If a goroutine blocks on a system call, the runtime moves the other goroutines on that P to a different M to keep them running.
The overhead averages about three cheap instructions per function call.
Channel
A Channel is a typed communication mechanism for passing data between goroutines.
Unbuffered Channel
ch := make(chan int)
Both sender and receiver must be ready for the transfer to complete. The sender blocks until the receiver takes the value; the receiver blocks until the sender sends. Communication and synchronization happen simultaneously.
sequenceDiagram
participant G1 as Goroutine 1
participant Ch as Channel (unbuffered)
participant G2 as Goroutine 2
G1->>Ch: Send (blocks)
Note over G1,Ch: Waits until G2 receives
G2->>Ch: Receive
Ch-->>G1: Send completes
Ch-->>G2: Value delivered
Buffered Channel
ch := make(chan int, 10) // buffer size 10
Sends complete immediately if buffer space is available. The sender blocks only when the buffer is full. Buffered channels can serve as semaphores to limit concurrency.
Directionality
Specifying channel direction clarifies a function’s intent.
func producer(out chan<- int) { // send-only
out <- 42
}
func consumer(in <-chan int) { // receive-only
val := <-in
}
select
The select statement executes whichever channel operation is ready. It handles waiting on multiple channels, timeouts, and non-blocking operations.
select {
case msg := <-ch1:
handle(msg)
case ch2 <- response:
// send completed
case <-quit:
return
default:
// no channel ready
}
Including default makes the select non-blocking when no channel is ready.
Key Patterns
flowchart LR
subgraph FanOut["Fan-Out"]
IN1["Input"] --> W1["Worker 1"]
IN1 --> W2["Worker 2"]
IN1 --> W3["Worker 3"]
end
subgraph FanIn["Fan-In"]
R1["Result 1"] --> OUT1["Output"]
R2["Result 2"] --> OUT1
R3["Result 3"] --> OUT1
end
Fan-Out. Multiple goroutines read from a single channel to distribute work.
Fan-In. Results from multiple channels merge into one.
Pipeline. Processing stages connected by channels. Each stage reads from an input channel, processes, and sends to an output channel.
sync Package
Channels are not always the best choice. For simple shared state protection, the sync package fits well.
Mutex. Ensures only one goroutine enters a critical section. Controlled with Lock() and Unlock().
RWMutex. Multiple goroutines read concurrently; writes are exclusive. Effective when reads far outnumber writes.
WaitGroup. Waits for multiple goroutines to finish. Add() increments the counter, Done() decrements it, Wait() blocks until zero.
Once. Runs a function exactly once. Used for initialization.
atomic Package
The sync/atomic package provides atomic operations on integers and pointers. It reads and writes single variables safely without locks.
CompareAndSwap (CAS) is the foundation of lock-free algorithms. If the current value equals the expected value, it swaps in the new value and returns true. Otherwise, it returns false and does nothing.
var counter int64
// Safe increment from multiple goroutines
atomic.AddInt64(&counter, 1)
// CAS: swap only if expected value matches
atomic.CompareAndSwapInt64(&counter, oldVal, newVal)
These are lower-level tools than the sync package. Suitable for simple counters and flags, but Mutex or Channel is better for complex synchronization.
Selection Criteria
| Scenario | Tool |
|---|---|
| Passing data between goroutines | Channel |
| Work distribution, result collection | Channel (fan-out/fan-in) |
| Protecting shared state (read/write) | sync.RWMutex |
| Limiting concurrency | Buffered Channel |
| Waiting for multiple goroutines | sync.WaitGroup |
| Simple counters/flags | sync/atomic |
The Go wiki summarizes it this way: channels suit ownership transfer, work distribution, and async result delivery. Mutexes suit caches and shared resource access control. Both are valid tools — choose based on the situation.