Die Boost C++ Bibliotheken

Skalierbarkeit und Multithreading

Wenn Sie auf eine Bibliothek wie Boost.Asio zugreifen, entwickeln Sie ein Programm anders als üblicherweise in C++ gewohnt. So geben Sie nicht aktiv die Reihenfolge aller Funktionsaufrufe vor. Die Reihenfolge, in der Handler ausgeführt werden, hängt von der Reihenfolge ab, in der asynchrone Operationen beendet werden – und die ist nicht unbedingt vorhersehbar. Dies macht es nicht einfacher, die Programmlogik nachzuvollziehen.

Eine Bibliothek wie Boost.Asio wird typischerweise dann eingesetzt, wenn eine höhere Effizienz erreicht werden soll. Ein Programm soll nicht mehr warten, bis eine Operation abgeschlossen wurde, sondern zwischenzeitlich anderes tun können. So können in einem Programm, das auf Boost.Asio basiert, beliebig viele asynchrone Operationen gestartet werden, die alle gleichzeitig ausgeführt werden – denken Sie daran, dass über asynchrone Operationen üblicherweise auf Ressourcen außerhalb des Programms zugegriffen wird. Da diese Ressourcen unterschiedliche Hardware-Komponenten Ihres Computers sein können, können sie unabhängig voneinander arbeiten und Operationen gleichzeitig ausführen.

Skalierbarkeit bezeichnet die Eigenschaft eines Programms, von zusätzlichen Ressourcen zu profitieren, wenn diese zur Verfügung gestellt werden. Mit Boost.Asio kann davon profitiert werden, dass mehrere Operationen auf externen Geräten gleichzeitig zum Code in einem Programm ausgeführt werden können. Werden zusätzlich zu Boost.Asio Threads verwendet, können außerdem mehrere Operationen in einem Programm gleichzeitig auf den verfügbaren Prozessorkernen ausgeführt werden. Boost.Asio mit Threads verbessert die Skalierbarkeit eines Programms, indem von möglichst vielen zur Verfügung stehenden internen und externen Geräten profitiert wird, die in der Lage sind, Operation selbstständig und im Verbund gleichzeitig auszuführen.

Wenn die Methode run() für ein Objekt vom Typ boost::asio::io_service aufgerufen wird, erfolgt der Aufruf von Handlern im gleichen Thread, in dem run() aufgerufen wurde. Verwendet ein Programm mehrere Threads, kann in jedem Thread ein Aufruf von run() erfolgen. Das I/O Serviceobjekt wird dann, wenn eine asynchrone Operation abgeschlossen wurde, den Handler in einem dieser Threads ausführen. Sollte eine zweite asynchrone Operation abgeschlossen werden, kann das I/O Serviceobjekt den entsprechenden Handler in einem anderen zur Verfügung stehenden Thread starten. Der Vorteil ist, dass Operationen nicht nur außerhalb des Programms gleichzeitig ausgeführt werden können, sondern auch Handler in einem Programm.

Beispiel 32.3. Zwei Threads für das I/O Serviceobjekt, um Handler zeitgleich auszuführen
#include <boost/asio/io_service.hpp>
#include <boost/asio/steady_timer.hpp>
#include <chrono>
#include <thread>
#include <iostream>

using namespace boost::asio;

int main()
{
  io_service ioservice;

  steady_timer timer1{ioservice, std::chrono::seconds{3}};
  timer1.async_wait([](const boost::system::error_code &ec)
    { std::cout << "3 sec\n"; });

  steady_timer timer2{ioservice, std::chrono::seconds{3}};
  timer2.async_wait([](const boost::system::error_code &ec)
    { std::cout << "3 sec\n"; });

  std::thread thread1{[&ioservice](){ ioservice.run(); }};
  std::thread thread2{[&ioservice](){ ioservice.run(); }};
  thread1.join();
  thread2.join();
}

Beispiel 32.2 wird im Beispiel 32.3 in ein Multithreaded-Programm umgewandelt. In der Funktion main() werden mit Hilfe der Klasse std::thread zwei Threads erstellt. In beiden Threads wird lediglich die Methode run() für das einzige I/O Serviceobjekt aufgerufen. Damit erhält das I/O Serviceobjekt die Möglichkeit, beide Threads zu nutzen, um Handler auszuführen, die bei Abschluss asynchroner Operationen aufgerufen werden müssen.

Im Beispiel 32.3 sollen beide Wecker nach drei Sekunden klingeln. Dadurch, dass zwei Threads zur Verfügung stehen, können beide Lambda-Funktionen zeitgleich ausgeführt werden. Für den Fall, dass der zweite Wecker klingelt und momentan der Handler des ersten Weckers ausgeführt wird, wird der Handler im zweiten zur Verfügung stehenden Thread ausgeführt. Sollte der Handler des ersten Weckers bereits beendet worden sein, steht es dem I/O Serviceobjekt frei, einen der beiden zur Verfügung stehenden Threads für den zweiten Handler zu verwenden.

Beachten Sie, dass der Einsatz von Threads nicht immer sinnvoll ist. Wenn Sie Beispiel 32.3 ausführen, kann es sein, dass die beiden Meldungen auf der Standardausgabe nicht nacheinander erscheinen, sondern gemischt werden. Beide Handler, die möglicherweise gleichzeitig in zwei Threads ausgeführt werden, greifen mit std::cout auf eine einzige Ressource zu und müssen sie sich teilen. Um sicherzustellen, dass eine Meldung vollständig ausgegeben wird, ohne dass gleichzeitig ein anderer Thread eine Meldung auf die Standardausgabe auszugeben versucht, müsste der Zugriff synchronisiert werden. Der Vorteil von Threads ist dahin, wenn Handler nicht unabhängig voneinander laufen können, sondern synchronisiert werden müssen.

Beispiel 32.4. Je ein Thread für zwei I/O Serviceobjekte, um Handler zeitgleich auszuführen
#include <boost/asio/io_service.hpp>
#include <boost/asio/steady_timer.hpp>
#include <chrono>
#include <thread>
#include <iostream>

using namespace boost::asio;

int main()
{
  io_service ioservice1;
  io_service ioservice2;

  steady_timer timer1{ioservice1, std::chrono::seconds{3}};
  timer1.async_wait([](const boost::system::error_code &ec)
    { std::cout << "3 sec\n"; });

  steady_timer timer2{ioservice2, std::chrono::seconds{3}};
  timer2.async_wait([](const boost::system::error_code &ec)
    { std::cout << "3 sec\n"; });

  std::thread thread1{[&ioservice1](){ ioservice1.run(); }};
  std::thread thread2{[&ioservice2](){ ioservice2.run(); }};
  thread1.join();
  thread2.join();
}

Der mehrfache Aufruf von run() eines I/O Serviceobjekts ist die empfohlene Vorgehensweise, um ein Programm, das auf Boost.Asio basiert, skalierbar zu machen. Sie können jedoch auch, anstatt mehrere Threads an ein I/O Serviceobjekt zu binden, mehrere I/O Serviceobjekte instanziieren.

Im Beispiel 32.4 werden neben zwei Weckern vom Typ boost::asio::steady_timer zwei I/O Serviceobjekte verwendet. Das Programm basiert auf zwei Threads, wobei jeweils ein Thread an ein I/O Serviceobjekt gebunden ist. Die beiden I/O Objekte timer1 und timer2 sind nicht mehr an das gleiche I/O Serviceobjekt gebunden, sondern an unterschiedliche.

Beispiel 32.4 funktioniert wie das vorherige, das lediglich ein I/O Serviceobjekt verwendet. Wann es von Vorteil ist, mehr als ein I/O Serviceobjekt zu verwenden, lässt sich nicht allgemein beantworten. Da boost::asio::io_service die Betriebssystemschnittstelle darstellt, hängt es von dieser ab, ob und wann mehr als eine Instanz von Vorteil ist. Unter Windows verbirgt sich hinter boost::asio::io_service üblicherweise IOCP, unter Linux epoll(). Mehrere boost::asio::io_service-Instanzen bedeuten demnach, dass mehrere I/O Completion Ports verwendet werden oder mehrfach epoll() aufgerufen wird. Ob dies besser ist als alle asynchronen Operationen über einen I/O Completion Port oder einen Aufruf von epoll() zu verwalten, hängt vom Einzelfall ab.