Paralleles Arbeiten

In Zeiten von immer mehr Prozessorkernen ist es für viele Anwendungen relevant, mehrere Dinge parallel zu tun. Endlich bietet daher auch C++ mit C++11 Multithreading-Unterstützung in der Standardbibliothek.

Video

Quellcode

#include <iostream>
#include <deque>
#include <memory>
#include <functional>
#include <chrono>
#include <thread>
#include <list>

class ExecutionManager
{
  std::deque<std::function<void ()>> mTasks;

public:
  
  void addTask(std::function<void ()> const &task) {
    mTasks.push_back(task);
  }
  
  void execute()
  {
    std::list<std::shared_ptr<std::thread>> threads;
    while (!mTasks.empty()) {
      auto task = mTasks.front();
      mTasks.pop_front();
      auto t = std::shared_ptr<std::thread>(new std::thread(task));
      threads.push_back(t);
    }
    std::cout << "Alle Threads gestartet. Warte...\n";
    for (auto t : threads) {
      t->join();
    }
    std::cout << "Alle Threads beendet.\n";
  }
};

void taskFn()
{
  std::cout << "taskFn() aufgerufen\n";
  std::this_thread::sleep_for(std::chrono::seconds(5));
  std::cout << "taskFn() beendet\n";
}

struct TaskFunctor
{
  void operator()()
  {
    std::cout << "TaskFunctor aufgerufen\n";
    std::this_thread::sleep_for(std::chrono::seconds(10));
    std::cout << "TaskFunctor beendet\n";
  }
};

int main()
{
  ExecutionManager e;
  
  e.addTask([]() {     
    std::cout << "lambda aufgerufen\n";
    std::this_thread::sleep_for(std::chrono::seconds(7)); 
    std::cout << "lambda beendet\n"; });
  e.addTask(taskFn);
  e.addTask(TaskFunctor());
  e.addTask(taskFn);
  
  e.execute();
}

Erklärung

Der Kern der Multithreading-Funktionalität ist die Klasse std::thread, wie sie in Zeile 25 verwendet wird. Ein Objekt der Klasse bekommt als Parameter ein Funktionsobjekt und ggf. weitere Parameter, die an dieses Funktionsobjekt übergeben werden sollen. Der Konstruktor erzeugt einen neuen, parallelen Ausführungspfad im Prozess (unter Linux bspw. unter Verwendung von pthread_create und Konsorten) und ruft dann in diesem neu erzeugten Pfad die übergebene Funktion auf. Die läuft dann parallel zu allen anderen Ausführungspfaden. Wenn sie zurückkehrt, wird der zugehörige Thread beendet. Um aus einem anderen Thread (in unserem Fall der Hauptthread des Programms) auf das Ende zu warten, existiert die Methode join (Zeile 30). Einmal aufgerufen blockiert diese Methode so lang (und legt damit den aufrufenden Thread schlafen), bis der aufgerufene Thread beendet ist. Danach kehrt sie zurück und der aufrufende Thread läuft weiter. Das funktioniert auch, wenn der aufgerufene Thread schon beendet ist. Dann kehrt sie einfach sofort zurück.

Threads sind typischerweise dann sinnvoll, wenn tatsächlich mehrere Dinge parallel gerechnet werden sollen (oder die Programmierung durch einen Thread vereinfacht wird, weil der parallel mehrere Pfade erlaubt, die in sich einfach sequentiell abgearbeitet werden. Beispiel: GUI-Programmierung, wo das Zeichnen der GUI gern mal in einen eigenen Thread ausgelagert wird, um es vom normalen User-Code zu entkoppeln). Das müssen wir hier jetzt mal simulieren, indem unsere übergebenen Funktionsobjekte einfach lang schlafen, bevor sie zurückkehren. Dafür verwenden wir die (ebenfalls in C++11 neu eingeführte) Methode sleep_for im Namespace std::this_thread. Dieser Namespace enthält ein paar Hilfsfunktionen, die sich immer auf den aktuellen Thread beziehen, in dem sie gerade aufgerufen werden. sleep_for legt den Aufrufer für die angegebene Zeit schlafen. Um hier eine Flexibilität zu erreichen was die Definition von Intervallen angeht, bietet C++11 einige Hilfsklassen im Namespace std::chrono, die letztlich Zeiten in verschiedenen Granularitäten repräsentieren. Die im Beispiel verwendete std::chrono::seconds repräsentiert Intervalle mit einer Auflösung von einer Sekunde.

Wird das Programm oben ausgeführt, dann sieht man, dass nach 10 Sekunden alles vorbei ist: statt sequentiell zu laufen (und damit 27 Sekunden zu brauchen), laufen alle Tasks in je einem Thread und damit parallel, so dass der längste Task die Gesamtlaufzeit bestimmt. Das trifft natürlich in der Praxis nicht immer zu (wenn die Tasks tatsächlich rechnen, dann gibt es einen gewissen Overhead für Speicherzugriffe etc.), aber ein Stück weit ist bei bestimmten Sachen eine lineare Skalierung mit der Anzahl der Prozessorkerne möglich.