SSL/TLS-Netzwerkprogrammierung mit Boost.Asio, Teil 1: Grundlagen

Seite 2: synchron / asynchron

Inhaltsverzeichnis

Die Ein-/Ausgabeverarbeitung kann synchron oder asynchron erfolgen. Synchrones Verarbeiten ist dasselbe wie blockierende Verarbeitung (blocking I/O). Auf ein Netzwerkprogramm übertragen heißt das, dass nach dem Schreiben von Daten ins Socket der weitere Programmfluss angehalten wird. Hierzu unterbricht das Programm sämtliche Verarbeitung und wartet, bis die erwarteten Daten der Antwort am Socket ankommen. Anschließend liest das Programm sie ein und verarbeitet sie. Danach schreibt es die Antwort ins Socket und setzt beim erneuten Warten wieder den Programmablauf aus.

Hingegen blockiert das asynchrone Verarbeiten nicht (non-blocking I/O). Statt auf Daten zum Verarbeiten zu warten, prüft das Programm regelmäßig, ob Daten zum Lesen vorhanden sind. Ist das nicht der Fall, nutzt es die Zeit bis zur nächsten Prüfung für andere Arbeiten. Ein typisches Beispiel für Nebenläufigkeit entsteht.

In der Regel übernimmt das Betriebssystem das Prüfen auf vorhandene Daten. Das geschieht auf POSIX- oder UNIX-konformen Systemen über den System-Call select(), auf POSIX.1-2001-konformen mit poll(), auf Linux über das ereignisorientierte epoll(), auf FreeBSD durch das ebenfalls ereignisorientierte kqueue-Verfahren oder auf Windows über die I/O Completion Ports. Um diese Vielfalt abzudecken, müssen portable Programmierer viel Eigenarbeit leisten. Der Wunsch nach einer plattformunabhängigen Library ist durchaus berechtigt.

Den select()-Ansatz nutzen die WOPR-Beispiele aus der vorangegangenen OpenSSL-Reihe. Die Programme waren damit zwar schlank, aber gleichzeitig auf POSIX- beziehungsweise UNIX-konforme Betriebs- und Subsysteme beschränkt. Mit Boost.Asio lässt sich das nun ändern.

Die komplette betriebssystemspezifische Kommunikation kapselt die Klasse boost::asio::io_service. Entwickler müssen sie nur instanziieren, um einheitlichen Zugriff auf die systemspezifischen I/O-Services zu erhalten. Für die meisten Programme genügt dabei ein einziges io_service-Objekt -- mehrere sind ebenfalls erlaubt.

Entwickler können nun in Boost.Asio I/O-Objekte wie Sockets und serielle Schnittstellen instanziieren und an ein io_service-Objekt binden. Sämtliche darüber ausgelöste I/O-Operationen führt das io_service-Objekt betriebssystemspezifisch aus.

Bei synchronen Operationen ist das Vorgehen recht simpel. Die Anwendung ruft eine Methode zum Lesen des Socket-Objekts auf, das wiederum die jeweilige Methode des io_service-Objekts aufruft. Dieser Callstack blockiert den Ablauf, bis Daten zum Lesen da sind. Dann liest io_service die Daten über das Socket ein und reicht sie seinerseits an die Anwendung weiter.

Asynchrone Operationen sind infolge der Nebenläufigkeit deutlich komplexer. Ein linearer Programmablauf existiert nicht mehr. Vielmehr sendet das Programm einen Operationswunsch und arbeitet danach an etwas anderem weiter. Ist die Operation abgeschlossen oder schlägt sie fehl, erhält das Programm eine Nachricht über eine Callback-Funktion. Konkret läuft das Socket-Beispiel folgendermaßen ab: Die Anwendung übergibt dem Socket-Objekt eine Leseoperation und eine Funktion oder Methode, die nach dem Abarbeiten der Operation aufgerufen werden soll, den sogenannten Handler. Da das Socket mit dem io_service-Objekt verbunden ist, landet die Operation über das Socket in dem io_service. Nach der Übergabe arbeitet das Programm weiter. Sind Daten zum Lesen da, ruft io_service den betreffenden Handler als Callback-Funktion auf. In ihr kann die Anwendung die gelesenen Daten verarbeiten oder entsprechend auf Fehler reagieren.

Damit io_service die übergebenen Operationen auch abarbeitet, ist die Methode run() des io_service aufzurufen. Diese Methode besteht aus einer Schleife, die auf die jeweiligen I/O-Ereignisse wie Lesen, Schreiben oder Connect für die übergebenen Operationen wartet. Tritt ein passendes Ereignis ein oder ein Fehler auf, entfernt run() die betreffende Operation aus der Liste der abzuarbeitenden Operationen und ruft den korrespondierenden Handler auf.

Die Methode run() verlässt diese Schleife und kehrt erst zurück, wenn alle Operationen abgearbeitet sind. Im Umkehrschluss heißt das, dass immer wenigstens eine Operation in der Queue sein muss, damit run() ständig weiterarbeitet.

Gerade bei einem Server hat die Applikation also dafür zu sorgen, dass run() mit genügend Operationen gefüttert wird. Normalerweise ist mindestens eine, die auf einen Connect wartet, in der Warteschlange. Bei Clients, die dauerhaft eine Verbindung offenhalten, steht in der Regel immer eine Operation zum Lesen von Daten in der Warteschlange der io_service-Methode run().

Der Aufruf eines Handlers tilgt zeitgleich die betreffende Operation aus der Warteschlange. Entwickler dürfen im Handler jedoch eine neue asynchrone Operation starten und somit sicherstellen, dass run() immer mit Arbeit versorgt ist. Initial können sie die Warteschlange mit Operationen vor dem Aufruf von io_service::run() befüllen. Damit findet run() bereits beim Start eine Reihe von Operationen in der Warteschlange vor. Das vermeidet, dass run() sofort nach dem Aufruf mangels Arbeit terminiert.