C++ Threads: gemeinsame Variablen

T_55

Lieutenant
Registriert
Feb. 2013
Beiträge
643
Hallo,

ich hab mal zwei Fragen zum Threading und parallelen Zugriff auf Variablen.

1. Paralleles Lesen einer Variable durch verschiedene Threads ist wohl kein Problem und sicher. Paralleles Schreiben ist verboten. Jetzt wäre meine Frage wie ist es im Fall wenn nur ein Thread schreibend zugriff hat und die anderen nur lesen, wäre das noch sicher?

2. Wenn ein Thread einen Mutex blockiert und andere Threads die per lock auf ihn zugreifen und solange blockiert werden, ist die Warteschlange chronologisch oder zufällig? Also erhalten die Threads die bei Zugriff auf lock blockiert wurden in der gleichen Reihenfolge die Freigabe wie sie blockiert wurden?

Grüße
 
1. Nicht sicher ausser das Schreiben ist atomisch. Z.B. ein String. da werden 10 Bytes geschrieben und Thread 2 liest grade wenn die ersten 5 Bytes geschrieben wurden, dann ist es ein kaputter String.
 
Jetzt wäre meine Frage wie ist es im Fall wenn nur ein Thread schreibend zugriff hat und die anderen nur lesen, wäre das noch sicher?
Nein. Entweder nur lesender Zugriff, oder du musst dich um die synchronisierung kümmern.

2. Wenn ein Thread einen Mutex blockiert und andere Threads die per lock auf ihn zugreifen und solange blockiert werden, ist die Warteschlange chronologisch oder zufällig? Also erhalten die Threads die bei Zugriff auf lock blockiert wurden in der gleichen Reihenfolge die Freigabe wie sie blockiert wurden?

Zu 2. : Meines Wissens nach schon. Lock ordering spielt bei der Vermeidung von Deadlocks eine wichtige Rolle. Wenn die Order keine Rolle spielen würde, wäre das ja wohl nicht möglich. Ich sage also ja, korrigiert mich falls ich Unsinn erzähle. PS: Zufall gibt es nicht, wenn dann eher nicht-determinismus :D
 
Zu 1.
Ist von der Architektur abhängig und davon was für Speicherinstruktionen der Compiler erzeugt. Auf x86 ist zum Beispiel afaik ein 4 Byte load/store atomar, solange er nicht über zwei 64 Byte cachelines verteilt stattfindet.

Zudem ist bei den meisten modernen Hardwarearchitekturen die Reihenfolge, in der Stores im Speicher sichtbar werden, nur für denjenigen Thread konsistent, der die Stores auch ausführt. Zum Beispiel im folgenden Fall:
Code:
//Thread 1:
*a = New_Value;
*a_updated = true;

//Thread 2:
if(*a_updated )
  float My_Value = *a;
Kann es sein, dass Thread 2 immer noch den alten Wert von a liest, obwohl Thread 1 a schon längst geupdated hat.

Auch kann das Umsortieren von Speicherinstruktionen von Compilern afaik einen ähnlichen effekt erzielen, weshalb es auch im C standard diverse Ordering Restriktionen für atomics gibt. Mutexe erfüllen den selben Effekt. Alternativ gibt es in diversen Programmiermodellen dafür memfences.
 
Danke für die Antworten!
Atomaren Datentypen kannte ich bis hier noch gar nicht und ich bin schwer interessiert, damit spart man sich sogar die eigenhändige Syncronisierung und es soll schneller sein. Ist es denn damit auch möglich andere Datentypen wie std::vector oder gar ein struct lock-free zu bekommen? Das wäre mehr als genial :D
Ergänzung ()

Ok man kann nicht alles haben ;) http://stackoverflow.com/questions/32694114/can-i-make-a-thread-safe-stdatomicvectorint
vector ist nicht trivial kopierbar daher geht nicht.

Heißt dann für einfache Datentypen kann ich mit atomic arbeiten und alles komplexe wird dann klassisch mit mutex lock gemacht.
 
Zuletzt bearbeitet:
Heißt dann für einfache Datentypen kann ich mit atomic arbeiten und alles komplexe wird dann klassisch mit mutex lock gemacht.
​Man kann auch andere Dinge lockfree bekommen. Hier ist insbesondere "Compare and Swap" ein hilfreiches Feature.
Vieles gibts aber diesbezüglich schon fertig im Internet ;)
 
Habe ich mir gleich mal angeschaut und die Performance verglichen:

Code:
// OHNE SYNC -> ergibt natürlich falsches ergebnis
for(int x = 0; x<laenge; x=x+1)
{
   ++zaehler;
}

Code:
// MIT std::atomic<bool> lock;
while(lock.exchange(true)) {}
for(int x = 0; x<laenge; x=x+1)
{
   ++zaehler;
}
lock=false;

Code:
// MIT std::mutex mtx;
mtx.lock();
for(int x = 0; x<laenge; x=x+1)
{
   ++zaehler;
}
mtx.unlock();


Habe die Funktionen mit 6 threads parallel laufen lassen.
1000000000 Durchläufe (der zeahler als volatile damit nichts wegoptimiert wird)

Ergebnis:
- ohne sync: 6068 ms
- atomic: 18938 ms
- mutex: 11169 ms

Also atomic ist bei diesem test deutlich langsamer als mutex.
Vielleicht habe ich aber auch "Compare and Swap" falsch angewendet?
Natürlich steht zaehler stellvertretend im test für ein komplexeren Datentyp sonst könnte man logischerweise std::atomic<int> nehmen.

Grüße
Ergänzung ()

und noch als Ergänzung lockfree mit atomicvariable:

Code:
// lockfree durch std::atomic<long long> zaehler2;
for(int x = 0; x<laenge; x=x+1)
{
   ++zaehler2;
}

Ich dachte jetzt kommt der Outperfomer aber genau das Gegenteil!!! Es dauert so lange das ich abgebrochen habe. Habe die Durchläufe auf 100000000 verringert hier nochmal im Vergleich:

748 ms -> ohne sync
1825ms -> std::atomic<bool> lock
1123 ms -> mutex
80792 ms -> std::atomic<long long>

Hätte ich so nicht erwartet :)
 
Zuletzt bearbeitet:
Ich benutze für aufwendigere Prozesse die Syncronisierung benötigen ein Ticket verfahren, wie z.b. als zieht man ne Nummer bei der KFZ-Zulassungsstelle und wartet dann bis man dran kommt.

Damit wird alles in der Reihenfolge wie sie reinkommen auch abgearbeitet.

Das ganze ist super trivial und erfordert nichts außer zwei Atomic-Adds:

Code:
struct TicketMutex {
	volatile U64 aquired;
	volatile U64 finished;
};
inline U64 AtomicAddU64(volatile U64 *value, U64 addend) {
	U64 result = _InterlockedExchangeAdd64((__int64 volatile *)value, addend);
	return (result);
}
inline void BeginTicketMutex(TicketMutex *mutex) {
	U64 ticket = AtomicAddU64(&mutex->aquired, 1);
	while (ticket != mutex->finished);
}

inline void EndTicketMutex(TicketMutex *mutex) {
	AtomicAddU64(&mutex->finished, 1);
}

Ja es ist ein Spinlock-Verfahren, aber in der Praxis funktioniert das sehr gut.
Ich benutze dass z.b. um meine Assets die geladen werden müssen in ne Queue zu stopfen oder um meine Row-Texturen in OpenGL hochzuladen.
 
Zuletzt bearbeitet:
Warum deine Variante so lange dauert:

Im ersten Fall geht's total schief (ein guter Compiler schreibt erst am Ende das Ergebnis, daher alle Ergebnisse falsch).

Im zweiten Fall (atomic<bool>) machst du es seriell, da du keine Speicherabhängigkeiten hast, dauert das ca. genau so viele Takte, wie du hochzählst.

Im dritten Fall (mutex) genauso.

Im vierten Fall wird es richtig schlimm, du erzwingst fast jeden Takt eine Synchronisierung mit den anderen CPU-Kernen. Das ist zwar optimiert, aber kostet doch etwas Zeit. Das bedeutet statt 4 Synchronisierungen (bei 4 Threads) hast du 1 Mio., das dauert natürlich länger.

Ich denke, du solltest mal ein paar Beispiele haben, wo atomics ihre Macht ausspielen.

COM: AddRef und Release, ohne atomare ++ und -- müsste man Mutexe verwenden, das wäre sehr teuer.
Singleton: Keine Race-Condition mehr möglich. Sonst nur via Mutex, das ist aber sehr teuer.
Queues: Kann man damit ohne Mutexe implementieren.

Wie immer gilt, möglichst nicht ins Gehege kommen. Atomics haben den Vorteil, dass erst WENN zwei Threads kollidieren, es (signifikant) langsamer wird. Solange nur ein Thread was macht, läuft das fast gleich schnell.
Musst du mutexe verwenden, wird es automatisch langsamer, da du jedes Mal den Mutex locken und unlocken musst.
 
Effizientes Multithreading basiert normalerweise auf einer effizienten Parallelisierung der jeweiligen tasks, und nicht ob die konkrete Implementierung der locks/Synchronisationen nun ein paar clocks mehr oder weniger verbraedt.

Wie Hancock schon bemerkt hat, dein Code rennt seriell ab -> und somit wohl auch langsamer als wenn dus ueberhaupt einfach als 6-fache Schleife im main-thread machst (weil du dir damit saemtlichen threading-overhead ersparst). Probiers mal aus und poste die Werte zum Vergleich.

Ein wirkliche und somit effiziente Parallelisierung waere der Fall, wenn jeder thread seinen eigenen Zaehler hat -> dann kann jeder thread sofort mit dem Zaehlen beginnen, und wenn er fertig ist werden die Zaehlerwerte jedes threads einfach zu einer Gesamtsumme addiert. Dazu kann die Variable fuer diese Gesamtsumme ein std::atomic sein, oder Zugriffe auf eine "normale" (non-atomic) Variable werden per mutex-lock geschuetzt, oder man kommt auch z.b. komplett ohne Synchronisation aus indem du im main-thread etwa einen std::vector derselben Groesse wie Anzahl an threads erstellst, jeder thread beim Erstellen "seine" Position in dem vector bekommt und er seinen Zaehlerwert an diese Stelle reinschreibt, und am Schluss addiert einfach der main-thread alle vector-Elemente.
Solange die Aufgaben der einzelnen threads nicht so kurz sind dass sich Multithreading eh nicht wirklich auszahlt, wirst du zw. diesen Implementierungen wohl kaum Performanceunterschiede messen werden (falls dus misst, wiederhole alle Messungen auch ein paar Mal, damit du weisst wie sehr sich einzelne Laeufe auch bei gleicher Implementierung unterscheiden koennen). Allen voran sollte es aber x-fach schneller ablaufen als deine serielle Implementierung.

Bei mutexes solltest du uebrigens eigene lock-Variablen im RAII-Sinne verwenden (nicht nur wegen ev. Vergessens des unlock(), sondern z.b. auch falls eine exception auftritt ...).
 
Weil hier ein häufig gemachter Fehler wiederholt wurde nochmal ganz deutlich:

Wenn zwei oder mehr Threads ohne Synchronisation auf eine normale Variable zugreifen und mindestens einer davon ein Schreibzugrif ist, dann ist das in C++ ein Datarace und somit undefined behavior. Dabei ist es völlig egal auf welcher Architektur du bist oder wie groß die Variable ist (bool, char, int, string ... macht alles keinen Unterschied).

Das Missverständnis kommt daher, dass z.B. auf x86 32Bit Zugriffe normalerweise Atomar sind und wenn du in Assembler schreiben würdest wäre das auch relevant. Leider garantiert der der C++ Compiler im Normalfall noch nichteinmal, dass Speicherzugriffe überhaupt stattfinden, geschweigedenn in der Reihenfolge oder auf die Art und Weise, wie es im Code steht.
 
Zurück
Oben