list :: empty () Multithread-Verhalten?

9

Ich habe eine Liste, aus der verschiedene Threads Elemente abrufen sollen. Um zu vermeiden, dass der Mutex die Liste schützt, wenn er leer ist, überprüfe ich dies empty()vor dem Sperren.

Es ist in Ordnung, wenn der Anruf zu list::empty()100% nicht richtig ist. Ich möchte nur vermeiden, dass gleichzeitig list::push()und bei list::pop()Anrufen abgestürzt oder gestört wird .

Kann ich davon ausgehen, dass VC ++ und Gnu GCC nur manchmal empty()falsch liegen und nichts Schlimmeres?

if(list.empty() == false){ // unprotected by mutex, okay if incorrect sometimes
    mutex.lock();
    if(list.empty() == false){ // check again while locked to be certain
         element = list.back();
         list.pop_back();
    }
    mutex.unlock();
}
Anne Quinn
quelle
1
Nein, das können Sie nicht annehmen. Sie könnten einen gleichzeitigen Container wie VC's concurrent_queue
Panagiotis Kanavos
2
@Fureeish Dies sollte eine Antwort sein. Ich würde hinzufügen, dass dies std::list::sizeeine konstante zeitliche Komplexität garantiert hat, was im Grunde bedeutet, dass die Größe (Anzahl der Knoten) in einer separaten Variablen gespeichert werden muss. Nennen wir es size_. std::list::emptydann wird wahrscheinlich etwas zurückgegeben als size_ == 0, und das gleichzeitige Lesen und Schreiben von size_würde Datenrennen verursachen, daher UB.
Daniel Langr
@ DanielLangr Wie wird "konstante Zeit" gemessen? Handelt es sich um einen einzelnen Funktionsaufruf oder um das gesamte Programm?
Neugieriger
1
@curiousguy: DanielLangr hat Ihre Frage mit "unabhängig von der Anzahl der Listenknoten" beantwortet. Dies ist die genaue Definition von O (1). Dies bedeutet, dass jeder Aufruf unabhängig von der Anzahl der Elemente in weniger als einer konstanten Zeit ausgeführt wird. en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions Die andere Option (bis C ++ 11) wäre linear = O (n), was bedeutet, dass die Größe die Elemente zählen müsste (verknüpfte Liste), was für noch schlimmer wäre die Parallelität (offensichtlicheres Datenrennen als nichtatomares Lesen / Schreiben auf dem Zähler).
Firda
1
@curiousguy: Wenn Sie Ihr eigenes Beispiel mit dV nehmen, ist die Zeitkomplexität dieselbe mathematische Grenze. Alle diese Dinge werden entweder rekursiv definiert oder in einer Form von "Es existiert C, so dass f (N) <C für jedes N" - das ist die Definition von O (1) (für gegebenes / jedes HW existiert eine Konstante C wie z dass das Algo bei jeder Eingabe in weniger als C-Zeit endet). Amortisiert bedeutet im Durchschnitt , was bedeutet, dass die Verarbeitung einiger Eingaben möglicherweise länger dauert (z. B. erneutes Hash / erneutes Zuweisen erforderlich), aber im Durchschnitt immer noch konstant ist (unter der Annahme aller möglichen Eingaben).
Firda

Antworten:

10

Es ist in Ordnung, wenn der Anruf zu list::empty()100% nicht richtig ist.

Nein, das ist nicht in Ordnung. Wenn Sie überprüfen, ob die Liste außerhalb eines Synchronisationsmechanismus (Sperren des Mutex) leer ist, haben Sie ein Datenrennen. Ein Datenrennen bedeutet, dass Sie ein undefiniertes Verhalten haben. Undefiniertes Verhalten bedeutet, dass wir nicht mehr über das Programm nachdenken können und jede Ausgabe, die Sie erhalten, "korrekt" ist.

Wenn Sie Wert auf Ihre geistige Gesundheit legen, nehmen Sie den Leistungstreffer und sperren den Mutex, bevor Sie ihn überprüfen. Die Liste ist jedoch möglicherweise nicht einmal der richtige Container für Sie. Wenn Sie uns genau mitteilen können, was Sie damit machen, können wir möglicherweise einen besseren Container vorschlagen.

NathanOliver
quelle
Persönliche Perspektive, Anruf list::empty()ist eine Leseaktion, die nichts damit zu tun hatrace-condition
Ngọc Khánh Nguyễn
3
@ NgọcKhánhNguyễn Wenn sie Elemente zur Liste hinzufügen, führt dies definitiv zu einem Datenrennen, da Sie gleichzeitig die Größe schreiben und lesen.
NathanOliver
6
@ NgọcKhánhNguyễn Das ist falsch. Eine Rennbedingung ist read-writeoder write-write. Wenn Sie mich nicht glauben, gibt dem Standardteil auf Daten Rennen eine Lese
NathanOliver
1
@ NgọcKhánhNguyễn: Da weder das Schreiben noch das Lesen garantiert atomar sind, kann es gleichzeitig ausgeführt werden, sodass das Lesen etwas völlig Falsches bewirken kann (als zerrissenes Lesen bezeichnet). Stellen Sie sich vor, das Schreiben ändert 0x00FF in 0x0100 in einer Little-Endian-8-Bit-MCU. Beginnen Sie mit dem Umschreiben von low 0xFF auf 0x00, und der Lesevorgang erhält jetzt genau diese Null. Lesen Sie beide Bytes (Schreiben des Threads verlangsamt oder angehalten). Der Schreibvorgang wird fortgesetzt, indem das High-Byte auf 0x01 aktualisiert wird. Der Lesethread hat jedoch bereits einen falschen Wert erhalten (weder 0x00FF noch 0x0100, aber unerwartete 0x0000).
Firda
1
@ NgọcKhánhNguyễn Es kann auf einigen Architekturen sein, aber die virtuelle C ++ - Maschine bietet keine solche Garantie. Selbst wenn Ihre Hardware dies tun würde, wäre es für den Compiler legal, den Code so zu optimieren, dass Sie niemals eine Änderung sehen würden, da er ohne Thread-Synchronisation davon ausgehen kann, dass nur ein einziger Thread ausgeführt wird, und entsprechend optimieren kann.
NathanOliver
6

Es gibt ein Lesen und ein Schreiben (höchstwahrscheinlich für das sizeMitglied von std::list, wenn wir davon ausgehen, dass es so benannt ist), die im Reader nicht miteinander synchronisiert sind . Stellen Sie sich vor, ein Thread ruft empty()(in Ihrem äußeren if()) auf, während der andere Thread in den inneren if()eintritt und ausgeführt wird pop_back(). Sie lesen dann eine Variable, die möglicherweise geändert wird. Dies ist undefiniertes Verhalten.

Fureeish
quelle
2

Als Beispiel dafür, wie etwas schief gehen könnte:

Ein ausreichend intelligenter Compiler könnte erkennen, dass mutex.lock()der list.empty()Rückgabewert möglicherweise nicht geändert werden kann, und somit die innere ifPrüfung vollständig überspringen , was schließlich zu pop_backeiner Liste führt, deren letztes Element nach dem ersten entfernt wurde if.

Warum kann es das tun? Es findet keine Synchronisation statt. list.empty()Wenn es also gleichzeitig geändert würde, würde dies ein Datenrennen darstellen. Der Standard besagt, dass Programme keine Datenrennen haben dürfen, daher nimmt der Compiler dies als selbstverständlich an (andernfalls könnte er fast keine Optimierungen durchführen). Daher kann es eine Single-Threaded-Perspektive auf das nicht synchronisierte annehmen list.empty()und daraus schließen, dass es konstant bleiben muss.

Dies ist nur eine von mehreren Optimierungen (oder Hardware-Verhaltensweisen), die Ihren Code beschädigen könnten.

Max Langhof
quelle
Aktuelle Compiler scheinen nicht einmal optimieren zu wollen a.load()+a.load()...
neugieriger Kerl
1
@curiousguy Wie soll das optimiert werden? Sie fordern dort volle sequentielle Konsistenz an, damit Sie das bekommen ...
Max Langhof
@MaxLanghof Sie glauben nicht, dass die Optimierung a.load()*2offensichtlich ist? Nicht einmal a.load(rel)+b.load(rel)-a.load(rel)ist sowieso optimiert. Nichts ist. Warum erwarten Sie, dass Sperren (die im Wesentlichen meistens die Konsistenz von seq aufweisen) optimiert werden?
Neugieriger
@curiousguy Weil die Speicherreihenfolge von nichtatomaren Zugriffen (hier vor und nach der Sperre) und Atomics völlig unterschiedlich ist? Ich erwarte nicht, dass die Sperre "mehr" optimiert wird, ich erwarte, dass nicht synchronisierte Zugriffe mehr optimiert werden als sequentiell konsistente Zugriffe. Das Vorhandensein des Schlosses ist für meinen Standpunkt irrelevant. Und nein, wird der Compiler nicht zu optimieren erlaubt a.load() + a.load()zu 2 * a.load(). Sie können gerne eine Frage dazu stellen, wenn Sie mehr wissen möchten.
Max Langhof
@ MaxLanghof Ich habe keine Ahnung, was du überhaupt sagen willst. Sperren sind im Wesentlichen sequentiell konsistent. Warum sollte die Implementierung versuchen, Optimierungen für einige Threading-Grundelemente (Sperren) und nicht für andere (Atomics) vorzunehmen? Erwarten Sie, dass nicht-atomare Zugriffe für die Verwendung von Atomics optimiert werden?
Neugieriger