C11: Neue Version der Programmiersprache, Teil 1

Seite 3: Selections, Threads, Atomic

Inhaltsverzeichnis

Mitunter ist es nützlich, eine Fallunterscheidung aufgrund des Typs eines Datums zu treffen. C++ bietet zu diesem Zweck an, Funktionen zu überladen, wodurch die Sprache abhängig vom Typ des Arguments die Funktion foo(int a) oder foo(float a) aufruft. C11 stellt mit sogenannten Generic Selections einen ähnlichen, wenn auch nicht vollständig vergleichbaren Mechanismus zur Verfügung. Generics liefern eine abgeschwächte Funktion, da sie keinen transparenten Funktions-Dispatch-Mechanismus implementieren. Die Gemeinsamkeit beruht darauf, dass sich Typinformationen benutzen lassen, um den Programmablauf zu verändern. Generic Selections leitet das Schlüsselwort _Generic ein, die, wie das Beispiel im Standard unter 6.5.1.1 zeigt, folgendermaßen verwendet werden:

#define cbrt(X) _Generic((X), \
long double: cbrtl, \
default: cbrt, \
float: cbrtf \
)(X)

Der Ausdruck evaluiert unter C11 zu cbrtl(x), wenn das Argument x vom Typ long double ist (zum Beispiel 23.0), oder zu cbrtf(x), wenn es vom Typ float (23.0f) ist. In allen anderen Fällen evaluiert der Ausdruck zu cbrt(). Das trifft auch darauf zu, wenn das Argument ein String-Literal sein sollte ("23.0"). Man könnte nun glauben, dass das Default Label immer mit assert(0) oder vergleichbarer Fehlerfunktion belegt werden sollte. Das ist aber nicht notwendig. Vielmehr sollte der Default-Fall den häufigsten Typen abdecken. Sollte die Funktion nun mit einem String-Literal aufgerufen werden, liefert der Compiler je nach Konfiguration eine Warnung oder Fehler und deutet auf den nicht kompatiblen Datentyp hin. Die Evaluierung findet ja zur Kompilier- und nicht zur Laufzeit statt.

Da C keine Klassen kennt und der unterstützte Typensatz auf skalare Typen und Zeiger begrenzt ist, sind die Möglichkeiten der Fallunterscheidung begrenzt. Der offensichtlichste Einsatzort von Generic Selections sind mathematische Funktionen, bei denen neben double und float auch mal ein komplexer Datentyp vorkommen kann. Um gleich die Standardverwendung abzudecken, definiert C11 die Header-Datei tgmath.h. Sie enthält hauptsächlich trigonometrische Funktionen, um etwa den Arkustangens zu berechnen. Folgendes Beispiel verdeutlicht ihre Anwendung:

#include <tgmath.h>

double complex ac = 5.0 + 3.0 * I;
long double ad = 2.0;

atan(ad); /* ruft atanl(ad) auf */
atan(ac); /* ruft catan(ac) auf */

Die Funktion gets(char *s) schreibt eine Zeile von der Standardeingabe in den Puffer s. Dabei liest gets(), bis ein Newline-Zeichen ('\n') oder das End of File (EOF) entdeckt wird. Es ist nicht möglich, die maximale Puffergröße zu spezifizieren – gets() liefert schlichtweg kein Argument dafür.

Die Funktion hat damit ein eklatantes Sicherheitsproblem, das für viele Pufferüberläufe verantwortlich war. Die Funktion wurde in C99 als "deprecated" deklariert, C11 entfernt sie nun vollständig. Alternativ lässt sich fgets oder gar gets_s() verwenden – dazu später mehr.

In einem – optionalen – Teil der Spezifikation definiert der C-Standard erstmals die Unterstützung von Threads. Bisher mussten hierfür Entwickler auf betriebssystemspezifische Erweiterungen, zum Beispiel die unter Unix-Derivaten verbreiteten POSIX Threads, zurückgreifen.

Glücklicherweise lehnen sich die Thread-Schnittstellen von C an die POSIX Threads an. Die Ähnlichkeit ist nicht zufällig. POSIX Threads werden unter Systemprogrammierern häufig eingesetzt. Sie sind gut dokumentiert, und die Programmiermodelle sind durch die gängige Literatur ausreichend beschrieben. Eine neue Semantik einzuführen würde den Anwender mehr verwirren und dadurch letztlich die Verbreitung erschweren.

Threads erzeugt der Programmierer mit der Funktion thrd_create. Wie bei der POSIX-Schnittstelle pthread_create wird ein Zeiger auf die vom neuen Thread auszuführende Funktion als Argument übergeben. Threads werden nach dem Ende der angegebenen Funktion beendet. Alternativ lässt sich die Funktion thrd_exit verwenden. Mit thrd_join wartet man auf das Ende eines bestimmten Threads. Soll die Laufzeitumgebung die durch einen Thread belegten Systemressourcen ohne die Notwendigkeit einer Join-Operation freigeben, lässt sich die Funktion thrd_detach verwenden. Auch diese Aufrufe sind nahezu identisch mit denen der pthread-Welt.

Im Gegensatz zu POSIX Threads kann man aber mit C-Sprachmitteln keine Scheduling-Prioritäten verändern. Wenn das notwendig ist, muss man auf plattformspezifische Konstrukte ausweichen. Sobald man aber bis in dieses Level vorgedrungen ist, ist ein wenig plattformspezifischer Code das kleinere "Übel". Prozessprioritäten sind eben nun mal ein plattformspezifisches Konzept, das auf jeder Plattform anders umzusetzen ist.

Neu ist das Schlüsselwort _Thread_local, das Compiler und Laufzeitumgebung anweist, die Variable in jedem Thread getrennt vorzuhalten – jeder Thread erhält eine eigene Instanz. Das wird auch als Thread-local Storage (TLS) bezeichnet. Die C- und C++-Frontends der GNU Compiler Collection (GCC) stellen eine vergleichbare Funktion schon über die Erweiterung __thread bereit.

Eine weitere, über POSIX Threads hinausgehende Neuerung sind die Unterstützung atomarer Prozessoroperationen sowie das explizite Erzwingen von Lade- und Speichervorgängen durch die CPU.

Wenn mehrere Threads die von der Variablen i repräsentierte Speicheradresse simultan nutzen können, sind Operationen der Form i++ problematisch, da die Operation in mindestens drei Schritten erfolgt. Zuerst ist der aktuelle Wert von i aus dem Speicher auszulesen. Danach lässt sich die Berechnung durchführen, und abschließend wird das Ergebnis in den Speicher zurückgeschrieben.

Führen zwei Threads die Operation gleichzeitig aus, geht eine Berechnung verloren – beide Threads laden denselben Wert aus dem Speicher und schreiben ihn zurück. Die Operation ist also zu serialisieren: Der zweite Thread darf mit der Operation erst beginnen, wenn der erste sie beendet hat. Dazu kann der Entwickler einen Mutex verwenden:

mtx_lock(&mutex);
i++;
mtx_unlock(&mutex);

Für kurze Operationen, zum Beispiel einfache Referenzzähler, sind Mutexe jedoch nicht performant, da die Implementierung der Synchronisationsprimitiven um ein Vielfaches komplexer ist als die eigentliche Operation.

Viele Prozessorarchitekturen bieten Maschinenbefehle, die einfache arithmetische Operationen als einen Schritt durchführen. Solche atomaren Operationen sind teurer als "gewöhnliche" Berechnungen, da beispielsweise der Speicherbus kurzzeitig für den Zugriff durch andere Prozessoren zu sperren ist.

C11 abstrahiert die Prozessorbefehle in der – wiederum optionalen – Header-Datei stdtomic.h. Sie stellt neue Typen, Makros und Funktionen für atomare Operationen bereit. Dazu gehören atomic-Varianten der bekannten primitiven Datentypen wie atomic_int oder atomic_address sowie Operationen auf solchen Typen. Neben den elementaren Grundfunktionen (Addition, Subtraktion, logische Operationen wie die Exclusive-Or-Verknüpfung) werden etwa weitergehende Operationen wie "Vergleiche und Vertausche"- (atomic_compare_exchange_-)Funktionen unterstützt.

So kompakt ein Indekrement in C ausschaut. Auf Maschinenebene ist diese Operation nicht atomar. Zwei gleichzeitig operierende Threads können zu nicht beabsichtigten Ergebnissen führen.

Atomare Tauschoperationen lassen sich beispielsweise nutzen, um exklusive Zugriffe zu regeln, ohne dabei den Programmablauf zu blockieren:

atomic_flag inuse = ATOMIC_FLAG_INIT;
[ .. ]
if (atomic_flag_test_and_set(&inuse) == 0) {
/* exklusiver Zugriff */
[ .. ]
atomic_flag_clear(&inuse);
}

atomic_flag_test_and_set vertauscht dabei atomar den Wert von inuse mit 1. Da der Wert von inuse direkt vor der Modifikation zurückgegeben wird, erhält bei gleichzeitigem Aufruf höchstens ein Thread den Rückgabewert 0.

Die ähnliche Funktion atomic_exchange() lässt sich verwenden, um einen Wert auszulesen und gleichzeitig zurückzusetzen:

old_count = atomic_exchange(counter, 0);

Die Version

old_count = atomic_read(counter);
atomic_init(counter, 0);

wäre dagegen inkorrekt, da sich counter vor der erneuten Wertinitialisierung von einem anderen Thread (oder einem Signalhandler) erneut erhöhen ließe.

Atomare Operationen werden im Linux Kernel oft eingesetzt. Sie sind deutlich effizienter als andere Locking-Mechanismen und sorgen damit für ein performantes Laufzeitverhalten. Gerade vor dem Hintergrund, dass die Entwicklung zunehmend mehr Kerne in einem System vorsieht, ist das ein wichtiger Aspekt.

Der erste Teil dieser zweiteiligen Serie über C11 stellte Spracherweiterungen vor, die lange auf der Wunschliste vieler Programmierer standen. Transparente Unions oder ein exklusiver Dateizugriff sind einfach nur praktisch – ohne Inkompatibilitäten zu erzeugen. Ob Entwickler die optionale Thread-Unterstützung annehmen, steht auf einem anderen Blatt. Wenn Plattform- oder Compilerhersteller die neue API nicht unterstützen, ergibt sich kein Vorteil für den Systemprogrammierer. Im zweiten Teil geht es um das Zusammenspiel zwischen Programmiersprache und Hardware. Außerdem geht er auf Unicode, die optionale Bound-Checking-Erweiterung und ein paar weitere Bonbons ein.

Hagen Paul Pfeifer
berät als Selbstständiger Firmen bei Konzeption und Umsetzung rund um die Themen Netzwerke und Netzwerkprotokolle. Er ist aktives Mitglied der IETF- und Linux-Kernelprogrammierer.

  • Richard Stevens; Unix Network Programming; Prentice Hall, 1990