Dev (Back & Front)ARTIGO

singleflight em Go

singleflight em Go
Imagem: Cesar Gimenes

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:

golang
type call struct {
	done chan struct{}
	val  string
	err  error
}

type Group struct {
	mu    sync.Mutex
	calls map[string]*call
}

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:

golang
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:

golang
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:

golang
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:

bash
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:

golang
	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:

bash
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

Trabalha com tecnologia desde a década de 90. Já atuou na área de educação e participou de projetos de mobilidade de grande volume para laboratórios farmacêuticos. Criou games tanto para PC, como para iOS. Hoje está direcionando seus esforços em plataformas de Sistemas Embarcados, IoT, microservices e cloud computing. É um entusiasta de tecnologias como Golang e Docker.

Ver perfil