Ist der Funktionsaufruf eine effektive Speicherbarriere für moderne Plattformen?

Lesezeit: 8 Minuten

Benutzeravatar von mikebloch
Mikebloch

In einer von mir überprüften Codebasis habe ich die folgende Redewendung gefunden.

void notify(struct actor_t act) {
    write(act.pipe, "M", 1);
}
// thread A sending data to thread B
void send(byte *data) {
    global.data = data;
    notify(threadB);
}
// in thread B event loop
read(this.sock, &cmd, 1);
switch (cmd) {
    case 'M': use_data(global.data);break;
    ...
}

„Moment mal“, sagte ich zum Autor, einem Senior-Mitglied meines Teams, „hier gibt es keine Erinnerungsbarriere! Das garantieren Sie nicht global.data werden aus dem Cache in den Hauptspeicher geleert. Wenn Thread A und Thread B in zwei verschiedenen Prozessoren ausgeführt werden, kann dieses Schema fehlschlagen.

Der leitende Programmierer grinste und erklärte langsam, als ob er seinem fünfjährigen Jungen erklären würde, wie man seine Schnürsenkel bindet: „Hören Sie, kleiner Junge, wir haben hier viele Thread-bezogene Fehler gesehen, in Hochlasttests und in echten Clients“, er hielt inne, um seinen langen Bart zu kratzen, “aber wir hatten noch nie einen Fehler mit dieser Redewendung”.

“Aber im Buch steht…”

„Ruhig!“, brachte er mich prompt zum Schweigen, „Vielleicht ist es theoretisch nicht garantiert, aber in der Praxis ist die Tatsache, dass Sie einen Funktionsaufruf verwendet haben, effektiv eine Speicherbarriere. Der Compiler wird die Anweisung nicht neu ordnen global.data = data, da es nicht wissen kann, ob es jemand im Funktionsaufruf verwendet, und die x86-Architektur stellt sicher, dass die anderen CPUs dieses Stück globaler Daten sehen, wenn Thread B den Befehl aus der Pipe liest. Seien Sie versichert, wir haben reichlich Probleme in der realen Welt, um die wir uns Sorgen machen müssen. Wir müssen keine zusätzlichen Anstrengungen in fingierte theoretische Probleme investieren.

“Seien Sie versichert, mein Junge, mit der Zeit werden Sie verstehen, das wirkliche Problem von den Ich-muss-promovieren-Nicht-Problemen zu trennen.”

Ist er richtig? Ist das in der Praxis wirklich kein Problem (z. B. x86, x64 und ARM)?

Es ist gegen alles, was ich gelernt habe, aber er hat einen langen Bart und sieht wirklich schick aus!

Extrapunkte, wenn Sie mir einen Code zeigen können, der ihm das Gegenteil beweist!

  • Natürlich hat er recht, das weiß er aus Erfahrung. Er hätte erwähnen können, dass das Schreiben oder Lesen einer Pipe oder eines Sockets immer eine Sperre im Kernel erfordert, was eine Barriere impliziert, aber einem jungen Whipper-Snapper dies zu beweisen, braucht viel Zeit.

    – Hans Passant

    22. Mai 2012 um 8:33 Uhr

  • @HansPassant, aber selbst diese Systemaufrufe können in pathologischen Fällen auf einem anderen Kern ausgeführt werden als dem, der sie aufgerufen hat, und die Speicherbarriere auf dem falschen Kern ausgeben, oder?

    – Mikebloch

    22. Mai 2012 um 8:44 Uhr

  • @HansPassant, kann der Kernel nicht von Zeit zu Zeit entscheiden, Syscall aus Leistungsgründen in einen anderen Thread zu verschieben? Kann das nicht genau vor dem Syscall passieren?

    – Mikebloch

    22. Mai 2012 um 9:02 Uhr

  • Frage 1: Kann eine Speicherbarriere „Cache in den Hauptspeicher leeren“ oder nur garantieren, dass „alle Schreibvorgänge durchgeführt wurden – in den Cache“? Werden die Cache-Kohärenzmechanismen nicht aktiviert, um Cache-Rennen zwischen Kernen zu bewältigen? Frage 2: Wie lange verzögert ein Prozessor einen Schreibvorgang? Reden wir von 10 Maschinenanweisungen oder 1000? Wächst das und wird es weiter wachsen? Ich frage, weil es viele hundert oder sogar tausende von Maschinenanweisungen gibt, die in diesem Aufruf von notification() ausgeführt werden sollen.

    – Johnnycrash

    25. Mai 2012 um 16:23 Uhr


  • the fact you used a function call is effectively a memory barrier, the compiler will not reorder the instruction global.data = data Barrieren sind nicht für den Compiler, sie sind für die Hardware.

    – Entwicklerbmw

    4. November 2015 um 1:23 Uhr


Speicherbarrieren dienen nicht nur dazu, die Neuordnung von Anweisungen zu verhindern. Selbst wenn Anweisungen nicht neu geordnet werden, kann dies immer noch Probleme mit der Cache-Kohärenz verursachen. Was die Neuordnung betrifft – es hängt von Ihrem Compiler und Ihren Einstellungen ab. ICC ist besonders aggressiv bei Nachbestellungen. MSVC mit Optimierung des gesamten Programms kann es auch sein.

Wenn Ihre gemeinsam genutzte Datenvariable als deklariert ist volatile, obwohl es nicht in der Spezifikation steht Die meisten Compiler generieren eine Speichervariable um Lese- und Schreibvorgänge aus der Variablen und verhindern eine Neuordnung. Dies ist nicht die richtige Art der Verwendung volatilenoch wofür es gedacht war.

(Wenn ich noch Stimmen hätte, würde ich Ihrer Frage für die Erzählung +1 geben.)

  • Aber ist das wirklich ein Thema in der x86/x64. Kann ich ein kurzes Programm schreiben, das zeigt, dass es fehlschlägt? (und danke für die netten Worte, Fachdiskussion soll Spaß machen).

    – Mikebloch

    22. Mai 2012 um 8:24 Uhr


  • x86 gibt einige Garantien in Bezug auf die Cache-Kohärenz. x64 nicht, aber in Wirklichkeit erkennt Intel, dass Entwickler beschissenen, unsicheren Code für x86 geschrieben haben und daher, obwohl sie nicht dazu verpflichtet sind und es nicht in der Spezifikation steht, viele Operationen atomar ausführen und auch Cache-Synchronisierung durchführen. Bei ARM ist jedoch alles möglich. In diesem Beitrag (obwohl er nicht x86-spezifisch ist) finden Sie viele weitere Informationen und etwas mehr ironische Erzählung: ridiculousfish.com/blog/posts/barrier.html

    – Mahmoud Al-Qudsi

    22. Mai 2012 um 8:27 Uhr


  • hast du gerade gesagt, dass Intel Belohnung Entwickler, die schlechten Code schreiben und die Dokumentation ignorieren, indem sie F&E-Ressourcen investieren, um das Problem zu lösen, das sie selbst zerstört haben? Wahrscheinlich auf Kosten meiner und Ihrer CPU-Effizienz? Mann, an manchen Tagen frage ich mich, warum ich mich überhaupt so anstrenge.

    – Mikebloch

    22. Mai 2012 um 8:32 Uhr

  • @mike sie haben es versucht nicht mit den Itanic, und wir alle wissen, wie erfolgreich sie dort waren. Dann kam AMD und sagte: „Hier ist eine 64-Bit-Plattform, auf der Ihre x86-Binärdateien ausgeführt werden und führen Sie Ihren beschissenen Code aus, der gerade für x64 neu kompiliert wurde, ohne Ihre Fehler zu beheben”, und so wurde x86_64 geboren.

    – Mahmoud Al-Qudsi

    22. Mai 2012 um 8:33 Uhr

  • Das Schlüsselwort volatile garantiert zwar weder Speicherbarrieren noch Thread-Sicherheit, es schützt jedoch eine Multithread-App vor Fehlern im Zusammenhang mit dem Compiler, der falsche Optimierungen durchführt, da es feststellt, dass Ihre Thread-Callback-Funktionen nirgendwo in Ihrem Code aufgerufen werden. Bei modernen Compilern für x86 ist dies wahrscheinlich unwahrscheinlich, bei Low-Level-Embedded-Compilern jedoch weitaus wahrscheinlicher.

    – Ludin

    22. Mai 2012 um 11:00 Uhr


In der Praxis ist ein Funktionsaufruf a Compiler Barriere, was bedeutet, dass der Compiler globale Speicherzugriffe nicht über den Aufruf hinaus verschiebt. Ein Vorbehalt hiervon sind Funktionen, von denen der Compiler etwas weiß, z. B. eingebaute Funktionen, eingebettete Funktionen (denken Sie an IPO!) usw.

Daher ist theoretisch eine Prozessorspeicherbarriere (zusätzlich zu einer Compilerbarriere) erforderlich, damit dies funktioniert. Da Sie jedoch read und write aufrufen, bei denen es sich um Syscalls handelt, die den globalen Status ändern, bin ich mir ziemlich sicher, dass der Kernel irgendwo in der Implementierung dieser Speicherbarrieren ausgibt. Es gibt jedoch keine solche Garantie, also brauchen Sie theoretisch die Barrieren.

  • Also in der Praxis, Kernel-Modus-Code == Speicherbarriere? Klingt vernünftig und hört sich an, als hätte der alte Mann doch Recht gehabt. Es klingt nicht so, als wäre ICC in der Lage, den Code um den Systemaufruf herum neu zu ordnen, da er nicht weiß, was der Kernel tun wird.

    – Mikebloch

    22. Mai 2012 um 8:27 Uhr


  • @janneb ja, aber selbst diese Syscalls können in pathologischen Fällen auf einem anderen Kern laufen als dem, der sie aufgerufen hat, und die Speicherbarriere im falschen Thread ausgeben.

    – Mikebloch

    22. Mai 2012 um 8:34 Uhr

  • @blaze: Wie ich im zweiten Satz zu erklären versucht habe, sind es Funktionsaufrufe, in die der Compiler nicht irgendwie hineinschauen kann, von denen der Compiler annehmen muss, dass sie den globalen Zustand berühren können. Aus Compiler-Perspektive unterscheidet sich ein Systemaufruf nicht von beispielsweise einer Funktion in einer gemeinsam genutzten Bibliothek (ohne dass Informationen über den Funktionsprototypen hinaus verfügbar sind).

    – janeb

    22. Mai 2012 um 8:37 Uhr

  • Soweit ich weiß, darf der Compiler die Reihenfolge der Befehlsausführung nach dem Aufruf nicht ändern, da es nach der Funktionsparameterauswertung, aber vor dem Aufruf einen C-Sprachsequenzpunkt gibt. Bevor die Funktion aufgerufen wird, muss der Compiler meiner Meinung nach damit fertig sein, mit der Neuordnung von Anweisungen herumzuspielen.

    – Ludin

    22. Mai 2012 um 11:08 Uhr


  • Dies ist eigentlich die einzige direkte Antwort auf die ursprünglichen Fragen. Die Antwort lautet: Nein. Ein Funktionsaufruf ist immer eine Compilerbarriere. Aber ein Funktionsaufruf ist nicht garantiert eine Speicherbarriere. Dies ist nur der Fall, wenn der Code in der aufgerufenen Funktion eine Speicherbarriere enthält.

    – Johannes Übermann

    4. November 2015 um 20:41 Uhr

Die Grundregel lautet: Der Compiler muss den globalen Zustand machen erscheinen genau so zu sein, wie Sie es codiert haben, aber wenn es beweisen kann, dass eine bestimmte Funktion keine globalen Variablen verwendet, kann es den Algorithmus beliebig implementieren.

Das Ergebnis ist, dass traditionelle Compiler immer Funktionen behandelt haben in einer anderen Übersetzungseinheit als Gedächtnisbarriere, weil sie nicht in diese Funktionen sehen konnten. Zunehmend bauen moderne Compiler Optimierungsstrategien für „ganze Programme“ oder „Link-Zeiten“ ein, die diese Barrieren abbauen und Wille dazu führen, dass schlecht geschriebener Code fehlschlägt, obwohl er seit Jahren gut funktioniert.

Wenn sich die betreffende Funktion in einer gemeinsam genutzten Bibliothek befindet, kann sie nicht hineinsehen. aber Wenn die Funktion vom C-Standard definiert ist, muss sie das nicht – sie weiß bereits, was die Funktion tut – also müssen Sie auch darauf achten. Beachten Sie, dass ein Compiler dies tun wird nicht einen Kernel-Aufruf als das erkennen, was er ist, aber das Einfügen von etwas, das der Compiler nicht erkennen kann (Inline-Assembler oder ein Funktionsaufruf einer Assembler-Datei), wird selbst eine Speicherbarriere erzeugen.

In Ihrem Fall, notify wird entweder eine Blackbox sein, die der Compiler nicht sehen kann (eine Bibliotheksfunktion), oder sie enthält eine erkennbare Speicherbarriere, sodass Sie höchstwahrscheinlich sicher sind.

In der Praxis muss man schreiben sehr schlechter Code, um darüber zu stolpern.

In der Praxis hat er Recht und in diesem speziellen Fall ist eine Gedächtnisbarriere impliziert.

Aber der Punkt ist, dass, wenn seine Anwesenheit “umstritten” ist, der Code bereits zu komplex und unklar ist.

Wirklich Leute, verwendet einen Mutex oder andere geeignete Konstrukte. Es ist der einzig sichere Weg, mit Threads umzugehen und wartbaren Code zu schreiben.

Und vielleicht sehen Sie andere Fehler, wie zum Beispiel, dass der Code unvorhersehbar ist, wenn send() mehr als einmal aufgerufen wird.

1416990cookie-checkIst der Funktionsaufruf eine effektive Speicherbarriere für moderne Plattformen?

This website is using cookies to improve the user-friendliness. You agree by using the website further.

Privacy policy