Cleanup Ordering — Optimization Exercises¶
Each exercise presents a piece of code with a cleanup-related performance issue. Identify the issue, propose a fix, and reason about the trade-offs.
Exercise 1: Defer in a Hot Loop¶
func process(items []int) {
for _, item := range items {
func() {
defer cleanup(item)
work(item)
}()
}
}
The issue. The anonymous function with defer allocates a _defer record per iteration (heap allocation). For 1 million items, 1M allocations.
Optimization. If cleanup is small and called only on the next iteration, inline it:
If cleanup must run on panic, keep the defer but lift the iteration body into a named helper:
func process(items []int) {
for _, item := range items {
processOne(item)
}
}
func processOne(item int) {
defer cleanup(item)
work(item)
}
The named helper is open-coded (one defer per function, no loop). Free.
Measured difference. ~30 ns/iteration improvement for the open-coded path vs the closure path.
Exercise 2: Too Many Defers¶
func bigFunc() {
defer a()
defer b()
defer c()
defer d()
defer e()
defer f()
defer g()
defer h()
defer i() // 9th defer; falls back to heap
}
The issue. The 9th defer pushes the function past the 8-defer open-coded budget. The compiler falls back to heap defers for all of them. Cost rises from ~5 ns to ~30 ns per defer.
Optimization. Reduce defer count by combining:
One defer; runs all cleanups in order. Open-coded; cheap.
Trade-off. The combined order is now FIFO (in the order written), not LIFO. Adjust if order matters.
Exercise 3: AfterFunc Without Stop¶
func handleRequest(ctx context.Context) {
context.AfterFunc(ctx, func() { log.Print("cancelled") })
// ... do work ...
}
The issue. No stop function captured. The callback registration persists until ctx is done or this function's frame is GC'd. For long-lived ctx, this leaks.
Optimization. Capture and defer stop:
Now the callback is deregistered on function exit. No leak.
Exercise 4: Excessive Goroutine Creation¶
func process(items []item) {
for _, item := range items {
ctx, cancel := context.WithTimeout(parent, time.Second)
context.AfterFunc(ctx, func() { cleanup(item) })
doWork(ctx, item)
cancel()
}
}
The issue. Each iteration creates a context, registers AfterFunc, calls cancel. The AfterFunc fires (or is stopped), spawning a goroutine per iteration. For 10K items: 10K goroutines created.
Optimization. If cleanup doesn't need to react to cancel, use defer:
for _, item := range items {
ctx, cancel := context.WithTimeout(parent, time.Second)
doWork(ctx, item)
cleanup(item)
cancel()
}
No AfterFunc. Cleanup runs sequentially. No extra goroutines.
If cleanup must be cancel-responsive, profile the AfterFunc overhead. For most workloads, the cost is acceptable.
Exercise 5: Slow Shutdown Due to Sequential Closes¶
func (s *Service) Shutdown(ctx context.Context) error {
// 5 components, each takes ~5 seconds
s.logger.Close()
s.metrics.Close()
s.cache.Close()
s.db.Close()
s.search.Close()
return nil
}
The issue. Total shutdown: 25 seconds. Exceeds typical graceful period.
Optimization. Parallelize independent closes:
func (s *Service) Shutdown(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return s.logger.Close() })
g.Go(func() error { return s.metrics.Close() })
g.Go(func() error { return s.cache.Close() })
g.Go(func() error { return s.db.Close() })
g.Go(func() error { return s.search.Close() })
return g.Wait()
}
Total shutdown: ~5 seconds (the slowest one).
Trade-off. Only safe for independent components. If db must close before cache, keep that pair sequential.
Exercise 6: Channel-Based Cleanup with FIFO¶
cleanups := make(chan func(), 100)
go func() {
for fn := range cleanups {
fn()
}
}()
// later
cleanups <- func() { resourceA.Close() }
cleanups <- func() { resourceB.Close() }
The issue. Cleanups run in FIFO order. Probably wrong (resources should release in reverse acquisition order).
Optimization. Either use defer (LIFO) or buffer in a slice and run in reverse:
var cleanups []func()
cleanups = append(cleanups, func() { resourceA.Close() })
cleanups = append(cleanups, func() { resourceB.Close() })
// on shutdown:
for i := len(cleanups) - 1; i >= 0; i-- {
cleanups[i]()
}
LIFO order, no channel overhead.
Exercise 7: Allocating Closures for Defer¶
The issue. Each defer captures x by value (as an arg), so the closure itself is shared, but the defer record stores x. Heap allocation per iteration.
Optimization. Same as Exercise 1: extract a helper:
for _, x := range largeSlice {
processWithDefer(x)
}
func processWithDefer(x int) {
defer process(x)
}
Helper is open-coded. No heap allocation.
Exercise 8: Lock Held During Slow Cleanup¶
func (c *Cache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
for _, item := range c.items {
item.Close() // I/O
}
c.items = nil
}
The issue. Lock held during item.Close. If Close does network I/O, all other Cache operations block for the duration.
Optimization.
func (c *Cache) Clear() {
c.mu.Lock()
items := c.items
c.items = nil
c.mu.Unlock()
for _, item := range items {
item.Close()
}
}
Lock released before slow work.
Exercise 9: GC Pressure from Many Defer Records¶
The issue. 1M heap-allocated _defer records. The per-P pool absorbs most, but allocation rate is still significant. GC scans them all.
Optimization. Hoist cleanup out of the inner function:
No defer; explicit cleanup. Free of GC pressure.
Trade-off. No panic safety. If work panics, cleanup is skipped. If panic is unlikely or acceptable to skip, fine. Otherwise use defer (a per-call helper is open-coded).
Exercise 10: AfterFunc Storm¶
func handler(ctx context.Context) {
for i := 0; i < 1000; i++ {
item := items[i]
context.AfterFunc(ctx, func() { item.Cleanup() })
}
}
The issue. 1000 AfterFunc registrations. On cancel, 1000 goroutines spawn. CPU spike.
Optimization. One AfterFunc that fans out:
func handler(ctx context.Context) {
items := items[:1000]
stop := context.AfterFunc(ctx, func() {
for _, item := range items {
item.Cleanup()
}
})
defer stop()
}
One goroutine on cancel, runs all cleanups sequentially.
Trade-off. Cleanups run serially. If parallel cleanup is needed, use a worker pool inside the callback.
Exercise 11: Recover Hides Cost¶
for i := 0; i < n; i++ {
func() {
defer func() {
if r := recover(); r != nil {
log.Print(r)
}
}()
work(i)
}()
}
The issue. The recover defer is registered per iteration. Even if no panic occurs, the defer registration costs (heap-allocated due to the closure).
Optimization. If panics are rare, accept the cost — recovery is important. If panics are impossible (you control work() and it doesn't panic), remove the recover.
Alternatively, lift the recover to a higher scope:
But this stops the loop on first panic. Different semantics.
Exercise 12: Excessive Argument Storage¶
The issue. The defer captures count, bigConfig, and elapsed as arguments. The _defer record stores them all (potentially many bytes).
Optimization. Use a closure:
The closure stores only a pointer to the function value (and a pointer to the captured variables). Smaller record.
Exercise 13: Cleanup with No Deadline¶
func (s *Service) Stop() error {
s.cancel()
s.wg.Wait() // could block forever
return s.db.Close()
}
The issue. No context. If wg.Wait hangs, Stop hangs.
Optimization. Take a context:
func (s *Service) Stop(ctx context.Context) error {
s.cancel()
done := make(chan struct{})
go func() { s.wg.Wait(); close(done) }()
select {
case <-done:
case <-ctx.Done():
return ctx.Err()
}
return s.db.Close()
}
Bounded wait. Predictable shutdown time.
Exercise 14: Slow Drain Channel¶
for {
select {
case v := <-in:
process(v)
case <-ctx.Done():
// drain
for v := range in {
process(v)
}
return
}
}
The issue. The drain loop processes every remaining value. If processing is slow and the channel has thousands of items, drain takes a long time.
Optimization. Limit drain duration:
case <-ctx.Done():
drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for {
select {
case v := <-in:
process(v)
case <-drainCtx.Done():
return
}
}
Drain stops after 5 seconds; in-flight items may be lost. Trade-off between drain completeness and bounded shutdown time.
Exercise 15: Profiling Defer Cost¶
Suppose pprof shows:
The issue. Defer machinery dominates. Indicates heap defers in a hot path.
Optimization. Identify the hot function via go tool pprof -list runtime.deferproc. Then: 1. Check if the function has more than 8 defers (combine them). 2. Check if defers are in loops (extract helper). 3. Compile with -gcflags='-d=defer=2' to see open-coded decisions. 4. Refactor to bring defers into the open-coded path.
After optimization, defer cost should drop to <5%.
Exercise 16: Cleanup During GC¶
The issue. Finalizer fires during GC, on a dedicated finalizer goroutine. If Close is slow, it blocks other finalizers. Also, finalizer timing is unpredictable.
Optimization. Replace with explicit Close + sync.Once:
type Object struct {
once sync.Once
}
func (o *Object) Close() {
o.once.Do(func() { o.realClose() })
}
Callers must call Close. Use go vet's lostcancel-style linter or static analysis to verify all paths close.
Exercise 17: Heavy Cleanup in errgroup Workers¶
The issue. If errgroup cancels due to a sibling's error, this worker's doWork returns immediately, then expensiveCleanup runs — wasting time on a doomed shutdown.
Optimization. Make cleanup respect cancellation:
g.Go(func() error {
defer func() {
ctx2, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
expensiveCleanup(ctx2)
}()
return doWork(ctx)
})
Cleanup is bounded. Even if the parent ctx is cancelled, cleanup has a fresh budget.
Exercise 18: AfterFunc Registered in Tight Loop¶
The issue. Each registration allocates and adds to ctx's child list. For 100K messages, the child list becomes large; iteration on cancel is O(N).
Optimization. If cleanup can run in batch:
stop := context.AfterFunc(ctx, func() {
for _, msg := range messages {
msg.Cancel()
}
})
defer stop()
One registration, batch cleanup on cancel.
Exercise 19: Combining defer with closure¶
The issue. expensiveCount() is called at the defer line (when registered), even though the log message only matters at function exit.
Optimization. If you want the value at exit, use a closure:
If you want the value at defer registration (current behaviour), keep it. Just make sure that matches your intent.
Exercise 20: Optimizing Real Shutdown¶
Suppose your service's shutdown takes 30 seconds: - HTTP shutdown: 20s (waiting for slow handlers) - Workers: 5s - DB: 3s - Other: 2s
Optimization options. 1. Reduce HTTP timeout for slow handlers (kill stuck requests). 2. Parallelize Workers, DB, Other (they're independent after HTTP). 3. Mark service as not-ready earlier (5s before SIGTERM, so LB stops routing). 4. Add metrics to identify the slowest handler; fix it.
After: shutdown in 10-15 seconds. Within the 30s graceful period with margin.
Discussion: When to Optimize Cleanup¶
Cleanup optimization is usually unnecessary. Most code spends <1% of CPU on cleanup. Profile first; optimize only if there's evidence.
Exception: shutdown latency. Even small inefficiencies compound under load. A service that takes 5 minutes to shut down is operationally broken regardless of CPU usage.
Exception: hot paths. Functions called millions of times per second can see defer cost. Open-coded defer (≤ 8 defers, no loops) is essentially free; heap defers cost ~30 ns each.
Exception: AfterFunc storms. Cancellation events that fire thousands of callbacks at once can cause CPU spikes. Coalesce.
Tools for Cleanup Optimization¶
go tool pproffor CPU profiles.go test -benchwith-benchmemfor allocation tracking.go vetfor lostcancel detection.staticcheckfor various lints.go build -gcflags='-d=defer=2'for defer analysis.goleakfor goroutine leak detection.runtime/tracefor execution traces.
Use them. Cleanup correctness and performance are both verifiable.
Summary¶
Cleanup optimization is mostly about: 1. Keeping defers in the open-coded path. 2. Avoiding defers in tight loops. 3. Limiting AfterFunc registrations and goroutine spawns. 4. Releasing locks before slow work. 5. Parallelizing independent cleanups. 6. Bounding cleanup with deadlines. 7. Profiling to find the actual bottleneck.
Apply the optimizations above only when profiling indicates a problem. Premature optimization of cleanup, like any optimization, is a trap.
End of optimize exercises.