In der Reihe Linux-Threads haben wir besprochen, wie ein Thread beendet werden kann und wie der Rückgabestatus vom beendenden Thread an seinen übergeordneten Thread weitergegeben wird. In diesem Artikel werden wir etwas Licht auf einen wichtigen Aspekt werfen, der als Thread-Synchronisation bekannt ist.
Linux Threads Series:Teil 1, Teil 2, Teil 3, Teil 4 (dieser Artikel).
Thread-Synchronisierungsprobleme
Nehmen wir einen Beispielcode, um Synchronisationsprobleme zu untersuchen:
#include<stdio.h> #include<string.h> #include<pthread.h> #include<stdlib.h> #include<unistd.h> pthread_t tid[2]; int counter; void* doSomeThing(void *arg) { unsigned long i = 0; counter += 1; printf("\n Job %d started\n", counter); for(i=0; i<(0xFFFFFFFF);i++); printf("\n Job %d finished\n", counter); return NULL; } int main(void) { int i = 0; int err; while(i < 2) { err = pthread_create(&(tid[i]), NULL, &doSomeThing, NULL); if (err != 0) printf("\ncan't create thread :[%s]", strerror(err)); i++; } pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); return 0; }
Der obige Code ist ein einfacher Code, in dem zwei Threads (Jobs) erstellt werden und in der Startfunktion dieser Threads ein Zähler verwaltet wird, durch den der Benutzer die Protokolle über die Jobnummer erhält, die gestartet und wann sie abgeschlossen ist. Der Code und der Ablauf sehen gut aus, aber wenn wir die Ausgabe sehen:
$ ./tgsthreads Job 1 started Job 2 started Job 2 finished Job 2 finished
Wenn Sie sich auf die letzten beiden Protokolle konzentrieren, sehen Sie, dass das Protokoll „Auftrag 2 beendet“ zweimal wiederholt wird, während kein Protokoll für „Auftrag 1 beendet“ zu sehen ist.
Wenn Sie nun zum Code zurückkehren und versuchen, einen logischen Fehler zu finden, werden Sie wahrscheinlich keinen Fehler leicht finden. Aber wenn Sie sich die Ausführung des Codes genauer ansehen und visualisieren, werden Sie Folgendes feststellen:
- Das Protokoll „Job 2 gestartet“ wird unmittelbar nach „Job 1 gestartet“ gedruckt, sodass leicht geschlussfolgert werden kann, dass der Planer Thread 2 geplant hat, während Thread 1 verarbeitet wurde.
- Wenn die obige Annahme zutraf, wurde der Wert der Variable „Zähler“ erneut erhöht, bevor Job 1 beendet wurde.
- Als also Job 1 tatsächlich fertig war, erzeugte der falsche Wert des Zählers das Protokoll „Job 2 fertig“ gefolgt von „Job 2 fertig“ für den eigentlichen Job 2 oder umgekehrt, da dies vom Planer abhängt.
- Wir sehen also, dass nicht das sich wiederholende Protokoll, sondern der falsche Wert der „Zähler“-Variable das Problem ist.
Das eigentliche Problem war die Verwendung der Variable „counter“ durch den zweiten Thread, wenn der erste Thread sie verwendete oder im Begriff war, sie zu verwenden. Mit anderen Worten, wir können sagen, dass die fehlende Synchronisierung zwischen den Threads bei Verwendung der gemeinsam genutzten Ressource „Zähler“ die Probleme verursacht hat, oder mit einem Wort können wir sagen, dass dieses Problem aufgrund eines „Synchronisierungsproblems“ zwischen zwei Threads aufgetreten ist.
Mutexe
Nun, da wir das Basisproblem verstanden haben, wollen wir die Lösung dafür diskutieren. Die beliebteste Art, Thread-Synchronisation zu erreichen, ist die Verwendung von Mutexes.
Ein Mutex ist eine Sperre, die wir vor der Verwendung einer gemeinsam genutzten Ressource festlegen und nach der Verwendung freigeben. Wenn die Sperre gesetzt ist, kann kein anderer Thread auf den gesperrten Codebereich zugreifen. Wir sehen also, dass selbst wenn Thread 2 geplant ist, während Thread 1 noch nicht auf die gemeinsam genutzte Ressource zugegriffen hat und der Code von Thread 1 mithilfe von Mutexes gesperrt ist, Thread 2 nicht einmal auf diesen Codebereich zugreifen kann. Dadurch wird ein synchronisierter Zugriff auf gemeinsam genutzte Ressourcen im Code gewährleistet.
Intern funktioniert es wie folgt:
- Angenommen, ein Thread hat einen Codebereich mit Mutex gesperrt und führt diesen Codeabschnitt aus.
- Wenn sich der Scheduler jetzt für einen Kontextwechsel entscheidet, werden alle anderen Threads, die bereit sind, dieselbe Region auszuführen, entblockt.
- Nur einer von allen Threads würde es bis zur Ausführung schaffen, aber wenn dieser Thread versucht, denselben Codebereich auszuführen, der bereits gesperrt ist, wird er wieder schlafen gehen.
- Der Kontextwechsel findet immer wieder statt, aber kein Thread wäre in der Lage, den gesperrten Codebereich auszuführen, bis die Mutex-Sperre darüber aufgehoben wird.
- Die Mutex-Sperre wird nur von dem Thread aufgehoben, der sie gesperrt hat.
- Dies stellt also sicher, dass, sobald ein Thread ein Stück Code gesperrt hat, kein anderer Thread dieselbe Region ausführen kann, bis sie von dem Thread, der sie gesperrt hat, entsperrt wird.
- Daher stellt dieses System die Synchronisierung zwischen den Threads sicher, während es an gemeinsam genutzten Ressourcen arbeitet.
Ein Mutex wird initialisiert und dann wird eine Sperre erreicht, indem die folgenden zwei Funktionen aufgerufen werden:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); int pthread_mutex_lock(pthread_mutex_t *mutex);
Die erste Funktion initialisiert einen Mutex und durch die zweite Funktion kann jeder kritische Bereich im Code gesperrt werden.
Der Mutex kann entsperrt und zerstört werden, indem folgende Funktionen aufgerufen werden:
int pthread_mutex_unlock(pthread_mutex_t *mutex); int pthread_mutex_destroy(pthread_mutex_t *mutex);
Die erste obige Funktion hebt die Sperre auf und die zweite Funktion zerstört die Sperre, sodass sie in Zukunft nirgendwo verwendet werden kann.
Ein praktisches Beispiel
Sehen wir uns ein Stück Code an, in dem Mutexe für die Thread-Synchronisation verwendet werden
#include<stdio.h> #include<string.h> #include<pthread.h> #include<stdlib.h> #include<unistd.h> pthread_t tid[2]; int counter; pthread_mutex_t lock; void* doSomeThing(void *arg) { pthread_mutex_lock(&lock); unsigned long i = 0; counter += 1; printf("\n Job %d started\n", counter); for(i=0; i<(0xFFFFFFFF);i++); printf("\n Job %d finished\n", counter); pthread_mutex_unlock(&lock); return NULL; } int main(void) { int i = 0; int err; if (pthread_mutex_init(&lock, NULL) != 0) { printf("\n mutex init failed\n"); return 1; } while(i < 2) { err = pthread_create(&(tid[i]), NULL, &doSomeThing, NULL); if (err != 0) printf("\ncan't create thread :[%s]", strerror(err)); i++; } pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); pthread_mutex_destroy(&lock); return 0; }
Im Code oben:
- Ein Mutex wird am Anfang der Hauptfunktion initialisiert.
- Derselbe Mutex ist in der Funktion „doSomeThing()“ gesperrt, während die gemeinsam genutzte Ressource „counter“ verwendet wird
- Am Ende der Funktion ‚doSomeThing()‘ wird derselbe Mutex entsperrt.
- Am Ende der Hauptfunktion, wenn beide Threads fertig sind, wird der Mutex zerstört.
Wenn wir uns nun die Ausgabe ansehen, finden wir :
$ ./threads Job 1 started Job 1 finished Job 2 started Job 2 finished
Wir sehen also, dass dieses Mal die Start- und Endprotokolle beider Jobs vorhanden waren. Die Thread-Synchronisation erfolgte also durch die Verwendung von Mutex.