C10K – Ein Schein-Problem

von Hubert Schmid vom 2013-07-21

Das Numeronym C10K steht für Concurrent Ten Thousand Connections. Dahinter verbirgt sich die Frage, wie sich Anwendungen realisieren lassen, um eine große Menge gleichzeitiger Nutzer zu bedienen. Der Wert 10 000 hat dabei keine konkrete Bedeutung. Er vermittelt lediglich ein Gefühl für die Größenordnung: Die Anwendung soll pro eingesetztem Rechner 10 000 gleichzeitige Client-Verbindungen verarbeiten.

Es handelt sich bei dieser Fragestellung allerdings um kein echtes Problem. Denn wie sich einfach testen lässt, kann heutzutage jeder Standardrechner ohne Weiteres die geforderte Anzahl paralleler Verbindungen verarbeiten. Das folgende Listing zeigt dazu ein Java-Programm, das einen entsprechenden Echo-Dienst realisiert, und in weniger als einer Sekunde gestartet und nutzbar ist (auf meinem privaten Notebook). Auf die wichtigsten Details gehe ich weiter unten ein.

class C10K { public static void main(String... args) throws Exception { Runnable echoService = new EchoService(8007); List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 10000; ++i) { threads.add(makeAndStartThread("echo-" + i, echoService)); } for (Thread thread : threads) { thread.join(); } } }

Schein-Probleme schaffen ein Trugbild: Scheinbar zu lösende Probleme, die es tatsächlich gar nicht gibt, und aufgrund des falschen Fokus werden die eigentlich zu realisieren Anforderungen vernachlässigt. Aus meiner Erfahrung ist das eine der häufigsten Ursachen für gescheiterte Softwareprojekte.

C10K geht sogar noch darüber noch hinaus – doch dazu unten mehr. Zunächst möchte ich auf ein zu berücksichtigendes Detail eingehen, damit der obige Code tatsächlich auf üblichen Notebooks läuft.

Unter Linux wird in der Voreinstellung für jeden erzeugten Thread ein Stack mit einer festen Größe von 8 MiB reserviert. Bei 10 000 Threads macht das in Summe annähernd 80 GiB. Unabhängig davon ob der Stack tatsächlich verwendet wird, beschränkt das Betriebssystem die reservierbare Menge abhängig von der Größe des physischen Speichers. Daher bricht das Programm auf Rechnern mit lediglich 8 GB Hauptspeicher bereits nach ungefähr 1 000 Threads ab.

Stacks mit 8 MiB werden allerdings nur ausgelastet, wenn Programme entweder sehr viele Daten auf dem Stack ablegen oder eine sehr tiefe Aufrufhierarchie besitzen. Letzteres ist praktisch nur durch ungewöhnliche Rekursion zu erreichen. Denn selbst in Java reichen 8 MiB Stack für bis zu 200 000 rekursive Methodenaufrufe.

Für fast alle Java-Anwendungen ist eine Stackgröße von 256 KiB mehr als ausreichend. Es bietet sich daher an, diesen Wert zu verwenden, wenn man mit sehr vielen gleichzeitig laufenden Threads arbeiten möchte. In Java wird die gewünschte Stackgröße dem Thread-Konstruktor als weiteres Argument übergeben. Das folgende Listing zeigt dazu die Methode makeAndStartThread, die aus obigem Programm für die Erzeugung der Threads verwendet wird.

private static Thread makeAndStartThread(String name, Runnable runnable) { long stackSize = 256 * 1024; Thread thread = new Thread(null, runnable, name, stackSize); thread.start(); return thread; }

Zurück zu C10K: Wie gesagt geht es dabei um die Größenordnung und nicht um die konkrete Anzahl von 10 000. Es stellt sich also die Frage, wie es denn um 20 000, 50 000 oder gar 100 000 gleichzeitig zu verarbeitende Verbindungen steht. Die Antwort ist sehr einfach: Das Betriebssystem skaliert mit der Anzahl gleichzeitiger Threads. Überzeugen kann man sich davon einfach mit obigem Programm. Selbst 200 000 und 500 000 gleichzeitige Verbindungen mit zugehörigen Threads sind für einen einzelnen Rechner kein Problem.

Trotzdem – und obwohl es so einfach ist – wird es immer wieder als Problem dargestellt. Denn mit solchen Fragestellungen lassen sich viele Entwickler vergleichsweise einfach begeistern, so dass es Technologie-Evangelisten und Blendern hilft, ihre Produkte und Anschauungen zu verkaufen. Typische Beispiele dafür sind Node.js, Boost.Asio und die Schlüsselwörter async und await in C#. In der Konsequenz führt das häufig dazu, dass vollkommen falsche und hinderliche Technologieentscheidungen getroffen werden, worunter die Realisierung der tatsächlichen Anforderungen leidet.

Der Vollständigkeit wegen gehe ich noch auf die Klasse EchoService ein, die im Hauptprogramm referenziert wurde. Die Implementierung ist im folgenden Listing zu sehen. Bemerkenswert daran ist lediglich, dass alles ganz gewöhnlich ist: Der Programmablauf ist strukturiert, Fehler- und Ressourcen-Management sind ersichtlich und es werden lediglich Core-APIs verwendet.

class EchoService implements Runnable { private final ServerSocket serverSocket; public EchoService(int port) throws IOException { this.serverSocket = new ServerSocket(port); } public void run() { for (;;) { try (Socket socket = serverSocket.accept()) { doEcho(socket.getInputStream(), socket.getOutputStream()); } catch (IOException e) { // log exception and continue } } } private void doEcho(InputStream is, OutputStream os) throws IOException { byte[] buffer = new byte[4 * 1024]; int count; while ((count = is.read(buffer)) > 0) { os.write(buffer, 0, count); } } }

C10K ist also kein Problem: Die einfachsten Modelle für die Verarbeitung von Verbindungen reichen aus, um pro Rechner mehrere hunderttausend Clients simultan zu bedienen. Lediglich die Größe der Stacks muss dafür unter Linux für Java angepasst werden. Das steht in keinem Vergleich zur weit verbreiteten Darstellung von C10K – und zu dem Aufwand der notwendig ist, um die gleiche Funktionalität mit anderen I/O-Modellen zu realisieren. Also: Lass dich nicht von Schein-Problemen beeindrucken!