"Don't communicate by sharing memory; share memory by communicating." — Rob Pike

Go's flagship feature is its concurrency model: cheap goroutines multiplexed onto OS threads, plus channels for typed message passing.

Goroutines

A goroutine is a function call run concurrently. Start one with go:

go fmt.Println("hello from a goroutine")

Goroutines start with a small stack (~2 KiB) that grows as needed, so a single program can have hundreds of thousands of them. They are scheduled cooperatively by the Go runtime, not by the OS.

The main function is itself a goroutine. When main returns, the program exits, even if other goroutines are still running. You typically wait for them with channels, sync.WaitGroup, or errgroup.

Channels

Channels carry typed values between goroutines.

ch := make(chan int)        // unbuffered
ch := make(chan int, 4)     // buffered (capacity 4)

ch <- 42       // send
v := <-ch      // receive
v, ok := <-ch  // receive; ok=false when channel is closed and drained
close(ch)      // sender closes; never close from the receiver side

Unbuffered channels synchronize

A send on an unbuffered channel blocks until another goroutine receives, and vice versa. This is the simplest form of synchronization in Go.

Buffered channels

A send on a buffered channel proceeds immediately if there's room. A receive proceeds immediately if there's a value. Use buffered channels for limited worker pools, batching, or to absorb bursts.

range over a channel

for v := range ch {
    fmt.Println(v)
}
// loop exits when ch is closed

select

select waits on multiple channel operations. The first one that can proceed is chosen; if multiple are ready, one is picked at random.

select {
case v := <-in:
    fmt.Println("in:", v)
case out <- nextValue:
    fmt.Println("sent")
case <-time.After(time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("nothing ready right now")
}

The default case makes the select non-blocking.

Synchronization primitives (sync)

When you need to share memory between goroutines:

var mu sync.Mutex
mu.Lock()
shared.value = 42
mu.Unlock()
  • sync.Mutex — exclusive lock.
  • sync.RWMutex — many readers or one writer.
  • sync.WaitGroup — wait for N goroutines to finish.
  • sync.Once — run an initializer exactly once.
  • sync.Pool — reusable per-goroutine object cache (use only when profile says so).
  • sync/atomic — lock-free atomic ops on integers and pointers.

WaitGroup

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        fmt.Println("worker", i)
    }(i)
}
wg.Wait()

(In Go 1.22+ the loop variable is per-iteration, so the explicit i parameter is no longer required for correctness — but it's still a clear style.)

context.Context

context.Context carries cancellation, deadlines and request-scoped values across API boundaries. It is the canonical way to stop work in Go:

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

select {
case <-time.After(5 * time.Second):
    fmt.Println("done")
case <-ctx.Done():
    fmt.Println("aborted:", ctx.Err())
}

Every long-running function in production Go takes a ctx context.Context as its first argument and respects it.

Worker pool pattern

jobs := make(chan int, 100)
results := make(chan int, 100)

for w := 0; w < 4; w++ {
    go func() {
        for j := range jobs {
            results <- j * j
        }
    }()
}

go func() {
    for i := 1; i <= 10; i++ {
        jobs <- i
    }
    close(jobs)
}()

for i := 0; i < 10; i++ {
    fmt.Println(<-results)
}

Pipelines

Stages are goroutines; channels connect them.

func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums { out <- n }
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in { out <- n * n }
    }()
    return out
}

for v := range square(gen(1, 2, 3, 4)) {
    fmt.Println(v)
}

The race detector

Always run tests with -race during development:

go test -race ./...
go run -race ./cmd/myapp

It instrumented your binary with a runtime that flags data races. If it fires, you have a bug. There are no false positives.

Common pitfalls

  1. Forgetting to wait for goroutines — they get killed when main returns. Use WaitGroup, errgroup, or block on the result channel.
  2. Sending on a closed channel — panics. Only the sender closes, never the receiver. With multiple senders, coordinate via a separate "done" channel or sync.Once.
  3. Leaking goroutines — a goroutine blocked on a channel that no one will send to lives forever. Always have a path to exit: ctx.Done(), close upstream channels, or a timeout.
  4. Sharing slices/maps without locks — they are NOT safe for concurrent write. Use a mutex or pass copies.

Run the example

go run ./part1-language/examples/10_concurrency