Concurrency#

Go’s concurrency model is goroutines + channels + context. A goroutine is a lightweight thread the runtime schedules across OS threads (M:N scheduler); channels carry typed values between goroutines; context carries cancellation and deadlines across function boundaries.

/operators`. For the surrounding control-flow constructs (select, defer), see Control flow.

Goroutines#

go f() schedules f to run concurrently. Cheap (a few KB of stack each); the operator launches thousands without worry.

go worker(id)

for _, item := range items {
    go process(item)             // unbounded; usually wrong
}

Launching a goroutine and forgetting about it leaks; the operator either waits on it (with sync.WaitGroup or errgroup) or owns its lifecycle through a context.

Channels#

A typed pipe between goroutines.

ch := make(chan int)          // unbuffered (synchronous)
ch := make(chan int, 10)      // buffered (async until full)

ch <- 42                       // send (blocks if unbuffered + no receiver)
v := <-ch                      // receive

v, ok := <-ch                  // ok=false when ch is closed and drained
close(ch)                      // sender closes; receiver detects

A closed channel returns the zero value forever; receiving from a nil channel blocks forever. Never closes a channel from the receiver side.

Direction in signatures.

func producer(out chan<- int) { out <- 1 }
func consumer(in <-chan int)  { fmt.Println(<-in) }

select#

select waits on multiple channel operations; the first ready case runs. Random if several are ready.

select {
case v := <-ch:
    handle(v)
case ch <- next:
    sent()
case <-ctx.Done():
    return ctx.Err()
case <-time.After(5 * time.Second):
    return errors.New("timeout")
default:
    // non-blocking; runs if no case is ready
}

sync.WaitGroup#

Wait for a known set of goroutines to finish.

var wg sync.WaitGroup

for _, item := range items {
    wg.Add(1)
    go func(item Item) {
        defer wg.Done()
        process(item)
    }(item)
}
wg.Wait()

For Go 1.22+, the loop variable is per-iteration; in older Go the operator passed item into the goroutine explicitly (as above) to avoid the closure trap.

errgroup#

golang.org/x/sync/errgroup is WaitGroup plus typed errors plus context cancellation. Go-tos for fan-out with shared cancellation.

import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
    url := url            // pre-1.22 loop-variable capture
    g.Go(func() error {
        return fetch(ctx, url)     // ctx cancelled on first error
    })
}
if err := g.Wait(); err != nil { return err }

g.SetLimit(n) caps in-flight goroutines.

context#

context.Context carries cancellation, deadlines, and request-scoped values across API boundaries. The operator threads ctx as the first parameter of every function that might block.

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

if err := op(ctx); err != nil { return err }

Common constructors.

Constructor

Effect

context.Background()

The root context. Use at main / init.

context.TODO()

Placeholder while the operator decides where the real context comes from.

context.WithCancel(ctx)

Returns a child cancellable by calling the returned cancel function.

context.WithTimeout(ctx, d)

Cancels after d.

context.WithDeadline(ctx, t)

Cancels at absolute time t.

context.WithValue(ctx, key, value)

Attach a request-scoped value. Use a typed key, sparingly.

A function honours the context by selecting on ctx.Done().

func op(ctx context.Context) error {
    select {
    case <-time.After(2 * time.Second):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

Mutexes#

For shared mutable state that does not fit cleanly into a channel model, sync.Mutex and sync.RWMutex are the primitives.

type Cache struct {
    mu   sync.Mutex
    data map[string]string
}

func (c *Cache) Get(k string) (string, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    v, ok := c.data[k]
    return v, ok
}

func (c *Cache) Set(k, v string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[k] = v
}

sync.RWMutex is the read-mostly variant; RLock / RUnlock allow many concurrent readers.

sync.Once#

Lazy initialisation, exactly once.

var (
    loaderOnce sync.Once
    loader     *Loader
)

func getLoader() *Loader {
    loaderOnce.Do(func() { loader = NewLoader() })
    return loader
}

Atomic#

sync/atomic exposes lock-free primitives for counters and flags. The Go 1.19+ typed atomics (atomic.Int64, atomic.Bool, atomic.Pointer[T]) replace the older function-form API.

var counter atomic.Int64
counter.Add(1)
fmt.Println(counter.Load())

The race detector#

go test -race and go build -race enable runtime race detection. Run every CI test suite with -race; it catches concurrent reads / writes on the same location.

References#