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

ScenarioTool
Passing data between goroutinesChannel
Work distribution, result collectionChannel (fan-out/fan-in)
Protecting shared state (read/write)sync.RWMutex
Limiting concurrencyBuffered Channel
Waiting for multiple goroutinessync.WaitGroup
Simple counters/flagssync/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.