Windows mit C++

Der Windows-Threadpool und Auslastungen

Kenny Kerr

Kenny KerrParallelismus hat verschiedene Bedeutungen für verschiedene Benutzer. Manche denken an Agenten und Nachrichten – kooperierende, jedoch asysnchrone Statusmaschinen. Andere denken an Aufgaben, meist in Form von Funktionen oder Ausdrücken, die parallel ausgeführt werden können. Wieder andere denken an Datenparallelismus, bei dem die Struktur der Daten den Parallelismus ermöglicht. Man kann dies sogar als komplementäre oder sich überschneidende Funktionen betrachten. Gleichgültig, wie man die Welt des Parallelismus betrachtet – im Zentrum des modernen Parallelismusansatzes befindet sich ein Threadpool in der einen oder anderen Form.

Threads sind relativ aufwendig zu erstellen. Eine zu große Anzahl von Threads führt zu einer Planungsüberlastung, die sich auf die Zwischenspeicherlokalität und die Gesamtleistung auswirkt. In den meisten gut entworfenen Systemen ist der Parallelismus relativ kurzlebig. Idealerweise ist es ein einfaches Verfahren, Threads zu erstellen, sie für zusätzliche Arbeiten wieder zu verwenden und auf intelligente Weise die Erstellung zu vieler Threads zu vermeiden, um die verfügbare Rechnerkapazität effizient zu nutzen. Zum Glück kann dies heute umgesetzt werden, und zwar nicht in einer Drittanbieterbibliothek, sondern im Zentrum der Windows-API. Die Windows-Threadpool-API erfüllt nicht nur diese Anforderungen, sondern sie ist auch nahtlos mit zahlreichen Bausteinen der Windows-API integriert. Dies reduziert die Komplexität beim Schreiben skalierbarer und reaktionsfähiger Anwendungen. Wenn Sie langjähriger Windows-Entwickler sind, sind Sie zweifellos mit dem Eckpfeiler der Windows-Skalierbarkeit vertraut, dem I/O-Komplettierungsport. Sie können sich darauf verlassen, dass sich im Zentrum des Windows-Threadpools ein I/O-Komplettierungsport befindet.

Denken Sie daran, dass ein Threadpool nicht einfach als eine Methode angesehen werden sollte, die den Aufruf von CreateThread mit allen Parametern und den entsprechenden Aufruf von CloseHandle auf dem resultierenden Handle vermeidet. Dies ist zwar möglicherweise bequem, kann jedoch in die Irre führen. Die meisten Entwickler haben Erwartungen hinsichtlich des prioritätsgesteuerten Vorausplanungsmodells, das Windows implementiert. Threads mit der gleichen Priorität teilen sich normalerweise die Prozessorzeit. Wenn das Quantum eines Threads – die Zeit, die er zur Ausführung benötigt – zu Ende geht, entscheidet Windows, ob ein weiterer Thread mit der gleichen Priorität zur Ausführung bereit ist. Natürlich beeinflussen viele Faktoren die Threadplanung, aber bei zwei Threads, die ungefähr zur gleichen Zeit mit der gleichen Priorität erstellt wurden und beide eine rechnergebundene Aufgabe ausführen, würde man erwarten, dass sie im Abstand von einigen wenigen Quanten ausgeführt werden.

Anders beim Threadpool. Der Threadpool – wie jede auf einem I/O-Komplettierungsport basierende Planungsabstraktion – beruht auf einem Arbeitswarteschlangenmodell. Der Threadpool garantiert die volle Kernauslastung, verhindert aber zugleich die übermäßige Einplanung. Wenn zwei Arbeitseinheiten ungefähr zur selben Zeit auf einem Computer mit einem einzelnen Kern abgesendet werden, wird nur die erste verteilt. Die zweite Arbeitseinheit wird erst begonnen, wenn die erste beendet oder angehalten wird. Dieses Modell ist optimal für den Durchsatz, da die Arbeit effizienter und mit weniger Unterbrechungen ausgeführt wird. Dies bedeutet jedoch auch, dass es weniger Latenzgarantien gibt.

Die Threadpool-API wurde als Satz kooperierender Objekte entwickelt. Dies sind Objekte, die Arbeitseinheiten, Zeitgeber, asynchrone I/O und mehr darstellen. Es gibt sogar Objekte, die anspruchsvolle Konzepte darstellen, wie Abbruch und Bereinigung. Zum Glück zwingt die API die Entwickler nicht, sich mit all diesen Objekten auseinanderzusetzen. Wie an einem Buffett können Sie so viel oder so wenig konsumieren, wie Sie möchten. Natürlich birgt diese Freiheit das Risiko einer ineffizienten oder unangemessenen Verwendung der API. Deshalb werde ich im Laufe der nächsten Monate dieses Thema in dieser Kolumne behandeln. Wenn Sie beginnen, sich mit den einzelnen Rollen vertraut zu machen, die die verschiedenen Teile der API spielen, werden Sie feststellen, dass der Code, den Sie schreiben müssen, einfacher und nicht etwa komplexer wird.

In diesem ersten Teil werde ich Ihnen zeigen, wie Sie damit beginnen können, dem Threadpool Arbeit zuzuleiten. Funktionen werden dem Threadpool als Arbeitsobjekte verfügbar gemacht. Ein Arbeitsobjekt besteht aus einem Funktionszeiger und einem leeren Zeiger, Kontext genannt, den der Threadpool jedes Mal, wenn er ausgeführt wird, an die Funktion leitet. Ein Arbeitsobjekt kann mehrmals zur Ausführung abgesendet werden, die Funktion und der Kontext können jedoch nicht geändert werden, ohne ein neues Arbeitsobjekt zu erstellen.

Die CreateThreadpoolWork-Funktion erstellt ein Arbeitsobjekt. Wenn die Funktion erfolgreich ist, gibt sie einen nicht transparenten Zeiger zurück, der das Arbeitsobjekt darstellt. Wenn sie nicht erfolgreich ist, gibt sie den Zeigerwert Null zurück. Mithilfe von GetLastError können Sie detailliertere Informationen erhalten. Bei Vorliegen eines Arbeitsobjekts informiert die CloseThreadpoolWork-Funktion den Threadpool, dass das Objekt freigegeben werden kann. Diese Funktion gibt keinen Wert zurück, und aus Effizienzgründen setzt sie voraus, dass das Objekt gültig ist. Zum Glück wird das von der Klassenvorlage unique_handle übernommen, die ich vorigen Monat vorgestellt habe. Hier sehen Sie eine „traits“-Klasse, die mit unique_handle verwendet werden kann, sowie ein „typedef“ zur Vereinfachung:

struct work_traits
{
  static PTP_WORK invalid() throw()
  {
    return nullptr;
  }

  static void close(PTP_WORK value) throw()
  {
    CloseThreadpoolWork(value);
  }
};

typedef unique_handle<PTP_WORK, work_traits> work;

Ich erstelle jetzt ein Arbeitsobjekt und lasse den Compiler die Gültigkeitsdauer bestimmen, ganz gleich, ob das Objekt sich im Stapel oder in einem Container befindet. Bevor ich dies tun kann, benötige ich natürlich eine Funktion, die es aufrufen kann; dies ist er Rückruf. Der Rückruf ist folgendermaßen definiert:

void CALLBACK hard_work(PTP_CALLBACK_INSTANCE, void * context, PTP_WORK);

Das Makro CALLBACK sorgt dafür, dass die Funktion die Aufruffunktion implementiert, die die Windows-API für Rückrufe erwartet, je nach Zielplattform. Das Erstellen eines Arbeitsobjekts für diesen Rückruf mit der typedef der Arbeit ist einfach und setzt das Muster fort, das in der Kolumne des letzten Monats vorgestellt wurde, wie hier gezeigt:

void * context = ... 
work w(CreateThreadpoolWork(hard_work, context, nullptr));
check_bool(w);

An diesem Punkt verfüge ich über ein Objekte, das eine durchzuführende Arbeit darstellt. Der Threadpool selbst ist jedoch nicht beteiligt, da der Arbeitsrückruf nicht für die Ausführung abgesendet wurde. Die SubmitThreadpoolWork-Funktion sendet den Arbeitsrückruf an den Threadpool. Er kann mehrmals mit dem gleichen Arbeitsobjekt aufgerufen werden, um die parallele Ausführung mehrerer Rückrufe zu ermöglichen. Die Funktion wird im Folgenden gezeigt:

SubmitThreadpoolWork(w.get());

Natürlich garantiert das Absenden der Arbeit nicht deren unmittelbare Ausführung. Der Arbeitsrückruf wird in die Warteschlange eingereiht, der Threadpool schränkt jedoch möglicherweise den Grad an Parallelismus (die Anzahl der Workerthreads) ein, um die Effizienz zu verbessern. Da all dies eher schlecht vorhersagbar ist, muss es eine Möglichkeit geben, auf ausstehende Rückrufe zu warten, sowohl auf Rückrufe, die zurzeit ausgeführt als auch auf Rückrufe, die zurzeit ausstehen. Idealerweise sollte es auch möglich sein, Arbeitsrückrufe abzubrechen, die noch keine Gelegenheit zur Ausführung erhalten haben. In der Regel sind Blockierungen von „wait“-Prozessen keine guten Nachrichten für den Parallelismus. Sie sind jedoch erforderlich, um vorhersagbare Abbrüche und Schließungen durchzuführen. Dies wird Gegenstand einer zukünftigen Kolumne sein. Daher möchte ich hier nicht zu viel Zeit für dieses Thema aufwenden. Für unsere Zwecke erfüllt jedoch die WaitForThreadpoolWorkCallbacks-Funktion die genannten Voraussetzungen. Im Folgenden finden Sie ein Beispiel:

bool cancel = ...
WaitForThreadpoolWorkCallbacks(w.get(), cancel);

Mit dem Wert des zweiten Parameters wird festgelegt, ob ausstehende Rückrufe abgebrochen werden sollen oder ob die Funktion darauf wartet, dass diese abgeschlossen werden, auch wenn deren Ausführung noch nicht gestartet wurde. Ich verfüge nun über genügend Code, um einen einfachen funktionalen Pool zu erstellen, indem ich die Threadpool-API und C++ 2011 verwende, um ein wesentliches komfortableres Tool zu entwickeln. Darüber hinaus ist es ein gutes Beispiel für die Verwendung der Funktionen, die ich Ihnen hier vorgestellt habe.

Ein einfacher funktionaler Pool sollte mir das Absenden einer Funktion für die asynchrone Ausführung ermöglichen. Ich sollte diese Funktion mittels eines lambda-Ausdrucks, einer benannten Funktion oder eines Funktionsobjekts definieren können, je nachdem. Ein Ansatz hierfür ist die Verwendung einer parallelisierten Auflistung, um eine Funktionswarteschlange zu speichern, und diese Warteschlange an einen Arbeitsaufruf zu übergeben. Visual C++ 2010 enthält die Klassenvorlage concurrent_queue, die diese Aufgabe übernimmt. Ich setze hier voraus, dass Sie die aktualisierte Implementierung aus Service Pack 1 verwenden. Die ursprüngliche Implementierung wies einen Fehler auf, der zu einer Zugriffsverletzung führte, wenn die Warteschlange nach der Destruktion nicht leer war.

Ich kann nun die funktionale Poolklasse wie folgt definieren:

class functional_pool
{
  typedef concurrent_queue<function<void()>> queue;

  queue m_queue;
  work m_work;

  static void CALLBACK callback(PTP_CALLBACK_INSTANCE, void * context, PTP_WORK)
  {
    auto q = static_cast<queue *>(context);

    function<void()> function;
    q->try_pop(function);

    function();
  }

Wie Sie sehen, verwaltet die Klasse functional_pool eine Warteschlange von Funktionsobjekten sowie ein einzelnes Arbeitsobjekt. Der Rückruf setzt voraus, dass der Kontext ein Zeiger auf die Warteschlange ist und dass mindestens eine Funktion in der Warteschlange vorhanden ist. Ich kann nun das Arbeitsobjekt für diesen Rückruf erstellen und den Kontext entsprechend festlegen, wie hier gezeigt:

public:

  functional_pool() :
    m_work(CreateThreadpoolWork(callback, &m_queue, nullptr))
  {
    check_bool(m_work);
  }

Es ist eine Funktionsvorlage erforderlich, um die verschiedenen Funktionstypen zu behandeln, die möglicherweise abgesendet werden. Ihre Aufgabe besteht lediglich darin, die Funktion in die Warteschlange einzureihen und SubmitThreadpoolWork aufzurufen, um den Threadpool anzuweisen, den Arbeitsrückruf zur Ausführung abzusenden, wie hier gezeigt:

template <typename Function>
void submit(Function const & function)
{
  m_queue.push(function);
  SubmitThreadpoolWork(m_work.get());
}

Schließlich muss der Destruktor functional_pool sicherstellen, dass keine weiteren Rückrufe ausgeführt werden, bevor das Löschen der Warteschlange gestattet wird. Andernfalls treten schwere Fehler auf. Im Folgenden finden Sie ein Beispiel:

~functional_pool()
{
  WaitForThreadpoolWorkCallbacks(m_work.get(), true);
}

Ich kann nun ein functional_pool-Objekt erstellen und mittels eines lambda-Ausdrucks auf einfache Weise eine Arbeitsaufgabe absenden:

functional_pool pool;

pool.submit([]
{
  // Do this asynchronously
});

Es wird sicherlich einige Abzüge bei der Leistung geben, da Funktionen explizit und Arbeitsrückrufe implizit in die Warteschlange eingereiht werden. Die Verwendung dieses Ansatzes ist wahrscheinlich in Serveranwendungen keine gute Idee, da hier der Parallelismus in der Regel ziemlich gut strukturiert ist. Wenn Sie nur über wenige eindeutige Rückrufe verfügen, die die Masse Ihrer asynchronen Auslastungen verarbeiten, dann sollten Sie wahrscheinlich besser Funktionszeiger verwenden. Dieser Ansatz ist möglicherweise für Clientanwendungen nützlich. Wenn es jedoch viele verschiedene kurzlebige Prozesse gibt, die Sie parallel verarbeiten möchten, um die Reaktionsfähigkeit zu verbessern, dann sind die Vorteile von lambda-Ausdrücken eher mehr als deutlich.

Dieser Artikel handelt jedoch nicht von lambda-Ausdrücken, sondern davon, wie Arbeitsaufgaben an den Threadpool gesendet werden. Einen anscheinend einfacheren Ansatz, um das gleiche Ziel zu erreichen, stellt die Funktion TrySubmitThreadpoolCallback bereit, wie hier gezeigt:

void * context = ...
check_bool(TrySubmitThreadpoolCallback(
  simple_work, context, nullptr));

Es ist beinahe so, als ob die Funktionen CreateThreadpoolWork und SubmitThreadpoolWork in eine Funktion zusammenführt wurden. Dies ist im Wesentlich auch der Fall. Die Funktion TrySubmitThreadpoolCallback weist den Threadpool an, intern ein Arbeitsobjekt zu erstellen, dessen Rückruf unmittelbar zur Ausführung abgesendet wird. Da der Threadpool das Arbeitsobjekt besitzt, müssen Sie sich nicht selbst um das Absenden kümmern. Sie können es auch gar nicht absenden, da das Arbeitsobjekt zu keinem Zeitpunkt von der API veröffentlicht wird. Die Rückrufsignatur belegt dies weiter, wie hier gezeigt:

void CALLBACK simple_work(
  PTP_CALLBACK_INSTANCE, void * context);

Der Rückruf sieht beinahe genau so aus wie vorher, nur der dritte Parameter fehlt. Zunächst sieht dies nach einer guten Idee aus: eine einfachere API und weniger Dinge, um die Sie sich kümmern müssen. Es gibt jedoch keine offensichtliche Möglichkeit, auf den Abschluss des Rückrufs zu warten oder diesen sogar abzubrechen. Der Versuch, die Klasse functional_pool mittels TrySubmitThreadpoolCallback zu erstellen, wäre problematisch und würde zusätzliche Synchronisierungen erfordern. In einer zukünftigen Kolumne werde ich besprechen, wie dies mittels der Threadpool-API erreicht werden kann. Auch wenn Sie diese Probleme lösen könnten, bleibt ein weniger offensichtliches Problem bestehen, das in der Praxis möglicherweise wesentlich weit reichendere Konsequenzen hat. Jeder Aufruf an TrySubmitThreadpoolCallback beinhaltet die Erstellung eines neuen Arbeitsobjekts zusammen mit den mit diesem verknüpften Ressourcen. Bei anspruchsvollen Auslastungen kann dies schnell dazu führen, dass der Threadpool einen großen Teil des Arbeitsspeichers beansprucht, was zu weiteren Abzügen bei der Leistung führen kann.

Die explizite Verwendung eines Arbeitsobjekts hat weitere Vorteile. Der letzte Parameter des Rückrufs stellt in seiner ursprünglichen Form einen Zeiger auf das gleiche Arbeitsobjekt bereit, das die ausgeführte Instanz abgesendet hat. Sie können diesen verwenden, um weitere Instanzen des gleichen Rückrufs in die Warteschlange einzureihen. Sie können ihn sogar dazu verwenden, das Arbeitsobjekt freizugeben. Diese Arten von Tricks können Sie jedoch in Schwierigkeiten bringen, da es zunehmend schwieriger wird, zu entscheiden, wann es besser ist, Arbeitsaufgaben abzusenden, und wann es besser ist, Anwendungsressourcen freizugeben. In der Kolumne für den nächsten Monat behandele ich im Rahmen meiner Besprechung der Windows-Threadpool-API die Threadpoolumgebung       

Kenny Kerr* ist Softwarespezialist mit dem Schwerpunkt auf der systemeigenen Windows-Entwicklung. Sie erreichen ihn unter kennykerr.ca.*

Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels:Hari Pulapaka und Pedro Teixeira