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.

código fonte completo

Cesar Gimenes

Última modificação
Tags: