Singleflight en Go

17 juin 2026

Singleflight en Go

Vous avez une fonction coûteuse et lourde. À un pic de trafic, six requêtes arrivent simultanément en demandant exactement la même chose. Sans le moindre soin, vous exécutez cette fonction six fois pour obtenir six résultats identiques.

singleflight est un motif qui fait s’effondrer ces appels. Le premier appel pour une clé exécute le travail réel. Tous les autres qui arrivent pendant qu’il est en cours attendent et reçoivent le même résultat. Une exécution, plusieurs consommateurs.

Et voici la partie qui complique beaucoup de gens : singleflight n’est pas un cache. Il ne garde rien pour plus tard. Dès que l’appel se termine, l’entrée est supprimée. Il ne regroupe que les appels qui sont simultanément en vol. D’où le nom, « vol unique ».

Voici une version minimale pour comprendre le mécanisme. Le cœur de tout est une struct qui représente un appel en cours :

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

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

Le Group conserve une carte des appels qui se déroulent actuellement, indexés par la clé. Le call possède un canal done qui est l’astuce centrale : ceux qui arrivent après restent bloqués en lisant ce canal jusqu’à ce qu’il soit fermé.

Toute la logique vit dans la méthode 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
}

Avec le verrou en main, le Do vérifie s’il existe déjà un appel en cours pour cette clé. S’il existe, il libère le verrou, attend que le canal done se ferme avec <-c.done et renvoie le résultat produit par l’autre goroutine.

Le second retour est un bool qui indique si le résultat a été partagé, utile pour savoir si vous étiez le « leader » ou un « passager ».

Si aucune appel pour cette clé n’existe, cette goroutine devient le leader : elle crée le call, l’enregistre dans la carte, libère le verrou et n’exécute fn() qu’ensuite. Libérer le verrou avant d’exécuter est l’astuce magique. C’est ce qui permet aux passagers d’entrer et de patienter dans la file pendant que le leader travaille.

Avec le verrou protégé pendant l’appel coûteux, aucun passager ne pourrait même voir qu’il y a déjà un appel en vol, et tout le gain irait à vau-l’eau. Quand le travail se termine, le canal est fermé avec close(c.done) (cela libère tout le monde qui attendait en même temps) et l’entrée est retirée de la carte.

Pour tester, une fonction coûteuse fictive qui dort seulement 300 ms et compte combien de fois elle a été réellement appelée :

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
}

Dans le main, six goroutines se déclenchent en appelant la même clé au même moment :

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=%vn", id, err)
				return
			}

			fmt.Printf("worker=%d shared=%v value=%qn", id, shared, val)
		}(i)
	}

	wg.Wait()

	fmt.Printf("expensive calls: %dn", expensiveCalls.Load())
}

En lançant, on obtient :

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

Six travailleurs, mais la fonction coûteuse ne s’est exécutée qu’une seule fois. Le worker=1 était le leader (shared=false) et les cinq autres ont filé sur le même résultat (shared=true). Notez que l’ordre des travailleurs dans le rendu change à chaque exécution, c’est l’ordonnanceur de goroutines qui fait ce qu’il veut. Celui qui devient leader varie aussi.

Maintenant, pour clarifier que ce n’est pas du cache, à la fin du main on appelle la même clé de nouveau, après que la première vague s’est terminée :

	val, shared, err := g.Do("same-key", expensiveFunc)
	if err != nil {
		fmt.Printf("worker=%d error=%vn", 0, err)
		return
	}

	fmt.Printf("worker=%d shared=%v value=%qn", 0, shared, val)

	fmt.Printf("expensive calls: %dn", expensiveCalls.Load())
}

Et la sortie :

worker=0 shared=false value="result from expensive call #2"
expensive calls: 2

Comme il n’y avait plus personne en vol, cet appel est redevenu leader (shared=false) et la fonction coûteuse s’est exécutée une deuxième fois. Aucun résultat n’a été conservé.

Un détail que ma version minimale ignore : si la fonction coûteuse entre en panic ou se bloque pour de bon, tous les passagers se bloquent ensemble, prisonniers dans <-c.done. Un seul point de défaillance qui affecte tous ceux qui ont pris le même vol. L’implémentation officielle de Go gère cela.

Cette implémentation officielle réside dans golang.org/x/sync/singleflight, avec gestion du panic, propagation de l’annulation et une méthode DoChan qui renvoie un canal plutôt que de bloquer. En production, utilisez la leur. Mais écrire la version minimale a été ce qui m’a aidé à comprendre pourquoi tout cela fonctionne. En fin de compte, ce n’est qu’une carte, un mutex et un canal.

code source complet

Fabien Delpont

Auteur

Fabien Delpont

Fabien Delpont, développeur et créateur du site Python Doctor.