- À partir d’un compteur concurrent Rust défectueux, l’article montre comment mettre en évidence, via un contrôle de l’ordonnancement d’exécution reproductible et minimisable, des problèmes que les tests de charge classiques sur threads ne détectent pas
- Un wrapper
AtomicU32dédié aux tests insèrepause(), et des managed threads s’arrêtent avant et après les opérations atomiques puis reprennent selon l’ordre choisi par le test - Un test simple peut faire exécuter
100incréments100fois chacun par100threads et produire un échec comme9598au lieu de la valeur attendue10000, mais cela dépend du timing et reste difficile à reproduire, déboguer et réduire - Un test de propriété basé sur
arbtestpermet de rejouer le même interleaving avec la même seed et de minimiser le cas d’échec jusqu’à0: increment,1: increment,0: unpause,1: unpause - En étendant la même approche avec
exhaustigen, on peut énumérer tous les interleavings jusqu’à5incréments maximum, et après correction avecfetch_add, les81133interleavings passent
Compteur concurrent non atomique
- L’exemple utilise
AtomicU32de Rust, maisincrement()effectueloadpuisstore(value + 1), donc l’opération d’incrément elle-même n’est pas atomique - La structure
Counterest simplevalue: AtomicU32increment()lit la valeur avecSeqCst, ajoute1à la valeur lue, puis la réécritget()lit la valeur courante avecSeqCst
- Deux threads peuvent lire la même valeur puis écrire le même résultat incrémenté, ce qui fait disparaître une mise à jour
Pourquoi les tests classiques sur threads sont insuffisants
- La vérification la plus simple consiste à faire incrémenter en boucle le même compteur par plusieurs threads, puis à vérifier la valeur finale
thread_count = 100increment_count = 100- la valeur attendue est
10000
- Un exemple d’exécution échoue avec
left: 9598,right: 10000 - Cette méthode dépend fortement du timing d’ordonnancement
- difficile de reproduire exactement le même échec de manière déterministe
- difficile à déboguer
- si l’on réduit le nombre de threads ou d’incréments, le test peut réussir par chance, ce qui rend la minimisation du cas d’échec difficile
Gérer les interleavings avec les tests basés sur les propriétés
- Les tests basés sur les propriétés (PBT) se prêtent bien aux tests de machines à états
- il est facile de générer des entrées aléatoires
- on peut poser comme propriété que le résultat concurrent doit être identique à celui d’un modèle d’exécution séquentielle
- cela correspond aussi au besoin de minimiser les entrées en échec
- La difficulté vient du fait qu’il est compliqué de faire progresser de vrais threads OS pas à pas exactement au moment voulu
- La solution consiste à choisir à chaque itération un thread aléatoire et à le faire avancer d’une étape
- il faut pouvoir insérer un autre thread entre le
loadet lestored’un thread donné - pour cela, l’auteur construit une API de managed thread pilotée directement
- il faut pouvoir insérer un autre thread entre le
AtomicU32 de test et insertion de pause
- Dans les builds de test,
std::sync::atomic::AtomicU32est remplacé par unmanaged_thread::AtomicU32maison#[cfg(test)] use managed_thread::AtomicU32#[cfg(not(test))] use std::sync::atomic::AtomicU32
- Le wrapper
AtomicU32appellepause()avant et aprèsload()etstore()load:pause()→ vraiload→pause()store:pause()→ vraistore→pause()
- Grâce à ces points d’insertion, le test peut arrêter et relancer les threads autour des opérations atomiques pour contrôler l’ordre d’exécution
Forme de l’API de managed thread
- Le test crée deux managed threads dans un
std::thread::scope- comme il s’agit de scoped threads, ils peuvent emprunter des données locales de pile
- on passe par exemple une référence au compteur comme état avec
spawn(scope, &counter)
- Un managed thread ne lance pas immédiatement une fonction
mainspécifique ; il exécute à la place une closure envoyée par le thread de contrôle viasubmit()t.submit(|c| c.increment())- le thread exécute la closure sur son propre état
T
- Tant qu’il reste de l’entropie, la boucle de test choisit aléatoirement une action pour chaque thread
- si le thread est arrêté, on appelle
unpause() - sinon, on lance
increment()viasubmit() - le modèle séquentiel
counter_modelest lui aussi incrémenté du même nombre de fois
- si le thread est arrêté, on appelle
- À la fin, tous les threads sont
join()puis on comparecounter_modelaveccounter.get()
Implémentation de pause et unpause
pause()utilisethread_local!pour retrouver le contexte du managed thread courant sans modifier l’APICountertestée- le contexte est partagé via
Arc<SharedContext> SharedContextcontientMutex<State>etCondvar
- le contexte est partagé via
- Les états sont
Ready,RunningetPausedReady: en attente de la prochaine closureRunning: le managed thread est en cours d’exécutionPaused: le thread est arrêté sur un pointpause()
- Quand un managed thread atteint
pause(), il fait passer son état deRunningàPausedet notifie le thread de contrôle via la condition variable unpause()fait passer l’état dePausedàRunning, réveille le managed thread, puis attend à nouveau tant que l’état resteRunning- cela évite que le thread de contrôle et le managed thread continuent à s’exécuter en même temps
- à tout moment, un seul des deux avance, ce qui réduit la non-détermination
Reproduire et minimiser les échecs
- L’exécution avec
arbtesttrouve un échec sur le compteur défectueux- dans l’exemple, la valeur du modèle est
4et la valeur réelle3 - la seed d’échec est
0x4fd7ddff00000020
- dans l’exemple, la valeur du modèle est
- En réutilisant la même seed, on obtient exactement le même interleaving, ce qui facilite la reproduction de l’échec
- Avec
.minimize(), le cas d’échec se réduit à une exécution plus courte- la seed du cas minimal final est
0x9c2a13a600000001 - la trace minimale tient en quatre étapes
0: increment1: increment0: unpause1: unpause
- la seed du cas minimal final est
- Dans ce cas minimal, la valeur attendue est
2mais la valeur réelle est1, ce qui met clairement en évidence le défaut de l’incrément basé surload/store
Extension à l’énumération de tous les interleavings
- La même structure peut être adaptée d’une stratégie aléatoire vers une stratégie d’énumération
- En utilisant
exhaustigen, l’auteur écrit un test qui explore tous les interleavings jusqu’à5incréments maximum- le test évite les itérations factices et veille à toujours soit
unpauseun thread, soit lui soumettre unincrement
- le test évite les itérations factices et veille à toujours soit
- L’implémentation défectueuse révèle le même bug
- exemple d’échec :
left: 2,right: 1
- exemple d’échec :
- En corrigeant
Counter::increment()avecfetch_add(1, SeqCst), le test passe- le wrapper
AtomicU32ajoute aussipause()avant et aprèsfetch_add() - le résultat d’exécution devient
all 81133 interleavings are fine! - temps d’exécution :
real 8.65s, CPU :8.16s, RSS :63.91mb
- le wrapper
Extension vers les modèles mémoire faibles et le model checking
- Dans cette implémentation jouet,
AtomicU32délègue actuellement à de vraies opérations atomiques - Une idée d’extension consiste à conserver, pour chaque atomic, l’ensemble des valeurs écrites et à renvoyer à la lecture une valeur aléatoire cohérente avec un modèle mémoire faible
- L’exploration des interleavings peut elle aussi devenir plus intelligente que le simple aléatoire
- une approche de type model checking permettrait de vérifier que tous les interleavings réellement différents ont été pris en compte
- on pourrait aussi énumérer tous les interleavings sur de petits domaines, comme dans l’approche Generate All The Things
Pourquoi la minimisation fonctionne sans shrinking
- Le
arbtestutilisé ressemble à une interface classique de PRNG, mais repose sur un PRNG fini- si l’on continue à demander des valeurs aléatoires, il finit par renvoyer
Err(OutOfEntropy) - c’est pour cela que le code de test utilise
?etwhile !rng.is_empty()
- si l’on continue à demander des valeurs aléatoires, il finit par renvoyer
- Quand le test épuise son entropie, il s’arrête plus tôt ; réduire l’entropie disponible raccourcit donc aussi l’exécution du test
- Son implémentation interne se rapproche conceptuellement de
&mut &[u8]- à chaque demande de nombre aléatoire, la tranche d’octets rétrécit
- plus la tranche initiale est courte, plus le test devient simple
- Grâce à cette approche, les cas d’échec peuvent se raccourcir sans qu’il soit nécessaire d’implémenter soi-même une logique de shrinking séparée
- Le code source de l’exemple est disponible dans properly-concurrent
1 commentaires
Commentaires sur Hacker News
En Rust, une bibliothèque appelée Temper est en cours de développement avec une approche similaire : https://github.com/reitzensteinm/temper/tree/main
Cela dit, pour modéliser les implications étranges produites par l’ensemble du modèle mémoire de Rust, il faut aller beaucoup plus loin et disposer d’un registre qui suit quelles écritures chaque thread a observées. Selon l’ordonnancement mémoire atomique, les barrières de lecture/écriture, etc., observer l’écriture X peut impliquer qu’il faut aussi nécessairement avoir observé l’écriture Y
Je pense que c’est probablement l’une des plus grandes collections de cas de test pour le modèle mémoire C++/Rust, avec presque tout ce qu’on peut trouver dans des livres, la norme C++, Stack Overflow, des blogs, etc. Par exemple, le fichier pour Rust Atomics and Locks de Mara Bos est ici : https://github.com/reitzensteinm/temper/blob/main/memlog/tes...
Loom, mentionné dans l’article, est une bibliothèque similaire mais bien plus aboutie, qui permet de tester de manière exhaustive des composants de plus haut niveau comme les mutex ou les files : https://github.com/tokio-rs/loom mais elle ne modélise pas le modèle mémoire lui-même de façon aussi fine que Temper, et j’envisageais de porter les cas de test vers Loom
J’ai été inspiré par la présentation de Will Wilson sur les tests de FoundationDB, et il construit actuellement chez Antithesis une solution basée sur un hyperviseur pour effectuer ce type de tests sur des conteneurs Docker arbitraires : https://www.youtube.com/watch?v=4fFDFbi3toc, https://antithesis.com/
Je suis convaincu que ce domaine va énormément grandir au cours des 10 prochaines années. WebAssembly se situe à un point d’équilibre remarquable : suffisamment complet pour compiler des logiciels arbitraires, tout en restant assez simple pour que construire quelque chose comme Antithesis ne devienne pas un projet de cinq ans réservé à une équipe d’élite ayant déjà lancé une base de données
J’ai implémenté en Rust des instantanés atomiques en mémoire partagée, et j’ai pris les tests automatisés aussi sérieusement que possible : https://github.com/kaymanb/todc/tree/main/todc-mem
Au début, j’ai utilisé Loom comme dans l’article, puis je suis ensuite passé à shuttle : https://github.com/tokio-rs/loom, https://github.com/awslabs/shuttle
Au lieu d’une exploration exhaustive comme Loom, shuttle utilise une approche aléatoire, mais son ordonnanceur fournit toujours des garanties probabilistes pour la découverte de bugs. À l’usage, shuttle s’est révélé plus rapide et a mieux évolué vers des scénarios de test plus complexes
Comme dans l’approche de l’article, si un ordonnanceancement précis fait échouer un test, on peut enregistrer la graine aléatoire. La capacité à reproduire rapidement un test en échec est très importante, et permet d’écrire des cas de test explicites pour des bugs déjà trouvés et corrigés : https://github.com/kaymanb/todc/blob/0e2874a70ec8beed8fae773...
Côté Kotlin/Java, Lincheck de JetBrains est une bonne bibliothèque pour ce genre de travail : https://github.com/JetBrains/lincheck
J’aime particulièrement son aspect déclaratif et la façon dont il affiche les résultats de linéarisabilité
Je me demande s’il existe une bibliothèque de type Loom aussi en C++. J’ai des structures de données lock-free que j’aimerais tester
C’est un outil assez ancien et facile à prendre en main. Il a été créé par Dmitry Vyukov, un expert du domaine de la concurrence
https://github.com/facebook/folly/blob/main/folly/test/Deter...
Si j’ai bien compris, cette approche a des limites en ce qui concerne la faible garantie de progression
Le calcul principal n’est pas totalement trivial, mais sur du vrai matériel avec un vrai ordonnanceur, on peut imaginer une boucle
cmpxchgdont la probabilité d’être interrompue sur un CPU donné est extrêmement faible. S’il y anCPU, dans le pire des cas la probabilité de progression est de1/n, alors qu’avec cette méthode de test elle devient1/t^p. Ici,test le nombre de tâches, qui peut être bien supérieur au nombre de CPU, etpest le nombre de pauses dans le corps de la boucle, qui peut facilement être de 3 ou plus. C’est largement suffisant pour faire paraître cassé un algorithme qui fonctionne en pratiqueÀ l’inverse, même si l’on veut considérer la faible progression comme un bug et exiger une forte progression, cette méthode ne semble pas vraiment fournir d’outil utile
Cela dit, elle est clairement utile pour beaucoup de problèmes de concurrence
1/t^pne me semble pas juste, j’aurais tendance à dire simplement1/t. Au bout du compte, aprèstunités de temps, au moins une tâche aura forcément progressé, et s’il y attâches, la probabilité que ce soit la mienne est de1/tLa confusion centrale semble être qu’être interrompu ne signifie pas forcément perdre au CAS
À propos du passage « Pour être honnête, il y a ici un peu de connaissance préalable. À moins de faire quelque chose de profondément maudit avec de l’assembleur inline, je ne vois pas comment éviter de créer de vrais threads. Si quelque chose doit appeler une fonction
pause()et qu’on veut le laisser bloqué jusqu’à nouvel ordre, cela doit se produire dans un thread qui garde une pile distincte de celle du test », je me demande s’il ne serait pas possible d’utiliser un type de runtime asynchroneOn dirait qu’on instrumente les opérations atomiques pour obtenir du multitâche coopératif. Il me faut peut-être plus de café, mais faire ça sans threads semble plus simple
Un inconvénient de cette approche est qu’il faut modifier le code testé lui-même pour l’adapter au code de test
On pourrait sans doute obtenir le même effet en lançant deux threads et en faisant du pas à pas avec
ptrace, en intercalant « aléatoirement » l’exécution des instructions. Un peu comme le mode chaos de rrCela dit, certaines instructions peuvent ne pas être atomiques, donc à moins de faire de l’émulation, il faudrait probablement un moyen d’exécuter en pas à pas à l’échelle du « microcode atomique », si c’est seulement possible
Utiliser Loom semble nécessiter de la compilation conditionnelle ; pour tester une bibliothèque, ça va sans doute, mais c’est assez intrusif
#[cfg(loom)]pub(crate) use loom::sync::atomic::AtomicUsize;#[cfg(not(loom))]pub(crate) use std::sync::atomic::AtomicUsize;Je me demande s’il existe des langages qui facilitent davantage l’usage de son propre ordonnanceur
Pour être vraiment exhaustif, on pourrait sans doute exécuter le test avec
ptraceet faire avancer les threads en pas à pas afin de produire différents entrelacements au niveau des instructions. Je me demande si quelqu’un a déjà réellement vu cette approche en pratiqueQuand on ne peut pas instrumenter le code comme ici, existe-t-il une alternative pour faire des tests en boîte noire ?
ninstructions, il suffit denexécutions où l’on injecte le signal après 0 àninstructions, puis le gestionnaire de signal s’exécute jusqu’au bout avant que le thread principal ne termine aussi. Le temps total est deO(n^2)Mais s’il y a
tthreads exécutant chacunninstructions et qu’ils peuvent tous s’interrompre mutuellement à chaque frontière, cela devient difficilement praticable pour des valeurs réalistes den. Il faudrait probablement réduire le problème en ne retenant et en simulant que les opérations au comportement intéressantÇa a l’air assez cool, il faudra que j’essaie. Cela dit, ça ne détectera pas tous les types d’erreurs. Chaque appel à
pause()introduit une synchronisation entre les threads ; est-ce que cela ne risque pas de masquer certains problèmes de course aux données ? En Rust, ce n’est peut-être pas un problème