Die Win32-Thread-Pool-API — Nebenläufigkeit ohne eigene Threads, über CreateThreadpoolWork

· · Windows, Multithreading, C++, Windows-Entwicklung, Win32-API, Leistungsverbesserung

„Ein CreateThread pro Client.“ „Einer für den Timer.“ „Einer für das Warten auf ein Ereignis.“ — In nativem Windows-Code neigen Threads dazu, sich zu vermehren. Jeder Thread verbraucht einen Stack und ein Kernelobjekt, und das Anlegen und Zerstören hat ebenfalls Kosten. Die Arbeit ist feinkörnig, der Thread aber schwer — der Thread-Pool ist das, was das Betriebssystem bereitstellt, um diese Diskrepanz aufzufangen.

Die Bequemlichkeit von ThreadPool und Task.Run in .NET ist bekannt, aber tatsächlich hat auch Win32 nativ eine gut entworfene, betriebssystemeigene Thread-Pool-API. In Windows Vista umfassend neu entworfen, ist diese API die Grundlage nativer Nebenläufigkeit: Sie kann Arbeit, Timer, Warten und asynchrones I/O über einen einheitlichen Callback-Mechanismus behandeln. Gerichtet an Entwickler, die Windows-Apps, Dienste und DLLs in C/C++ schreiben, erklärt dieser Artikel Struktur und Nutzung dieser API und die Fallstricke, in die man leicht gerät, anhand von Primärquellen.

1. Zuerst das Fazit

  • Für das Ausgeben einer großen Zahl kurzlebiger Aufgaben und für das Ersetzen von Threads, die nur warten, schlägt ein Thread-Pool eigenes CreateThread. Sie überlassen die Threadverwaltung dem Betriebssystem und können Threadzahl und Kontextwechsel senken.1
  • Was Sie nutzen sollten, ist die neue API (die Familie CreateThreadpoolWork). Der Vista-Neuentwurf machte sie einfacher, zuverlässiger und leistungsfähiger als die alte API (die Familie QueueUserWorkItem), und Sie können auch mehrere unabhängige Pools in einem Prozess anlegen.12
  • Es gibt vier Arten von Objekten. work, dem Sie Aufgaben übergeben; timer, der zu einem Zeitpunkt oder periodisch auslöst; wait, der auslöst, wenn ein Kernelobjekt signalisiert wird; und io, der auslöst, wenn asynchrones I/O fertig ist. Alle sitzen auf demselben Callback-Mechanismus.3
  • Herunterfahren heißt „warten, dann schließen“. Eine Disziplin, keinen laufenden Callback zurückzulassen — Warten auf Abschluss mit der Familie WaitForThreadpoolWorkCallbacks oder Sammelbehandlung über eine Cleanup-Gruppe — ist erforderlich.4
  • In einem Callback: nicht lange blockieren (wenn doch, CallbackMayRunLong); nicht synchron auf Abschluss im selben Pool warten; den Zustand des Threads nicht verschmutzen. Diese drei sind eiserne Regeln.56
  • Nutzung aus einer DLL: auf einen Entlade-Wettlauf achten. Warten Sie in einer expliziten Herunterfahrfunktion auf Abschluss, und kennen Sie die eigenen APIs wie FreeLibraryWhenCallbackReturns.3

2. Warum ein Pool, und wann ein Pool

Die Idee eines Thread-Pools ist einfach. Statt einen Thread pro Aufgabe anzulegen, werfen Sie Aufgaben (Callbacks) auf eine Gruppe von Worker-Threads, die das Betriebssystem verwaltet. Die Worker führen Aufgaben nacheinander aus, und das Betriebssystem passt die Zahl an die Last an.

Die offizielle Dokumentation nennt konkrete App-Typen, bei denen ein Pool sich lohnt.1

  • Apps, die eine große Zahl kleiner Arbeitselemente parallel ausgeben (Suche, Netz-I/O und so weiter)
  • Apps, die häufig kurzlebige Threads anlegen und wieder abbauen
  • Apps, die unabhängige Arbeit parallel im Hintergrund verarbeiten
  • Apps, die Threads halten, die ausschließlich auf Kernelobjekte oder Ereignisse warten

Der letzte Punkt ist leicht zu übersehen. Haben Sie fünf Threads, die nur schlafen, damit sie „laufen, wenn das Ereignis signalisiert wird“, können die durch fünf wait-Objekte auf dem Pool ersetzt werden, und das Warten wird auf die Waiter-Threads des Pools zusammengezogen.

Umgekehrt gibt es auch Arbeit, die nicht zu einem Pool passt. Arbeit, die eine Änderung der Threadpriorität braucht, die COM STA verlangt, die die ganze Lebensdauer des Prozesses weiterläuft — Arbeit, die eine „Persönlichkeit“ auf dem Thread braucht, wird auf einem eigenen Thread gehalten. Ein Worker-Thread ist eine geteilte Ressource; er wird ausgeliehen.

Wahl zwischen eigenem Thread und PoolPrüfen Sie zuerst, ob eine Thread-Persönlichkeit wie Priorität oder STA nötig ist und ob die Arbeit lange läuft; nur kurzlebige, hochvolumige oder Warte-Arbeit, die zu keinem von beiden passt, kommt auf den Thread-PoolJaNeinJaNeinPersönlichkeit nötig(Prio/STA)?Auf eigenem Thread haltenLäuft sie lange?Auf den Thread-PoolKurzlebig, Warten, Timer, I/O

Abbildung 1: Die einzige Arbeit, die Sie auf einen Pool legen dürfen, ist Arbeit, die „keine Persönlichkeit braucht und kurz endet“. Alles andere bleibt wie bisher auf einem eigenen Thread.

Es gibt außerdem ein Stück Geschichte festzuzurren. Die Thread-Pool-API hat zwei Generationen. Die alte API, die seit Windows 2000 weiterläuft (QueueUserWorkItem, RegisterWaitForSingleObject und so weiter), und die neue API, die in Vista umfassend neu entworfen wurde (die Familie CreateThreadpoolWork). Die neue API vereinheitlicht die Arten von Worker-Threads, stellt eigene persistente Threads, mehrere Pools in einem Prozess, Cleanup-Gruppen und mehr bereit, und die offizielle Dokumentation sagt wörtlich, sie sei „einfacher, zuverlässiger, leistungsfähiger und flexibler“.1 Die alte API hat auch strukturelle Einschränkungen wie „Sie können Arbeit nach dem Einreihen nicht abbrechen“.2 Von hier an behandelt dieser Artikel nur die neue API.

Entsprechung zwischen alter Thread-Pool-API und neuer APIQueueUserWorkItem der alten API entspricht dem work-Objekt der neuen API, Timer-Warteschlangen timer, registrierte Wartevorgänge wait und BindIoCompletionCallback ioQueueUserWorkItemworkTimer-WarteschlangentimerRegistrierte WaitswaitBindIoCompletionCallbackio

Abbildung 2: Das Migrationsziel von der alten API ist eins zu eins. Eine Inventur bestehenden Codes kann von dieser Entsprechung ausgehen.

3. Die vier Objekte — work, timer, wait und io

Im Zentrum der neuen API stehen vier Arten von Objekten, deren Callback-Auslösebedingungen sich unterscheiden.3

Objekt Anlegefunktion Wann der Callback auslöst
work CreateThreadpoolWork Wenn er mit SubmitThreadpoolWork übergeben wird
timer CreateThreadpoolTimer Wenn der angegebene Zeitpunkt oder die Periode eintritt
wait CreateThreadpoolWait Wenn ein Kernelobjekt signalisiert wird
io CreateThreadpoolIo Wenn asynchrones I/O auf dem zugeordneten Handle fertig ist
Die vier Objekte des Thread-Pools und der Callback-Mechanismuswork löst bei expliziter Übergabe aus, timer zur Zeit, wait bei einem Kernelobjekt-Signal und io bei Fertigstellung asynchronen I/O; alle laufen als Callbacks auf derselben Gruppe von Worker-ThreadsWelches Objekt?work(bei Übergabe)Timer, wait oder io?timer(Zeit / Periode)Wait oder io?wait(bei Signal)io(I/O-Fertigstellung)Worker führen Callback aus

Abbildung 3: Die Auslösebedingungen unterscheiden sich, aber alle vier sind in einem Mechanismus vereinheitlicht, in dem „Worker auf demselben Pool den Callback ausführen“.

Diese Vereinheitlichung ist eine praktische Stärke. Statt periodische Verarbeitung, Ereignisreaktion und I/O-Fertigstellungsverarbeitung jeweils auf einem eigenen Thread zu schreiben, können Sie sie auf einen Callback-Stil ausrichten. Timer werden in einer einzigen Timer-Warteschlange für den ganzen Pool gesammelt, und Wartevorgänge werden auf eine kleine Zahl von Waiter-Threads zusammengezogen — Threads, die „nur schlafen“, verschwinden aus dem Prozess.1

Ersetzen von Threads, die nur warten, durch wait-ObjekteThreads, die nur warten und früher je Ereignis einzeln schliefen, werden zu wait-Objekten und auf die Waiter-Threads des Pools zusammengezogen, sodass der Callback nur bei Signal läuft5 eigene Waiter-Threads, einzeln schlafendVerbraucht 5 Stacks und 5 Threads5 wait-ObjekteAuf Waiter-Threads des PoolsCallback nur bei Signal

Abbildung 4: Threads, die „nur schlafen und warten“, können Sie entfernen, indem Sie sie zu wait-Objekten machen. Das ist ein klarer erster Schritt für eine Pool-Migration.

4. Das Grundmuster — Ein Hin und Zurück mit einem work-Objekt

Wir gehen die Manieren einmal mit dem work-Objekt durch, das am häufigsten genutzt wird.4

VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
                           PVOID context, PTP_WORK work)
{
    // The context is fixed at creation time. Per-item data is passed through a synchronised queue
    WORK_QUEUE* queue = (WORK_QUEUE*)context;
    ITEM* item = Dequeue(queue);        // Take one item under exclusive control
    ProcessItem(item);
}

// 1) Create (bind the callback to the shared context = the queue)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* Failure handling with GetLastError */ }

// 2) Submit once for each item you enqueue (keep the item count and the submit count in step)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);

// 3) Stop the submitter, then wait for completion (TRUE also attempts to cancel work that has not yet started)
WaitForThreadpoolWorkCallbacks(work, FALSE);

// 4) Close
CloseThreadpoolWork(work);

Es gibt zwei Punkte festzuhalten. Erstens dürfen Sie dasselbe work-Objekt mehr als einmal mit SubmitThreadpoolWork übergeben. Jede Übergabe führt den Callback aus (parallel).7 Allerdings ist der Kontext, der an den Callback übergeben wird, zum Anlegezeitpunkt fest, also legen Sie, wenn Sie „N Elemente derselben Arbeitsart“ mit einem work-Objekt schreiben, wie im Code oben eine synchronisierte Warteschlange in den Kontext und nehmen pro Übergabe ein Element (ein Entwurf, der ein work-Objekt pro Element anlegt, ist ebenfalls in Ordnung). Zweitens warten Sie stets auf Abschluss, bevor Sie schließen. Das Objekt zu schließen, während ein laufender oder eingereihter Callback bleibt, oder Speicher freizugeben, auf den der Callback verweist, ist unmittelbar Use-after-free. Damit dieses Warten ein sicheres Schließen wird, ist zuerst das Stoppen des Übergebers Voraussetzung — in einer Struktur, in der ein anderer Thread parallel zum Warten noch Submit aufrufen kann, wettet eine Übergabe nach dem Warten mit Close. TRUE als zweites Argument von WaitForThreadpoolWorkCallbacks versucht außerdem, Übergaben abzubrechen, die noch nicht begonnen haben.

Lebenszyklus eines work-ObjektsAnlegen mit CreateThreadpoolWork; Übergeben mit SubmitThreadpoolWork, Callbacks laufen parallel. Beim Herunterfahren zuerst neue Übergaben stoppen, mit WaitForThreadpoolWorkCallbacks auf den Abschluss jedes Callbacks warten, dann mit CloseThreadpoolWork schließenAnlegen mit CreateThreadpoolWorkÜbergeben mit SubmitThreadpoolWork(wiederholbar)Callbacks laufen parallelNeue Übergaben stoppenWarten mit WaitForThreadpoolWorkCallbacksSchließen mit CloseThreadpoolWork

Abbildung 5: Die Herunterfahrreihenfolge ist „Übergaben stoppen → auf Abschluss warten → schließen“. Überspringen Sie eines davon, erhalten Sie Use-after-free oder einen Wettlauf.

Standardmäßig laufen Callbacks auf dem prozessweiten Standardpool. Für viele Einsätze reicht das. Das nächste Kapitel gilt, wenn Sie Pools teilen wollen.

5. Eigene Pools und Cleanup-Gruppen

Den Pool teilen. Sie können mit CreateThreadpool einen unabhängigen Pool anlegen und mit SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum die Ober- und Untergrenze der Threadzahl setzen.8 Der typische Einsatz ist Isolierung. Damit „batchartige Arbeit, die langsam sein darf“ nicht die Worker von „Arbeit, die sofort reagieren muss“ auffrisst, teilen Sie die Pools und geben jedem sein eigenes Threadbudget.

Mit einer Callback-Umgebung binden. Auf welchem Pool die Arbeit läuft, geben Sie an, indem Sie eine TP_CALLBACK_ENVIRON (Callback-Umgebung) initialisieren, sie mit SetThreadpoolCallbackPool auf den Pool zeigen und das als drittes Argument von CreateThreadpoolWork und Ähnlichem übergeben.9

Mit einer Cleanup-Gruppe zusammenfalten. In einem Modul, das viele Objekte anlegt, neigt die Herunterfahrverarbeitung dazu, ein Hersagen von „auf alle warten, alle schließen“ zu werden. Legen Sie mit CreateThreadpoolCleanupGroup eine Gruppe an und hängen Sie jedes Objekt über die Callback-Umgebung daran, führt ein einziges CloseThreadpoolCleanupGroupMembers Abschlusswarten und Freigabe für jedes Mitgliedsobjekt gemeinsam aus.34

Binden der Konfiguration über eine Callback-UmgebungDie Callback-Umgebung zeigt auf einen eigenen Pool und eine Cleanup-Gruppe; work und timer, die mit dieser Umgebung angelegt werden, laufen auf diesem Pool, und eine Sammeloperation auf der Cleanup-Gruppe bündelt Abschlusswarten und FreigabeCallback-Umgebung(TP_CALLBACK_ENVIRON)Eigener Pool(Threadzahl steuern)Cleanup-GruppeBeim Anlegen von work / timer / wait / ioAbschlusswarten und Freigabe auf einmal

Abbildung 6: Eine Callback-Umgebung ist der Mechanismus, der „auf welchem Pool es läuft und wer aufräumt“ zum Anlegezeitpunkt des Objekts einspeist.

6. Fallstricke — Disziplin in einem Callback

Fast jeder Thread-Pool-Fehler kommt daher, „auf einem ausgeliehenen Thread nach Belieben zu tun“.

Langes Blockieren. Der Pool passt seine Threadzahl unter der Annahme an, dass Callbacks zügig zurückkehren. Lange Arbeit oder langes Warten bei den Voreinstellungen verzögert die Ausführung anderer Callbacks. Ein Callback, der lange laufen darf, sollte mit CallbackMayRunLong erklären „das wird lange laufen“ (der Pool nimmt das als Hinweis, einen Thread hinzuzufügen), oder von vornherein auf einen eigenen Thread geschickt werden. Beachten Sie, dass CallbackMayRunLong FALSE zurückgibt, wenn es keinen Worker für andere Callbacks vorbereiten kann. Blockieren Sie weiter, ohne den Rückgabewert zu prüfen, verstopfen Sie den Pool trotzdem; bei FALSE fallen Sie auf die Seite, die nicht blockiert — die Arbeit teilen, sie auf einen eigenen Thread schicken und so weiter.5

Synchrones Warten auf Abschluss im selben Pool. Eine Form, in der Sie innerhalb von Callback A mit WaitForThreadpoolWorkCallbacks oder Ähnlichem auf den Abschluss von Arbeit B warten, die demselben Pool übergeben wurde, wird in dem Moment zu einem Deadlock durch Poolverhungern, in dem jeder Worker „auf einen anderen Worker wartet“. Schreiben Sie eine Abhängigkeit zwischen Aufgaben nicht als Warten, sondern als Fortsetzung, die „das Nächste aus dem Abschluss-Callback von B übergibt“.

Die Struktur eines Deadlocks durch PoolverhungernWartet jeder Worker-Thread synchron auf den Abschluss anderer Arbeit, die demselben Pool übergeben wurde, bleibt kein freier Worker, der diese Arbeit ausführen könnte, und alle warten ewigWorker 1: wartet auf Arbeit XArbeit X und Y warten auf AusführungWorker 2: wartet auf Arbeit YKein freier Worker mehrAlle warten ewig(Verhungern)

Abbildung 7: Warten Sie synchron von innerhalb eines Workers auf einen Worker, bleibt niemand, der die erwartete Arbeit ausführt.

Den Zustand des Threads verschmutzen. Ein Worker-Thread wird für den nächsten Callback wiederverwendet. Eine Änderung der Threadpriorität, ein COM-Initialisierungszustand, ein in TLS gelassener Wert, eine Sperre, die Sie zu verlassen vergessen haben — jedes davon wird zur Verunreinigung des nächsten (unverwandten) Callbacks. „Eine Funktion, die Sie auf einen Pool werfen, darf nicht von der Persönlichkeit des Threads abhängen“ ist eine offizielle Warnung schon seit der Ära der alten API.6 Für die Aufräumarbeit gibt es eigene Mechanismen; zum Beispiel kann LeaveCriticalSectionWhenCallbackReturns den Pool bitten, „diese Sperre freizugeben, wenn dieser Callback zurückkehrt“.3

Ein Wettlauf mit dem Entladen einer DLL. Wird die DLL, die den Code enthält, entladen, während ein Callback läuft, gibt es eine Zugriffsverletzung. Die Grundform ist, in der Herunterfahrfunktion der DLL gründlich auf Abschluss zu warten; FreeLibraryWhenCallbackReturns ist für die Situation vorgesehen „dieser Callback ist die letzte Aufgabe, und wenn er endet, will ich die DLL einschließlich meiner selbst freigeben“. Diese API „lässt nur eine Referenz los, wenn der laufende Callback zurückkehrt“; sie verhindert kein Entladen, bevor der Callback begonnen hat. Sie nutzen sie als Paar: Nehmen Sie vor der Übergabe mit GetModuleHandleEx eine eigene Modulreferenz, und lassen Sie den Callback diese Referenz mit dieser API los.3 Und Sie dürfen dieses Abschlusswarten nicht innerhalb von DllMain tun — wie in „DllMain and the Loader Lock“ gesagt, ist Warten auf einen anderen Thread innerhalb von DllMain ein Deadlock-Muster.

Vorbereitung auf einen Wettlauf zwischen DLL-Entladen und einem CallbackEin Entladen der DLL während ein Callback läuft wird zu einer Zugriffsverletzung, also lautet die Grundform, in einer expliziten Herunterfahrfunktion auf Abschluss zu warten und dann zu schließen; wenn der letzte Callback selbst die DLL freigibt, nutzen Sie FreeLibraryWhenCallbackReturnsUnload während CallbackAccess ViolationWarten, dann schließenSicheres EntladenIn Shutdown-FunktionFreeLibraryWhenCallbackReturnsletzter Callback gibt freiNicht in DllMain

Abbildung 8: Die Grundform ist, „warten, dann schließen“ in einer expliziten Herunterfahrfunktion zu tun. Warten innerhalb von DllMain lädt einen anderen Deadlock ein.

Ausnahmen und Abstürze in übergebener Arbeit. Eine unbehandelte Ausnahme auf einem Worker-Thread nimmt den Prozess mit. Wenden Sie eine Richtlinie an, Ausnahmen am Eingang des Callbacks umfassend zu fangen und zu protokollieren, genauso wie Sie es für die Threadfunktion eines eigenen Threads tun würden.

7. Wie das zur Standardbibliothek und zu .NET steht — Auf welcher Schicht Sie schreiben

Schließlich ordnen wir ein, wie das zu den anderen Werkzeugen steht.

  • Wenn C++ std::async / std::thread reichen, sind sie der erste Kandidat. Sie sind portabel, der Code ist kurz, und selbst die Semantik von future ist durch den Standard geklärt.10
  • Gründe, den Win32-Thread-Pool direkt zu nutzen, sind, wenn Sie (1) einen einheitlichen Callback-Mechanismus wollen, der timer, wait und io einschließt, (2) Poolteilung oder Threadzahlsteuerung wollen oder (3) innerhalb einer DLL oder einer COM-Komponente keine eigenen Threads halten wollen.
  • Auf der .NET-Seite spielen ThreadPool und Task dieselbe Rolle, und I/O-Fertigstellung ist an IOCP gebunden. Diese Kellerstruktur erklärt „IOCP und der .NET-Thread-Pool“.
Entscheiden, mit den Werkzeugen welcher Schicht Sie schreibenReichen Standard-C++ async oder thread, nutzen Sie die; nutzen Sie den Win32-Thread-Pool direkt, wenn Sie Integration von Timern, Wartevorgängen und I/O-Fertigstellungen, Poolteilung oder Threadzahlsteuerung brauchen oder innerhalb einer DLL oder COM-Komponente keine eigenen Threads wollenJaNeinIntegration von timer, wait und ioPoolteilung / ZahlsteuerungKeine eigenen Threads in einer DLLReichen die C++-Standardwerkzeuge?std::async / std::threadWas brauchen Sie?Win32-Thread-Pool

Abbildung 9: Im Zweifel beginnen Sie mit der Standardbibliothek; die Reihe dieser API kommt, wenn eine Anforderung erscheint, die sie nicht ausdrücken kann.

Mit anderen Worten ist diese API die Grundlage der Nebenläufigkeit an dem Punkt, an dem Sie entschieden haben, „nativ zu schreiben“. Eine realistische Aufräumung ist eine gestufte: Als Migrationsziel von einer Vermehrung eigenen CreateThread führen Sie zuerst das work-Objekt ein, ersetzen dann Threads, die nur warten, durch wait und Timer-Threads durch timer.

8. Zusammenfassung

  • Für das Ausgeben einer großen Zahl kurzlebiger Aufgaben und für das Aufräumen von Threads, die nur warten oder nur Timer sind, der betriebssystemeigene Thread-Pool statt eigener Threads. Was Sie nutzen, ist die neue API ab Vista.
  • Im Zentrum stehen die vier Objekte work, timer, wait und io. Die Auslösebedingungen unterscheiden sich; sie sind auf derselben Gruppe von Workern und demselben Callback-Stil vereinheitlicht.
  • Die Manieren sind „anlegen → übergeben → auf Abschluss warten → schließen“. Mehrere Übergaben laufen parallel. Eine Cleanup-Gruppe kann die Herunterfahrverarbeitung bündeln.
  • Die drei eisernen Regeln eines Callbacks: nicht lange blockieren (wenn doch, CallbackMayRunLong); nicht synchron im selben Pool warten; den Zustand des Threads nicht verschmutzen.
  • Nutzung aus einer DLL: auf einen Entlade-Wettlauf achten. Warten Sie in einer expliziten Herunterfahrfunktion auf Abschluss; tun Sie es nicht in DllMain.
  • Wo Standard-C++ oder .NET reichen, nutzen Sie die. Die Reihe dieser API kommt, wenn Sie Integration von timer/wait/io oder Poolsteuerung brauchen.

Die Thread-Pool-API gehört unter den Win32-APIs zu den neueren und besser entworfenen. Haben Sie den Wechsel von der Idee „einen Thread anlegen“ zur Idee „einen Callback werfen“ abgeschlossen, wird Nebenläufigkeit in nativem Code deutlich klarer zu schreiben.

Weiterführende Artikel

Zugehörige Beratungsfelder

KomuraSoft LLC übernimmt den Migrationsentwurf von nativem Code, dessen Threads sich vermehrt haben, auf den Thread-Pool, Design-Reviews nebenläufiger Verarbeitung in C++-Apps und -DLLs sowie die Ursachenuntersuchung von Hängern und Abstürzen, die durch Poolverhungern oder Callbacks verursacht werden. Sie können uns gern beginnend bei einer Inventur bestehenden Codes konsultieren.

Quellen

  1. Microsoft Learn, Thread Pools. Dazu, dass ein Thread-Pool eine Sammlung von Worker-Threads ist, die asynchrone Callbacks im Auftrag einer App effizient ausführen; zu den App-Typen, für die er geeignet ist (eine große Zahl kleiner Arbeitselemente parallel ausgeben, häufig kurzlebige Threads anlegen und abbauen, unabhängige Arbeit parallel verarbeiten, ausschließliches Warten auf Kernelobjekte und so weiter); sowie zum umfassenden Vista-Neuentwurf (Vereinheitlichung der Worker-Thread-Arten, eine einzige Timer-Warteschlange, eigene persistente Threads, Cleanup-Gruppen, mehrere Pools in einem Prozess und die neue API).  2 3 4 5

  2. Microsoft Learn, Thread Pooling. Zur Struktur der älteren Thread-Pool-APIs (QueueUserWorkItem, Timer-Warteschlangen, registrierte Wartevorgänge, BindIoCompletionCallback); dazu, dass es keinen Weg gibt, Arbeit nach dem Einreihen abzubrechen; sowie dazu, dass die in Vista eingeführte neue Thread-Pool-API als einfacher und in Zuverlässigkeit, Leistung und Flexibilität überlegen bezeichnet wird.  2

  3. Microsoft Learn, threadpoolapiset.h header. Zur Funktionsliste, die die vier Objekt-Anlegefunktionen CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait und CreateThreadpoolIo umfasst; zu Cleanup-Gruppen (CreateThreadpoolCleanupGroup); sowie zur an den Callback-Abschluss gebundenen Aufräumarbeit (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns und so weiter).  2 3 4 5 6

  4. Microsoft Learn, Using the Thread Pool Functions. Zum Grundverfahren Anlegen mit CreateThreadpoolWork, Übergeben mit SubmitThreadpoolWork, Warten auf Abschluss mit WaitForThreadpoolWorkCallbacks und Schließen mit CloseThreadpoolWork; sowie zu einem Konfigurationsbeispiel, das einen eigenen Pool mit einer Callback-Umgebung und einer Cleanup-Gruppe kombiniert.  2 3

  5. Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). Zum Mitteilen an den Pool, dass der aktuelle Callback lange laufen darf, sodass der Pool das als Material für die Entscheidung nutzen kann, ob er einen Thread für andere Callbacks sichert; sowie dazu, wo möglich für einen langlebigen Callback einen eigenen Thread zu erwägen.  2

  6. Microsoft Learn, Thread Pooling. Dazu, dass einem Thread-Pool übergebene Arbeitselemente und die Funktionen, die sie aufrufen, thread-pool-sicher sein müssen; dazu, dass Sie nicht annehmen dürfen, der ausführende Thread sei ein eigener, persistenter Thread; sowie dazu, TLS und asynchrone Aufrufe zu vermeiden, die einen persistenten Thread brauchen.  2

  7. Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). Dazu, dass Sie dasselbe work-Objekt mehr als einmal übergeben können, ohne auf den Abschluss eines vorausgehenden Callbacks zu warten, sodass Callbacks parallel laufen; sowie dazu, dass der Pool die Threadzahl zur Effizienz anpassen (drosseln) kann. 

  8. Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). Dazu, dass Sie für einen mit CreateThreadpool angelegten Pool eine Obergrenze der Worker-Thread-Zahl setzen können (die Untergrenze ist SetThreadpoolThreadMinimum). 

  9. Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). Zum Anlegen eines work-Objekts aus einer Callback-Funktion und einem Kontextzeiger; sowie dazu, dass das dritte Argument, TP_CALLBACK_ENVIRON, die Ausführungsumgebung des Callbacks angeben kann (den Pool, zu dem er gehört, und so weiter), wobei NULL bedeutet, dass er in der Standardumgebung läuft. 

  10. Microsoft Learn, <future>. Dazu, dass asynchrone Ausführung pro Aufgabe über std::async und future als Standardbibliothek bereitsteht, sodass Sie Nebenläufigkeit schreiben können, ohne Threads direkt zu verwalten. 

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Was ist an einem Thread-Pool besser als am Anlegen eigener Threads mit CreateThread?
Effizienz, wenn Sie eine große Zahl kurzlebiger Aufgaben erledigen müssen, und weniger Code zur Threadverwaltung. Das Anlegen und Zerstören eines Threads hat Kosten, die Sie nicht ignorieren können; eine App, die „CreateThread für jede Aufgabe und nach Abschluss zerstören“ wiederholt oder viele Threads hält, die nur schlafen, um auf ein Ereignis zu warten, kann durch den Wechsel auf einen Pool ihre Threadzahl und Kontextwechsel senken. Die offizielle Dokumentation nennt als Pool-Kandidaten auch Apps, die eine große Zahl kleiner Arbeitselemente parallel ausgeben, Apps, die viele kurzlebige Threads anlegen, und Apps, die Threads ausschließlich zum Warten auf Kernelobjekte haben. Umgekehrt sollte Arbeit, die „eine eigene Persönlichkeit auf dem Thread braucht“ — eine Änderung der Priorität, COM STA, langlebige Spezialverarbeitung — weiterhin auf einem eigenen Thread gehalten werden.
Wie unterscheidet sich das von den älteren Thread-Pool-Funktionen wie QueueUserWorkItem?
Der Thread-Pool wurde in Windows Vista umfassend neu entworfen. Die heutigen APIs der Familie threadpoolapiset (CreateThreadpoolWork und so weiter) sind die neue API; QueueUserWorkItem, RegisterWaitForSingleObject und Ähnliches sind die alte (Legacy-)API. Die neue API vereinheitlicht die Arten von Worker-Threads, lässt Sie mehrere unabhängige Pools in einem Prozess anlegen und stellt Mechanismen bereit wie die Sammelfreigabe über eine Cleanup-Gruppe und die an den Callback-Abschluss gebundene Freigabe einer Sperre oder das Entladen einer DLL. Die offizielle Dokumentation sagt außerdem, die neue API sei einfacher und in Zuverlässigkeit, Leistung und Flexibilität überlegen. Die alte API hat auch strukturelle Einschränkungen wie „es gibt keinen Weg, Arbeit nach dem Einreihen abzubrechen“, also nutzen Sie in neuem Code die neue API.
Gibt es Dinge, die Sie in einem Callback nicht tun dürfen?
Es gibt drei große. Erstens langes Blockieren oder lange Arbeit bei den Voreinstellungen. Der Pool passt seine Threadzahl unter der Annahme an, dass Callbacks zügig enden; für Arbeit, die lange dauert, erklären Sie das entweder mit CallbackMayRunLong oder nutzen Sie einen eigenen Thread. Zweitens synchrones Warten auf den Abschluss anderer Arbeit, die Sie demselben Pool übergeben haben. Endet jeder Worker damit, „auf einen anderen Worker zu warten“, entsteht ein Deadlock durch Poolverhungern. Drittens Abhängigkeit von der Persönlichkeit des Threads. Worker-Threads werden über Callbacks hinweg geteilt; eine geänderte Threadpriorität oder ein geänderter COM-Initialisierungszustand beim Zurückkehren oder Zustand, der in TLS bleibt, verunreinigt den nächsten Callback. Für die Aufräumarbeit am Ende (eine Sperre freigeben oder eine DLL entladen) sind eigene Mechanismen wie LeaveCriticalSectionWhenCallbackReturns und FreeLibraryWhenCallbackReturns vorgesehen.
Gibt es Punkte, auf die Sie achten müssen, wenn Sie den Thread-Pool aus einer DLL nutzen?
Die größte Gefahr ist „die DLL wird entladen, während ein Callback noch läuft“. Läuft der Callback nach dem Entladen, gibt es eine Zugriffsverletzung. Die DLL-Seite muss in ihrer Herunterfahrverarbeitung zuverlässig auf den Abschluss der Callbacks warten, die sie ausgegeben hat — mit einer Wartefunktion wie WaitForThreadpoolWorkCallbacks oder CloseThreadpoolCleanupGroupMembers auf einer Cleanup-Gruppe — und erst dann die Objekte schließen. Dieses Warten innerhalb von DllMain kann jedoch durch Wechselwirkung mit der Loader-Sperre zu einem Deadlock führen; die Regel lautet, es in einer expliziten Herunterfahrfunktion zu tun, nicht in DllMain. Für die Situation, in der der Callback selbst die DLL freigeben will, weil „diese Arbeit die letzte ist“, ist eine eigene API vorgesehen: FreeLibraryWhenCallbackReturns.
Gibt es, wo wir C++ std::async und den ThreadPool von .NET haben, noch Anlässe, diese API direkt zu nutzen?
Ja. Das Kriterium ist „reicht das Werkzeug auf dieser Schicht“. Deckt die Granularität der Nebenläufigkeit, die Sie in C++ brauchen, std::async oder std::thread ab, ist die Standardbibliothek schon aus Portabilitätsgründen der erste Kandidat. Andererseits: Timer, Warten auf Kernelobjekte und asynchrone I/O-Fertigstellungen in einem Callback-Mechanismus vereinheitlichen wollen; Pools teilen und Threadzahlen pro Arbeitsart steuern wollen; innerhalb einer DLL oder einer COM-Komponente keine eigenen Threads halten wollen — das sind Anforderungen, die der Win32-Thread-Pool abdeckt. Das Verhältnis zum ThreadPool und IOCP von .NET behandelt ein verwandter Artikel; solange Sie nativ schreiben, ist die Kenntnis dieses Mechanismus, der eine Schicht darunter sitzt, nicht verschwendet.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog