Kontextwechsel

von Hubert Schmid vom 2014-07-06

Auf die Frage Wie teuer sind Kontextwechsel? gibt es eine einfache Antwort: Das hängt davon ab! Das hängt sogar von vielen Dingen ab, wie beispielsweise der Hardware-Architektur, dem Betriebssystem, dem Auslöser sowie der Art des Wechsels – zwischen Threads oder Prozessen.

Tatsächlich geht es mir jedoch um eine sehr konkrete Fragestellung, für die ich allerdings ein wenig ausholen muss: Ein wesentlicher Unterschied zwischen asynchroner und multi-threaded Programmierung sind die Kontextwechsel. In multi-threaded Programmen finden diese bei blockierenden Ein-/Ausgabe-Operationen statt, wohingegen in asynchronen Programmen lediglich zwischen Benutzer- und Kernel-Modus gewechselt wird. Dabei geht es mir eigentlich nur um den Overhead, der durch den Kontextwechsel entsteht.

Wie wird getestet?

Mit einem Testprogramm werden sowohl die Kosten für den Kontextwechsel als auch für den Wechsel zwischen Benutzer- und Kernel-Modus (Moduswechsel) bestimmt. Der Kontextwechsel wird dabei durch eine pipe gesteuert. Das Lesen eines Zeichens führt zu einer Unterbrechung wohingegen das Schreiben eines Zeichens die Wiederaufnahme auf der Gegenseite veranlasst. Es gäbe zahlreiche, weitere Methoden einen Kontextwechsel zu erzwingen. Der Test mit der pipe besitzt allerdings zwei interessante Eigenschaften, die für den Vergleich hilfreich sind: Erstens erfolgen Kontextwechsel in multi-threaded Netzwerk-Programmen auf ähnliche Art und Weise, und zweitens lassen sich die Kosten für die Systemaufrufe einfach von den Kontextwechseln abgrenzen.

Das folgende Listing enthält die Definition der Klasse pipe. Die Implementierung besteht im Wesentlichen aus Aufrufen der POSIX-Funktionen ::pipe, ::read, ::write und ::close mit zugehöriger Fehlerbehandlung.

class pipe final { int _fds[2]; public: explicit pipe(); explicit pipe(char initial); ~pipe() noexcept; char read(); void write(char c); // delete copy & move operations pipe(const pipe&) = delete; auto operator=(const pipe&) & -> pipe& = delete; };

Der zweite Teil besteht aus einer Hilfsfunktion, die ein Funktionsobjekt erzeugt, das n‑Mal ein Zeichen aus einer pipe liest und in eine andere schreibt. Solche Funktionsobjekte werden unten für die eigentlich zu verrichtende Arbeit verwendet.

auto make_transfer(std::size_t n, pipe& in, pipe& out) { return [&,n] { for (auto i = n; i > 0; --i) { out.write(in.read()); } }; }

Der dritte und letzte Teil der Vorarbeiten besteht aus zwei einfachen Hilfsfunktionen, um mehrere Aktionen parallel auszuführen. Dem Funktionstemplate execute_all können mehrere Argumente übergeben werden, die mittels std::aync asynchron ausgeführt werden, wobei die Funktion implizit auf die vollständige Abarbeitung aller Aktionen wartet.

template <typename ...Args> void pass(Args&&...) { } template <typename ...Tasks> void execute_all(Tasks&&... tasks) { pass(std::async(std::launch::async, std::forward<Tasks>(tasks))...); }

Kontextwechsel

Nach diesen Vorarbeiten ist das Programm zur Messung der Kontextwechsel fast schon trivial. Es werden einfach zwei pipes erzeugt, wobei eine mit einem Zeichen initial befüllt wird. Anschließend werden zwei parallel laufende Aktionen gestartet, die Zeichen zwischen den beiden pipes in entgegengesetzter Richtung übertragen. Insgesamt wird dabei 100 Millionen Mal ein Kontextwechsel zwischen den beiden Threads ausgeführt.

int main() { std::size_t n = 100'000'000; pipe p0{'X'}, p1; execute_all( make_transfer(n / 2, p0, p1), make_transfer(n / 2, p1, p0)); }

Die Messung der Laufzeit erfolgt nicht aus dem Programm heraus, sondern von der Kommandozeile. Dafür eignet sich das zum Linux-Kernel gehörende Programm perf. Neben der tatsächlich verbrauchten Zeit gibt es auch die Anzahl der Kontextwechsel aus, was die Korrektheit des Tests bestätigt. Wichtig ist noch, die Ausführung mit taskset auf einen CPU-Kern zu beschränken, da wir speziell an dem Overhead interessiert sind, der entsteht, wenn ein CPU-Kern einen Thread durch einen anderen ersetzt – im Gegensatz zum Wechsel eines Threads zwischen zwei Kernen.

$ taskset -c 0 perf stat /tmp/context_switch 100,000,145 context-switches # 0.949 M/sec 105.274145623 seconds time elapsed

Die Ausgabe zeigt im Wesentlichen, dass auf meinem – etwas betagtem – Rechner annähernd eine Million Kontextwechsel pro Sekunde und Kern möglich sind.

Moduswechsel

Im obigen Programm werden pro Kontextwechsel zwei Systemaufrufe verwendet, auf die ein Teil der Gesamtlaufzeit zurückzuführen ist. Durch eine kleine Änderung wird aus dem multi-threaded ein single-threaded Programm, das die gleiche Anzahl Systemaufrufe ausführt. Dadurch lässt sich der Kostenanteil für die Kontextwechsel relativ gut abgrenzen.

int main() { std::size_t n = 100'000'000; pipe p{'X'}; execute_all( make_transfer(n, p, p)); }

Das Programm wird wiederum mit perf und taskset ausgeführt. Bei meiner Messung ist diese Variante ungefähr doppelt so schnell, wobei die Ausgabe von perf bestätigt, dass dieses Mal praktisch keine Kontextwechsel durchgeführt wurden. Anders formuliert: Ungefähr die halbe Laufzeit des ersten Programms ist auf die Kontextwechsel zurückzuführen.

$ taskset -c 0 perf stat /tmp/mode_switch 96 context-switches # 0.002 K/sec 50.417598870 seconds time elapsed

Ergebnis

Was bedeutet das nun? Absolut gesehen zeigt der Test, dass ein CPU-Kern bis zu einer Million Kontextwechsel pro Sekunde ausführen kann. Das ist für sich genommen ein guter Wert – wenn auch schwierig zu fassen.

Interessanter ist der Vergleich mit asynchroner Programmierung. Der Test zeigt, dass bei diesem E/A-lastigem Testprogramm ungefähr die Hälfte der Kosten durch die Systemaufrufe entstehen. Nimmt man nun an, dass bei asynchroner Programmierung die selben Systemaufrufe notwendig sind, so kann ein entsprechendes Programm bestenfalls doppelt so schnell wie ein multi-threaded Programm sein. In den meisten Fällen ist der Unterschied jedoch deutlich geringer. Denn erstens benötigt ein asynchrones Programm in der Regel deutlich mehr Systemaufrufe (beispielsweise epoll_ctl), und zweitens muss auch im Benutzer-Modus ein Kontext nach jedem E/A-Ereignis bestimmt werden, um den passenden Callback zu identifizieren.

Kontextwechsel sind also im Vergleich betrachtet gar nicht so teuer, und die Performance-Vorteile asynchroner Programme sind in vielen Szenarien doch eher marginal.