Singleflight
When a dozen apprentices clamour for the same scroll, send the familiar but once.
package main
import (
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
var g singleflight.Group
func lookup(key string) (string, error) {
// One in-flight call per key; everyone else waits and shares the result.
v, err, shared := g.Do(key, func() (any, error) {
time.Sleep(2000 * time.Millisecond) // the slow wellspring
return "scroll-of-" + key, nil
})
_ = shared
if err != nil {
return "", err
}
return v.(string), nil
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s, _ := lookup("the-prophecy")
fmt.Println(s)
}()
}
wg.Wait()
// upstream calls actually made: 1 (saved 3)
} What you are witnessing
A singleflight group is a small, watchful door-warden. When several
goroutines call g.Do(key, fn) with the same key at the same
time, the warden lets exactly one through to perform fn. The rest wait
politely by the door and, when the chosen one returns, are each handed an identical
copy of the result.
Why a wizard cares
The classic use is the cache stampede: a popular value expires, a thousand requests arrive in the same instant, and your poor database is asked the same question a thousand times. With singleflight, it is asked once; the other 999 sip from the same cauldron.
Try it: crank callers up to 8 and turn singleflight
off. Watch each apprentice fire their own bolt at the wellspring. Now turn it back
on — the bolts collapse into a single ceremonious strike, and the reply fans out
to all. The little counter at the bottom of the code (upstreamCalls)
is the number that matters to your bill.
Pitfalls (the asterisks every wizard must read)
- Failure is shared too. If the one in-flight call errors, every waiter receives that same error. Wrap appropriately.
- Slow callers are punished by the slowest. All waiters block until the chosen call returns. Pair with a
contextwhen latency matters. - Keys must be canonical.
"User:42"and"user:42"are two separate doors. - It is not a cache. Once the call completes, the next caller starts a fresh casting. Combine with a real cache if you need memoisation.
Reagents required: go get golang.org/x/sync/singleflight. No newt eyes.