Die Win32-Thread-Pool-API — Nebenläufigkeit ohne eigene Threads, über CreateThreadpoolWork
· Go Komura · 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 FamilieQueueUserWorkItem), 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
WaitForThreadpoolWorkCallbacksoder 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.
flowchart TB
accTitle: Wahl zwischen eigenem Thread und Pool
accDescr: Prü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-Pool
q1{"Persönlichkeit nötig(Prio/STA)?"} -->|"Ja"| ded["Auf eigenem Thread halten"]
q1 -->|"Nein"| q2{"Läuft sie lange?"}
q2 -->|"Ja"| ded
q2 -->|"Nein"| pool["Auf den Thread-Pool"]
pool -.-> ex["Kurzlebig, 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.
flowchart TB
accTitle: Entsprechung zwischen alter Thread-Pool-API und neuer API
accDescr: QueueUserWorkItem der alten API entspricht dem work-Objekt der neuen API, Timer-Warteschlangen timer, registrierte Wartevorgänge wait und BindIoCompletionCallback io
o1["QueueUserWorkItem"] --> n1["work"]
o2["Timer-Warteschlangen"] --> n2["timer"]
o5["Registrierte Waits"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
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 |
flowchart TB
accTitle: Die vier Objekte des Thread-Pools und der Callback-Mechanismus
accDescr: work 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-Threads
kind{"Welches Objekt?"}
kind --> w["work(bei Übergabe)"]
kind --> more{"Timer, wait oder io?"}
more --> t["timer(Zeit / Periode)"]
more --> rest{"Wait oder io?"}
rest --> wt["wait(bei Signal)"]
rest --> io["io(I/O-Fertigstellung)"]
w --> pool["Worker führen Callback aus"]
t --> pool
wt --> pool
io --> pool
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
flowchart TB
accTitle: Ersetzen von Threads, die nur warten, durch wait-Objekte
accDescr: Threads, 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äuft
old2["5 eigene Waiter-Threads, einzeln schlafend"] -.-> waste["Verbraucht 5 Stacks und 5 Threads"]
new2["5 wait-Objekte"] --> agg["Auf Waiter-Threads des Pools"]
agg --> cb2["Callback 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.
flowchart TB
accTitle: Lebenszyklus eines work-Objekts
accDescr: Anlegen 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ßen
c["Anlegen mit CreateThreadpoolWork"] --> s["Übergeben mit SubmitThreadpoolWork(wiederholbar)"]
s --> run["Callbacks laufen parallel"]
run --> stop3["Neue Übergaben stoppen"]
stop3 --> w["Warten mit WaitForThreadpoolWorkCallbacks"]
w --> cl["Schließ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
flowchart TB
accTitle: Binden der Konfiguration über eine Callback-Umgebung
accDescr: Die 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 Freigabe
env["Callback-Umgebung(TP_CALLBACK_ENVIRON)"] --> cp["Eigener Pool(Threadzahl steuern)"]
env --> cg["Cleanup-Gruppe"]
env --> obj["Beim Anlegen von work / timer / wait / io"]
cg -.-> close["Abschlusswarten 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“.
flowchart TB
accTitle: Die Struktur eines Deadlocks durch Poolverhungern
accDescr: Wartet 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 ewig
w1["Worker 1: wartet auf Arbeit X"] --> q["Arbeit X und Y warten auf Ausführung"]
w2["Worker 2: wartet auf Arbeit Y"] --> q
q -.-> none["Kein freier Worker mehr"]
none -.-> dead["Alle 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.
flowchart TB
accTitle: Vorbereitung auf einen Wettlauf zwischen DLL-Entladen und einem Callback
accDescr: Ein 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 FreeLibraryWhenCallbackReturns
risk["Unload während Callback"] -.-> av["Access Violation"]
g1["Warten, dann schließen"] --> safe["Sicheres Entladen"]
g1 -.-> g1N["In Shutdown-Funktion"]
g2["FreeLibraryWhenCallbackReturns"] --> safe
g2 -.-> g2n["letzter Callback gibt frei"]
g1 -.-> ng["Nicht in DllMain"]
av ~~~ g1
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::threadreichen, sind sie der erste Kandidat. Sie sind portabel, der Code ist kurz, und selbst die Semantik vonfutureist 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
ThreadPoolundTaskdieselbe Rolle, und I/O-Fertigstellung ist an IOCP gebunden. Diese Kellerstruktur erklärt „IOCP und der .NET-Thread-Pool“.
flowchart TB
accTitle: Entscheiden, mit den Werkzeugen welcher Schicht Sie schreiben
accDescr: Reichen 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 wollen
q1{"Reichen die C++-Standardwerkzeuge?"} -->|"Ja"| std["std::async / std::thread"]
q1 -->|"Nein"| q2{"Was brauchen Sie?"}
q2 -->|"Integration von timer, wait und io"| tp["Win32-Thread-Pool"]
q2 -->|"Poolteilung / Zahlsteuerung"| tp
q2 -->|"Keine eigenen Threads in einer DLL"| tp
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
- Die Tiefen von Windows-I/O (Teil 3) — I/O-Completion-Ports (IOCP) und der .NET-Thread-Pool: Der Keller unter async/await
- Praktische Multithreading-Best-Practices: C++-Edition — Unfälle mit RAII und jthread strukturell beseitigen
- Praktische Multithreading-Best-Practices: C-Edition — Sicher schreiben nach Win32-API-Konvention
- Spurious Wakeups — Why Condition Variables Wake “Without Being Notified” and How to Wait Correctly on Windows
- DllMain and the Loader Lock — The Real Reason You’re Told to “Do Nothing in DLL Initialization”
- Warum Sie unter Windows Ereigniswarten gegenüber Sleep(1) bevorzugen sollten
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.
- Windows-Anwendungsentwicklung
- Technische Beratung und Design-Review
- Fehleruntersuchung und Ursachenanalyse
- Kontakt
Quellen
-
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
-
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
-
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
-
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
-
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
-
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
-
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. ↩
-
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). ↩
-
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. ↩
-
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. ↩
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Named Pipes in der Praxis — Windows' Standard-IPC von Entwurf bis Sicherheit
Ein Praxisleitfaden zu Named Pipes, der Standard-Prozesskommunikation unter Windows. Der Artikel ordnet anhand von Primärquellen die Wahl...
Praktische Multithreading-Best-Practices: C++-Edition — Unfälle mit RAII und jthread strukturell beseitigen
In C++ ist Multithreading eine Welt, in der eine Datenrace zu undefiniertem Verhalten wird. Dieser Artikel behandelt die Falle des std::t...
Was bedeutet Windows' „Speicherauslastung“ eigentlich? — Working Set, Private Bytes, Commit und die Auslagerungsdatei richtig lesen
Der Speicherwert im Task-Manager, Working Set, Private Bytes und Commit sind nicht dasselbe. Dieser Artikel erklärt das Verhältnis von vi...
Praktische Multithreading-Best-Practices: C-Edition — Sicher schreiben nach Win32-API-Konvention
Bei Multithreading in C mit Win32 sind Thread-Erstellung über _beginthreadex, SRW-Locks und Bedingungsvariablen, Interlocked-Funktionen s...
Praktische Best Practices für Multithreading: .NET-Edition — Was Sie entscheiden sollten, bevor Sie weitere Threads hinzufügen
Eine praxisnahe Übersicht der Entwurfsregeln, die verhindern, dass Multithreading-Code in .NET/C# gelegentlich abstürzt oder hängen bleib...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
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.