Frontend Labs
⋯

Day 14 — sync Package

Learning Objectives

  • Use sync.Mutex vs sync.RWMutex correctly for shared state
  • Coordinate goroutine completion with sync.WaitGroup
  • Run the race detector: go test -race

Key Concepts

// sync.RWMutex — multiple concurrent readers OR one writer
// Use when reads are much more frequent than writes (e.g., config, IP blocklist)
type SafeMap struct {
    mu sync.RWMutex
    m  map[string]int
}
func (s *SafeMap) Get(k string) (int, bool) {
    s.mu.RLock()         // multiple goroutines can hold RLock simultaneously
    defer s.mu.RUnlock()
    v, ok := s.m[k]
    return v, ok
}
func (s *SafeMap) Set(k string, v int) {
    s.mu.Lock()          // exclusive: blocks all readers AND writers
    defer s.mu.Unlock()
    s.m[k] = v
}

// sync.Once — exactly one initialization, thread-safe
var (
    instance *Config
    once     sync.Once
)
func GetConfig() *Config {
    once.Do(func() { instance = loadConfig() })
    return instance
}

Exercise: Thread-Safe Request Counter

package main

import (
    "fmt"
    "sync"
    "testing"
)

// RequestCounter tracks request counts per endpoint.
// Reads (metrics scraping) are frequent; resets are rare.
type RequestCounter struct {
    mu       sync.RWMutex
    counts   map[string]int64
    total    int64
}

func NewRequestCounter() *RequestCounter {
    return &RequestCounter{counts: make(map[string]int64)}
}

func (rc *RequestCounter) Inc(path string) {
    rc.mu.Lock()
    defer rc.mu.Unlock()
    rc.counts[path]++
    rc.total++
}

func (rc *RequestCounter) Get(path string) int64 {
    rc.mu.RLock()
    defer rc.mu.RUnlock()
    return rc.counts[path]
}

func (rc *RequestCounter) Snapshot() map[string]int64 {
    rc.mu.RLock()
    defer rc.mu.RUnlock()
    copy := make(map[string]int64, len(rc.counts))
    for k, v := range rc.counts {
        copy[k] = v
    }
    return copy
}

func (rc *RequestCounter) Reset() {
    rc.mu.Lock()
    defer rc.mu.Unlock()
    rc.counts = make(map[string]int64)
    rc.total = 0
}

// Test with race detector: go test -race -v
func TestRequestCounter_Concurrent(t *testing.T) {
    rc := NewRequestCounter()
    var wg sync.WaitGroup
    paths := []string{"/api/v1/users", "/api/v1/auth", "/health"}

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            path := paths[id%len(paths)]
            for j := 0; j < 100; j++ {
                rc.Inc(path)
            }
        }(i)
    }
    wg.Wait()

    snap := rc.Snapshot()
    var total int64
    for _, count := range snap {
        total += count
    }
    if total != 10000 {
        t.Errorf("expected 10000 total, got %d", total)
    }
}

func main() {
    rc := NewRequestCounter()

    var wg sync.WaitGroup
    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            rc.Inc("/api/resource")
            rc.Inc("/health")
        }(i)
    }
    wg.Wait()

    for path, count := range rc.Snapshot() {
        fmt.Printf("%s: %d requests\n", path, count)
    }
}

Run the test with race detector:

go test -race -v ./...

Real-World Context

Your rate limiter (Day 76) uses this exact pattern — a sync.RWMutex-protected map from API key → token bucket. Without RWMutex, the write lock serializes all requests. With it, 1000 concurrent reads go through simultaneously, blocked only when a new bucket is created.


Generated from week2.md · 8 Oct 2026 16:36