14 / 20 · Day 5
Day 5 · Concept 14

select, sync, context

Three primitives that complete Go's concurrency story. select waits on multiple channels at once. sync.Mutex/RWMutex protect shared state when channels don't fit. context.Context propagates cancellation and deadlines through goroutine trees — the foundation of every production Go service.


1 · select — multiplex channels

select is to channels what switch is to values. Each case is a channel operation. The runtime waits until one is ready, then picks one (randomly if multiple ready) and executes its body.

go main.go · select with timeout
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)

    go func() {
        time.Sleep(200 * time.Millisecond)
        ch <- "got it!"
    }()

    select {
    case v := <-ch:
        fmt.Println(v)
    case <-time.After(100 * time.Millisecond):
        fmt.Println("timeout")
    }
}
The four uses of select. Timeout: race a real operation against time.After. Cancel: race against a "done" channel. Non-blocking: add default — runs if no other case is ready. Multi-channel fan-in: read from whichever of N channels is ready.

2 · Non-blocking send / receive

go main.go · default case
package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 99

    // Non-blocking send
    select {
    case ch <- 1:
        fmt.Println("sent")
    default:
        fmt.Println("would block — drop the value")
    }

    // Non-blocking receive — drain
    select {
    case v := <-ch:
        fmt.Println("got:", v)
    default:
        fmt.Println("empty")
    }
}

3 · sync.Mutex — when channels don't fit

Channels are for moving data between goroutines. When you have shared state that multiple goroutines read and write — a counter, a cache, a map — a mutex is simpler.

go main.go · safe counter
package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

func (c *Counter) Val() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

func main() {
    var c Counter
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Inc()
        }()
    }
    wg.Wait()
    fmt.Println(c.Val())  // always 1000, never racy
}

4 · sync.RWMutex — many readers, one writer

go main.go · read-write mutex
package main

import (
    "fmt"
    "sync"
)

type Cache struct {
    mu sync.RWMutex
    m  map[string]string
}

func (c *Cache) Get(k string) string {
    c.mu.RLock()          // many goroutines can hold this concurrently
    defer c.mu.RUnlock()
    return c.m[k]
}

func (c *Cache) Set(k, v string) {
    c.mu.Lock()           // exclusive — no readers either
    defer c.mu.Unlock()
    c.m[k] = v
}

func main() {
    c := &Cache{m: map[string]string{}}
    c.Set("answer", "42")
    fmt.Println(c.Get("answer"))
}
RWMutex vs Mutex. Use RWMutex when reads outnumber writes by ~5× or more. The overhead of the extra state isn't worth it for balanced workloads. Profile if unsure.

5 · context — cancellation, deadlines, values

The context.Context is the standard way to signal "stop working" to a tree of goroutines. Every request handler accepts one. Every long-running operation checks it. Every spawned goroutine inherits it.

go main.go · ctx with timeout
package main

import (
    "context"
    "fmt"
    "time"
)

func work(ctx context.Context, id int) {
    select {
    case <-time.After(2 * time.Second):
        fmt.Printf("worker %d finished
", id)
    case <-ctx.Done():
        fmt.Printf("worker %d cancelled: %v
", id, ctx.Err())
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel()  // release resources

    go work(ctx, 1)
    go work(ctx, 2)

    time.Sleep(1 * time.Second)
}

6 · The four context constructors

ConstructorReturnsUse case
context.Background()An empty root contextTop of main, tests, init
context.TODO()Same as Background"I don't know which context yet" — use Background or TODO before passing in
context.WithCancel(parent)(ctx, cancel)Explicit cancellation by calling cancel()
context.WithTimeout(parent, d)(ctx, cancel)Auto-cancel after duration d
context.WithDeadline(parent, t)(ctx, cancel)Auto-cancel at absolute time t
context.WithValue(parent, k, v)ctxCarry request-scoped data (sparingly!)

7 · Other sync primitives

  • sync.Once. Run code exactly once across goroutines. Use for lazy initialisation: var once sync.Once; once.Do(initFn).
  • sync.WaitGroup. Already met. Add, Done, Wait. The standard goroutine-tree barrier.
  • sync.Map. A concurrent map, optimised for "many reads + occasional writes" or "disjoint key spaces per goroutine". Slower than a plain mutex+map for balanced workloads.
  • sync.Pool. An ephemeral object pool. Get/Put avoid GC pressure on hot paths. Used by the standard library for bytes.Buffer recycling in fmt.
  • sync/atomic. Atomic int/pointer operations without locking. Fastest, lowest-level. Reach for it only when profiling proves a mutex is the bottleneck.

8 · From the wild

go net/http · Server.Shutdown — every primitive at once
func (srv *Server) Shutdown(ctx context.Context) error {
    srv.inShutdown.Store(true)

    srv.mu.Lock()                            // sync.Mutex on Server
    lnerr := srv.closeListenersLocked()
    srv.closeDoneChanLocked()
    for _, f := range srv.onShutdown {
        go f()                                // best-effort callbacks
    }
    srv.mu.Unlock()

    pollIntervalBase := time.Millisecond
    timer := time.NewTimer(nextPollInterval())
    defer timer.Stop()

    for {
        if srv.closeIdleConns() {
            return lnerr
        }
        select {
        case <-ctx.Done():                    // caller's timeout
            return ctx.Err()
        case <-timer.C:
            timer.Reset(nextPollInterval())
        }
    }
}
From the wild: go standard library · BSD-3-Clause

Mutex for shared state. Context for cancellation budget. Select to wait on either. Goroutines for the parallel callbacks. The standard library's canonical concurrency pattern.

9 · Common mistakes

  • Forgetting to call cancel. Every WithTimeout/WithCancel returns a cancel function. Forgetting to call it leaks resources. defer cancel() is the reflex.
  • Storing things in context values. Context values are for request-scoped identifiers (request ID, user ID, tenant). Not for application config. Not for type-checked data.
  • Holding a mutex across a channel op. Deadlock waiting to happen. Release the mutex before blocking on a channel.
  • Copying a Mutex. Pass *Mutex, not Mutex by value. go vet warns about this.
  • RWMutex when writes dominate. RWMutex has more overhead than Mutex. For balanced workloads, plain Mutex wins.
  • Select with only one case. If only one channel involved, just receive directly — the select adds no value.

10 · Exercises (~15 min)

  1. Timeout an operation. Wrap a function that takes 2s in a WithTimeout(500ms). Verify it cancels.
  2. Worker with cancel. Spawn 5 goroutines, each reading from a channel in a loop. After 1 second, cancel() and watch them all exit.
  3. Mutex vs channel. Implement a thread-safe counter twice: once with mutex, once with a channel + goroutine. Benchmark both with testing.B. Surprise yourself.
  4. sync.Once. Use it to lazily initialise an expensive resource (a fake "open DB connection"). Verify only one of 1000 concurrent callers triggers the init.

11 · When it clicks

  • Every long-running function in your code accepts a ctx context.Context as its first argument.
  • You pair every context.WithCancel / WithTimeout with defer cancel() on the next line.
  • You reach for select when "first one wins" is the requirement.
  • You can decide instantly whether a piece of shared state belongs behind a Mutex, a channel, or an atomic.
Found this useful?