Fehlschlag: Stack Koroutinen
« | 19 Sep 2021 | »Aus Fehlern lernt man bekanntlich am meisten, und deshalb möchte ich einen
meiner Irrtümer hier schildern.
Es begann mit der Idee:
Man könnte doch einen Stack durch diverse Funktionsaufrufe aufblasen und dann per
setjmp()
“Einsprungpunkte” in den Stack setzen. Wenn man dann noch einen Scheduler schreibt, der perlongjmp()
zwischen diesen Einsprungpunkten auswählen kann, dann hätte man:
Eine rein generische C Implementierung für Koroutinen.
Spoiler: Ich lag falsch.
Naja, in kleinen Teilen lag ich fast richtig … doch leider nur im
32 bit Debug-Modus, wo der Callstack schön und nicht normiert ist.
Ein Patent brauche ich auf meine Idee also nicht anmelden.
Es hat sich nämlich ganz deutlich gezeigt, dass man mit
setjmp
und longjmp()
im Stack
nur “zurückspringen” kann, also in Richtung des Aufrufers.
Der andere Weg, also der Sprung in eine Unterfunktion klappt leider
nicht und führt zu Segmentation-Faults und Schlimmerem.
Idee: Stack-Aufteilung per setjmp
Stellt euch vor, wer setzen in einer Funktion einen Anker mit setjmp()
und rufen dann eine Funktion auf, die einen Datenblock auf dem Stack allokiert
und sich dann selbst aufruft.
Zuvor legen wir mit einem weiteren setjmp
einen Anker auf den aktuellen
Funktionsaufruf, bevor wir uns selbst aufrufen.
Und mit einem Counter würden wir sicherstellen, dass das nicht unendlich oft
passiert und wir nach N-Aufrufen per longjmp
zum Ausgangspunkt zurückkehren.
Da wir (theoretisch) aus keiner Funktion mit return
zurückgesprungen sind,
sondern nur longjmp
dafür gebraucht haben, sollte der Stapelspeicher (Stack)
über uns (eigentlich unter uns) dann immer noch aufrecht sein.
Nun könnten wir mit weiteren longjmp
Aufrufen zwischen den Stack-Teilen
hin und her springen und dort andere Funktionen aufrufen, da wir ja
stets einen “lokalen” Block allokiert hatten, der den Stack weiter
aufgeblasen hat.
Die Funktionen in den Stack-Teilen sollten sich also gegenseitig nicht
stören, so lange ihre eigenen Allokationen kleiner als unser Block
sind.
Ein Beispiel-Code sagt mehr als tausend Worte:
1#include <setjmp.h> 2typedef void(*entry_point_function)(void* param); 3 4static jmp_buf volatile origin; 5 6struct context 7{ 8 jmp_buf volatile anchor; 9 entry_point_function func; 10 void* func_param; 11}; 12 13#define MAX_STACKS 16 14#define MAX_STACK_SIZE 16384 15 16static struct context volatile contexts[MAX_STACKS]; 17 18static void stack_entry_point(int counter); 19 20static void create_stack_partition(int counter) 21{ 22 /* just reserve stack space */ 23 char volatile stack_buffer[MAX_STACK_SIZE]; 24 stack_entry_point(counter); 25} 26 27static void stack_entry_point(int counter) 28{ 29 struct context volatile* ptr_context = &contexts[counter]; 30 if(setjmp(ptr_context->anchor) == 0) 31 { 32 /* stack-entry-point registration completed */ 33 if(counter > 0) 34 { 35 create_stack_partition(counter - 1); 36 } 37 } 38 else 39 { 40 /* entry point from longjmp() */ 41 ptr_context->func(ptr_context->func_param); 42 } 43 longjmp(origin, 1); 44} 45 46void scheduler() 47{ 48 /* set function-pointers in @contexts */ 49 /* ... */ 50 /* prepare stack */ 51 if(setjmp(origin) == 0) 52 { 53 create_stack_partition(MAX_STACKS - 1); 54 } 55 56 /* longjmp into stack-partition */ 57 /* ... */ 58}
Jetzt muss man noch ein paar Details erweitern, z.B., dass am besten jede
Variable volatile
wird und der stack_buffer
am besten mit einer
for
Schleife befüllt wird, weil der Compiler den Block trotz volatile
gerne rausoptimiert … aber grundsätzlich “läuft das” … denkt man.
Problem != x86_32
Der x86 Befehlssatz im 32 Bit
Modus erlaubt das tatsächlich. Wir könnten mit setjmp
/longjmp
tatsächlich
Koroutinen bauen und mit ein paar Tricks auch manche Optimierungsfallen
des Compiler umgehen.
Und ich wette, dass man auf x86-16bit und diversen Mikrocontrollern
ebenfalls Erfolge sehen könnte.
Doch andere Architekturen wie x86-64 oder ARM sind durchaus anders aufgebaut
und machen das Arbeiten mit longjmp
generell schwer.
Microsoft schreibt etwa in seine Dokumentation:
In Microsoft C++ code on Windows, longjmp uses the same stack-unwinding semantics as exception-handling code. It is safe to use in the same places that C++ exceptions can be raised. However, this usage is not portable, and comes with some important caveats.
Tja, und da fängt wohl das Problem an, denn während unter 32-Bit das
Exception-Handling von Funktion zu Funktion gesetzt werden musste,
registrieren sich diese Handler heute über das OS um von dort
im Fehlerfall aufgerufen zu werden.
Durch meinen longjmp
nach der “Partitionierung” des Stacks zurück zur
Scheduler-Funktion wird also die Stackaufteilung zerstört.
Und dass dann eine Funktion in ntdll.dll
die Validität des
Stacks prüft, wenn ein weiterer longjmp
in ein Stack-Fragment erfolgt,
ist für meine Zwecke natürlich schlecht
… aber es war ja auch nirgends definiert, dass das nicht so sein darf.
Letztendlich sind meine Experimente mit “undefiniertem Verhalten” gelaufen. Und bestimmt gibt es zahlreiche ähnliche Probleme auf weiteren Plattformen.
Fazit
Jetzt kenne ich also den Grund, warum boost::coroutine
und andere Bibliotheken nicht ohne Assembler Code auskommen, um
Ausführungskontexte zu sichern und wiederherzustellen.
Denn die hätten setjmp
/longjmp
ansonsten sicher auch gerne genutzt …
Wie heißt es etwa im Film Das Vermächtnis der Tempelritter:
Gescheitert bin ich nicht, ich kenne 2.000 Wege wie man Glühbirnen nicht bauen darf. Aber ein einziger hätte gereicht, damit es funktioniert.
Tja, schade. Ich hatte schon eine ganze Koroutinen Implementierung fertig, die auf diesem Schema basiert. Und laut einiger Tests hat die auch super funktioniert … aber eben nur unter Windows 32 bit und sonst nirgends.
Ich habe das “Negativ-Beispiel” trotzdem in den “Class-Room” aufgenommen. Denn wie schon gesagt: “Aus Fehlern lernt man.”