Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Go  Module 15 – Concurrency patterns in Go

Concurrent caches, singleflight and pub/sub

Build a TTL cache that goroutines share, collapse duplicate loads of a cold key with singleflight, and fan events out with a policy for slow subscribers.

  • Advanced
  • 15 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: A cache shared by goroutines needs a lock, and expiry needs a clock · singleflight lets one goroutine load a cold key while the others wait · Pub/sub gives each subscriber a buffered channel of its own. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

What you will learn

  • Build a TTL cache that is safe for concurrent use, with a sweeper that Close stops
  • Prevent duplicate concurrent loads of one key with singleflight
  • Design a broker that gives each subscriber its own buffer, and choose a policy for a slow one
  • Move slow loads and callbacks out of critical sections

Before you start

A hundred requests arrive in the same instant for a product whose cache entry is cold, and each one misses and asks the database: in stampede.go below that was 100 database loads for one row, and with singleflight: The package golang.org/x/sync/singleflight. Its Group runs at most one call in flight per key, and callers that ask for that key during the call wait for it and get its result. it was 1.

Concurrent caches, collapsed loads and pub/sub: Publish/subscribe: a publisher sends each event to a broker, which passes it on to every subscriber, so the publisher never needs to know who the subscribers are. are where shared state meets many goroutines. You will build a cache that goroutines share, let one goroutine load a cold key while the others wait, and fan events out so that a slow subscriber cannot hold up the rest. Its programs ran on Go 1.27.1, the singleflight ones with golang.org/x/sync at version 0.23.0.

A cache shared by goroutines needs a lock, and expiry needs a clock

Concurrent caches in Go are commonly a map behind a sync.RWMutex, which lets any number of readers in at once and a writer alone. Each entry keeps the moment it expires. Get checks that moment itself, so an expired entry is a miss at once, and a sweeper goroutine with a ticker removes expired entries from the map now and then. cache.go keeps two prices for 100 ms and sweeps every 300 ms:

cache.go: a generic TTL cache with a sweeper that Close stops Go · cache.go
// cache.go: a cache that many goroutines share. A map behind a sync.RWMutex holds each value with the moment it
// expires; Get treats an expired entry as missing at once, and a sweeper goroutine with a ticker removes expired
// entries from the map until Close stops it.
package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

type entry[V any] struct {
	value   V
	expires time.Time
}

// Cache maps keys of type K to values of type V, each kept for ttl after it was set.
type Cache[K comparable, V any] struct {
	mu   sync.RWMutex
	ttl  time.Duration
	m    map[K]entry[V]
	stop chan struct{}
	done chan struct{}
}

// New starts a cache whose sweeper removes expired entries every sweepEvery.
func New[K comparable, V any](ttl, sweepEvery time.Duration) *Cache[K, V] {
	c := &Cache[K, V]{ttl: ttl, m: map[K]entry[V]{}, stop: make(chan struct{}), done: make(chan struct{})}
	go c.sweep(sweepEvery)
	return c
}

// Set stores v under k until ttl from now.
func (c *Cache[K, V]) Set(k K, v V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[k] = entry[V]{value: v, expires: time.Now().Add(c.ttl)}
}

// Get returns the value under k, unless it is missing or has expired. Many goroutines may read at the same time.
func (c *Cache[K, V]) Get(k K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	e, ok := c.m[k]
	if !ok || !time.Now().Before(e.expires) {
		var zero V
		return zero, false
	}
	return e.value, true
}

// Len is the number of entries the map holds, expired ones that the sweeper has not removed yet included.
func (c *Cache[K, V]) Len() int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return len(c.m)
}

func (c *Cache[K, V]) sweep(every time.Duration) {
	defer close(c.done)
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-t.C:
			now := time.Now()
			c.mu.Lock()
			for k, e := range c.m {
				if !now.Before(e.expires) {
					delete(c.m, k)
				}
			}
			c.mu.Unlock()
		case <-c.stop:
			return
		}
	}
}

// Close stops the sweeper and waits until it has returned.
func (c *Cache[K, V]) Close() {
	close(c.stop)
	<-c.done
}

func main() {
	before := runtime.NumGoroutine()
	prices := New[string, int](100*time.Millisecond, 300*time.Millisecond) // prices in paise
	prices.Set("SKU-1", 49900)
	prices.Set("SKU-2", 12900)
	v, ok := prices.Get("SKU-1")
	fmt.Printf("at once:   Get(SKU-1) = %d, %v; entries in the map: %d\n", v, ok, prices.Len())
	time.Sleep(150 * time.Millisecond)
	v, ok = prices.Get("SKU-1")
	fmt.Printf("at 150 ms: Get(SKU-1) = %d, %v; entries in the map: %d\n", v, ok, prices.Len())
	time.Sleep(200 * time.Millisecond) // the sweeper has run at 300 ms
	fmt.Printf("at 350 ms: entries in the map: %d\n", prices.Len())
	prices.Close()
	fmt.Println("goroutines left after Close:", runtime.NumGoroutine()-before)
}

Output

at once:   Get(SKU-1) = 49900, true; entries in the map: 2
at 150 ms: Get(SKU-1) = 0, false; entries in the map: 2
at 350 ms: entries in the map: 0
goroutines left after Close: 0

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run cache.go

At 150 ms SKU-1 was a miss although both entries were still in the map: Get read the expiry and reported it gone. The sweep at 300 ms freed the memory, and after Close no goroutine of the cache was left. The sweeper is a loop on a ticker, the kind of goroutine that leaks when nothing stops it, which is why Close stops it and waits until it has returned.

Checkpoint

Check: A cache that goroutines share

  1. Question 1 of 2 cache.go keeps entries for 100 ms and sweeps every 300 ms. At 150 ms, what do Get("SKU-1") and Len() report?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A miss, and 2 entries still in the map

    Expiry has two parts: Get decides at once whether an entry is still good, and the sweeper frees the memory of expired entries later, as Len went from 2 at 150 ms to 0 at 350 ms.

  2. Question 2 of 2 Why does the cache's Close stop the sweeper and wait until it has returned?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Otherwise the sweeper's ticker loop would run for ever, a goroutine leak for every cache made

    A type that starts a goroutine owns it, and a Close that stops it and waits is how the owner keeps the goroutine from outliving the value.

singleflight lets one goroutine load a cold key while the others wait

A cache stampede: Many callers missing the same cache key at the same moment and each loading it from the source, so one cold or expired key turns into a burst of identical queries. is many callers missing the same key at once and all loading it. golang.org/x/sync/singleflight prevents it: g.Do(key, fn) runs fn for a key only if no call for that key is in flight, and a caller that comes while one is makes no call of its own and waits for its result. stampede.go sends 100 requests for one cold product, first without it:

sf/stampede/main.go and sf/go.mod

Go · sf/stampede/main.go

// stampede/main.go: 100 requests for one product arrive together while its cache entry is cold. Without help, each
// one misses the cache and loads the product from the database. With singleflight.Group.Do, the first request loads
// it and the others wait for that load and get its result.
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"golang.org/x/sync/singleflight"
)

var dbLoads atomic.Int64

// loadProduct stands for a database query of 50 ms.
func loadProduct(id string) (string, error) {
	dbLoads.Add(1)
	time.Sleep(50 * time.Millisecond)
	return id + ": steel bottle, 1 litre", nil
}

// cache is a map behind a mutex, enough for this program.
type cache struct {
	mu sync.Mutex
	m  map[string]string
}

func (c *cache) get(id string) (string, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	v, ok := c.m[id]
	return v, ok
}

func (c *cache) set(id, v string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[id] = v
}

// burst sends 100 requests for the same product at once and returns how many database loads they caused.
func burst(get func(id string) (string, error)) int64 {
	dbLoads.Store(0)
	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() { get("P-42") })
	}
	wg.Wait()
	return dbLoads.Load()
}

func main() {
	plain := &cache{m: map[string]string{}}
	n := burst(func(id string) (string, error) {
		if v, ok := plain.get(id); ok {
			return v, nil
		}
		v, err := loadProduct(id)
		if err == nil {
			plain.set(id, v)
		}
		return v, err
	})
	fmt.Printf("without singleflight: 100 requests, %d database loads\n", n)

	var g singleflight.Group
	collapsed := &cache{m: map[string]string{}}
	n = burst(func(id string) (string, error) {
		if v, ok := collapsed.get(id); ok {
			return v, nil
		}
		v, err, _ := g.Do(id, func() (any, error) {
			if v, ok := collapsed.get(id); ok { // a late caller may come after the load has finished
				return v, nil
			}
			v, err := loadProduct(id)
			if err == nil {
				collapsed.set(id, v)
			}
			return v, err
		})
		if err != nil {
			return "", err
		}
		return v.(string), nil
	})
	fmt.Printf("with singleflight:    100 requests, %d database load\n", n)
}

MOD · sf/go.mod

module example.com/sf

go 1.27

require golang.org/x/sync v0.23.0
100 requests for one cold product, without and with singleflight Python · stampede_session.py
"""100 requests for one cold product, without singleflight and with it."""

import session

session.run(
    """
cd sf && go run ./stampede
""",
    modules=["sf"],
)

Output

$ cd sf && go run ./stampede
without singleflight: 100 requests, 100 database loads
with singleflight:    100 requests, 1 database load

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stampede_session.py

100 database loads became 1. Step through how the 99 waited:

Interactive · message by message 100 requests, one load Step through the singleflight run of stampede.go and watch 99 requests wait for the one load in flight.
100 requests, one load, step 6 of 6. Messages so far: 100 requests to singleflight: Do("P-42"); singleflight to database: SELECT P-42; database to singleflight: the product; singleflight to 100 requests: one result, 100 callers.100 requests1 load, not 100singleflightone call per keydatabase50 ms a queryDo("P-42")SELECT P-42the productone result, 100 callers

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The cache is cold. 100 requests for product P-42 arrive in the same instant, and none finds it in the cache. Without help, each would now ask the database.
  2. The first call loads. The first request calls g.Do with the key P-42. No call for that key is in flight, so the group records one and runs the load.
  3. One query. The load queries the database, which takes 50 ms. This is the only query the burst causes.
  4. 99 calls wait. The other 99 requests call g.Do with the same key while the load runs. A call is in flight, so each one waits for it instead of starting its own.
  5. The answer comes back. After 50 ms the database answers. The load stores the product in the cache and returns it to the group.
  6. Every caller gets it. The group hands the same value to all 100 callers, and Do's third result, shared, says it was given to many. Then it forgets the key, so a later miss loads again.

Do returns a third result, shared, which says whether the value went to more than one caller. Two more methods finish the package: DoChan returns a channel for the result instead of waiting, and Forget makes the next call for a key load again even while one is in flight.

Key idea

singleflight gives each key one load in flight: the first caller loads, and every caller that comes during the load waits for it and gets the same result.

Checkpoint

Check: One load per key with singleflight

  1. Question 1 of 2 200 requests arrive at once for 4 cold keys, 50 requests per key, all going through one singleflight.Group. How many loads run?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 4 loads

    singleflight keeps one call in flight per key, so each of the 4 keys is loaded once, and the other 49 callers of each key wait for its load, as 100 callers of P-42 caused 1 load in stampede.go.

  2. Question 2 of 2 A caller runs g.Do("P-42", load) while a call for "P-42" is in flight. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It does not call load; it waits for the call in flight and gets the same result

    Duplicates of a key in flight join the call. After the call returns, the key is forgotten, so the next caller loads again.

Pub/sub gives each subscriber a buffered channel of its own

In pub/sub, a publisher sends each event to every subscriber. A broker in Go gives each subscriber its own buffered channel, so a fast one never waits for a slow one, and decides what Publish does when a subscriber’s buffer is full. The broker of the pubsub package drops the oldest event to make room. Its test publishes six events, one every 10 ms, to a subscriber that reads each at once and one that needs 35 ms for each:

pubsub/broker.go and pubsub/broker_test.go

Go (broker.go) · pubsub/broker.go

// Package pubsub is a small broker. Each subscriber has a buffered channel of its own, and Publish never waits for a
// slow subscriber: when a subscriber's buffer is full, the oldest event in it is dropped to make room for the new one.
package pubsub

import "sync"

// Broker delivers every published event to every subscriber.
type Broker struct {
	mu   sync.Mutex
	subs []chan string // in the order they subscribed
}

// Subscribe returns a channel of the events published from now on, with room for buffer of them, and a function that
// ends the subscription and closes the channel.
func (b *Broker) Subscribe(buffer int) (<-chan string, func()) {
	ch := make(chan string, buffer)
	b.mu.Lock()
	b.subs = append(b.subs, ch)
	b.mu.Unlock()
	var once sync.Once
	return ch, func() {
		once.Do(func() {
			b.mu.Lock()
			defer b.mu.Unlock()
			for i, s := range b.subs {
				if s == ch {
					b.subs = append(b.subs[:i], b.subs[i+1:]...)
					break
				}
			}
			close(ch) // under the lock, so no Publish can send on it afterwards
		})
	}
}

// Publish delivers ev to every subscriber without waiting, dropping the oldest event of a subscriber whose buffer is
// full.
func (b *Broker) Publish(ev string) {
	b.mu.Lock()
	defer b.mu.Unlock()
	for _, ch := range b.subs {
		dropOldest(ch, ev)
	}
}

// dropOldest sends ev on ch, first taking the oldest event out of ch for as long as its buffer is full.
func dropOldest(ch chan string, ev string) {
	for {
		select {
		case ch <- ev:
			return
		default:
		}
		select {
		case <-ch: // the buffer is full: drop its oldest event
		default:
		}
	}
}

Go (broker_test.go) · pubsub/broker_test.go

package pubsub

import (
	"fmt"
	"strings"
	"sync"
	"testing"
	"testing/synctest"
	"time"
)

// TestTwoSubscribers publishes six events, one every 10 ms of the bubble's clock, to a subscriber that reads each at
// once and to one that needs 35 ms for each, both with room for 2 events.
func TestTwoSubscribers(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		var b Broker
		fast, stopFast := b.Subscribe(2)
		slow, stopSlow := b.Subscribe(2)
		var gotFast, gotSlow []string
		var wg sync.WaitGroup
		wg.Go(func() {
			for ev := range fast {
				gotFast = append(gotFast, ev)
			}
		})
		wg.Go(func() {
			for ev := range slow {
				gotSlow = append(gotSlow, ev)
				time.Sleep(35 * time.Millisecond)
			}
		})
		var waited time.Duration
		for i := 1; i <= 6; i++ {
			time.Sleep(10 * time.Millisecond)
			t0 := time.Now()
			b.Publish(fmt.Sprintf("e%d", i))
			waited += time.Since(t0)
		}
		time.Sleep(200 * time.Millisecond) // the slow subscriber finishes what it holds
		stopFast()
		stopSlow()
		wg.Wait()
		fmt.Println("fast subscriber got:", strings.Join(gotFast, " "))
		fmt.Println("slow subscriber got:", strings.Join(gotSlow, " "))
		fmt.Println("Publish waited for subscribers:", waited)
	})
}
A fast and a slow subscriber of the drop-oldest broker Python · broker_session.py
"""The drop-oldest broker with a fast and a slow subscriber, on a synctest bubble's clock."""

import session

session.run(
    """
cd pubsub && go test -v -count=1 -run TwoSubscribers .
"""
)

Output

$ cd pubsub && go test -v -count=1 -run TwoSubscribers .
=== RUN   TestTwoSubscribers
fast subscriber got: e1 e2 e3 e4 e5 e6
slow subscriber got: e1 e3 e5 e6
Publish waited for subscribers: 0s
--- PASS: TestTwoSubscribers (…s)
PASS
ok  	example.com/pubsub	…s

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 broker_session.py

The fast subscriber got all six, the slow one e1, e3, e5 and e6, and Publish never waited. Step through the slow subscriber’s buffer:

Interactive · step by step A slow subscriber's buffer under drop-oldest Step through the broker test and watch the slow subscriber's two places fill up, and the oldest event make room.
A slow subscriber's buffer under drop-oldest, step 6 of 6. Showing Publish, slow's buffer (e5 e6), slow subscriber (busy with e3 until 80 ms), dropped (e2 e4). In focus: slow subscriber.Publishone event every 10 msslow's buffere5 e6slow subscriberbusy with e3 until 80 msdroppede2 e4

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 10 ms: e1. The slow subscriber is waiting, so it takes e1 at once and is busy with it until 45 ms. Its buffer is empty.
  2. 20 and 30 ms: e2, e3. e2 and e3 wait in the buffer, which now holds two events, all it has room for.
  3. 40 ms: e4. The buffer is full. Publish takes the oldest event, e2, out of it and puts e4 in, so the publisher never waits.
  4. 45 ms: e3. The slow subscriber finishes e1 and takes e3, the oldest event still waiting, which keeps it busy until 80 ms.
  5. 50 and 60 ms: e5, e6. e5 fills the buffer again, and e6 pushes e4 out. The subscriber will get e5 at 80 ms and e6 at 115 ms.
  6. What each got. The slow subscriber got e1, e3, e5 and e6, the newest events at each moment; the fast subscriber got all six; and Publish never waited.

A full buffer leaves four choices: wait for room, which lets one slow subscriber delay every publish; drop the new event; drop the oldest one, as here; or disconnect the subscriber. Unsubscribing closes the subscriber’s channel, under the broker’s lock, so its loop ends and no publish can send on it afterwards.

Checkpoint

Check: A broker with a buffer per subscriber

  1. Question 1 of 2 A subscriber with room for 3 events is busy for 100 ms while the drop-oldest broker publishes e1 to e5, none of which it has taken. Which events does it find when it comes back?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: e3, e4 and e5

    Drop-oldest keeps the newest events that fit in the buffer, as the slow subscriber of the test ended with e5 and e6.

  2. Question 2 of 2 Why does each subscriber get a channel of its own, instead of all subscribers reading one shared channel?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A channel gives each value to one receiver, so a shared channel would deliver each event to only one subscriber

    A channel per subscriber also lets the broker treat a slow subscriber on its own, with its own full buffer.

singleflight in depth: failed loads, deadlines, Forget and stale values Pro: opens with a Pro, Premium or Ultimate pass

singleflight shares more than successful loads, and how it shares them decides what callers see when a load fails, takes too long or returns data that has just changed. This part runs each of those cases, then serves expired values while one goroutine refreshes them.

In this part:

  • A failed load fails every waiter, and the next call loads again
  • Forget lets a caller after an update skip the stale load in flight
  • A panic in a shared load reaches every caller
  • DoChan bounds how long one caller waits for a shared load
  • Serve the expired value while one goroutine refreshes it

2 runnable examples (Go (main.go), Python) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Pub/sub under load: four policies measured, and the callback deadlock Pro: opens with a Pro, Premium or Ultimate pass

What a broker does with a slow subscriber decides what every other subscriber sees. This part runs four policies on the same slow subscriber, counts what each costs, then reproduces the deadlock of a broker that calls subscribers’ callbacks while it holds its lock.

In this part:

  • Four answers to a full buffer, measured on the bubble’s clock
  • A slow subscriber’s share, worked out
  • Closing a subscriber without a send on a closed channel
  • A callback called under the lock deadlocks the broker, reproduced

3 runnable examples (Go, Go (policies.go), Go (policies_test.go), Python) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise: a cache that loads each key once

practice/cache.go has Cache to write: keep each value for its TTL, load a missing or expired key once for all its callers, load different keys at the same time, and never cache an error. practice/cache_test.go checks all four:

practice/cache.go and practice/cache_test.go

Go (cache.go) · practice/cache.go

// Package practice is the practice of the lesson on caches: a generic cache that loads a missing key once, however
// many goroutines ask for it at the same time.
package practice

import "time"

// Cache keeps each value for ttl after it was loaded. Get loads a key that is missing or has expired with load, once
// for all the callers that ask for that key while it loads, and never caches an error.
type Cache[K comparable, V any] struct {
	ttl  time.Duration
	load func(key K) (V, error)
}

// New returns a cache that loads values with load and keeps them for ttl.
func New[K comparable, V any](ttl time.Duration, load func(key K) (V, error)) *Cache[K, V] {
	return &Cache[K, V]{ttl: ttl, load: load}
}

// Get returns the value of key, loading it if it is missing or has expired. Callers that ask for a key while it loads
// wait for that load and get its result; loads of different keys run at the same time.
func (c *Cache[K, V]) Get(key K) (V, error) {
	var zero V
	return zero, nil // replace this with a map behind a mutex and one load per key
}

// Close stops any goroutine the cache started and waits for it.
func (c *Cache[K, V]) Close() {}

Go (cache_test.go) · practice/cache_test.go

package practice

import (
	"errors"
	"sync"
	"sync/atomic"
	"testing"
	"testing/synctest"
	"time"
)

// TestOneLoadFor200Callers: 200 goroutines ask for the same cold key at once, and the load takes 50 ms. Exactly one
// load may happen, and every caller gets its value. It runs on the real clock: a goroutine waiting for a sync.Mutex
// would stop a synctest bubble's clock, and a cache that loads under its lock would then hang instead of failing.
func TestOneLoadFor200Callers(t *testing.T) {
	var loads atomic.Int64
	c := New(time.Minute, func(key string) (string, error) {
		loads.Add(1)
		time.Sleep(50 * time.Millisecond)
		return "value of " + key, nil
	})
	defer c.Close()
	var wg sync.WaitGroup
	var right atomic.Int64
	for range 200 {
		wg.Go(func() {
			if v, err := c.Get("P-42"); err == nil && v == "value of P-42" {
				right.Add(1)
			}
		})
	}
	wg.Wait()
	if loads.Load() != 1 || right.Load() != 200 {
		t.Fatalf("%d loads, %d callers got the value; want 1 load and 200", loads.Load(), right.Load())
	}
}

// TestReloadAfterTTL: a value is kept for its ttl of 100 ms, then loaded again.
func TestReloadAfterTTL(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		var loads atomic.Int64
		c := New(100*time.Millisecond, func(key string) (int, error) {
			return int(loads.Add(1)), nil
		})
		defer c.Close()
		a, _ := c.Get("k")
		time.Sleep(50 * time.Millisecond)
		b, _ := c.Get("k")
		time.Sleep(100 * time.Millisecond)
		d, _ := c.Get("k")
		if a != 1 || b != 1 || d != 2 {
			t.Fatalf("Get gave %d, %d, %d at 0, 50 and 150 ms; want 1, 1, 2", a, b, d)
		}
	})
}

// TestKeysLoadInParallel: three keys whose loads take 50 ms each must all be ready well before 150 ms, so a load
// must not run while the cache's lock is held. It runs on the real clock, for the reason given above.
func TestKeysLoadInParallel(t *testing.T) {
	c := New(time.Minute, func(key string) (string, error) {
		time.Sleep(50 * time.Millisecond)
		return key, nil
	})
	defer c.Close()
	keys := []string{"a", "b", "c"}
	got := make([]string, len(keys))
	start := time.Now()
	var wg sync.WaitGroup
	for i, k := range keys {
		wg.Go(func() { got[i], _ = c.Get(k) })
	}
	wg.Wait()
	took := time.Since(start)
	if got[0] != "a" || got[1] != "b" || got[2] != "c" {
		t.Fatalf("got %q; want [a b c], loaded at the same time", got)
	}
	if took >= 120*time.Millisecond {
		t.Fatalf("three keys loaded in %v; want well under 150ms, about the 50ms of one load", took.Round(time.Millisecond))
	}
}

// TestErrorNotCached: a failed load returns its error, and the next Get loads again.
func TestErrorNotCached(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		broken := errors.New("database: connection refused")
		var loads atomic.Int64
		c := New(time.Minute, func(key string) (string, error) {
			if loads.Add(1) == 1 {
				return "", broken
			}
			return "ok", nil
		})
		defer c.Close()
		_, err1 := c.Get("k")
		v, err2 := c.Get("k")
		if !errors.Is(err1, broken) || err2 != nil || v != "ok" || loads.Load() != 2 {
			t.Fatalf("first Get: %v; second: %q, %v; %d loads; want the error, then \"ok\" after 2 loads", err1, v, err2, loads.Load())
		}
	})
}

The starter code compiles, and every test fails:

The starter Cache against its tests Python · practice_session.py
"""The starter Cache of practice/cache.go against its tests: all four fail until it loads once per key."""

import session

session.run(
    """
cd practice && go test .
""",
    expect_failure=["cd practice && go test ."],
    modules=["practice"],
)

Output

$ cd practice && go test .
--- FAIL: TestOneLoadFor200Callers (…s)
    cache_test.go:34: 0 loads, 0 callers got the value; want 1 load and 200
--- FAIL: TestReloadAfterTTL (…s)
    cache_test.go:52: Get gave 0, 0, 0 at 0, 50 and 150 ms; want 1, 1, 2
--- FAIL: TestKeysLoadInParallel (…s)
    cache_test.go:75: got ["" "" ""]; want [a b c], loaded at the same time
--- FAIL: TestErrorNotCached (…s)
    cache_test.go:97: first Get: <nil>; second: "", <nil>; 0 loads; want the error, then "ok" after 2 loads
FAIL
FAIL	example.com/practice	…s
FAIL
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

Make go test . pass, then go test -race . as well. A first hint: keep the entries and the loads in flight in two maps behind one mutex, give each load a channel that you close when it is done, and release the mutex while you load.

The self-check below covers the cache, singleflight and the broker.

Practice that adapts to you

Practise this lesson

8 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 Why does cache.go use a sync.RWMutex rather than a sync.Mutex?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: Many goroutines may read at the same time, and only writers need the lock alone

    An RWMutex suits data that is read much more than it is written; with as many writes as reads, a plain Mutex is simpler and as fast.

  2. Question 2 of 8 A handler makes a new cache of cache.go for every request and never calls Close. What happens after 10,000 requests?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: 10,000 sweeper goroutines are left, each waking at every tick

    Make one cache for the service and close it on shutdown; a value that starts a goroutine needs an owner who closes it.

  3. Question 3 of 8 The sweeper removes expired entries every 300 ms. Why must Get still check each entry's expiry?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: Between sweeps an expired entry can stay in the map for up to 300 ms, and Get would return it

    Lazy expiry on read keeps the answers right; the sweeper keeps the memory bounded. A cache needs both.

  4. Question 4 of 8 What does the third result of g.Do, shared, report?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: Whether the value was given to more than one caller

    shared is useful for a metric: how often requests were collapsed into another's load.

  5. Question 5 of 8 One singleflight.Group serves every key of a service. A load of key A takes 2 s. Does it delay a caller of key B?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: No; calls are shared per key, so B's caller runs its own load at once

    One Group for all keys is the usual setup; a slow key slows only its own callers.

  6. Question 6 of 8 With singleflight in front of the database, does the service still need a cache?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: Yes; singleflight only shares calls in flight, so once a load has returned the next caller loads again

    The two work together: the cache answers repeated requests, and singleflight protects the source when the cache is cold or expired.

  7. Question 7 of 8 Which of the four policies lets one slow subscriber delay every publish?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: Waiting for room in its buffer

    A broker that waits gives every subscriber every event at the pace of the slowest one.

  8. Question 8 of 8 Why does the broker close a subscriber's channel while it holds its lock?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: Publish sends under the same lock, so no publish can send on the channel after it is closed

    Sends and the close of a channel need an order that nothing can break; doing both under one lock gives it.

Interview questions

Three questions from the module bank on caches and brokers under load:

  1. Mid to senior

    A deploy empties the cache, then 5,000 requests a second hit product pages whose entries are all cold, and the database falls over. Explain the stampede, fix it in Go, and say what the waiting requests get when a shared load fails.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    A Go broker sends price updates to 2,000 WebSocket subscribers, one channel send each, and a slow mobile client makes every update late for everyone. Explain why, and redesign delivery so that a slow client hurts only itself.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A cache calls an OnEvict callback while it holds its mutex, and under load the service sometimes freezes with no error and no panic. Explain the freeze and redesign the callback path.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Concurrent caches keep a map behind an RWMutex, treat expired entries as misses at once, and stop their sweeper in Close.
  • singleflight gives each key one load in flight, so 100 callers of a cold key cause 1 load, not 100.
  • In pub/sub, each subscriber gets a buffered channel of its own, and the broker states what it does when one is full.

Terms in this lesson

cache stampede
Many callers missing the same cache key at the same moment and each loading it from the source, so one cold or expired key turns into a burst of identical queries.
pub/sub
Publish/subscribe: a publisher sends each event to a broker, which passes it on to every subscriber, so the publisher never needs to know who the subscribers are.
singleflight
The package golang.org/x/sync/singleflight. Its Group runs at most one call in flight per key, and callers that ask for that key during the call wait for it and get its result.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.