Windows mit C++

Die Threadpoolumgebung

Kenny Kerr

Kenny KerrDie Objekte, aus denen die Windows-Threadpool API besteht, lassen sich in zwei Gruppen einteilen. Die erste enthält solche, die Arbeit, Timer, E/A und wartbare Objekte repräsentieren. Diese resultieren potentiell alle in Rückrufen, die auf dem Threadpool ausgeführt werden. Ich habe bereits das Arbeitsobjekt in der Spalte des letzten Monats eingetragen und werde die verbleibenden Objekte in späteren Artikeln behandeln. In der zweiten Gruppe sind die Objekte, die die Umgebung kontrollieren, in der diese Rückrufe ausgeführt werden. Dies ist der Schwerpunkt für diesen Monatsartikel.

Die Threadpool-Umgebung wirkt sich darauf aus, ob Rückrufe im Standardpool ausgeführt werden oder einem speziellen, von Ihnen eingerichteten Pool, ob Rückrufe priorisiert werden sollen usw. Die Fähigkeit, diese Umgebung zu kontrollieren, wird zunehmend wichtiger, sobald man mit mehr als einer Handvoll Arbeitsobjekte oder Rückrufe zu tun hat. Sie reduziert auch die Komplexität der Koordinierung und Beendigung dieser Objekte. Dies ist das Thema des nächsten Monatsartikels.

Eine Threadpoolumgebung ist kein Objekt in dem Sinne wie die anderen Objekte, aus denen die Threadpool-API besteht. Aus Gründen der der Effizienz wird sie einfach als eine Struktur deklariert, sodass Sie ihr direkt innerhalb Ihrer Anwendung Speicherplatz zuweisen können. Sie sollten sie jedoch genauso behandeln wie die anderen Objekte und nicht glauben, dass Sie ihre Interna kennen, sondern nur über den öffentlichen Satz von API-Funktionen auf sie zugreifen. Die Struktur heißt TP_CALLBACK_ENVIRON, und wenn Sie nachsehen, werden Sie sofort feststellen, dass sie sich bereits verändert hat, seit sie erstmals in Windows Vista eingeführt wurde. Dies ist ein weiterer Hinweis darauf, dass Sie sich die API-Funktionen halten müssen. Die Funktionen selbst manipulieren einfach diese Struktur, schirmen Sie aber von allen Änderungen ab. Sie werden inline deklariert, um dem Compiler zu erlauben, sie soweit wie möglich zu optimieren, glauben Sie also nicht, dass Sie es besser machen könnten.

Die Funktion InitializeThreadpoolEnvironment bereitet die Struktur mit den Standardeinstellungen vor. Die Funktion DestroyThreadpoolEnvironment stellt alle von der Umgebung verwendeten Ressourcen frei. Zu dem Zeitpunkt, da dies geschrieben wird, tut sie nichts. Dies kann sich in Zukunft jedoch ändern. Da es sich um eine Inlinefunktion handelt, schadet es nichts, sie aufzurufen, da sie einfach wegkompiliert wird. Abbildung 1 zeigt eine Klasse, die dies zusammenfasst.

Abbildung 1 ‑ Zusammenfassen der Funktion InitializeThreadpoolEnvironment

class environment
{
  environment(environment const &);
  environment & operator=(environment const &);
 
  TP_CALLBACK_ENVIRON m_value;
 
public:
 
  environment() throw()
  {
    InitializeThreadpoolEnvironment(&m_value);
  }
 
  ~environment() throw()
  {
    DestroyThreadpoolEnvironment(&m_value);
  }
 
  PTP_CALLBACK_ENVIRON get() throw()
  {
    return &m_value;
  }
};

Die vertraute Funktion get member wird bereitgestellt für die Konsistenz mit der unique_handle-Klassenvorlage I, die ich in meinem Artikel vom Juli 2011 vorgestellt habe (https://msdn.microsoft.com/magazine/hh288076). Aufmerksame Leser werden sich erinnern, dass die Funktionen CreateThreadpoolWork und TrySubmitThreadpoolCallback, die ich vorigen Monat vorgestellt habe, einen letzten Parameter haben, den ich nicht erwähnt habe. Ich habe in jedem Fall einfach einen Zeigerwert Null weitergegeben. Dieser Parameter zeigt eigentlich auf eine Umgebung, und er stellt dar, wie Sie verschiedene Arbeitsobjekte mit einer Umgebung verbinden.

environment e;
work w(CreateThreadpoolWork(callback, nullptr, e.get()));
check_bool(w);

Welchen Nutzen hat das? Nun, keinen großen – das heißt, bis Sie beginnen, die Umgebung anzupassen.

Private Pools

Standardmäßig leitet die Umgebung Rückrufe an den Standard-Threadpool für den Prozess weiter. Dieser Threadpool hätte die Rückrufe nicht verarbeitet, wenn Sie nicht die Arbeit mit einer Umgebung verbunden hätten. Beachten Sie, was es bedeutet, einen Standard-Threadpool für einen Prozess zu haben. Jeder Code, der innerhalb des Prozesses ausgeführt wird, kann diesen Threadpool verwenden. Denken Sie daran, dass ein durchschnittlicher Prozess Dutzende von DLLs direkt oder indirekt lädt. Es sollte klar sein, dass sich dies ernstlich auf die Leistung auswirken kann. Dies ist nicht unbedingt etwas Schlechtes. Das Aufteilen eines Threadpools untere mehrere Subsysteme kann oft die Leistung verbessern, da die beschränkte Anzahl physischer Prozessoren im Computer effizient geteilt werden kann.

Die Alternative ist, das jedes Subsystem seinen eigenen Pool von Threads hat, die alle in einem wenig kooperativen Wettbewerb um Prozessorzyklen stehen. Wenn andererseits ein spezielles Subsystem missbräuchlich den Standard-Threadpool verwendet, können Sie sich davor schützen, indem Sie einen privaten Pool verwenden. Das andere Subsystem kann Rückrufe mit langer Laufzeit oder so viele Rückrufe in die Warteschlange einreihen, dass die Wartzeit für Ihre Rückrufe inakzeptabel ist. Vielleicht haben Sie auch spezifische Erfordernisse, die bestimmte Grenzwerte für die Anzahl der Threads im Pool erforderlich machen. Nun kommt das Poolobjekt ins Spiel.

Die Funktion CreateThreadpool erstellt ein privates Poolobjekt vollkommen unabhängig vom Standard-Threadpool. Wenn die Funktion erfolgreich ist, gibt sie einen nicht transparenten Zeiger zurück, der das Poolobjekt 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 Poolobjekts informiert die CloseThreadpool-Funktion das System, dass das Objekt freigegeben werden kann. Wiederum wird die Klassenvorlage unique_handle übernommen, die ich im Juli 2011 vorgestellt habe, um diese Details mithilfe einer poolspezifischen "traits"-Klasse zu behandeln.

struct pool_traits
{
  static PTP_POOL invalid() throw()
  {
    return nullptr;
  }
 
  static void close(PTP_POOL value) throw()
  {
    CloseThreadpool(value);
   }
};
 
typedef unique_handle<PTP_POOL, pool_traits> pool;

Ich kann jetzt die passende Typedef verwenden und ein Poolobjekt wie folgt erstellen:

pool p(CreateThreadpool(nullptr));
check_bool(p);

Diesmal blende ich nichts aus. Der Parameter ist in diesem Fall für zukünftige Verwendung reserviert und muss auf den Zeigerwert Null eingestellt werden. Die Inlinefunktion SetThreadpoolCallbackPool aktualisiert die Umgebung, um anzuzeigen, welche Pool-Rückrufe weitergeleitet werden sollen an:

SetThreadpoolCallbackPool(e.get(), p.get());

Auf diese Weise werden Arbeitsobjekte und eventuelle weitere in dieser Umgebung erstellte Objekte mit dem gegebenen Pool verbunden. Sie könnten einige verschiedene Umgebungen erstellen, jeweils mit eigenem Pool, um verschiedene Teile der Anwendung zu isolieren. Achten Sie darauf, die Konkurrenz zwischen verschiedenen Pools auszubalancieren, damit keine übertriebene Planung mit zu vielen Threads entsteht.

Wie oben angedeutet, ist es auch möglich, obere und untere Grenzwerte für die Anzahl der Threads in Ihrem eigenen Pool festzulegen. Das Kontrollieren des Standard-Theadpools auf diese Weise ist nicht erlaubt, da es sich auf andere Subsysteme auswirken würde und zu verschiedenen Kompatibilitätsproblemen führen würde. Ich könnte zum Beispiel einen Pool mit genau einem Thread erstellen, um eine API zu behandeln, die über eine Threadverbindung verfügt, und einen weiteren Pool ohne Grenzwerte für E/A-Abschluss und andere damit zusammenhängende Rückrufe, um es dem System zu erlauben, die Anzahl der Threads nach Bedarf dynamisch anzupassen. So würde ich einen Pool so einstellen, dass er konsistent einen Thread zuweist:

check_bool(SetThreadpoolThreadMinimum(p.get(), 1));
SetThreadpoolThreadMaximum(p.get(), 1);

Beachten Sie, dass das Einstellen des Minimums misslingen kann, das Einstellen des Maximums dagegen nicht. Das Standardminimum ist Null, und das Umstellen auf einen anderen Wert kann misslingen, da er versuchen wird, so viele Threads zu erstellen, wie nachgefragt werden.

Priorisieren von Rückrufen

Eine weitere Funktion, die durch den Threadpool aktiviert wird, ist die Fähigkeit, Callbacks zu priorisieren. Dies ist zufällig die einzige Hinzufügung zur Windows Thread Pool API in Windows 7. Denken Sie daran, wenn Sie auf Windows Vista ausgerichtet arbeiten. Ein priorisierter Rückruf wird garantiert vor allen Rückrufen mit geringerer Priorität ausgeführt. Dies wirkt sich nicht auf Threadprioritäten aus und führt daher nicht dazu, dass die Ausführung von Rückrufen verhindert wird Priorisierte Rückrufe wirken sich einfach auf die Reihenfolge der Rückrufe aus, deren Ausführung aussteht.

Es gibt drei Prioritätsebenen: niedrig, normal und hoch. Die Funktion SetThreadpoolCallbackPriority legt die Priorität einer Umgebung fest:

SetThreadpoolCallbackPriority(e.get(), TP_CALLBACK_PRIORITY_HIGH);

Wiederum werden für alle Arbeitsobjekte und weitere in dieser Umgebung erstellte Objekte die Rückrufe entsprechend priorisiert.

Ein serieller Pool

Im vorigen Artikel habe ich die Beispielklasse functional_pool eingeführt, um die verschiedenen mit Arbeitsobjekten zusammenhängenden Funktionen vorzuführen. Diesmal zeige ich Ihnen, wie man einen einfachen seriellen Pool erstellt, unter Verwendung aller Funktionen, die ich in diesem Monat vorgestellt habe und die sich auf die Threadpoolumgebung beziehen. Mit seriell meine ich, das ich möchte, dass der Pool konsistent genau einen Thread verwaltet. Und durch "Priorisiert" werde ich einfach die Übermittlung von Funktionen entweder mit normaler oder mit hoher Priorität unterstützen. Ich kann nun die Klasse serial_pool definieren, wie in Abbildung 2 gezeigt.

Abbildung 2 ‑ Definition der Klasse Serial_Pool

class serial_pool
{
  typedef concurrent_queue<function<void()>> queue;
 
  pool m_pool;
  queue m_queue, m_queue_high;
  work m_work, m_work_high;
 
  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();
  }

Anders als die Klasse functional_pool, verwaltet serial_pool eigentlich ein Poolobjekt. Sie benötigt auch getrennte Warteschlangen und Arbeitsobjekte für normale und hohe Priorität. Die Arbeitsobjekte können mit verschiedenen Kontextwerten erstellt werden, die auf die jeweiligen Warteschlangen verweisen, und dann kann einfach die private Rückruffunktion wiederverwendet werden. Dadurch werden meinerseits Verzweigungen während der Laufzeit vermieden. Der Rückruf zeigt weiterhin eine einzelne Funktion aus der Warteschlange an und ruft sie auf. Der Konstruktor serial_pool (dargestellt in Abbildung 3) muss etwas mehr Arbeit leisten.

Abbildung 3 Der Konstruktor Serial_Pool

public:
 
  serial_pool() :
    m_pool(CreateThreadpool(nullptr))
  {
    check_bool(m_pool);
    check_bool(SetThreadpoolThreadMinimum(m_pool.get(), 1));
    SetThreadpoolThreadMaximum(m_pool.get(), 1);
 
    environment e;
    SetThreadpoolCallbackPool(e.get(), m_pool.get());
 
    SetThreadpoolCallbackPriority(e.get(), TP_CALLBACK_PRIORITY_NORMAL);
    check_bool(m_work.reset(CreateThreadpoolWork(
      callback, &m_queue, e.get())));
 
    SetThreadpoolCallbackPriority(e.get(), TP_CALLBACK_PRIORITY_HIGH);
    check_bool(m_work_high.reset(CreateThreadpoolWork(
      callback, &m_queue_high, e.get())));
  }

Das Erste ist die Erstellung des privaten Pools und das Festlegen seiner Parallelitätslimits, um die serielle Ausführung aller Rückrufe zu gewährleisten. Als Nächstes erstellt er eine Umgebung und legt für nachfolgende Objekte den zu verwendenden Pool fest. Schließlich erstellt er Arbeitsobkjekte und passt die Priorität der Umgebung an, um die jeweiligen Prioritäten der Arbeitsobjekte einzurichten und die Verbindung zu dem von ihnen gemeinsam genutzten privaten Pool herzustellen. Obwohl der Pool und das Arbeitsobjekt während der gesamten Lebensdauer eines serial_pool-Objekts beibehalten werden müssen, wird die Umgebung im Stapel erstellt, da sie nur dazu dient, die Beziehungen zwischen verschiedenen beteiligten Parteien herzustellen.

Der Destruktor muss jetzt auf beide Arbeitsobjekte warten, um sicherzugehen, dass keine Rückrufe ausgeführt werden, nachdem das serial_pool-Objekt zerstört wurde.

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

Und schließlich sind zwei Senden-Funktionen erforderlich, um Funktionen entweder mit normaler oder mit hoher Priorität in die Warteschlange einzureihen:

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

Am Ende liegt alles daran, wie die Arbeitsobjekte erstellt wurden, insbesondere, welche Informationen über die gewünschte Threadpool-Umgebung bereitgestellt wurde. Abbildung 4 zeigt ein einfaches Beispiel, das Sie verwenden und in dem Sie das serielle und das priorisierte Verhalten bei der Arbeit sehen können.

Abbildung 4 Serielles und priorisiertes Verhalten bei der Arbeit

int main()
{
  serial_pool pool;
 
  for (int i = 0; i < 10; ++i)
  {
    pool.submit([]
    {
      printf("normal: %d\n", GetCurrentThreadId());
    });
 
    pool.submit_high([]
    {
      printf("high: %d\n", GetCurrentThreadId());
    });
  }
  getch();
}

In dem in Abbildung 4 gezeigten Beispiel ist es möglich, dass ein Rückruf mit normaler Priorität zuerst ausgeführt wird – je nachdem, wie schnell das System reagiert – da er zuerst gesendet wurde. Abgesehen davon sollten alle Rückrufe mit hoher Priorität ausgeführt werden, gefolgt von den übrigen mit normaler Priorität. Sie können mit dem Hinzufügen von Sleep-Anrufen experimentieren und den Grad der Konkurrenz anheben, um zu sehen, wie der Threadpool sein Verhalten entsprechend Ihren Spezifikationen anpasst.

Ich hoffe, Sie sind auch nächsten Monat dabei, wenn es um die kritischen Abbruch- und Bereinigungsfunktionen geht, die von der Windows-Threadpool-API bereitgestellt werden.

Kenny Kerr ist Softwarespezialist mit einer Vorliebe für die systemeigene Windows-Entwicklung. Sie erreichen ihn unter http://kennykerr.ca.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels:Stephan T. Lavavej