C, Speicherbarrieren und Signale

Paldium

Well-Known Member
Wenn ich Speicherbarrieren richtig verstanden habe, dann stellen sie sicher, dass der Compiler Code nur zwischen zwei Barrieren beliebig austauschen darf. Zwei Barrieren wären "volatile" Variablen und Funktionsaufrufe.

Mein Beispielcode wäre dieser hier:
Code:
struct cleanable *c;
c = malloc(sizeof(*c));
if (c == NULL)
 break;
c->fn = "hallo";
LIST_INSERT_HEAD(&head, c, files); /* head is global */

So weit, so simpel. Ich nehme an, dass die if-Abfrage nicht umsortiert wird, weil eine Umsortierung die Bedeutung des Codes ändern würde. Der malloc-Aufruf ist eine Speicherbarriere, weil es ein externer Funktionsaufruf ist.

Nun zum LIST_INSERT_HEAD... Das ist nämlich ein Makro. Aufgelöst sieht der Aufruf so aus:
Code:
do {
 if (((c)->files.le_next = (&head)->lh_first) != ((void *)0))
  (&head)->lh_first->files.le_prev = &(c)->files.le_next;
 (&head)->lh_first = (c); (c)->files.le_prev = &(&head)->lh_first;
} while ( 0);

Der Compiler müsste LIST_INSERT_HEAD und c->fn = "hallo" austauschen dürfen. Oder übersehe ich eine Regel?

Der Code stammt aus einem Programm, das ohne Threads arbeitet. Hat aber einen Signalhandler, der auf eben diese Liste zugreift. Ich würde jetzt sagen, dass das Programm zwei Schwächen hat:

- Elemente können in der Liste auftauchen, die "fn" nicht gesetzt haben. Und zwar dann, wenn der Code umsortiert wurde und das Signal genau zwischen den zwei Aufrufen kommt.
- Pointer-Zuweisungen sind nicht garantiert atomar. Stimmt das auf irgendeinem System, das von *BSD unterstützt wird?

Ich hab den Code testweise mit GCC kompiliert und den Signalhandler alle paar Millisekunden aufrufen lassen. Offenbar hab ich keine Optimierungsstufe gefunden, die den Code umsortieren würde. Wenn ich es per Hand umsortiere, braucht es nur ein paar Sekunden, bis der Signalhandler auf so ein Element stößt. Deshalb weiß ich jetzt nicht, ob das Programm eben doch richtig ist (und ich falsch liege :P) oder ob es einfach nur Glück war.
 
Ein Funktionsaufruf ist keine Speicherbarriere. Lass in dem Signalhandler die Finger von der Liste. Setze nur ein volatile sig_atomic_t Flag oder verwende einen anderen Mechanismus. Falls auf deiner Platform kevent()/kqueue() verfügbar ist kannst du EVFILT_SIGNAL benutzen um die Signale abzufragen. Sollte das Signal nicht threadspezifisch sein kannst du auch das Signalhandling in einen Thread verlegen der auf das Signal blockiert.
 
Und volatile hat auch nichts mit "Speicherbarrieren" zu tun. Das ist einfach dazu da, um den Compiler zu zwingen, dass er die Variable als "immer nichtaktuell" markiert, damit sie jedes Mal abgeholt wird in parallelen Ausführungen (Threads, Signal-Handler etc) und nicht als optimiert angenommen wird ("Ach, die ist ja schon im Register/einer anderen Variable, nehmen wir das/die.").

Man sollte nicht komplexe Geschichten in Signal-Handlern machen. Vor allem nicht Code aufrufen, der auch regulär von der normalen Ausführung besucht wird. Man kann es nicht mit Semaphoren lösen, denn dann gibt es sogar Verklemmungen. Signal-Handler ist (meines Wissens nach) wie ein Interrupt auf Prozessebene zu betrachten. Die Code-Ausführung steht still an beliebigen Positionen (auch innerhalb der Statements!) bis der Handler seine Arbeit getan hat.
 
Okay, Speicherbarriere ist definitiv hardwarenäher als gedacht und eine schlechte Wortwahl meinerseits. An welchen Stellen hört der Compiler auf, Code durcheinanderzuwürfeln? Ab wann weiß ich, dass bestimmte Funktionen in bestimmten Reihenfolgen garantiert ausgeführt werden?

Lass in dem Signalhandler die Finger von der Liste.
Man sollte nicht komplexe Geschichten in Signal-Handlern machen. Vor allem nicht Code aufrufen, der auch regulär von der normalen Ausführung besucht wird.

Als Faustregeln unterschreibe ich das, und generell rate ich Leuten davon ab, überhaupt Signalhandler einzusetzen. Meistens geht es auch ohne. Selbst in dem von mir genannten Fall bin ich nicht der Autor, allenfalls ein interessierter Reviewer. Man kann auch sagen "Iss niemals Fugu!" oder aber man schaut bei einem Koch mal genauer hin. Ich tue letzteres -- und esse es trotzdem nicht. Oder gerade deshalb. ;)

Ich hab den Code extra abstrakt gehalten, aber vielleicht hilft es ja kontextbezogen. Es geht um FreeBSDs sort und die Art und Weise, wie temporäre Dateien aufgeräumt werden.

Signalhandler ist hier:
http://svnweb.freebsd.org/base/head/usr.bin/sort/sort.c?revision=272398&view=markup#l449
Einfügen und Aufräumen findet hier statt (tmp_file_atexit und clear_tmp_files):
http://svnweb.freebsd.org/base/head/usr.bin/sort/file.c?revision=251245&view=markup#l137

Man kann es nicht mit Semaphoren lösen, denn dann gibt es sogar Verklemmungen.

Dann ist der Code an der Stelle schon Murks, weil der Signalhandler sich Semaphorenzugriff holt. Dachte ich mir schon... Ich lasse ja schon großzügig weg, dass der Signalhandler exit(-1) aufruft. :)

OpenBSD hat den Code übernommen, offenbar von einer früheren Version -- oder schon stark angepasst. Jedenfalls gibt es dort keine Semaphoren-Kontrolle, womit zwar DAS Problem behoben ist, damit aber der ungeschützte Pointer wieder offenliegt.

Also: Mir gehts hier nicht darum, dass irgendwer das Problem lösen muss! Ich will stattdessen generell die Zuverlässigkeit von Speicheroperationen zwischen Signalhandler und normalem Prozess verstehen. Ich kann zwar mit sigprocmask ganz leicht die Signale für einen Moment unterbinden, den Pointer aktualisieren und dann wieder freigeben. Dann kann womöglich ein alter Zustand greifen (Liste ist nicht volatile) und eine Datei wird nicht aufgeräumt, aber weit besser als ein Deadlock oder irgendwas aufräumen weil die Liste inkonsistent ist. Nur... wie garantiere ich, dass bestimmte Codebereiche mit sigprocmask geschützt sind?
 
Ich habe den Eindruck du missverstehst, was ein Signalhandler und wie er ausgelöst wird. Das ist nicht (direkt) eine Frage des Compilers. Die CPU wird von einem Interrupt unterbrochen und der Kernel handhabt den Interrupt indem er asynchron einen Funktionsaufruf des Signalhandlers in den Threadkontext einfügt. Anschließend läuft der Thread weiter. Du kannst dich auf nichts Verlassen, dass von einem Interrupt unterbrochen werden kann z.B. kritische Abschnitte mit Locks. Das einzige auf das du dich verlassen kannst sind atomare Instruktionen bzw. atomare Folgen von Instruktionen falls deine CPU so etwas kennt.
 
Das einzige auf das du dich verlassen kannst sind atomare Instruktionen bzw. atomare Folgen von Instruktionen falls deine CPU so etwas kennt.

Meine Fragen zielten doch genau darauf ab:
  • Darf der Austausch eines Pointers unter *BSD als atomar angesehen werden? Ich vermute: Nein.
  • Kann der Compiler die von mir geposteten Instruktionen in ihrere Reihenfolge austauschen? Ich vermute: Ja.
Die Reihenfolge beziehe ich natürlich nicht auf die Mischung zwischen Signalhandler und Funktionsaufruf im Thread. Selbstverständlich hat der Compiler hier nichts mitzureden. Sondern auf die Reihenfolge des Maschinencodes innerhalb der Funktionen. Darf der Compiler diesen Abschnitt

Code:
c->fn = "hallo";
(&head)->lh_first = c;

gegen diesen Abschnitt austauschen?

Code:
(&head)->lh_first = c;
c->fn = "hallo";

Oder womöglich

Code:
sigprocmask(...); /* blockiere Signale */
c->fn = "hallo";
(&head)->lh_first = c;
sigprocmask(...); /* aktiviere Signale */

gegen

Code:
sigprocmask(...); /* blockiere Signale */
(&head)->lh_first = c;
sigprocmask(...); /* aktiviere Signale */
c->fn = "hallo";
 
Das was Theo da fixt, hat IMHO nichts mit deinen Annahmen im Post #6 zu tun.
Deine Frage wurde damit bisher auch nicht beantwortet.

Rob
 
Stimmt, ich leite aus seinem Commit lediglich ab, dass der Code nicht zerwürfelt wird. Einfach aus der Tatsache, dass er eine Racecondition erkannt und mit einfachem sigprocmask behoben hat.

Es ist jetzt natürlich eine Unterstellung meinerseits, dass Theo weiß, was er da tut. Mit dem Restrisiko, dass ich da falsch liege, muss ich wohl leben -- oder ich warte auf eine passende Antwort. :)
 
Also ich denke, dass du mit deinen Annahmen beiden richtig liegst. Genau genommen ist beides nicht definiert, und nicht nur der Compiler sortiert Code um sondern auch der Prozessor (bzw. sein Microcode).
Ein Pointertausch ist außerdem vermutlich nur dann atomar, wenn die Zielarchitektur über eine effiziente swap-Instruktion verfügt. Durch moderne Mehrkern-Prozessoren dürften solche Instruktionen aber tendenziell eher teuer sein, weshalb ich mal nicht von atomaren Instruktionen ausgehen würde.

Was die Code-Zerwürfelung angeht, ich weiß da nicht, wie es bei den BSDs aussieht, aber der Linux-Kernel baut "nur" mit `gcc -O2` richtig, die dabei erfolgende Umsortierung wird mit einberechnet. Vielleicht ist das hier genauso. Oder der Compiler kennt sigprocmask und fügt selber Speicherbarrieren, die das Umsortieren verhindern, ein. Das ist aber halt endgültig alles nur noch geraten.
 
Stimmt, ich leite aus seinem Commit lediglich ab, dass der Code nicht zerwürfelt wird. Einfach aus der Tatsache, dass er eine Racecondition erkannt und mit einfachem sigprocmask behoben hat.

Was er mit dem Blockieren der Signale behebt ist, dass der Signalhandler bzw. das LIST_INSERT_HEAD nicht während der eigenen Ausführung unterbrochen werden kann. Stell dir vor, der Signalhandler wird vom selben Signal unterbrochen. Dann läuft das LIST_INSERT_HEAD mitten im traversieren der Liste - somit kann es zu einer Wettbewerbssituation kommen.

Das hat nichts damit zu tun, ob der Compiler irgendwie Code zusammenwürfelt.

Rob
 
Wer sich gerne mit Klugscheißern, Haarspalter und Dogmatikern auseinander setzt, der kann ja mal die Jungs und Mädels in ##c auf freenode dazu befragen. ;)
 
Ich muss mich korrigieren, die erwähnte Funktion ist ja gar kein Signalhandler. Der Signalhandler ruft clear_tmp_files() auf und steigt dann sofort über _exit() aus. Es könnte also passieren, dass die Liste inkonsistent ist, wenn LIST_INSERT_HEAD in tmp_file_atexit() vom Signalhandler unterbrochen wird.
Aber wie gesagt, mit dem Compiler hat es nichts zu tun.

Rob
 
Ich verweise nochmal auf Post #6. Woher weiß ich, welche Aufrufe der Compiler in ihrer Reihenfolge austauschen darf?

Und ich wiederhole nochmal: Mir geht es beim Compiler natürlich nicht um den Signalhandleraufruf.

Woher weiß ich, dass die Reihenfolge der Instruktionen wirklich eingehalten wird und nicht etwa sigprocmask-Aufrufe in Reihe erfolgen und nachträglich der Pointer ausgetauscht wird?
 
Wer sagt denn, dass der Compiler einfach Aufrufe austauschen darf, die ein anderes Ergebnis, als Du es (angenommen es ist im C-Standard definiert und gültig) spezifizieren wolltest erzeugen könnten? Nein, das darf er natürlich nicht. Dann hätten wir ja ein Chaos sondergleichen beim Programmieren.

Wenn Du ein Statement mit ";" abschließt, heißt es generell erstmal "danach". Der Compiler darf natürlich nur so eingreifen, dass das Endresultat das gleiche bleibt, also da wo die Reihenfolge garantiert keine Rolle spielt.

Folglich gilt, dass in #6 das erste Beispiel wohl gehen würde. Der Compiler kann da noch Sachen zusammenziehen, die "halb" umgeordnet sind. Beim zweiten geht das eher nicht, weil da ein Funktionsaufruf ist. Allerdings ist es wiederum möglich, wenn die Funktion inline ist, denke ich (aber das ist sie eher nicht, denn es ist eine bereits kompilierte externe Funktion, wo der Compiler keine Info mehr hat über die Ausführung und worauf sie zugreift).

Ich würde aber nie zu hohe Optimierungsstufen wählen. Die machen dann nicht viel solcher Hampeleien.
 
Zurück
Oben