4e433158cd
---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---
8.0 KiB
8.0 KiB
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 fromdomains/docs; introduces no new P-rules (D-063). Seelanguages/go.mdfor 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 unstructuredgo f()leaks when the parent returns. Usesync.WaitGroup,errgroup.Group, or acontext-scoped pattern to bound lifetime.errgroup.WithContextfor structured concurrency: aGroupcancels its context on first error; siblings see the cancellation and exit. MirrorsTaskGroupsemantics cross-language.- Goroutines share only immutable inputs:
go process(snap)wheresnapis a copy. A goroutine sharing a mutable slice with the parent is a race (Concurrency P1 Immutability, P6 No Silent Races). - No
goin a library function without a documented lifetime: a library that spawns unbounded goroutines leaks them into the caller. Either accept acontext.Contextor return aStop()method.
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). Unboundedmake(chan T)lets the producer run ahead and OOM. selectwithdefaultfor non-blocking send/receive: adefaultcase 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
selectover 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.
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.Contextis the first parameter of every I/O function:func fetchUser(ctx context.Context, id string) (*User, error). A function that does I/O without actxcannot be cancelled (Concurrency P7).context.WithTimeoutfor 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.Contextin a struct: pass it as a parameter. A struct holding actxcaptures a request-scoped value into a long-lived object. - Applies
concurrency/P7: cancellation propagates viactx.Done(). Aselecton<-ctx.Done()is the cancel-aware wait.
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)
selectmultiplexes channel operations: it picks a ready case at random (fair). Aselectwith<-ctx.Done()plus a data case is the cancel-aware wait.defaultmakesselectnon-blocking: use for "send if ready, else drop" (a bounded queue with drop-oldest policy).select {}blocks forever: aselect{}with no cases is a permanent block. Use only in a goroutine that should run until the process exits.- Applies
concurrency/P7: theselectoverctx.Done()and a result channel is the canonical cancel pattern.
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.Mutexscoped minimally: not held across I/O (aSendon a channel, an HTTP call). Hold the lock, mutate, release — then do I/O (Concurrency P3 Lock Scope).sync.RWMutexfor read-heavy,Mutexfor write-heavy: RWMutex adds overhead; only prefer it when reads dominate by 10x+.sync.Mapfor specific cases (append-only, disjoint keys): not a generalmap[K]Vreplacement. For most maps,Mutex+mapis clearer and often faster.sync.Oncefor 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.
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 -raceenforcesP6: the race detector instruments memory accesses and fails on data races. Seego-tooling.mdfor 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.
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—errgroupand error propagation in concurrent code.languages/go-types.md— typed channels carry the named types defined there.languages/go-tooling.md— the-raceCI gate that enforces Concurrency P6.languages/go-testing.md— concurrent tests that exercise the race detector.