singleflight em Go
Você tem uma função cara e pesada. Em um pico de tráfego chegam seis requisições ao mesmo tempo pedindo exatamente a mesma coisa. Sem nenhum cuidado, você executa esse função seis vezes para obter seis resultados idênticos.
singleflight é um padrão que colapsa essas chamadas. A primeira chamada para uma chave executa o trabalho de verdade. Todas as outras que chegam enquanto ela está em andamento ficam esperando e recebem o mesmo resultado. Uma execução, vários consumidores.
E aqui vai a parte que confunde muita gente: singleflight não é cache. Ele não guarda nada para depois. Assim que a chamada termina, a entrada é removida. Ele só junta as chamadas que estão simultaneamente em voo. Daí o nome, “voo único”.
Esta é uma versão mínima para entender o mecanismo. O coração de tudo é um struct que representa uma chamada em andamento:
type call struct {
done chan struct{}
val string
err error
}
type Group struct {
mu sync.Mutex
calls map[string]*call
}
O Group guarda um mapa das chamadas que estão acontecendo agora, indexadas pela chave. O call tem um canal done que é o truque central: quem chega depois fica bloqueado lendo desse canal até ele ser fechado.
Toda a lógica vive no método Do:
func (g *Group) Do(key string, fn func() (string, error)) (string, bool, error) {
g.mu.Lock()
if g.calls == nil {
g.calls = make(map[string]*call)
}
if c, ok := g.calls[key]; ok {
g.mu.Unlock()
<-c.done
return c.val, true, c.err
}
c := &call{
done: make(chan struct{}),
}
g.calls[key] = c
g.mu.Unlock()
c.val, c.err = fn()
close(c.done)
g.mu.Lock()
delete(g.calls, key)
g.mu.Unlock()
return c.val, false, c.err
}
Com o lock em mãos, o Do verifica se já existe uma chamada em andamento para aquela chave. Se existe, solta o lock, espera o canal done fechar com <-c.done e devolve o resultado que a outra goroutine produziu.
O segundo retorno é um bool que diz se o resultado foi compartilhado, útil para saber se você foi o “líder” ou um “carona”.
Se não existe chamada para aquela chave, essa goroutine vira a líder: cria o call, registra no mapa, solta o lock e só então executa fn(). Soltar o lock antes de executar é o pulo do gato. É isso que deixa os caronas entrarem e enfileirarem na espera enquanto o líder trabalha.
Com o lock seguro durante a chamada cara, nenhum carona conseguiria nem ver que já tem uma chamada em voo, e o ganho todo iria pro lixo. Quando o trabalho termina, o canal é fechado com close(c.done) (isso libera todo mundo que estava esperando de uma vez) e a entrada é removida do mapa.
Para testar, uma função cara de mentira que só dorme 300ms e conta quantas vezes foi de fato chamada:
var expensiveCalls atomic.Int64
func expensiveFunc() (string, error) {
n := expensiveCalls.Add(1)
time.Sleep(300 * time.Millisecond)
return fmt.Sprintf("result from expensive call #%d", n), nil
}
No main, seis goroutines disparam pedindo a mesma chave ao mesmo tempo:
func main() {
var g Group
var wg sync.WaitGroup
// Start 6 goroutines that call the same key at the same time.
for i := range 6 {
wg.Add(1)
go func(id int) {
defer wg.Done()
val, shared, err := g.Do("same-key", expensiveFunc)
if err != nil {
fmt.Printf("worker=%d error=%v\n", id, err)
return
}
fmt.Printf("worker=%d shared=%v value=%q\n", id, shared, val)
}(i)
}
wg.Wait()
fmt.Printf("expensive calls: %d\n", expensiveCalls.Load())
Rodando:
worker=1 shared=false value="result from expensive call #1"
worker=2 shared=true value="result from expensive call #1"
worker=0 shared=true value="result from expensive call #1"
worker=5 shared=true value="result from expensive call #1"
worker=3 shared=true value="result from expensive call #1"
worker=4 shared=true value="result from expensive call #1"
expensive calls: 1
Seis workers, mas a função cara rodou uma única vez. O worker=1 foi o líder (shared=false) e os outros cinco pegaram carona no mesmo resultado (shared=true). Repare que a ordem dos workers na saída muda a cada execução, isso é o escalonador de goroutines fazendo o que quer. Quem vira o líder também varia.
Agora, para deixar claro que isso não é cache, no fim do main a mesma chave é chamada de novo, depois que a primeira leva já terminou:
val, shared, err := g.Do("same-key", expensiveFunc)
if err != nil {
fmt.Printf("worker=%d error=%v\n", 0, err)
return
}
fmt.Printf("worker=%d shared=%v value=%q\n", 0, shared, val)
fmt.Printf("expensive calls: %d\n", expensiveCalls.Load())
}
E a saída:
worker=0 shared=false value="result from expensive call #2"
expensive calls: 2
Como não tinha mais ninguém em voo, essa chamada virou líder de novo (shared=false) e a função cara rodou pela segunda vez. Nenhum resultado ficou guardado.
Um detalhe que a minha versão mínima ignora: se a função cara entrar em panic ou travar pra sempre, todos os caronas travam junto, presos no <-c.done. Um único ponto de falha que afeta todo mundo que pegou carona. A implementação oficial do Go lida com isso.
Essa implementação oficial mora em golang.org/x/sync/singleflight, com tratamento de panic, propagação de cancelamento e um método DoChan que devolve um canal em vez de bloquear. Em produção, use a deles. Mas escrever a versão mínima foi o que me fez entender por que a coisa funciona. No fim, é um mapa, um mutex e um canal.