lang-wiz
a grimoire of advanced Go incantations · turn the page slowly

← back to the grimoire

Singleflight

When a dozen apprentices clamour for the same scroll, send the familiar but once.

grimoire fragment · main.go
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)
}
The summoning circle
callers singleflight.Group Scroll Rack one casting binds all fetch · 2000ms familiar
Dials & sigils

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 context when 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.