Plattform-weite Sperren
« | 19 Apr 2020 | »Irgendwann zwischen den Jahren 2002 und 2008 - so glaube ich mich zu erinnern - meldeten alle großen OS und Kernel Hersteller, sie hätten das Global-Lock Problem gelöst.
Dieser “globale Mutex”, der das gesamte System sperrt bis eine kritische Aufgabe erledigt ist, war offenbar seit langem tief in den Kernelquellen eingedrungen und konnte nur durch Designabänderungen an vielen Stellen wieder entfernt werden.
Tja … und ich ärgere mich, dass ich genau so einen Dinosaurier jetzt bewusst wieder im User-Space einbauen muss, damit er Steinzeit-Trolle bekämpft.
Die beiden Funktionen gate_platform_lock()
und gate_platform_unlock()
bedienen ab sofort einen globalen Mutex im GATE Framework.
Es widert mich zwar an, zu solchen Konstrukten greifen zu müssen, doch
dank schlechter Plattformschnittstellen blieb mir keine Alternative.
Das Problem sind klassische C APIs aus den 80er Jahren, die ihre Ergebnisse in globale Variablen schreiben und per Definition nicht thread-safe sind.
Dass jetzt einige (wenige) dieser Funktionen inzwischen über diverse Tricks wie “Thread-local-storage” oder ähnliche Technologien multithreading-tauglich sind, mag ja schön sein, garantiert ist das jedoch nicht.
Und wenn man zwischen unterschiedlichen POSIX
Plattformen wechselt, dann stellt man leider fest, dass die neueren auf
_r
-endenden Funktionen nicht überall verfügbar sind.
Die einzig wirklich portable Lösung ist also die Funktion mit ihren globalen Variablen nur innerhalb eines gesperrten Mutex auszuführen und ihre Ergebnisse herauszukopieren bevor man diese Sperre wieder aufhebt.
Die typischen Verdächtigen heißen beispielsweise:
- Environment Variablen lesen und schreiben
- Account Informationen auslesen
- Globale Prozess-Flags
C Funktionen wie strtok
erwähne ich gleich gar nicht, weil sie ohnehin
durch thread-sichere Varianten im GATE Framework ersetzt werden.
Am effizientesten wäre es natürlich, für jede Funktion einen eigenen Mutex
zu nutzen, doch deren Initialisierung ist “etwas” komplizierter und daher
reduziere ich dies auf eine einzige globale Sperre.
Und das natürlich in der Hoffnung, dass die geschützten Systemfunktionen
entsprechend selten aufgerufen werden und daher keinen Performanceverlust
generieren.
Problemfall: Global Initialisierung
In C haben wir (offiziell) keine globalen Konstruktoren … und selbst wenn
dann wäre deren Aufrufreihenfolge nicht garantiert.
Wir können nur flache Datenstrukturen mit Zahlen initialisieren.
Aber ein Mutex braucht sowohl unter Windows als auch unter POSIX einen
Funktionsaufruf zur Initialisierung.
Wir könnten uns (was mehrere Libs tun) auf eine bestimmte init()
Funktion
einigen, die (durch den Entwickler explizit eingebaut) vor allen anderen
aufgerufen werden muss.
Doch wer garantiert, das dies geschieht?
Aus diesem Grund prüft der Aufruf von gate_platform_lock()
, ob die
Initialisierung schon stattgefunden hat und wenn nicht, so wird dies
(genau einmal) durchgeführt.
Also, man braucht:
- Die Mutex-Instanz selbst als globale Variable
- Ein globales
mutex-ready
Flag, das uns sagt, ob der Mutex schon nutzbar ist - Einen globalen Spinlock, der die Initialisierung gegen mehrfache parallele Aufrufe absichert.
- Ein globales
init-done
Flag, welches innerhalb des Mutex nur dem ersten Aufrufer gestattet, den Systemaufruf zur Initialisierung abzusetzen.
Das ganze sieht dann vereinfacht etwa so aus:
1static system_mutex_t global_mutex; 2 3void global_mutex_init() 4{ 5 static atomic_bool_t global_mutex_ready = false; 6 static atomic_bool_t global_mutex_init_done = false; 7 static spinlock_t spinner = spinlock_init_value; 8 9 if(!atomic_bool_get(&global_mutex_ready)) 10 { 11 spinlock_begin(&spinner); /* block parallel execution */ 12 if(!atomic_bool_get(&global_mutex_init_done)) 13 { /* only the first thread will initialize the mutex */ 14 system_mutex_init(&global_mutex); 15 atomic_bool_set(&global_mutex_init_done, true); 16 atomic_bool_set(&global_mutex_ready, true); 17 } 18 spinlock_end(&spinner); 19 } 20} 21void global_mutex_lock() 22{ 23 global_mutex_init(); 24 system_mutex_lock(&global_mutex); 25} 26void global_mutex_unlock() 27{ 28 system_mutex_unlock(&global_mutex); 29}
Jetzt das ganze noch mit Fehlerbehandlung ausstatten und wir haben eine
thread-sichere Lösung geschaffen, die nach der erstmaligen Initialisierung
nur noch ganz wenig Overhead produziert und zwar beim Prüfen global_mutex_ready
.
Fazit
Wer es natürlich ganz perfekt machen will, der schreibt zwei Funktionen und
einen globalen Funktionszeigern.
Der Funktionszeiger verweist am Anfang auf die init
-Routine welche nach dem
ersten Aufruf den Funktionszeiger auf eine zweite Funktion umbiegt, wo nicht
mehr initialisiert wird.
Wie auch immer, ich hasse es, dass gerade wichtige Systemfunktionen im Jahr 2020 immer noch “so blöd” gebaut sind, dass man globale Sperren um sie errichten muss um zu garantieren, dass nichts Schlimmes passiert.
Das üble ist, dass viele Bibliotheken das nicht tun und von der Hoffnung leben,
dass solche Initialisierungen nie von zwei Threads gleichzeitig angestiftet
werden.
Und ja, ich gebe zu, dass das in verdammt vielen Fällen “eh immer”
funktioniert.
… und dann kommt eben der 10000ste Aufruf und die EXE crashed, und keiner weiß was passiert ist und es werden Hexen, das Wetter oder Politiker verdächtigt.
Aber nein … es sind immer Programmierfehler!