Tasks mit Parametern

Die Tasks in unserem ExecutionManager sollen auch Parameter übergeben bekommen. Dafür steigen wir in das Thema "variadische Templates" und "Parameter Packs" ein.

Video

Quellcode

#include <iostream>
#include <deque>
#include <memory>
#include <functional>
#include <chrono>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>
#include <list>

class ExecutionManager
{
  struct ExecutorBase
  {
    virtual void operator()() = 0;
  };
  
  template <typename Task, typename Ret> struct Executor : public ExecutorBase
  {
    Task fn;
    std::promise<Ret> result;
    
    Executor(Task const &f)
      : fn(f)
    {
    }
    
    void operator()() override
    {
      auto res = fn();
      result.set_value(res);
    }
  };
  
  std::deque<std::shared_ptr<ExecutorBase>> mTasks;
  std::list<std::shared_ptr<std::thread>> mThreads;
  std::mutex mMutex;
  std::condition_variable mCond;
  bool mFinish;

  void executor()
  {
    std::cout << "- Thread gestartet\n";
    bool running = true;
    while (running) {
      std::unique_lock<std::mutex> l(mMutex);
      mCond.wait(l, [this]() { 
	    return mFinish || mTasks.size() != 0;
      });
      std::cerr << "Prüfe Warteschlange\n";
      if (!mTasks.empty()) {
	auto task = mTasks.front();
	mTasks.pop_front();
	l.unlock();
	(*task)();  
      }
      else {
	running = !mFinish;
      }
    }
    std::cout << "- Thread beendet\n";
  }
  
public:
  
  explicit ExecutionManager(unsigned int threads)
  {
    mFinish = false;
    for (unsigned int i = 0; i < threads; ++i)
    {
      mThreads.push_back(std::shared_ptr<std::thread>(
	new std::thread(&ExecutionManager::executor, this)));
    }
  }
  
  template <typename F, typename... Arguments> auto addTask(F task, Arguments... args) 
      -> std::future<decltype(task(args...))> {
    typedef decltype(task(args...)) Ret;
    std::unique_lock<std::mutex> l(mMutex);
    auto t = std::make_shared<Executor<decltype(std::bind(task, args...)), Ret>>
	    (std::bind(task, args...));
    mTasks.push_back(t);
    mCond.notify_one();
    return t->result.get_future();
  }
  
  void joinAll()
  {
    {
      std::unique_lock<std::mutex> l(mMutex);
      mFinish = true;
    }
    mCond.notify_all();
    for (auto t : mThreads) {
      t->join();
    }
    std::cout << "-- Alle Threads beendet.\n";
  }
};

int add(int a, int b)
{
  std::cout << "add(" << a << ", " << b << ") aufgerufen\n";
  std::this_thread::sleep_for(std::chrono::seconds(3));
  std::cout << "add() beendet\n";
  return a+b;
}

int main()
{
  ExecutionManager e(2);

  std::this_thread::sleep_for(std::chrono::seconds(2));
  
  auto  t1 = e.addTask([]() {     
    std::cout << "lambda aufgerufen\n";
    std::this_thread::sleep_for(std::chrono::seconds(7)); 
    std::cout << "lambda beendet\n"; 
    return 10;
  });
  auto t2 = e.addTask(add, 20, 55);
  
  auto r1 = t1.get();
  auto r2 = t2.get();
  
  std::cout << "r1 (" << typeid(r1).name() << ") = " << r1 << std::endl;
  std::cout << "r2 (" << typeid(r2).name() << ") = " << r2 << std::endl;
  
  e.joinAll();
}

Erklärung

Die Methode addTask() soll zusätzlich zum eigentlichen Callable auf noch potentielle Parameter erhalten, die später an den Task verfüttert werden. Das heißt, dass sie keine feste Anzahl an Parametern mehr aufnimmt, sondern 1 bis n (mindestens das Callable, potentiell eine unbegrenzte Anzahl für den Task). C++11 bietet dafür eine typsichere Variante: Parameter Packs. Gebraucht werden diese, um die sogenannten Variadischen Templates zu bauen: Templates, die eine beliebige Anzahl an Parametern aufnehmen können. Zu erkennen sind die Parameter Packs an der Ellipse: den drei Punkten entweder hinter dem Schlüsselwort typename (bei Template Parameter Packs) oder dem vorher als Template Parameter Pack festgelegten Typ (bei Function Parameter Packs). Beides ist in Zeile 77 zu sehen: Arguments ist ein Template Parameter Pack, welches weiter hinten mit der Syntax Arguments... args das Function Parameter Pack args deklariert. Dieses wird durch 0 bis n Parameter ersetzt, die an der Aufrufstelle von addTask übergeben werden. Um diese Parameter wiederum nun zu verwenden, muss das Pack expandiert werden. Auch dafür muss wieder die Ellipse herhalten: hinter den Namen des Parameter Packs gesetzt wird dieses in einzelne Parameter expandiert, so, als hätten wir die direkt hingeschrieben (bspw. Zeile 82).

Ein Problem: um unsere Tasks speichern und später aufrufen zu können, müsste bspw eine Variable vom Typ des Parameter Packs deklariert werden (als Feld der entsprechenden Executor-Klasse). Leider geht das so einfach nicht. Wir kommen aber um das Problem herum: std::bind hat es für uns gelöst. Diese Template-Funktion wird mit einem Callable und der dazu passenden Anzahl an Parametern aufgerufen und liefert seinerseits ein Callable zurück, welches ohne Parameter aufgerufen werden kann. Die Parameter für das ursprüngliche Callable werden im zurückgegebenen Objekt gespeichert und später verwendet, wenn dieses aufgerufen wird. Das ursprüngliche Callable und seine Parameter werden also zu einem Objekt gebunden.

Die Verwendung des Objektes ist an sich ganz einfach. Die Deklaration einer passenden Variable (oder wie in unserem Beispiel: eines passenden Templateparameters) leider nicht. Der Rückgabetyp von std::bind ist nicht genau spezifiert. Wir müssen also wieder den Weg über decltype wählen, um diesen Typ zu ermitteln (Zeile 81).

Der eigentliche Executor bleibt dann im Grunde genommen gleich: lediglich der gespeicherte Task-Typ wird durch einen Templateparameter ersetzt (den wir mittels decltype aus dem Rückgabetyp von std::bind ermitteln). Damit kann das Objekt diese Funktoren speichern und später geeignet aufrufen.