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.
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
What you’ll learn · Before you start
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
On this page
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 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
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:
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 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- One query. The load queries the database, which takes 50 ms. This is the only query the burst causes.
- 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.
- The answer comes back. After 50 ms the database answers. The load stores the product in the cache and returns it to the group.
- 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
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:
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)
})
} """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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 20 and 30 ms: e2, e3. e2 and e3 wait in the buffer, which now holds two events, all it has room for.
- 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.
- 45 ms: e3. The slow subscriber finishes e1 and takes e3, the oldest event still waiting, which keeps it busy until 80 ms.
- 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.
- 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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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:
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 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.
Interview questions
Three questions from the module bank on caches and brokers under load:
-
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.
-
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.
-
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.
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
- Package sync: RWMutex (The Go Authors)
- Package sync: Mutex.Lock (The Go Authors)
- Package golang.org/x/sync/singleflight (The Go Authors)
- Package time: NewTicker (The Go Authors)
- Package testing/synctest (The Go Authors)
- The Go Programming Language Specification: Select statements (The Go Authors)
Report a problem with this lesson
Kept only in this browser. Your Learn progress