Un générateur de Snowflake ID lock-free

De la concurrence sans mutex. Ne refaites pas ça chez vous !

Mise en garde : je ne suis absolument pas un expert de la programmation concurrente lock-free. Ce billet présente surtout une implémentation que j’ai faite en guise d’exercice pour en apprendre plus sur le sujet. Le code que j’écris cache peut-être des bugs subtils que mon œil naïf ne saurait encore voir. Si vous en décelez une, dites-le moi s’il vous plaît !

Pour en apprendre plus sur la programmation lock-free (code concurrent sans système de verrouillage, souvent de type mutex/futex), j’ai décidé d’écrire un générateurs de Snowflake ID qui marche dans une situation de haute concurrence.

En l’essence, un Snowflake ID est un entier sur 64 bits qui embarque un timestamp sur ses premiers bits (les plus significatifs), suivis d’un numéro de séquence sur les derniers bits restants. La version originale de Twitter inclut aussi un identifiant de machine entre le timestamp et le numéro de séquence, mais j’ai implémenté une version plus simple définie comme suit :

  • 48 premiers bits : timestamp Unix en millisecondes
  • 16 derniers bits : numéro de séquence, allant de 0 à 65 535 (0xffff)

Cette version est similaire à celle que Mastodon implémente.

Les Snowflake ID ont des propriétés qui les rendent intéressantes si l’on veut les implémenter avec un algorithme thread-safe. Ils doivent être uniques et générés dans un ordre strictement croissant parmi tous les workers qui en veulent. Cela signifie qu’il est nécessaire de maintenir un état interne et de le protéger des accès non sérialisés qui pourraient le corrompre et invalider les propriétés définies.

Version Mutex

Voici une première version qui utilise une mutex. Elle servira de base de comparaison et permet d’illustrer l’algorithme de génération d’un Snowflake ID. Le code est en Go, mais il reste lisible pour les non-pratiquants de Go.

État interne :

type IDGen struct {
    mu  sync.Mutex
    ts  int64  // Dernier timestamp en usage
    seq int64  // Dernier numéro de séquence pour ce timestamp
}

Cette fonction assure les accès sérialisés grâce à la mutex. Quand le verrou est actif, on peut ainsi mettre à jour l’état du timestamp ts et du numéro de séquence seq en une opération conceptuellement atomique. C’est le cas facile, la version lock-free ne donnera pas ce luxe.

func (g *IDGen) NewID() int64 {
    ts := time.Now().UnixMilli()

    g.mu.Lock()
    // Réinitialiser seq et ts si le timestamp a avancé dans le temps
    if ts > g.ts {
        g.seq = 0
        g.ts = ts
    }
    seq := g.seq
    g.seq++
    g.mu.Unlock()

    // Pour la rigueur.
    // Ma machine met 25ns pour générer un ID, soit 40k IDs/ms.
    // On ne devrait pas atteindre cette valeur dans la pratique.
    if seq > 0xffff { 
        return -1
    }

    // 48 bits de poids fort : timestamp
    // 16 bits de poids faible : séquence
    id := (ts << 16) | seq 
    return id
}

Est-ce rapide ?

Sur ma machine (Intel(R) Core(TM) i7-1255U en mode performance), cela met environ 35 nanosecondes sans aucune contention. Il y a donc une pénalité de 10 ns comparée à la version sans aucune protection (non thread-safe) qui met 25 ns.

La version Lock-Free

Cette version n’utilise plus de mutex du tout. À la place, elle utilise deux instructions atomiques :

  • AddInt64(*v, delta) incrémente et ré-affecte *v += delta et retourne cette nouvelle valeur.
  • CompareAndSwapInt64(*v, old, new) ré-affecte *v = new, mais à la condition que *v == old au moment où l’on effectue l’opération, c’est-à-dire sans qu’un autre thread n’eût mis à jour *v pendant ce temps.

Ce que font ces deux opérations est plus clair avec le code, pas de panique.

Mais ici il devient difficile de gérer la mise à jour de deux variables (le timestamp et la séquence) de façon cohérente avec les instructions atomiques. Je travaille donc avec une seule valeur : un entier id qui encode aussi bien le timestamp que la séquence et sur lequel on peut opérer :

type IDGen struct {
    id  int64  //  Deux en un : ts + seq
}

Remarques sur le code

Avant de voir le nouveau code, voici quelques éléments à rappeler pour bien le comprendre :

Le timestamp s’extrait en faisant id >> 16.
Incrémenter l’ID entier, c’est incrémenter son numéro de séquence placé sur les bits de poids faible, en supposant que l’on n’atteigne pas le cas limite où la séquence dépasse 0xffff. On peut donc utiliser AddInt64 directement sur l’ancien ID généré.
Puisque que CompareAndSwapInt64(*v, old, new) peut échouer, il nous faut une boucle pour ré-essayer jusqu’au succès, d’où le for.
On considère que le temps ne peut aller qu’en avant (le retour d’un appel à time.Now() ne saurait être strictement inférieur à un précédent).

Allez, place au code !

func (g *IDGen) NewID() int64 {
    for {  // boucle infinie, en Go
    
        ts := time.Now().UnixMilli()

        candidateID := atomic.AddInt64(&g.id, 1)
        if ts == candidateID>>16 {
            // On a eu un ID incrémenté et le timestamp est toujours
            // sur la même milliseconde. C'est bon, on a notre ID.
            return candidateID
        }
        
        // Cas où le timestamp a changé depuis la dernière génération d'ID.
        // On doit alors le ré-initiation avec le nouveau timestamp et
        // repartie avec seq = 0.
        // Si aucun autre thread n'a touché g.id, alors il devrait
        // être encore égal à candidateID retourné par AddInt64 plus haut.

        if atomic.CompareAndSwapInt64(&g.id, candidateID, ts<<16) {
            // Succès, on a notre nouvel ID. Pas besoin de OR, car seq = 0
            return ts << 16
        }        
        // Échec... On dirait qu'un autre thread a touché g.id entre temps.
        // Il faut ré-essayer.
    }
}

Est-ce rapide ?

Dans un cas sans aucune contention, cette version met 29 ns (pour rappel, la version mutex met 35 ns et la version sans protection en met 25 ns).

Mutex vs. Lock-free en cas de haute concurrence

Passons aux choses intéressantes.

L’intérêt de ces algorithmes lock-free, c’est d’être efficaces dans des situations de haute concurrence, quand la contention peut augmenter de façon non triviale. Alors testons la rapidité de nos algorithmes avec ce mode.

Pour chaque run, c workers vont générer 10 000 ID de façon concurrente. J’ai fait tourner chaque benchmark 1000 fois et j’ai pris la moyenne (en microsecondes). J’ai laissé le CPU refroidir entre chaque run.

Résultats :

Plus c'est bas, mieux c'est.
Plus c’est bas, mieux c’est.

Quand le niveau de concurrence est bas les deux versions sont semblables. Dès que l’on passe à 4, la différence commence à s’affirmer plus franchement, quand la contention est supposément bien plus haute. Au delà de ce niveau, la version mutex se vautre juste.

Je ne suis pas allé au dessus de 12 workers, je n’ai que 12 cores.

Conclusion

Si le niveau de contention attendu est bas, voire proche de 0 la plupart du temps, alors il n’est certainement pas nécessaire d’aller sur autre chose que de la mutex. Les mutex modernes (futex) sont en réalité assez rapides dans ces cas-ci. Rappelez-vous : la pénalité n’est que de 10 ns dans un cas sans contention. La version lock-free, quant à elle, est difficile à concevoir et à comprendre. Honnêtement je ne suis pas sûr d’avoir géré les petits cas subtiles pouvant mener à des bugs pernicieux, et j’ai dû faire quelques présupposés pour valider cette version.

Mais l’exercice était rigolo.

Autre

Ce billet est la traduction en français de l’original en anglais, rédigé en préparation au défi de l’Avent.

Crédits de l’image de la vignette : domaine public, source : https://commons.wikimedia.org/wiki/File:Snowflakeschapte00warriala-p11-p21-p29-p39.jpg



11 commentaires

En vérifiant le comportement de time.Now().UnixMilli(), je découvre que Go est l’un des rares langages qui mélange volontairement chronomètre (horloge monotone) et horodatage légal. Je ne suis pas certain que c’était une bonne idée, tant les deux usages sont différents. À priori ton code est OK de ce côté là et tu ne risques pas d’avoir des IDs en double en cas de mise à jour de l’horloge système ou changement d’heure, mais j’ai pu louper quelque chose.

PS : le concept de Snowflake ID a aussi été repris pour les UUIDv7 : https://buildkite.com/blog/goodbye-integers-hello-uuids

En vérifiant le comportement de time.Now().UnixMilli(), je découvre que Go est l’un des rares langages qui mélange volontairement chronomètre (horloge monotone) et horodatage légal. Je ne suis pas certain que c’était une bonne idée, tant les deux usages sont différents. À priori ton code est OK de ce côté là et tu ne risques pas d’avoir des IDs en double en cas de mise à jour de l’horloge système ou changement d’heure, mais j’ai pu louper quelque chose.

PS : le concept de Snowflake ID a aussi été repris pour les UUIDv7 : https://buildkite.com/blog/goodbye-integers-hello-uuids

SpaceFox

Oui, Go fait ça c’est exact. J’ai pas forcément d’avis sur la question, mais ça avait fait grand débat à l’époque où le langage était encore en phase de maturation.

Après, j’ai voulu illustrer la logique elle-même sans forcément m’attarder trop sur les spécificités de Go. Il se trouve que c’est un langage que je connais bien, lisible, et qui propose de base les atomics et les mutex, donc c’était le candidat idéal pour illustrer le billet ^^

P.-S. : Je prépare peut-être du contenu sur les UUIDv7, justement :-°

Je pense que Twitter aurait sûrement pu apprécier les UUID (même si la v7 n’existait pas, rien n’aurait empêché de faire une implémentation custom d’UUID k-sortable), mais ils ont fait Snowflake ID. Je crois que c’était en grande partie pour ne pas casser l’existant qui imposait les int64 comme clé pour beaucoup de choses.

+0 -0

Billet très intéressant.

Ce billet est la traduction en français de l’original en anglais

Euh, à moitié hein. ^^"
Il en est difficile à lire tellement c’est un mélange français / anglais.

+0 -1

Billet très intéressant.

Ce billet est la traduction en français de l’original en anglais

Euh, à moitié hein. ^^"
Il en est difficile à lire tellement c’est un mélange français / anglais.

ache

Content que le billet te plaise !

En général j’essaie d’employer les termes qui sont en usage dominant dans le milieu en France (pas en francophonie). Quand une traduction existe et est dominante, je ne m’en prive pas.

@ache tu penses à quels termes en particulier ?

L’article jargonne un peu, mais il est tellement techniques que ça me semble vain de traiter ce point, parce que les personnes qui ne comprennent pas le jargon auront probablement du mal avec le fond aussi. Et une version très vulgarisée (et donc à déjargonner) de l’article serait très différente.

Je pense qu’outre le jargon technique, les mots qui m’ont le plus embêté personnellement sont « lock-free » et « contention » que je traduis dans ma tête par « sans blocage » et par « saturation ».

+0 -0

En vérifiant le comportement de time.Now().UnixMilli(), je découvre que Go est l’un des rares langages qui mélange volontairement chronomètre (horloge monotone) et horodatage légal. Je ne suis pas certain que c’était une bonne idée, tant les deux usages sont différents. À priori ton code est OK de ce côté là et tu ne risques pas d’avoir des IDs en double en cas de mise à jour de l’horloge système ou changement d’heure, mais j’ai pu louper quelque chose.

PS : le concept de Snowflake ID a aussi été repris pour les UUIDv7 : https://buildkite.com/blog/goodbye-integers-hello-uuids

SpaceFox

Ben justement, en lisant la doc j’ai l’impression que c’est pas bon. Quand on appelle time.Now(), on obtient un objet qui wrappe un time stamp non monotone, et une lecture d’une horloge monotone. La lecture de l’horloge monotone ne sert que pour comparer et soustraire des instants (par exemple obtenus avec time.Now()), mais si on se sert de l’instant pour obtenir un time stamp, ce dernier n’est pas monotone parce qu’il utilise le time stamp système mais pas l’horloge monotone. Le morceau pertinent de la doc:

The monotonic clock reading exists only in Time values. It is not a part of Duration values or the Unix times returned by t.Unix and friends.

J’imagine que t.UnixMilli() qui est utilisé dans le code fait parti des "t.Unix and friends" que mentionne la doc (bonjour la clarté :-° ). Il n’y a pas de miracle, les time stamps systèmes ne peuvent pas être monotones par construction… La raison pour laquelle on sépare les horloges systèmes des horloges monotones est simplement parce que sinon on se retrouve comme ici avec une API piège où il faut lire les petites lettres en bas de page pour être sûr que ça fait bien ce qu’on veut…

+2 -0

@adri1 je te fais confiance, je n’ai pas épluché la doc en détail. Mais le simple fait que l’on puisse se poser la question montre qu’il y a un gros problème de conception dans cette API.

Ah il faut pas me faire confiance non plus. Je suis même allé voir le code source comme la formulation de la doc n’est vraiment pas claire, et pour être honnête je ne suis toujours pas complètement certain. Le mélange timestamp/horloge monotone se retrouve de partout dans le code de la lib elle-même donc c’est assez obscur (si je me retrouvais à écrire du code comme ça, perso ça me ferait réfléchir de nouveau à mes abstractions :-° ).

En fait, la raison pour laquelle je suis aller mettre le nez dedans à la base était parce que je voulais par curiosité comparer mutex Go, atomic Go, mutex Rust standard, atomic Rust (où je pense qu’on doit pouvoir faire mieux que l’ordering très strict qu’impose Go sur ses atomiques), et potentiellement d’autres libs de synchronisation en Rust. Donc je voulais l’implémentation la plus proche possible par ailleurs, jusqu’à ce que je me rende compte que savoir ce que fait time.Now().UnixMilli() demande plus d’effort que ce que je voulais mettre dans la question au début… Si quelqu’un peut expliquer ça simplement, je prends… Et si c’est pas correct (dans le sens où le timestamp obtenu n’est pas monotone), comme implémenter ça proprement en Go.

+0 -0

@adri1: Cloudflare a écrit un article il y a quelques année sur le problème de time.Now() en Go. La solution à l’époque était d’utiliser runtime.nanotime() mais il semble qu’aujourd’hui la package runtime ne propose pas d’accès à nanotime même si la fonction est utilisée en interne. Des bibliothèques externes comme goarista remplissent le besoin.

+1 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte