C11: Neue Version des Sprachstandards, Teil 2

Der neue ISO/IEC-Standard für C11 wurde im Dezember 2011 offiziell ratifiziert. Im zweiten Artikel zu den Neuerungen dreht sich alles um das Zusammenspiel zwischen Programmiersprache und Hardware sowie um Bonbons wie Unicode-Unterstützung und die optionale Bound-Checking-Erweiterung.

In Pocket speichern vorlesen Druckansicht 9 Kommentare lesen
Lesezeit: 21 Min.
Von
  • Hagen Paul Pfeifer
Inhaltsverzeichnis

Die Anforderungen an Programmiersprachen ändern sich mit der Zeit. Auch das Standardisierungskomitee für C versucht, dem nachzukommen. Der neue ISO/IEC-Standard für C11 wurde im Dezember 2011 offiziell ratifiziert. Im zweiten Artikel dreht sich alles um das Zusammenspiel zwischen Programmiersprache und Hardware sowie um Bonbons wie Unicode-Unterstützung und die optionale Bound-Checking-Erweiterung.

Compilern steht es frei, Optimierungen am Programm durchzuführen, solange sie dadurch den Programmablauf nicht verändern. Beispielsweise ist es unerheblich, in welcher Reihenfolge Zuweisungen ausgeführt werden, wenn sie nicht voneinander abhängig sind. Beispielsweise lässt sich

i++;
y++;
value = 0;

beliebig umsortieren.

i++;
y++;
value = i * y;

Es ist unerheblich, ob i oder y zuerst inkrementiert wird. Nur die Multiplikation ist zwingend zuletzt durchzuführen.

Falls die Speicherinhalte jedoch in mehreren Threads sichtbar sind, kann die Reihenfolge der Lade- und Speicheroperationen jedoch relevant sein. Als Beispiel könnte das Hinzufügen eines Elements das Ende einer verketteten Liste dienen. Zunächst scheint das eine einfach zu lösende Aufgabe zu sein:

element->next = NULL;
tail->next = element;
tail = element;

Für den Compiler ist das einfach: Die Adresse des elements wird nicht verändert, die von tail erst im letzten Schritt geändert. Somit muss nur die zweite Zuweisung vor der dritten erfolgen, die erste kann dagegen auch zuletzt oder "dazwischen" erfolgen. Wenn sich die Adresse von tail->next "zwischenspeichern" lässt, ist die Reihenfolge beliebig. Falls andere Threads lesend auf diese Liste zugreifen können, ist das aber nicht der Fall. Wenn beispielsweise

tail->next = element;

zuerst ausgeführt würde, kann ein anderer Thread das neue Element bereits am Ende der Liste vorfinden, bevor dessen next-Zeiger einen definierten Zustand – NULL – besitzt. Das ist wahrscheinlich nicht das, was der Programmierer im Sinn hatte. Doch nicht nur der Compiler kann solche "Umsortierungen" durchführen, auch für die CPU sind die Vorgänge unabhängig, da die angegebenen Schreibzugriffe (stores) an verschiedenen Adressen erfolgen und der zu schreibende Wert (die Adresse des Elements) identisch ist. Wie aggressiv die CPU umsortiert, unterscheidet sich je nach Zielarchitektur. Alpha-CPUs zum Beispiel haben ein "schwaches" Speichermodell. Vorteil ist dann eine vereinfachte Cache-Architektur, die sich letztlich in einer höheren Taktfrequenz niederschlägt.

Operationen auf Mutexe und viele der neuen Atomic-Operationen sind sogenannte Synchronisierungsoperationen. Solche sind notwendig, um Veränderungen von Speicherinhalten zuverlässig in anderen Threads zu visualisieren. Beispielsweise ist garantiert, dass atomic_load stets das Ergebnis der letzten Modifikation liefert.

Einige der Funktionen existieren auch in einer explicit-Version (zum Beispiel atomic_load_explicit() statt atomic_load()), was zusätzliche Optimierungen durch den Entwickler ermöglicht: Hier lässt sich die Synchronisationsregel über ein zusätzliches Funktionsargument direkt angeben. Es handelt sich um den von stdatomic.h bereitgestellten Aufzählungstyp memory_order, der unterschiedliche Speicher-Synchronisationsmethoden enthält.

  • memory_order_relaxed: keine besonderen Synchronisationsregeln bei Speicherzugriffen. Damit kann man sogar eine atomare Operation ohne Synchronisation durchführen.
  • memory_order_seq_cst ("Sequential Consistency"): die "stärkste" Synchronisierung (Vorher-nachher). Alle ausstehenden Speicher und Ladevorgänge werden abgeschlossen und nicht über den Aufruf hinweg umsortiert. atomic-Funktionen ohne die "explicit"-Variante verwenden diese Synchronisationsmethode.

Weitere Aufzählungstypen ermöglichen es, die Synchronisation zum Beispiel auf Stores zu beschränken. Beispielsweise lässt sich der Prozessor anweisen, alle ausstehenden Schreiboperationen abzuschließen, bevor der nächste Maschinenbefehl abgearbeitet wird, während das Umsortieren unabhängiger Ladeanweisungen weiter erlaubt ist.

Bei Verwendung von memory_order_relaxed kann das Laden eines atomic-Werts infolge möglicher Umsortierung der Lade- und Store-Anweisung ein älteres Ergebnis liefern als die letzte Schreibmodifikation. Selbstverständlich wird dabei die eigentliche Eigenschaft – die Durchführung einer Operation in einem einzigen Schritt – nicht aufgeweicht.

Führen zwei Threads gleichzeitig

atomic_fetch_add_explicit(&x, 1, memory_order_relaxed);

(x sei auf 1 initialisiert) durch, darf

atomic_load_explicit(&x, memory_order_relaxed);

infolge der Sortierung der Lade- und Schreibvorgänge 3, 2, oder sogar 1 zurückliefern. Es ist aber ausgeschlossen, dass eine der atomaren Additionen verloren geht.

Synchronisationsoperationen ohne eine Speicheradresse werden in C11 als "Fences" bezeichnet. Das Konzept ist auch unter dem Namen "Barriers" bekannt. Dabei handelt es sich um Funktionen, mit denen sich eine bestimmte Ausführungsreihenfolge erzwingen lässt.

Ein im Listen-Initialisierungsbeispiel direkt nach der Element-Initialisierung eingefügter Schreib-Fence unterbindet beispielsweise die "Umsortierung" durch Compiler und CPU:

element->next = NULL;
atomic_thread_fence(memory_order_seq_cst);
tail->next = element;

Das garantiert, dass kein anderer Thread element in der Liste vorfindet, bevor dessen next-Zeiger auf NULL gesetzt wurde.

Wenn auch andere Threads ebenfalls Elemente an die Liste anfügen können, entsteht ein zusätzliches Synchronisationsproblem, da nun auszuschließen ist, dass zwei Threads gleichzeitig ein Element an das vermeintlich letzte Glied der Kette anfügen. Das Problem lässt sich mit einer expliziten Sperre – einem Mutex – lösen. Er verhindert, dass nur jeweils ein Thread die Anfügeprozedur durchführen kann. Hier liefert C11 in gewohnter Manier das komplette Repertoire an Funktionen. Den Programmierern werden Sprachkonstrukte an die Hand gegeben, um auf einer tiefen Ebene in das Laufverhalten einzugreifen.

Sprachen, die auf einem höheren Abstraktionsniveau arbeiten, wie Java oder Python, kapseln diese Vorgänge und verbergen sie vor dem Entwickler. Der Programmierer ist nur begrenzt in der Lage, feingranular auf den Ablauf Einfluss zu nehmen. Aber genau diese Eigenschaft ist fundamental für C.