- Zeigt anhand eines defekten nebenläufigen Counters in Rust, wie sich Probleme, die normale Thread-Lasttests übersehen, durch reproduzierbare und minimierbare Steuerung der Ausführungsreihenfolge sichtbar machen lassen
- Ein Test-Wrapper für
AtomicU32fügtpause()ein, und managed threads halten vor und nach atomaren Operationen an und laufen anschließend in der vom Test gewählten Reihenfolge weiter - Ein einfacher Test kann
100Threads jeweils100Inkremente ausführen lassen und statt des erwarteten Werts10000einen Fehler wie9598erzeugen, ist aber timingabhängig und daher schwer zu reproduzieren, zu debuggen und zu verkleinern - Property-based Testing auf Basis von
arbtestreproduziert mit demselben Seed dasselbe Interleaving und minimiert den Fehlerfall bis auf0: increment,1: increment,0: unpause,1: unpause - Erweitert man dieselbe Struktur mit
exhaustigen, lassen sich bis zu5Inkremente alle Interleavings aufzählen; nach der Korrektur mitfetch_addbestehen81133Interleavings
Nicht-atomarer nebenläufiger Counter
- Das Beispiel verwendet Rusts
AtomicU32, aberincrement()führt nach einemloadeinstore(value + 1)aus, sodass die Inkrement-Operation selbst nicht atomar ist - Die Struktur
Counterist einfachvalue: AtomicU32increment()liest den Wert mitSeqCst, addiert1auf den gelesenen Wert und speichert ihn wiederget()liest den aktuellen Wert mitSeqCst
- Zwei Threads können denselben Wert lesen und anschließend dasselbe Inkrement-Ergebnis speichern, wodurch ein Update verloren geht
Warum normale Thread-Tests nicht ausreichen
- Die einfachste Prüfung lässt mehrere Threads denselben Counter wiederholt inkrementieren und prüft am Ende den Wert
thread_count = 100increment_count = 100- Erwarteter Wert ist
10000
- Ein Beispiellauf schlägt mit
left: 9598,right: 10000fehl - Diese Methode hängt stark vom Scheduling-Timing ab
- Es ist schwierig, denselben Fehler deterministisch zu reproduzieren
- Sie ist schwer zu debuggen
- Reduziert man die Anzahl der Threads oder Inkremente, kann der Test mit Glück bestehen, wodurch sich der Fehlerfall nur schwer minimieren lässt
Interleavings mit Property-based Testing behandeln
- Property-based Testing (PBT) passt gut zu State-Machine-Tests
- Zufällige Eingaben lassen sich leicht erzeugen
- Man kann die Eigenschaft formulieren, dass das Ergebnis der nebenläufigen Ausführung dem sequentiellen Ausführungsmodell entsprechen muss
- Das passt auch zum Bedarf, fehlschlagende Eingaben zu minimieren
- Die Schwierigkeit besteht darin, echte OS-Threads zu gewünschten Zeitpunkten Schritt für Schritt weiterlaufen zu lassen
- Die Lösung ist eine Struktur, die in jeder Iteration einen zufälligen Thread auswählt und ihn einen Schritt ausführen lässt
- Es muss möglich sein, zwischen
loadundstoreeines Threads einen anderen Thread einzuschieben - Dafür wird eine managed thread-API aufgebaut, die Threads direkt steuert
- Es muss möglich sein, zwischen
Test-AtomicU32 und Einfügen von pause
- In Test-Builds wird statt
std::sync::atomic::AtomicU32ein eigenesmanaged_thread::AtomicU32verwendet#[cfg(test)] use managed_thread::AtomicU32#[cfg(not(test))] use std::sync::atomic::AtomicU32
- Der Wrapper
AtomicU32ruft vor und nachload()undstore()jeweilspause()aufload:pause()→ tatsächlichesload→pause()store:pause()→ tatsächlichesstore→pause()
- Dank dieser Einfügepunkte kann der Test Threads rund um atomare Operationen anhalten, wieder fortsetzen und so die Ausführungsreihenfolge steuern
Form der managed-thread-API
- Der Test erzeugt innerhalb von
std::thread::scopezwei managed threads- Da scoped threads verwendet werden, können stack-lokale Daten ausgeliehen werden
- Wie bei
spawn(scope, &counter)wird eine Counter-Referenz als Zustand übergeben
- Ein managed thread führt nicht von Anfang an eine bestimmte
main-Funktion aus, sondern die Closures, die der Steuer-Thread persubmit()sendett.submit(|c| c.increment())- Der Thread führt die Closure auf seinem Zustand
Taus
- Die Testschleife verhält sich für jeden Thread zufällig, solange noch Entropie vorhanden ist
- Wenn der Thread angehalten ist,
unpause() - Wenn er nicht angehalten ist, per
submit()increment()ausführen - Auch das sequentielle Modell
counter_modelwird um dieselbe Anzahl erhöht
- Wenn der Thread angehalten ist,
- Am Ende werden alle Threads mit
join()verbunden, undcounter_modelwird mit dem tatsächlichencounter.get()verglichen
Implementierung von pause und unpause
pause()findet den Kontext des aktuellen managed threads überthread_local!, damit die API des zu testendenCounternicht geändert werden muss- Der Kontext wird als
Arc<SharedContext>geteilt SharedContextbesitztMutex<State>undCondvar
- Der Kontext wird als
- Der Zustand ist in
Ready,RunningundPausedunterteiltReady: wartet auf die nächste ClosureRunning: der managed thread läuftPaused: ampause()-Punkt angehalten
- Wenn ein managed thread
pause()erreicht, ändert er den Zustand vonRunningzuPausedund benachrichtigt den Steuer-Thread über die Condition Variable unpause()ändert den Zustand vonPausedzuRunning, weckt den managed thread und wartet anschließend, bis der Zustand nicht mehrRunningist- So wird verhindert, dass Steuer-Thread und managed thread gleichzeitig weiterlaufen
- Zu jedem Zeitpunkt läuft nur einer von beiden, was die Nichtdeterministik reduziert
Fehler reproduzieren und minimieren
- Ein
arbtest-Lauf findet im defekten Counter einen Fehler- Der Beispielfehler hat Modellwert
4, tatsächlichen Wert3 - Der Fehler-Seed ist
0x4fd7ddff00000020
- Der Beispielfehler hat Modellwert
- Gibt man denselben Seed an, erhält man dasselbe Interleaving erneut, wodurch sich der Fehler leicht reproduzieren lässt
- Mit
.minimize()wird der Fehlerfall auf eine kürzere Ausführung reduziert- Der finale minimale Seed ist
0x9c2a13a600000001 - Der minimale Trace besteht aus vier Schritten
0: increment1: increment0: unpause1: unpause
- Der finale minimale Seed ist
- In diesem Minimalfall ist der erwartete Wert
2, der tatsächliche Wert aber1, wodurch der Defekt desload/store-basierten Inkrements sichtbar wird
Erweiterung auf das Aufzählen aller Interleavings
- Dieselbe Struktur lässt sich von zufälligen Interleavings auf eine aufzählungsbasierte Variante umstellen
- Mit
exhaustigenwird ein Test geschrieben, der bis zu5Inkremente alle Interleavings durchsucht- Der Test vermeidet Dummy-Iterationen und ist so aufgebaut, dass er Threads immer per
unpausefortsetzt oder einincrementeinreicht
- Der Test vermeidet Dummy-Iterationen und ist so aufgebaut, dass er Threads immer per
- Die defekte Implementierung findet denselben Bug
- Der Beispielfehler ist
left: 2,right: 1
- Der Beispielfehler ist
- Korrigiert man
Counter::increment()zufetch_add(1, SeqCst), besteht der Test- Auch dem
AtomicU32-Wrapper wird vor und nachfetch_add()einpause()hinzugefügt - Das Ergebnis lautet
all 81133 interleavings are fine! - Die Laufzeit beträgt
real 8.65s, CPU8.16s, RSS63.91mb
- Auch dem
Erweiterung auf schwache Speichermodelle und Model Checking
- Die aktuelle Spielzeugimplementierung von
AtomicU32delegiert an echte Atomics - Eine Erweiterungsidee besteht darin, für jedes Atomic die Menge der geschriebenen Werte zu pflegen und beim Lesen einen beliebigen Wert zurückzugeben, der mit einem schwachen Speichermodell konsistent ist
- Auch die Interleaving-Suche lässt sich intelligenter gestalten als rein zufällig
- Mit einem Model-Checking-Ansatz kann geprüft werden, ob alle sinnvoll verschiedenen Interleavings berücksichtigt wurden
- Wie im Ansatz von Generate All The Things lassen sich in kleinen Bereichen alle Interleavings aufzählen
Warum Minimierung ohne Shrinking möglich ist
- Das verwendete
arbtestsieht wie eine vertraute PRNG-Schnittstelle aus, verwendet aber einen endlichen PRNG- Fordert man weiter Zufallswerte an, gibt er irgendwann
Err(OutOfEntropy)zurück - Deshalb erscheinen im Testcode
?undwhile !rng.is_empty()
- Fordert man weiter Zufallswerte an, gibt er irgendwann
- Wenn der Test die Entropie aufgebraucht hat, endet er kurz; reduziert man also die verfügbare Entropie, wird auch die Testausführung kürzer
- Die interne Implementierung ist konzeptionell nahe an
&mut &[u8]- Bei jeder Anforderung einer Zufallszahl wird der Byte-Slice kleiner
- Je kürzer der anfängliche Slice ist, desto einfacherer Test entsteht
- Dadurch können Fehlerfälle kürzer werden, ohne dass man eine separate Shrinking-Logik selbst implementieren muss
- Der Beispielquellcode befindet sich unter properly-concurrent
1 Kommentare
Hacker-News-Kommentare
In Rust entsteht mit einem ähnlichen Ansatz eine Bibliothek namens Temper: https://github.com/reitzensteinm/temper/tree/main
Allerdings muss man deutlich weiter gehen, um die merkwürdigen Implikationen des gesamten Rust-Speichermodells zu modellieren; dafür braucht es eine Art Buchführung, die nachverfolgt, welche Schreibvorgänge jeder Thread wahrgenommen hat. Je nach atomarer Speicherordnung, Read-/Write-Fences usw. können Garantien entstehen, etwa dass man, wenn man Schreibvorgang X wahrnimmt, zwingend auch Schreibvorgang Y wahrnehmen muss.
Ich denke, dass dies eine der umfangreichsten Sammlungen von Testfällen für das C++-/Rust-Speichermodell ist; ich habe fast alles zusammengetragen, was sich in Büchern, dem C++-Standard, auf Stack Overflow, in Blogs usw. finden lässt. Die Datei zu Mara Bos’ Rust Atomics and Locks ist zum Beispiel hier: https://github.com/reitzensteinm/temper/blob/main/memlog/tes...
Das im Artikel erwähnte Loom ist eine ähnliche, aber deutlich ausgereiftere Bibliothek, mit der sich höherstufige Komponenten wie Mutexes oder Queues gründlich testen lassen: https://github.com/tokio-rs/loom Allerdings modelliert sie das Speichermodell selbst nicht so engmaschig wie Temper; ich hatte darüber nachgedacht, die Testfälle nach Loom zu portieren.
Inspiriert wurde das Ganze von Will Wilsons Vortrag über Tests bei FoundationDB; er baut jetzt bei Antithesis eine hypervisorbasierte Lösung, die diese Art von Tests auf beliebige Docker-Container anwendet: https://www.youtube.com/watch?v=4fFDFbi3toc, https://antithesis.com/
Ich bin fest davon überzeugt, dass dieser Bereich in den nächsten zehn Jahren deutlich wachsen wird. WebAssembly liegt an einem idealen Punkt: vollständig genug, um beliebige Software dafür zu kompilieren, aber einfach genug, damit der Bau von etwas wie Antithesis nicht zu einem Fünfjahresprojekt für ein Eliteteam wird, das bereits eine Datenbank auf den Markt gebracht hat.
Ich habe in Rust atomare Snapshots für Shared Memory implementiert und automatisierte Tests so ernsthaft wie möglich behandelt: https://github.com/kaymanb/todc/tree/main/todc-mem
Anfangs habe ich das im Artikel erwähnte Loom verwendet, später aber zu shuttle gewechselt: https://github.com/tokio-rs/loom, https://github.com/awslabs/shuttle
shuttle verwendet im Gegensatz zu Loom keine vollständige Erschöpfungssuche, sondern einen randomisierten Ansatz; der Scheduler liefert aber dennoch probabilistische Garantien für das Finden von Bugs. In der Praxis war shuttle schneller und skalierte auch auf komplexere Testszenarien.
Ähnlich wie beim Ansatz im Artikel lässt sich der Zufalls-Seed speichern, wenn ein bestimmter Schedule einen Testfehler auslöst. Die Fähigkeit, fehlgeschlagene Tests schnell zu reproduzieren, ist sehr wichtig und ermöglicht es, explizite Testfälle für zuvor gefundene und behobene Bugs zu schreiben: https://github.com/kaymanb/todc/blob/0e2874a70ec8beed8fae773...
Für Kotlin/Java ist JetBrains’ Lincheck eine gute Bibliothek für solche Aufgaben: https://github.com/JetBrains/lincheck
Besonders gefällt mir, dass sie deklarativ ist und wie sie Ergebnisse zur Linearisierbarkeit ausgibt.
Ich frage mich, ob es auch für C++ eine Bibliothek wie Loom gibt. Ich habe einige lock-free Datenstrukturen, die ich testen möchte.
Ein ziemlich altes Tool und leicht zu handhaben. Es wurde von Dmitry Vyukov entwickelt, einem Experten für Nebenläufigkeit.
https://github.com/facebook/folly/blob/main/folly/test/Deter...
Wenn ich es richtig verstanden habe, hat dieser Ansatz im Hinblick auf schwache Fortschrittsgarantien Grenzen.
Die Berechnung im Text ist zwar nicht ganz trivial, aber man kann sich eine
cmpxchg-Schleife vorstellen, bei der es auf realer Hardware und mit einem realen Scheduler extrem unwahrscheinlich ist, dass sie auf einer bestimmten CPU unterbrochen wird. Wenn die Zahl der CPUsnbeträgt, liegt die Fortschrittswahrscheinlichkeit im Worst Case bei1/n; bei dieser Testmethode wird sie jedoch zu1/t^p. Dabei isttdie Zahl der Tasks, die viel größer sein kann als die Zahl der CPUs, undpist die Zahl der Pausen innerhalb dieses Schleifenrumpfs, die leicht 3 oder mehr erreichen kann. Das reicht aus, um einen Algorithmus, der in der Praxis funktioniert, als kaputt erscheinen zu lassen.Umgekehrt scheint diese Methode auch dann kein nützliches Werkzeug zu bieten, wenn man schwachen Fortschritt als Bug erfassen möchte und deshalb starken Fortschritt verlangt.
Für viele Nebenläufigkeitsprobleme ist sie aber eindeutig nützlich.
1/t^pscheint mir nicht zu stimmen; ich würde eher einfach1/tansetzen. Nach Ablauf vontZeit muss schließlich irgendein Task Fortschritt gemacht haben, und wenn estTasks gibt, beträgt die Wahrscheinlichkeit, dass es der Task war, den ich ausgeführt habe,1/t.Die zentrale Verwechslung scheint darin zu liegen, dass eine Unterbrechung nicht zwangsläufig bedeutet, bei CAS zu verlieren.
Zu der Stelle „Ehrlich gesagt steckt hier ein wenig Vorwissen drin. Solange man nicht mit Inline-Assembly extrem verfluchte Dinge tut, scheint es nicht möglich zu sein, die Erzeugung echter Threads zu vermeiden. Wenn etwas die Funktion
pause()aufruft und wir es bis zu einer späteren Anweisung angehalten lassen wollen, muss das in einem Thread passieren, der einen separaten Stack vom Stack des Tests verwaltet“ frage ich mich, ob man nicht irgendeine Art von asynchroner Runtime verwenden könnte.Es sieht so aus, als würde durch Instrumentierung atomarer Operationen kooperatives Multitasking erreicht. Vielleicht brauche ich mehr Kaffee, aber ohne Threads wirkt es einfacher.
Ein Nachteil dieses Ansatzes ist, dass der zu testende Code selbst für den Testcode angepasst werden muss.
Man könnte vermutlich dasselbe erreichen, indem man zwei Threads startet und sie mit
ptraceim Single-Step-Modus ausführt, während man die Ausführung von Instruktionen „zufällig“ ineinander verschachtelt. Also ähnlich wie der Chaos-Modus von rr.Allerdings sind manche Instruktionen möglicherweise nicht atomar; daher bräuchte man wohl, sofern es ohne Emulation überhaupt möglich ist, eine Möglichkeit, im Single-Step-Modus auf Ebene „atomarer Mikrocode“-Einheiten auszuführen.
Um Loom zu verwenden, scheint bedingte Kompilierung nötig zu sein; beim Testen einer einzelnen Bibliothek ist das in Ordnung, aber es ist ziemlich invasiv.
#[cfg(loom)]pub(crate) use loom::sync::atomic::AtomicUsize;#[cfg(not(loom))]pub(crate) use std::sync::atomic::AtomicUsize;Ich frage mich, ob es Sprachen gibt, mit denen sich ein eigener Scheduler besser nutzen lässt.
Wenn man es wirklich gründlich machen wollte, könnte man den Test mit
ptraceausführen und die Threads per Single-Step voranschreiten lassen, um auf Instruktionsebene unterschiedliche Interleavings zu erzeugen. Ich frage mich, ob jemand so etwas tatsächlich schon gesehen hat.Wenn man den Code wie hier nicht instrumentieren kann: Gibt es Alternativen für Black-Box-Tests?
nInstruktionen ausführt, braucht man nurnLäufe, bei denen vor dem Einschieben des Signals 0 bisnInstruktionen ausgeführt werden; danach läuft der Signal-Handler bis zum Ende und anschließend auch der Hauptthread bis zum Ende. Die Gesamtzeit istO(n^2).Wenn es aber
tThreads gibt, die jeweilsnInstruktionen ausführen, und sie einander an jeder Grenze unterbrechen können, wird der Ansatz bei realistischen Werten fürnunzugänglich. Man müsste ihn wohl reduzieren, indem man nur Operationen auswählt und simuliert, die interessantes Verhalten zeigen.Sieht ziemlich cool aus, ich sollte es einmal ausprobieren. Allerdings wird es nicht alle Arten von Fehlern finden. Entsteht nicht bei jedem
pause()-Aufruf eine Synchronisierung zwischen den Threads, sodass manche Data-Race-Probleme verdeckt werden? In Rust ist das vielleicht kein Problem.