Files
atelier/languages/go-concurrency.md
T
Jon Chery 4e433158cd docs(P03): complete language-derived extension — v0.4
---ci---
project: atelier
phase: 3
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.3
requirements:
  covered: [ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105]
  partial: []
---/ci---
2026-08-05 16:07:21 +00:00

171 lines
8.0 KiB
Markdown

# Go Concurrency — Derived Application
> Applies Atelier's domain principles to Go's concurrency specifically. Go's distinctive strength (goroutines, channels, context) earns a dedicated concurrency doc rather than a `go-async.md`.
> Derives from `domains/` docs; introduces no new P-rules (D-063).
> See `languages/go.md` for the language first-principles stub.
## Goroutines and Structured Concurrency (Concurrency P1 Immutability by Default, C6 Composability)
- **`go f()` spawns a goroutine; ensure it does not outlive its parent:** an unstructured `go f()` leaks when the parent returns. Use `sync.WaitGroup`, `errgroup.Group`, or a `context`-scoped pattern to bound lifetime.
- **`errgroup.WithContext` for structured concurrency:** a `Group` cancels its context on first error; siblings see the cancellation and exit. Mirrors `TaskGroup` semantics cross-language.
- **Goroutines share only immutable inputs:** `go process(snap)` where `snap` is a copy. A goroutine sharing a mutable slice with the parent is a race (Concurrency P1 Immutability, P6 No Silent Races).
- **No `go` in a library function without a documented lifetime:** a library that spawns unbounded goroutines leaks them into the caller. Either accept a `context.Context` or return a `Stop()` method.
```go
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, ids []string) ([]*User, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]*User, len(ids))
for i, id := range ids {
i, id := i, id // capture loop vars
g.Go(func() error {
u, err := fetchUser(ctx, id)
if err != nil { return err }
results[i] = u
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
```
## Channels: Bounded Queues and Backpressure (Concurrency P9 Bounded Queues, C6 Composability)
- **Bounded channels apply backpressure:** `make(chan T, N)` blocks the sender when full (Concurrency P9 — bounded queues). Unbounded `make(chan T)` lets the producer run ahead and OOM.
- **`select` with `default` for non-blocking send/receive:** a `default` case makes the channel a queue with try semantics; without it, the operation blocks.
- **Close channel from the sender, never the receiver:** closing a channel signals "no more sends." A receiver closing it is a race; the sender may still be writing.
- **One channel, one responsibility:** do not multiplex control and data on the same channel. Use a `select` over multiple channels instead.
- **Applies `messaging/queues`:** a bounded Go channel is an in-process broker — bounded buffer, backpressure, at-most-once handoff. The same semantics apply; the broker is local.
```go
func pipeline(ctx context.Context, in <-chan Job, out chan<- Result) {
for {
select {
case j, ok := <-in:
if !ok { return }
r := process(j)
select {
case out <- r:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}
// bounded: backpressure when out is full
out := make(chan Result, 16)
```
## context.Context for Cancellation (Concurrency P7 Cancellation Support, Concurrency P8 Timeout Discipline)
- **`context.Context` is the first parameter of every I/O function:** `func fetchUser(ctx context.Context, id string) (*User, error)`. A function that does I/O without a `ctx` cannot be cancelled (Concurrency P7).
- **`context.WithTimeout` for a deadline:** `ctx, cancel := context.WithTimeout(ctx, 5*time.Second); defer cancel()`. Every external call races against a deadline (Concurrency P8).
- **`cancel()` always called, even on success:** `defer cancel()` immediately after creating the context. A leaked context leaks its timer.
- **Never store a `context.Context` in a struct:** pass it as a parameter. A struct holding a `ctx` captures a request-scoped value into a long-lived object.
- **Applies `concurrency/P7`:** cancellation propagates via `ctx.Done()`. A `select` on `<-ctx.Done()` is the cancel-aware wait.
```go
func fetchWithTimeout(ctx context.Context, url string) (*Response, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, ErrTimeout
}
return nil, err
}
return resp, nil
}
```
## select and Multiplexed Channels (Concurrency P7 Cancellation Support, C6 Composability)
- **`select` multiplexes channel operations:** it picks a ready case at random (fair). A `select` with `<-ctx.Done()` plus a data case is the cancel-aware wait.
- **`default` makes `select` non-blocking:** use for "send if ready, else drop" (a bounded queue with drop-oldest policy).
- **`select {}` blocks forever:** a `select{}` with no cases is a permanent block. Use only in a goroutine that should run until the process exits.
- **Applies `concurrency/P7`:** the `select` over `ctx.Done()` and a result channel is the canonical cancel pattern.
```go
func processUntilCancel(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok { return }
// ...
}
}
}
```
## sync Primitives and Lock Scope (Concurrency P3 Boundaries are Locks, Concurrency P5 Lock Minimization)
- **`sync.Mutex` scoped minimally:** not held across I/O (a `Send` on a channel, an HTTP call). Hold the lock, mutate, release — then do I/O (Concurrency P3 Lock Scope).
- **`sync.RWMutex` for read-heavy, `Mutex` for write-heavy:** RWMutex adds overhead; only prefer it when reads dominate by 10x+.
- **`sync.Map` for specific cases (append-only, disjoint keys):** not a general `map[K]V` replacement. For most maps, `Mutex` + `map` is clearer and often faster.
- **`sync.Once` for one-time init:** `var once sync.Once; once.Do(func(){ init() })`. Idempotent and race-free.
- **Applies `concurrency/P5` (lock minimization):** prefer channels over locks; when a lock is needed, hold it for the smallest possible scope.
```go
type Cache struct {
mu sync.Mutex
items map[string]*User
}
func (c *Cache) Get(id string) (*User, bool) {
c.mu.Lock()
defer c.mu.Unlock()
u, ok := c.items[id]
return u, ok
}
func (c *Cache) Set(id string, u *User) {
c.mu.Lock()
c.items[id] = u
c.mu.Unlock() // explicit unlock before any I/O
}
```
## Race Detection (Concurrency P6 No Silent Races)
- **`go test -race` enforces `P6`:** the race detector instruments memory accesses and fails on data races. See `go-tooling.md` for the CI gate.
- **Tests must exercise the concurrent path:** a serial test of a `Mutex`-protected map finds no race. Write tests with N goroutines hitting the map under `-race`.
- **Applies `concurrency/P6`:** a race detected at test time is a bug fixed; a race undetected is a production heisenbug.
```go
func TestCacheConcurrent(t *testing.T) {
c := &Cache{items: map[string]*User{}}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
c.Set(strconv.Itoa(i), &User{})
c.Get(strconv.Itoa(i))
}()
}
wg.Wait()
}
```
## Cross-References
- `domains/concurrency/patterns.md` — the cancellation/timeout/semaphore patterns applied here.
- `domains/concurrency/first-principles.md` — Concurrency P1, P3, P5, P6, P7, P8, P9 traced throughout.
- `domains/messaging/queues.md` — bounded Go channels as in-process brokers; backpressure parallels (IDEATE-40).
- `domains/errors/patterns.md``errgroup` and error propagation in concurrent code.
- `languages/go-types.md` — typed channels carry the named types defined there.
- `languages/go-tooling.md` — the `-race` CI gate that enforces Concurrency P6.
- `languages/go-testing.md` — concurrent tests that exercise the race detector.