Aus Linux-Magazin 01/2005

Native Posix Thread Library - ein Blick hinter die Kulissen

Der Standard für Threads unter Linux ist heute die Native Posix Threads Library (NPTL). Die Bibliothek überzeugt durch große Kompatibilität zum Standard und hohe Performance. Dieser Artikel untersucht die neue Threading-Engine und zeigt, wie Benutzeranwendungen davon profitieren.

Vor Kernel 2.6 herrschte bei Threads unter Linux große Verwirrung, denn gleich mehrere Implementationen konkurrierten um den ersten Platz: Linuxthreads, Gnu Pth, NGPT (Next Generation Posix Threads) und NPTL (Native Posix Threads Library). Mittlerweile hat sich die NPTL von Ulrich Drepper und Ingo Molnar durchgesetzt[1].

Verwirrende IDs

Die neuen Threads setzen allerdings eine bessere Thread-Abstraktion im Kernel voraus. Ein Prozess wird nun als Thread-Gruppe abgebildet. Ein Thread entspricht einer Kernel-Task, ein Prozess einer Thread-Gruppe mit einem Thread-Gruppenführer. Einem Kernel-Thread entspricht ein Userspace-Thread, denn NPTL benutzt das 1:1-Scheduling-Modell, siehe Abbildung 1.

Die Struktur »task_t« enthält zwei Werte vom Typ »pid_t«: »pid« ist die Task-ID, »tgid« die Thread-Gruppen-ID. Bei Single-Thread-Prozessen besitzen die beiden IDs den gleichen Wert. Userspace-NPTL-Code bildet die TID auf die Posix-Thread-ID (Thread-Handle) ab. Tabelle 1 stellt alle Identifikatoren der NPTL- Threads gegenüber.

Tabelle 1:
Thread-Identifikatoren

 

User-Mode-IDs

Typ

Funktion (Bibliothek)

Kernel-Mode-Äquivalente

Typ

Posix-Thread-ID

»pthread_t«

»pthread_create()« (NPTL)

PID (Prozess-ID)

»pid_t«

»getpid()« (Glibc/Systemaufruf)

»task_t:tgid« (Thread-Gruppen-ID)

»pid_t«

TID (Kernel-Thread-ID)

»pid_t«

»clone()«, »gettid()«,
»tkill()« (Systemaufrufe)

»task_t:pid« (Thread-ID)

»pid_t«

Im Kernel erzeugt der Systemaufruf »clone()« einen neuen Thread mit Thread-ID (TID) und »tgid« (PID). Ist beim Aufruf von »clone()« das »CLONE_ THREAD«-Flag gesetzt, wird der neue Thread einer Gruppe zugeordnet. Anwendungsprogrammierer benutzen statt des Systemaufrufs besser die Posix-Schnittstelle. Als ID stehen ihnen der Thread-Handle und die PID zur Verfügung, die TID ist ein privates Detail der Kernel-Implementierung. Für Neugierige gibt es trotzdem zwei TID-bezogene Systemaufrufe, die keine entsprechenden Glibc-Wrapper-Funktionen besitzen: »gettid()« gibt die TID zurück, »tkill()«, sendet dem Thread ein Signal.

Das C++-Programm in Listing 1 zeigt die verschiedenen IDs an. Der Haupt-Thread erzeugt zunächst mit »pthread_create()« einen Arbeits-Thread und wartet dann auf ihn. Optional versteht das Programm eine Kommandozeilenoption für eine Verzögerung in Sekunden. Damit hat man beim Testen Gelegenheit, »ps« auszuführen und die Ergebnisse zu vergleichen. Zur Übersetzung des Code siehe den Kasten “Kompilieren”.

Kompilieren

Alle hier aufgeführten Programme lassen sich mit dem Befehl »make Programm LDFLAGS=-lpthread« übersetzen, wobei Programm durch den Namen der jeweiligen C++-Datei zu ersetzen ist. In der gedruckten Version fehlen der Übersichtlichkeit halber die Includes. Die vollständigen Listings sind auf dem Server des Linux-Magazins [3] zu finden.

Die Ausgabe des kompilierten Programms sieht so aus:

# ./ids
Main thread: PID=3775, pthread_t=
  4143878816, TID=3775
Worker thread: PID=3775, pthread_t=
  4143877040, TID=3776

Die beiden Threads besitzen die gleiche PID, die der »getpid()«-Systemaufruf zurückgibt. Der Haupt-Thread hat die gleichen PID- und TID-Werte, weil er Thread-Gruppenführer ist. Das Kommando »ps« zeigt den Prozess an und die einzelnen Threads mit der Option »-m«. TIDs werden vom generischen PID-Allokator erzeugt, der durch einen cleveren Algorithmus von Molnar deutlich schneller und mit konstanter Komplexität O(1) arbeitet.

Das folgende Beispiel zeigt das Verhalten desselben Programms mit Linuxthreads. Der Kasten “Rückwärtskompatibilität” erklärt, wie man Linuxthreads aktiviert:

# export LD_ASSUME_KERNEL=2.4.1
# getconf GNU_LIBPTHREAD_VERSION
linuxthreads-0.10
# ./ids
Main thread: PID=3824, pthread_t=16384, U TID=3824
Worker thread: PID=3826, pthread_t=16386, U TID=3826

Im Gegensatz zu den alten Linuxthreads benutzt NPTL keinen Manager-Thread, dessen Aufgaben hat der Kernel übernommen.

Abbildung 1: Drei Modelle ordnen Threads von User- und Kernelspace unterschiedlich zu. NPTL arbeitet nach dem 1:1-Modell.

Abbildung 1: Drei Modelle ordnen Threads von User- und Kernelspace unterschiedlich zu. NPTL arbeitet nach dem 1:1-Modell.

Vaterschaftstest

Mit LWP-Threads gab es noch ein grundsätzliches Problem mit der Exec-Familie von Systemaufrufen: Wenn ein ande- rer als der Haupt-Thread eines Multi-Thread-Prozesses (MT-Prozesses) einen Prozess durch »fork()« und »exec…()« startet, erhält der Kindprozess als PPID (Parent Process ID) die Vater-Thread-PID. NPTL korrigiert dieses Fehlverhalten. In Listing 2 kreiert der Haupt-Thread einen Arbeits-Thread, der Mozilla startet. Die Ausgabe sieht unter Verwendung von NPTL so aus:

# ./spawn
I'm main thread in the parent process: 19322
I'm forked process: 19324 from parent: 19322

Der Befehl »ps« zeigt zusätzlich die Mozilla-PID, die auch in der zweiten Meldung zu sehen ist.

Mit Linuxthreads ergibt sich ein anderes Resultat:

# export LD_ASSUME_KERNEL=2.4.1
# ./spawn
I'm main thread in the parent process: 19337
I'm forked process: 19340 from parent: 19339

Rückwärtskompatibilität

NPTL ist binärkompatibel zu Linuxthreads. Alle Anwendungen, die sich an den Posix-Standard halten, laufen weiterhin problemlos mit NPTL. Anwendungen, die weiterhin Linuxthreads benutzen möchten, haben folgenden Mechanismus zur Verfügung: Die bekannten Distributionen (Red Hat Linux 9.0, Suse Linux 9.1, Fedora Core 1, 2) liefern drei Versionen der »libpthread.so«:

  • Im Verzeichnis »/lib« befindet sich Linuxthreads
    ohne Floating Stacks (OS ABI 2.2.5).
  • »/lib/i686« enthält Linuxthreads mit
    Floating-Stacks (OS ABI 2.4.1).
  • Die NPTL-Version findet sich in »/lib/tls« (OS ABI
    2.4.20).

In jedem der Verzeichnisse gibt es passende Runtime-Bibliotheken. Die OS-ABI-Versionen beziehen sich auf Red-Hat-Kernel. NPTL ist ab Kernel 2.5.36 verfügbar, Red Hat hat aber die nötigen Änderungen auf Version 2.4.20 rückportiert. Suse kommt ab Version 9.1 mit Kernel 2.6 und mit Glibc2.3/NPTL.

Der dynamische Linker erfragt den Wert der Umgebungsvariablen »LD_ASSUME_KERNEL« und lädt die passende Bibliothek. Die Konfigurationsvariable »GNU_LIBPTHREAD_VERSION« zeigt die aktuelle Bibliotheksversion:

# getconf GNU_LIBPTHREAD_VERSION
NPTL 0.61
# export LD_ASSUME_KERNEL=2.4.1
# getconf GNU_LIBPTHREAD_VERSION
linuxthreads-0.10

Dieselbe Information zeigt auch der folgende Befehl:

`ldd Programm | grep libc.so.6
 | cut -d' ' -f 3` | grep -i 
 'linuxthreads|nptl'

Die NPTL-Bibliothek lässt sich sogar direkt ausführen:

# /lib/tls/libpthread.so.0
NPTL 0.61 by Ulrich Drepper
Copyright (C) 2003 Free Software 
Foundation, Inc ...

Listing 1:
»ids.cpp«

01 void printThreadInfo(const char* pstrThread)
02 {
03   cout << pstrThread << ": PID=" << getpid() << ", pthread_t=" 
04    << pthread_self() << ", TID=" << syscall(__NR_gettid) << endl;
05 }
06 
07 void* doWork(void* pArg)
08 {
09   printThreadInfo("Worker thread");
10   int nSeconds = *(int*)pArg;
11   sleep(nSeconds);
12 }
13 
14 int main(int argc, char* const argv[])
15 {
16   printThreadInfo("Main thread");
17   int nSeconds = (argc > 1) ? atoi(argv[1]) : 0;
18   pthread_t hThread;
19   pthread_create(&hThread, 0, &doWork, (void*)&nSeconds);
20   pthread_join(hThread, 0);
21   return 0;
22 };

Hier ist die Haupt-Thread-PID 19337, die Arbeits-Thread-PID 19339 und die PID des Kindprozesses 19340. Die PPID des Kindprozesses ist 19339, weil ihn der Arbeits-Thread gestartet hat.

Die Routine »pthread_atfork()« registriert Funktionen, die das System unmittelbar vor und nach dem Systemaufruf »fork()« im Vater- und Kindprozess ausführt. Im Gegensatz zu Linuxthreads werden mit der NPTL diese Funktionen bei »vfork()« nicht ausgeführt.

Alle NPTL-Threads eines Prozesses dürfen gleichberechtigt auf den Kindprozess warten, wenn der Programmierer die »wait…()«-Systemaufrufe verwendet. Das geht mit Linuxthreads nicht. In Listing 3 forkt der Arbeits-Thread einen Prozess, auf den der Haupt-Thread wartet. Mit NPTL ist das Warten erfolgreich, bei Linuxthreads liefert die »waitpid()«-Funktion den Fehlercode »ECHILD«.

# ./wait
waitpid successfull!
# export LD_ASSUME_KERNEL=2.4.1
# ./wait
waitpid failed: : No child processes

Eines der wichtigsten Designziele für NPTL war gute Skalierbarkeit. Die ersten Benchmarks zeigten sehr gute Ergebnisse. Entwickler Ingo Molnar erzeugte 100000 Threads in etwa 2 Sekunden – der gleiche Test mit Linuxthreads dauert 15 Minuten! Obwohl die meisten Anwendungen nur wenige Threads benutzen, gibt es auch solche, die Hunderte von Threads erzeugen: Serveranwendungen, die für jeden Request oder jede Verbindung einen Thread starten, oder virtuelle Maschinen mit Thread-Pools und asynchronen Frameworks. Damit das Betriebssystem mit der steigenden Anzahl von Threads gut skaliert, sollen Kernelroutinen möglichst konstante Komplexität besitzen.

Listing 2:
»spawn.cpp«

01 void* doWork(void* pArg)
02 {
03   pid_t nChildPID = fork();
04   if (0 < nChildPID)
05     waitpid(nChildPID, 0, 0);
06   else if (0 == nChildPID)
07   {
08     cout << "I'm forked process: " << getpid() << " from parent: " << getppid() << endl;
09     execvp ("mozilla", 0);
10     exit(1);
11   }
12   return 0;
13 }
14 
15 int main (int argc, char* const argv[])
16 {
17   cout << "I'm main thread in the parent process: " << getpid() << endl;
18   pthread_t hThread;
19   pthread_create(&hThread, 0, &doWork, 0);
20   pthread_join(hThread, 0);
21   return 0;
22 };

Listing 3:
»wait.cpp«

01 static pid_t nChildPID = 0;
02 
03 void* doWork(void* pArg)
04 {
05   nChildPID = fork ();
06   if (0 == nChildPID)
07   {
08     exit(0);
09   }
10   return 0;
11 }
12 
13 int main (int argc, char* const argv[])
14 {
15   pthread_t hThread;
16   pthread_create(&hThread, 0, &doWork, 0);
17   pthread_join(hThread, 0);
18   pid_t pid = -1;
19   if (0 >= (pid = waitpid(nChildPID, 0, 0)))
20     perror("waitpid failed: ");
21   else
22   {
23     assert(pid == nChildPID);
24     cout << "waitpid successfull!" << endl;
25   }
26 
27   return 0;
28 };

Listing 4:
»sigstop.cpp«

01 void* doWork(void* pArg)
02 {
03   for (int i = 0; i < 4; ++i)
04   {
05     cout << "Worker thread doing the job!" << endl;
06     sleep(1);
07   }
08   return 0;
09 }
10 
11 int main (int argc, char* const argv[])
12 {
13   pthread_t hThread;
14   pthread_create(&hThread, 0, &doWork, 0);
15   sleep(2);
16   cout << "Main thread sending SIGSTOP to worker thread!" << endl;
17   pthread_kill(hThread, SIGSTOP);
18 
19   for (int i = 0; i < 2; ++i)
20   {
21     cout << "Main thread doing the job!" << endl;
22     sleep(1);
23   }
24 
25   cout << "Main thread sending SIGCONT to worker thread!" << endl;
26   pthread_kill(hThread, SIGCONT);
27   pthread_join(hThread, 0);
28   return 0;
29 };

Flags für schnellere Threads

Um Threads schneller zu erzeugen und zu beenden, gibt es drei neue »clone()«-Flags: Das Flag »CLONE_SETTLS« erzeugt einen neuen Thread mit geladenem Thread-Register. Mit »CLONE_PARENT_SETTID« und »CLONE_CHILD_ CLEARTID« schreibt der Kernel die TID auf eine von der NPTL angegebene Adresse und löscht sie, nachdem der Thread sich beendet hat.

Das ermöglicht performante Verwaltung von Userspace-Speicherblöcken, die NPTL als Thread-Stacks benutzt. NPTL implementiert einen Stack-Cache, der die freigegebenen Stacks recycelt und dadurch teure Speicherallokierung vermeidet. Der neue Systemaufruf »exit_ group()« beendet schnell und korrekt einen Multithreading-Prozess.

Abbildung 2 zeigt die Ergebnisse eines Benchmarks mit Linuxthreads, Win32- und NPTL-Threads. Er spiegelt die Gesamtperformance aller Beteiligten wider: des Kernels beim Erzeugen oder Beenden der Threads, der Thread-Bibliothek, der Synchronisationsmechanismen und des Schedulers. Optionale Argumente sind die Anzahl der Threads (in Zweierpotenzen) und die Stackgröße. Jeder Thread startet zwei Nachfolger-Threads und wartet auf sie. Die Threads-Pyramide endet, wenn die angegebene Anzahl der Threads erreicht ist.

In allen Fällen geht NPTL als klarer Sieger hervor, obwohl der Stack-Cache nicht einmal aktiviert wird, denn alle Threads haben etwa die gleiche Lebensdauer. NPTL ist in allen Bereichen schneller als die Win32-Threads, besonders bei mehr als 2048 Threads, dann ist NPTL bis zum Faktor 4,5 schneller.

Mehr Threads

Wer die Tests mit vielen Threads ausführen möchte, muss die folgenden zwei Variablen auf höhere Werte setzen:

echo 100000 > /proc/sys/kernel/pid_max
echo 100000 > /proc/sys/kernel/threads-max

Die maximale Stackgröße lässt sich mit dem Bash-Befehl »ulimit -s Stackgröße in KByte« einstellen.

Der Benchmark lief unter Fedora Core 2 und Windows XP SP 2 auf einem Athlon XP 2000+ mit 256 MByte RAM. Auf beiden Plattformen kamen zur Zeitmessung hochauflösende CPU-Timer zum Einsatz. Weitere Details sind in der Datei »readme« und im Quellcode des Unterverzeichnisses »perf« zu finden.

Abbildung 2: Das Diagramm zeigt die Ergebnisse des Benchmark für drei Thread-Typen. Die logarithmische Darstellung übertreibt Unterschiede bei wenigen Threads. Die Differenz zwischen dem größten und dem kleinsten Wert sieht deshalb geringer aus, als sie ist. NPTL ist in allen Fällen schneller als Linuxthreads und Win32.

Abbildung 2: Das Diagramm zeigt die Ergebnisse des Benchmark für drei Thread-Typen. Die logarithmische Darstellung übertreibt Unterschiede bei wenigen Threads. Die Differenz zwischen dem größten und dem kleinsten Wert sieht deshalb geringer aus, als sie ist. NPTL ist in allen Fällen schneller als Linuxthreads und Win32.

Signalverarbeitung

Kernel 2.6 implementiert die Posix-konforme, prozessbezogene Signalbearbeitung. Damit beheben die Entwickler ein Problem mit dem »SIGSTOP«-Signal, wie Listing 4 demonstriert: Der Haupt-Thread kreiert einen Arbeits-Thread und sendet ihm das Signal. Mit NPTL werden die beiden Threads angehalten, was korrekte Jobverwaltung in der Shell ermöglicht sowie den MT-Prozess im Debugger anzuhalten.

Der Signal-Handler eines Prozesses kann in jedem Thread ausgeführt werden, der nicht das Signal blockiert. In Listing 5 registriert der Haupt-Thread einen Signal-Handler für »SIGUSR1« und erzeugt danach einen Arbeits-Thread, der sich gleich schlafen legt. Der Haupt-Thread blockiert das »SIGUSR1«-Signal und sendet es danach an den Prozess. Mit NPTL wird der Signal-Handler im Kontext des Arbeits-Thread ausgeführt, bei Linux- threads beendet sich das Programm, ohne das Signal zu bearbeiten.

Listing 5:
»sigblock.cpp«

01 void handler (int nSignalNo)
02 {
03   cout << "Signal SIGUSR1 received by thread: " << pthread_self() << endl;
04 }
05 
06 void* doWork(void* pArg)
07 {
08   cout << "Worker thread (" << pthread_self() << ") doing the job!" << endl;
09   sleep(5);
10   return 0;
11 }
12 
13 int main (int argc, char* const argv[])
14 {
15   struct sigaction sa;
16   memset (&sa, 0, sizeof (sa));
17   sa.sa_handler = &handler;
18   sigaction (SIGUSR1, &sa, 0);
19   pthread_t hThread;
20   pthread_create(&hThread, 0, &doWork, 0);
21 
22   sigset_t signal_mask;
23   sigemptyset (&signal_mask);
24   sigaddset (&signal_mask, SIGUSR1);
25   pthread_sigmask (SIG_BLOCK, &signal_mask, 0);
26   cout << "Main thread (" << pthread_self() << ") sending SIGUSR1 to the process!" << endl;
27   kill(getpid(), SIGUSR1);
28   pthread_join(hThread, 0);
29 
30   return 0;
31 };

Listing 6:
»cleanup.cpp«

01 class T
02 {
03 public:
04   T(const char* pstrThread): m_strThread(pstrThread) {}
05   ~T() {cout << m_strThread << " : destructor called!" << endl;}
06 private:
07   string m_strThread;
08 };
09 
10 void* doWork(void* pArg)
11 {
12   T t("First thread");
13   pthread_setcanceltype (PTHREAD_CANCEL_ASYNCHRONOUS, 0);
14   sleep(10);
15   return 0;
16 }
17 
18 void* doWork2(void* pArg)
19 {
20   T t("Second thread");
21   pthread_exit(0);
22   return 0;
23 }
24 
25 void* doWork3(void* pArg)
26 {
27   try
28   {
29     T t("Third thread");
30     pthread_setcanceltype (PTHREAD_CANCEL_ASYNCHRONOUS, 0);
31     sleep(10);
32   }
33   catch(...)
34   {
35     cout << "Exception handler called!" << endl;
36     throw;
37   }
38   return 0;
39 }
40 
41 int main(int argc, char* const argv[])
42 {
43   // create the three worker threads, cancel first and third, join them and exit
44   ...
45   return 0;
46 };

C++-Integration

NPTL bringt wichtige Verbesserungen für C++ mit. Abbrechen und Beenden eines Thread durch »pthread_cancel()« und »pthread_exit()« ähneln semantisch einer C++-Exception. Die Funktionen lösen lokale C++-Destruktoren und »catch()«-Exception-Handler aus.

Im Listing 6 erzeugt der Haupt-Thread drei Threads nacheinander. Der erste Thread legt eine Klasseninstanz vom Typ »T« an und wird danach vom Haupt-Thread asynchron abgebrochen. Der zweite Thread beendet sich selbst, der dritte verfügt über einen »catch()«-Exception-Händler und wird ebenfalls abgebrochen:

# ./cleanup
First thread : destructor called!
Second thread : destructor called!
Third thread : destructor called!
Exception handler called!

Es ist nicht mehr notwendig, im C++-Code die Cleanup-Händler mit »pthread _cleanup_push()« zu registrieren.

Listing 7a:
»process1.cpp«

01 int main (int argc, char* const argv[])
02 {
03   size_t nSize = 4*1024;
04   int fd = open("/tmp/pmutex", O_CREAT | O_TRUNC | O_RDWR);
05   ftruncate(fd, nSize);
06   void* pMem = mmap (NULL, nSize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
07   pthread_mutex_t* pMutex = (pthread_mutex_t *)pMem;
08 
09   pthread_mutexattr_t a;
10   pthread_mutexattr_init (&a);
11   pthread_mutexattr_setpshared (&a, PTHREAD_PROCESS_SHARED);
12   pthread_mutex_init (pMutex, &a);
13   pthread_mutexattr_destroy(&a);
14 
15   pthread_mutex_lock(pMutex);
16   cout << "Process 1 locked the mutex ..." << endl;
17 
18   pid_t nChildPid = fork();
19   if (0 == nChildPid)
20   {
21         execvp("./process2", 0);
22         exit(1);
23   }
24 
25   sleep (5);
26   pthread_mutex_unlock(pMutex);
27   cout << "Process 1 released the mutex!" << endl;
28   waitpid(nChildPid, 0, 0);
29   cout << "Process 1 exiting ..." << endl;
30   close(fd);
31   return 0;
32 }

Listing 7b:
»process2.cpp«

01 int main (int argc, char* const argv[])
02 {
03   int fd = open("/tmp/pmutex", O_RDWR);
04   void* pMem = mmap (NULL, 4*1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
05   pthread_mutex_t* pMutex = (pthread_mutex_t*)pMem;
06 
07   cout << "Process 2 waiting for the mutex ..." << endl;
08   pthread_mutex_lock (pMutex);
09   cout << "Process 2 got the mutex ..."  << endl;
10   pthread_mutex_unlock(pMutex);
11   cout << "Process 2 released mutex. Exiting ..." << endl;
12   close(fd);
13   return 0;
14 }

Synchronisationsprimitive

Eventuelle Zugriffskonflikte auf geteilte Ressourcen verhindern Posix-Synchronisationsprimitive: Mutex, Read-Write-Lock, Bedingungsvariable, Semaphor, Barriere und Spinlock.

Linuxthreads benutzte zur Synchronisation Signale, was zu vielen Problemen führte. Kernel 2.5.7 führte einen neuen Mechanismus ein, den Futex (Fast Userspace Mutex). Er besteht aus einem 4 Byte großen Integer-Flag an einer bestimmten Adresse und einer zugehörigen Warteschlange. Die Adresse darf sich im Shared-Memory-Bereich befinden, womit Futexe mehreren Prozessen zugänglich sind. Das schafft die Basis für die Implementierung der Interprozessvariante von allen Synchronisationsprimitiven (Option »_POSIX_THREAD_ PROCESS_SHARED«).

Ein Thread kann den freien Futex ohne Kernel-Intervention setzen (Fast-Path). Ist der Futex schon gesetzt, legt sich der Thread schlafen. Er wird vom Kernel erst wieder durch eine »FUTEX_WAKE«- oder »FUTEX_CMP_REQUEUE«-Aktion, einen Interrupt oder einen abgelaufenen Timeout geweckt.

Mehrere Threads können gleichzeitig auf einen Futex warten – dank der Futex-Warteschlange. Mit diesem flexiblen Basismechanismus lassen sich alle anderen Synchronisationsprimitive effizient implementieren. Anwendungsprogrammierer dürfen Futexe direkt in ihrem Code benutzen.

Die Listings 7a und 7b zeigen einen Interprozess-Mutex im Einsatz. Der erste Prozess (7a) legt den Mutex in einer, in seinem Adressraum eingeblendeten Datei an, setzt ihn und startet dann den Prozess im Listing 7b. Der zweite Prozess blendet dieselbe Datei in seinen Adressraum ein, nimmt die Mutex-Referenz und versucht den Mutex zu setzen. Er muss aber warten, bis der erste Prozess den Mutex freigibt.

Thread-lokaler Speicher (TLS) enthält Daten, von denen es je eine Instanz per Thread gibt. Die meisten Plattformen stellen dafür so genannten Thread-Register zur Verfügung. Auf IA-32 und x86-64 war die TLS-Implementierung früher etwas problematisch. Sie benutzte die LDT-Strukturen (Local Descriptor Table), was die maximale Anzahl der Threads per Prozess auf 8192 beschränkte.

Änderungen in
»/proc«

Das virtuelle Proc-Dateisystem hat in Kernel 2.6 einige Änderungen erfahren. Jeder Prozess besitzt außer dem Verzeichnis »/proc/pid« nun ein Unterverzeichnis »task« für die Thread-Einträge. Die Datei »/proc/pid/stat« zeigt im 20. Feld die Anzahl der Threads, die auch im neuen Threads-Feld von »/proc/pid/status« steht.

Thread-Einträge unterscheiden sich nicht von Single-Prozess-Einträgen. In der Ausgabe von »/proc/pid/task/tid/stat« steht jedoch die TID am erster Stelle, während die Vater-Task-TID als viertes Feld erscheint. Die Datei »/proc/pid/task/tid/status« zeigt in den »Tgid«-, »Pid«- und »PPid«-Feldern die Werte der PID, TID und PTID.

Die maximale Anzahl der PIDs ist auf vier Millionen gesetzt, »/proc« kann bis zu 64 K Einträge haben. Der Autor empfiehlt sein QT-basiertes Prozess-Monitoring-Tool[2], um Prozess- und Thread-Infos zu analysieren.

Thread-Speicher

Ingo Molnar implementierte im Kernel 2.5.28 einen neuen Mechanismus für Thread-lokalen Speicher. Der Kernel besitzt eine GDT-Struktur (Global Descriptor Table), in der der neue Systemaufruf »set_thread_area()« für jeden Thread Einträge anlegt. NPTL benutzt das Flag »CLONE_SETTLS«, um den GDT-Eintrag gleich beim Kreieren des Thread einrichten zu lassen.

Die Posix-Funktionen »pthread_getspecific()« und »pthread_setspecific()« reservieren dynamisch Thread-lokale Variablen über Key-Value-Paare. Für eine einfache Variable ist dieser Verwaltungs-Overhead zu groß. Auch die Benutzung der Funktionen in dynamisch geladenen Modulen ist problematisch. Die GCC-Familie bietet für beide Fälle die Extension »__thread«, die eine Variable als Thread-lokal deklariert. Ein Beispiel ist die C-Runtime-Variable »errno«.

Nichts ist perfekt

Die Designziele der NPTL sind erreicht, trotzdem gibt es noch Anlass zur Kritik. NPTL implementiert den Thread-Handle als einen Zeiger auf den zugehörigen Thread-Deskriptor. Der Handle hat daher große Werte, was die Übersicht erschwert. Außerdem verwendet der Stack-Cache den Handle wieder. Das verwirrt, wenn man zwei nacheinander kreierte Threads mit dem gleichen Handle debuggt. Zweites Problem sind die Ressourcen des MT-Prozesses. Standardmäßig sollen sie von allen Threads geteilt werden. Beim aktuellen Kernel können aber zwei Threads verschiedene UIDs oder verschiedene Ressourcen-Limits haben. Betroffen sind die Systemaufrufe »getuid()«, »setuid()«, »getgid()«, »setsid()«, »nice()«, »getrlimit()« und »setrlimit()«. (ofr)

Infos

[1] Ulrich Dreppers Homepage: [http://people.redhat.com/drepper]

[2] Prozess-Monitoring-Tool Pscan: [http://forge.novell.com/modules/xfmod/project/?pscan]

[3] Vollständige Listings zum Artikel: [https://www.linux-magazin.de/Service/Listings/2005/01/NPTL/].

Der Autor

Aleksandar Colovic arbeitet als Senior-Software-Entwickler bei der Evosoft GmbH. Seine Interessen sind Betriebssysteme, Komponenten-Frameworks und Middleware-Technologien.

LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben