Plattform-weite Sperren

Irgendwann zwischen den Jahren 2002 und 2008 - so glaube ich mich zu erinnern - meldeten alle großen OS und Kernelhersteller, 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 geschicht?

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 Funktionspointer.
Der Funktionspointer verweist am Anfang auf die init-Routine welche nach dem ersten Aufruf den Funktionspointer 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!


Wenn sich eine triviale Erkenntnis mit Dummheit in der Interpretation paart, dann gibt es in der Regel Kollateralschäden in der Anwendung.
frei zitiert nach A. Van der Bellen
... also dann paaren wir mal eine komplexe Erkenntnis mit Klugheit in der Interpretation!