Initial commit: Go SRE interview exercises
Includes solved 02-worker-pool and 09-safe-counter (mutex, atomic, sharded). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# 07 — Fan-in (Merge)
|
||||
|
||||
The mirror image of the worker pool: N producers, one stream. Every metrics
|
||||
pipeline, event aggregator, and multi-shard reader is this shape.
|
||||
|
||||
## The double-select most solutions miss
|
||||
|
||||
A forwarder that's correct under cancellation needs `ctx.Done()` in **two**
|
||||
places:
|
||||
|
||||
```go
|
||||
for {
|
||||
v, ok := <-ch // (1) can block forever if input never closes
|
||||
if !ok { return }
|
||||
out <- v // (2) can block forever if consumer walked away
|
||||
}
|
||||
```
|
||||
|
||||
Both blocking points must become selects. Miss (2) and you have the sneakier
|
||||
leak: the consumer stops reading (their own ctx died), your forwarders sit
|
||||
parked on `out <- v` until process exit. The test cancels *while* you're
|
||||
blocked on send, specifically to catch this. In pprof this leak has a
|
||||
signature: dozens of goroutines parked in `chan send` inside your merge
|
||||
function, ages in the minutes column.
|
||||
|
||||
## `runtime.NumGoroutine()` as a test assertion
|
||||
|
||||
Counting goroutines before/after is a legit leak-detection technique — crude
|
||||
but effective. The polished version is `goleak` (from Uber), worth namedropping:
|
||||
it snapshots goroutines at test end and fails on strays. In production the
|
||||
equivalent is watching `go_goroutines` from the Prometheus client — a sawtooth
|
||||
that only goes up is a leak with a deadline.
|
||||
|
||||
## Nil-channel select: the elegant alternative
|
||||
|
||||
For a *small, fixed* number of inputs there's a beautiful trick — select over
|
||||
all inputs directly and set a channel variable to `nil` when it closes:
|
||||
|
||||
```go
|
||||
for a != nil || b != nil {
|
||||
select {
|
||||
case v, ok := <-a:
|
||||
if !ok { a = nil; continue } // case permanently disabled
|
||||
out <- v
|
||||
case v, ok := <-b:
|
||||
if !ok { b = nil; continue }
|
||||
out <- v
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ops on nil channels block forever, so a nil case simply never fires — it's the
|
||||
idiomatic way to *disable* a select arm at runtime. This version needs no
|
||||
WaitGroup and no forwarder goroutines. Know both: goroutine-per-input scales
|
||||
to dynamic N; nil-channel select wins for 2–3 known inputs. Explaining *why*
|
||||
the nil trick works (nil channel semantics) is exactly the kind of
|
||||
language-lawyer flex these interviews reward.
|
||||
|
||||
## Bonus rabbit hole: what select actually does
|
||||
|
||||
With multiple ready cases, `select` picks **uniformly at random** — the runtime
|
||||
shuffles case order (`selectgo`) to prevent starvation. So merge output
|
||||
ordering isn't just "unspecified," it's actively randomized under contention.
|
||||
Determinism-dependent tests on select ordering are therefore always flaky —
|
||||
a code-review comment you'll get to make at least once a year for free.
|
||||
@@ -0,0 +1,49 @@
|
||||
// Exercise 07 — Fan-in / Merge Channels
|
||||
//
|
||||
// ASSIGNMENT
|
||||
// ----------
|
||||
// Implement Merge(ctx, chs...):
|
||||
//
|
||||
// 1. Returns a single channel carrying every value from every input channel.
|
||||
// 2. The output channel is CLOSED once all input channels are closed and
|
||||
// drained.
|
||||
// 3. If ctx is cancelled, Merge stops forwarding, and all its internal
|
||||
// goroutines exit even if inputs never close and even if nobody reads
|
||||
// the output anymore. (No goroutine leaks — the tests count goroutines.)
|
||||
// 4. Order across inputs is unspecified.
|
||||
//
|
||||
// Run the tests: go test -race ./07-fan-in/
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Merge fans in all input channels into one output channel.
|
||||
// TODO: implement me.
|
||||
func Merge[T any](ctx context.Context, chs ...<-chan T) <-chan T {
|
||||
out := make(chan T)
|
||||
// TODO:
|
||||
// - one forwarder goroutine per input channel
|
||||
// - each forward is a select: {out <- v} vs {<-ctx.Done()}
|
||||
// - reading the input must ALSO select on ctx.Done()
|
||||
// - closer goroutine: wg.Wait(); close(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
gen := func(vals ...int) <-chan int {
|
||||
ch := make(chan int)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for _, v := range vals {
|
||||
ch <- v
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
for v := range Merge(context.Background(), gen(1, 2, 3), gen(10, 20), gen(100)) {
|
||||
fmt.Println(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func gen(vals ...int) <-chan int {
|
||||
ch := make(chan int)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for _, v := range vals {
|
||||
ch <- v
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func TestMergeAllValues(t *testing.T) {
|
||||
out := Merge(context.Background(), gen(1, 2, 3), gen(4, 5), gen(6))
|
||||
got := []int{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for v := range out {
|
||||
got = append(got, v)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("output never closed after all inputs closed")
|
||||
}
|
||||
sort.Ints(got)
|
||||
want := []int{1, 2, 3, 4, 5, 6}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("want %v, got %v", want, got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("want %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoInputs(t *testing.T) {
|
||||
out := Merge[int](context.Background())
|
||||
select {
|
||||
case _, ok := <-out:
|
||||
if ok {
|
||||
t.Fatal("value from zero inputs")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("output not closed with zero inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationReleasesGoroutines(t *testing.T) {
|
||||
before := runtime.NumGoroutine()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Inputs that never close and keep producing.
|
||||
mk := func() <-chan int {
|
||||
ch := make(chan int)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case ch <- 1:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
out := Merge(ctx, mk(), mk(), mk())
|
||||
|
||||
// Read a few values, then walk away AND cancel — forwarders must not
|
||||
// stay blocked on `out <- v`.
|
||||
for i := 0; i < 5; i++ {
|
||||
select {
|
||||
case <-out:
|
||||
case <-time.After(2 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("no values arriving on merged channel")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
runtime.GC()
|
||||
if runtime.NumGoroutine() <= before+2 { // small slack
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("goroutine leak after cancel: before=%d now=%d", before, runtime.NumGoroutine())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user