Revision history (first version, published Jul 29, 2026)
- First published
Cite this article(DOI (registered archive): 10.5281/zenodo.22170814)
The DOIs below refer to previously archived versions and may not match the current text. Use this page’s URL to reference the current text.
Go Komura (2026). The Depths of Windows I/O (Part 3) — I/O Completion Ports (IOCP) and the .NET Thread Pool: The Basement Under async/await. KomuraSoft LLC. https://comcomponent.com/en/blog/windows-iocp-dotnet-threadpool/
- DOI (registered archive)
- 10.5281/zenodo.22170814
- DOI (last registered version)
- 10.5281/zenodo.22170815
How should you gather I/O completions so that a small number of threads can handle a large number of connections? While you wait on an await, who is doing the work, and on which thread does the code after the completion resume?
This instalment follows the path from how an I/O completion port (IOCP) gathers completion notifications through to how .NET receives those notifications and runs the rest of an await. Once you separate “receiving the completion” from “deciding where the rest runs,” ConfigureAwait(false) and thread-pool starvation fall into place as parts of the same flow.
Last time (Part 2) we looked at how asynchronous I/O is issued and at the four routes through which completion is received. IOCP is the one we take up here as the mechanism for receiving many concurrent I/O completions on a small number of threads.
This is Part 3 of the series “The Depths of Windows I/O.” The overall structure is laid out at the start of Part 1.
Jump Straight to What You Need
If you are reading in order, Section 2 establishes the limits of a design that dedicates a thread to each connection, Sections 3 and 4 cover Win32, and Section 5 maps it onto .NET. If you are in the middle of an investigation, the following guide takes you to the explanation you need.
| What you want to know or are struggling with | Where to read |
|---|---|
| Why thousands of simultaneous connections do not need the same number of threads | Section 2: Connection count and thread count, Section 3: The completion queue and thread control |
| How to identify which I/O on which handle completed | Section 3.1: CompletionKey and OVERLAPPED |
| The completion retrieval returned FALSE and you are unsure whether cleanup is safe | Section 3.2: Telling a failed I/O apart from a failed retrieval |
| The relationship between FIFO, LIFO and the concurrency value | Sections 3.3 and 3.4: How a waiting thread is chosen, and the runnable count |
| How to handle shutdown notifications, batched retrieval and synchronous completion | Section 4: APIs by purpose |
| Following an await ReadAsync from issue to resumption | Section 5.1: One await round trip |
| The scope of “an I/O wait does not consume a thread” | Section 5.2: The waiting thread and the processing thread |
| The UI freezes, or you cannot touch the UI, even with ConfigureAwait(false) | Section 5.3: Separating the continuation target from CPU work |
| Asynchronous processing as a whole slows down as load increases | Section 5.4: Synchronous waits and thread-pool starvation |
1. The Bottom Line First
What IOCP Is Responsible For
- IOCP unifies a “completion notification queue” with “thread-count control.” Completion packets are pushed onto the queue FIFO, and worker threads pull them off with
GetQueuedCompletionStatus(Section 3).1 - Threads are woken LIFO. The “warm” thread that was working until a moment ago is the one that picks up the next packet, so as long as the queue is full, context switches barely happen at all (Section 3.3).1
- The concurrency value caps the number of runnable threads. The recommended starting point is the CPU count (0 means the processor count). If a running thread blocks, a waiting thread is woken to fill the hole (Section 3.4).12
Key Points When Choosing an API
- A port can also carry your own notifications.
PostQueuedCompletionStatuslets you push packets unrelated to any I/O, so work requests for workers and shutdown instructions can flow through the same queue (Section 4).3 - For a new server implementation, the Windows thread pool API (
CreateThreadpoolIo) is recommended over raw IOCP. Internally it is still IOCP, and it takes thread management off your hands (Section 4).1
What to Distinguish in .NET and await
- The .NET thread pool is a two-story structure, worker threads and I/O completion threads, and handles used for asynchronous I/O are bound to the thread pool’s (own) IOCP. No thread exists for the duration of an
awaitI/O wait; only the post-completion continuation rides on a thread (Section 5).456 - Where a continuation goes is decided by the “captured context.” Await on a UI thread and the rest goes back to the UI thread; capture nothing and it continues on the thread pool (or on the thread that completed it).
ConfigureAwait(false)is an instruction to stop that capture, not a guarantee of moving to the thread pool (Section 5.3).6
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (32 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. Where the “Brute-Force It With Threads” Design Breaks Down
First, let us confirm the problem IOCP set out to solve. A naive server can be written as “one thread per connection”: read synchronously, process, and write. It is an easy design to understand.
The problem comes when connections grow. Compare a design that adds threads in step with the number of waiting connections against a design that processes only completed work on a small number of threads, in Figure 1.
flowchart TB
subgraph A["One thread per connection (synchronous I/O)"]
T1["Thread 1 waiting on a read for connection 1"]
T2["Thread 2 waiting on a read for connection 2"]
T3["Thread 3 waiting on a read for connection 3"]
TN["...threads grow with the connection count<br/>most are just asleep, waiting on I/O"]
end
subgraph B["The IOCP model (asynchronous I/O)"]
Q["Completion queue<br/>(completion notifications from every connection gather here)"]
W1["Worker 1"]
W2["Worker 2"]
WN["Workers are few, roughly the CPU count"]
Q --> W1
Q --> W2
Q --> WN
end
Figure 1: On the left, threads grow in proportion to the connection count. On the right, only “things that happened” are processed, by a small number of threads.
Even a Thread That Only Waits Costs Resources
Each one consumes a stack (1 MB reserved by default) and a kernel object, and as the count grows, the burden on the scheduler and on context switching piles up. Thousands of connections meaning thousands of threads is expensive even when most of them are merely “asleep, waiting for something to read.”
Making More Threads Runnable Does Not Add CPUs
Physically, only as many threads as there are CPUs can run at once. Making more threads than that runnable just increases the cost of switching between them.
Through Part 2 we saw ways of receiving I/O completion notifications via “events” and “APCs.” But the event approach runs into the 64-object limit of WaitForMultipleObjects and makes the design of the waits fiddly, and APCs are tied to the issuing thread. IOCP is the design built from the start around the shape of many I/O operations handled by a small number of threads.1
3. The IOCP Design — Unifying the Queue With Thread Control
3.1. The Two Faces of CreateIoCompletionPort
CreateIoCompletionPort does two jobs despite its name: creating a new port, and associating a handle with an existing port.2
flowchart LR
subgraph SRC["Associated handles (any number of them)"]
H1["File"]
H2["Socket"]
H3["Named pipe"]
end
subgraph PORT["I/O completion port"]
Q["Completion packet queue (FIFO)<br/>packet = bytes transferred +<br/>CompletionKey + OVERLAPPED pointer"]
C["Concurrency control<br/>runnable threads at or below the limit"]
end
subgraph W["Worker threads"]
G1["Waiting in GetQueuedCompletionStatus"]
G2["Waiting in GetQueuedCompletionStatus"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.controls.-> W
Figure 2: The structure of IOCP. Completions from many handles collect into one queue, and even the number of threads pulling from it is controlled.
The CompletionKey you pass at association time is a free-form value that tells the worker “this completion came from this handle.” The convention is to put a pointer to the connection object in it.2
In a completion packet, three pieces of information combine to identify the operation.27
| Information you receive | What it identifies or confirms |
|---|---|
| CompletionKey | Which handle or connection the completion came from |
| OVERLAPPED pointer | Which operation on that connection completed |
| Bytes transferred | How much data was transferred |
This is how the “operation’s docket” from Part 2 gets picked back up at completion time.
The target is not limited to “files.” Sockets, named pipes, mailslots and so on — any handle that can speak overlapped I/O can be associated.1 The “everything looks like a file” design we saw in Part 1 is at work here too.
3.2. The Journey of a Completion Packet
sequenceDiagram
participant DRV as Kernel (IRP completion)
participant Q as The port's queue (FIFO)
participant W as Worker thread
Note over W: Waiting in GetQueuedCompletionStatus
DRV->>Q: Push a completion packet<br/>(byte count / CompletionKey / OVERLAPPED)
Q->>W: Wake one waiting thread and hand it over
Note over W: Look at the packet and run completion handling<br/>(run the continuation, issue the next I/O, and so on)
W->>Q: Finish processing and call GetQueuedCompletionStatus again
Note over Q: If packets remain in the queue<br/>receive the next one without waiting
Figure 3: Completion packets are pushed FIFO, and a worker loops between pulling one off and processing it.
When an asynchronous I/O completes, a completion packet is pushed onto the port’s queue in FIFO order. A worker repeats the following loop.17
- Receive one packet with
GetQueuedCompletionStatus. - Run the completion handling for the operation it received.
- When that is done, call
GetQueuedCompletionStatusagain.
There is also GetQueuedCompletionStatusEx, which pulls several packets at once and cuts down the number of calls in high-frequency I/O.8
Do Not Leave the Loop on FALSE Alone
Even when GetQueuedCompletionStatus returns FALSE, you may still have retrieved the completion packet for a failed I/O. Decide by combining the return value with the OVERLAPPED pointer.7
| Return value | OVERLAPPED | Meaning and handling |
|---|---|---|
FALSE |
non-NULL | You retrieved the completion of a failed I/O. Error handling plus cleanup of the docket and buffer is required |
FALSE |
NULL | No packet could be retrieved. A timeout, the port being closed, and so on |
A failed operation still needs cleanup. If you handle this with nothing but if (!GetQueuedCompletionStatus(...)) break;, failed I/O operations slip through the cracks and leak. The lifetime management of the docket and buffer we saw in Part 2 is not only about successful completions.
Seeing the Order of the Checks in a Worker Loop
The skeleton below performs the two checks from the table above first, and then handles the shutdown packet and normal completions.
/* Skeleton of an IOCP worker loop (C / Win32) */
for (;;) {
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED *ov = NULL;
BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);
if (!ok && ov == NULL) {
/* No packet was retrieved (the port was closed, etc.). The only condition allowed to break out */
break;
}
if (!ok) {
/* ov != NULL -> the "completion packet for a failed I/O" was retrieved.
Cleanup (error handling, releasing the docket and buffer) is still required, so process it instead of breaking */
DWORD err = GetLastError();
handle_failed_io(key, ov, err);
continue;
}
if (key == SHUTDOWN_KEY) {
/* A shutdown packet pushed with PostQueuedCompletionStatus (Section 4) */
break;
}
handle_completed_io(key, ov, bytes); /* Normal completion handling. Keep it short (Section 3.4) */
}
The crux is not to dispose of ok == FALSE in a single branch, but to split it in two according to whether ov is NULL. The checks are the same when you use a timeout (anything other than INFINITE): running out of time shows up as ok == FALSE with ov == NULL.
Note that when a thread calls GetQueuedCompletionStatus for the first time, that thread becomes associated with that port (a single thread can be associated with only one port at a time).1 The picture of “a dedicated team of workers assigned to the port” is an accurate one to hold on to.
3.3. Threads Are Woken LIFO
Here, look at the order in which packets enter the queue separately from the order in which waiting threads are woken.1
| Subject | Order | What to note |
|---|---|---|
| Completion packets | FIFO | The order in which completion notifications are pushed onto the queue |
| Waiting threads | LIFO | The thread that entered the wait most recently is woken first |
Because the order differs between packets and threads, the thread that was working until a moment ago is the one that picks up the next packet too.
flowchart TB
Q["Queue holds P1, P2, P3 in FIFO order"]
subgraph TH["Waiting threads (a LIFO stack)"]
A["Thread A (ran until just now, warm)"]
B["Thread B (asleep for a while)"]
C["Thread C (asleep the whole time)"]
end
Q -->|"P1, P2 and P3 all go to<br/>Thread A first if it is free"| A
B -.->|"Only when A is busy"| Q
C -.->|"Rarely wakes at all"| Q
Figure 4: LIFO release. The busier things get, the more the same thread keeps cycling, while idle threads can stay asleep.
This design has two benefits.
- Context switches do not happen. As long as packets remain in the queue, a thread that finishes processing and calls
GetQueuedCompletionStatusreceives the next packet immediately, without waiting, and keeps running. The documentation explicitly states that, in a concurrency-value-1 scenario, “no thread switching occurs.”1 - The cache stays warm. Because the same thread keeps cycling, its stack and its scheduling-related state are more likely to still be sitting in the CPU cache. Sleeping threads are kept cheaply as spares against peak load.
3.4. The Concurrency Value — Counting What Is “Runnable”
The NumberOfConcurrentThreads passed when the port is created is the concurrency value. What it counts is the runnable threads associated with that port. While the cap is reached, no additional thread can receive a packet.1
Passing 0 uses the number of processors in the system. The documentation also names the CPU count as the best overall maximum, so that is where to start.21
It is not a cap on the total including waiting threads. See Figure 5 for what happens when someone enters a wait state.
flowchart TB
P["A packet arrives at the queue"]
Q{"Is the number of runnable threads<br/>below the concurrency value"}
RUN["Wake a waiting thread and let it process"]
HOLD["Wake no one and leave it in the queue<br/>(a running thread will come and take it)"]
BLK["A running thread entered<br/>a wait state for some other reason"]
COMP["Wake enough waiting threads to make up<br/>for the drop in the runnable count"]
P --> Q
Q -->|below| RUN
Q -->|at the cap| HOLD
BLK --> COMP
Figure 5: Concurrency control. Because the cap is on the “runnable count,” the system replenishes automatically if someone blocks.
A Worker That Enters a Wait Is Made Up For by Another Waiting Thread
When a running worker enters some kind of wait (a lock, a page fault, an accidentally synchronous I/O call), the runnable count drops, so the system wakes a waiting thread and fills the hole.1 That is why the convention is not to create exactly “the CPU count” of workers, but to keep more threads waiting than the concurrency value. If your processing mixes in long computation, raising the concurrency value itself is also an option, and the documentation’s position is that you ultimately tune it through profiling.1
Because the Cap Can Be Exceeded Temporarily, Keep Completion Handling Short
That said, replenishment is not magic. If a blocked thread later wakes up, the runnable count exceeds the cap for that moment (the documentation itself mentions this overshoot).1 Keeping completion handling short is the cardinal rule, and it plays out in exactly the same shape in .NET in Section 5.4.
4. The Toolbox — the APIs That Support a Port
4.1. Sending Work Requests and Shutdown Instructions Through the Same Queue
PostQueuedCompletionStatus — lets you push your own completion packet onto the queue without issuing any I/O.3 Handing out work to workers, shutdown instructions (pushing as many termination packets, colloquially called “poison-pill” packets, as there are workers), notifications from other threads: being able to process I/O completions and your own messages in the same queue and the same loop simplifies the design considerably.
4.2. Receiving High-Frequency I/O Completions in Batches
GetQueuedCompletionStatusEx — pulls several completion packets at once. It is effective for high-frequency I/O, where the overhead of one call per packet starts to bite.8
4.3. Skipping the Notification on Synchronous Completion
SetFileCompletionNotificationModes — for the case from Section 5 of Part 2, “issued asynchronously but completed synchronously,” lets you choose a mode that does not push a packet to the port (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). Since the result of a synchronous completion is already known on the spot, routing back through the queue is pure waste — hence the optimization.9
4.4. Delegating Thread Creation and Management
The Windows thread pool API — CreateThreadpoolIo and StartThreadpoolIo use IOCP internally while taking thread creation and management off your hands. Microsoft recommends that new server applications consider these first and use raw IOCP only when you want explicit control over the concurrency value or over thread management.1 And the .NET thread pool is precisely this “IOCP plus automated thread management,” implemented as part of the .NET runtime.
4.5. Three Lifetime Rules That Do Not Change Whichever API You Choose
- Do not block for a long time inside a worker. The replenishment in Section 3.4 only softens the degradation.
- Identify per handle and per operation separately. CompletionKey is per handle,
OVERLAPPEDis per operation. The “docket” from Part 2 must not be released before completion. - Do not close a handle while incomplete I/O remains. The cleanup behavior (Section 6 of Part 1) and the cancellation etiquette (Section 6 of Part 2) apply here unchanged.
5. The .NET Thread Pool — A Two-Story Structure Built on Top of IOCP
From here on, we map Win32 completion notifications onto .NET code. The order we look at them in is the thread that receives the completion, then one await round trip, then the waiting time, then where the continuation runs.
The .NET thread pool provides threads in the following two roles.4
| Kind | The role we look at in this article |
|---|---|
| Worker thread | Runs Task.Run and continuations |
| I/O completion thread | Receives the completion of asynchronous I/O |
ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) likewise returns these two as separate numbers. Below, we follow “the side that receives completions” and “the side that runs the work” as distinct things.4
On Windows, the thread pool holds its own I/O completion port. The low-level API for associating an OS handle with this port is ThreadPoolBoundHandle.BindHandle. Asynchronous I/O on a bound handle is handled together with NativeOverlapped, the .NET-side counterpart of the OVERLAPPED from Part 2.5
The long-standing ThreadPool.BindHandle remains for the same role, but ThreadPoolBoundHandle.BindHandle is the one to use in new code. When a FileStream or a Socket opens a handle in asynchronous mode, this kind of binding happens internally.5
Split into issuing and completion, the correspondence with Win32 looks like this.
- Part 2’s “asynchronous-mode handle plus OVERLAPPED” is the mechanism for issuing
- This article’s IOCP is the mechanism for receiving completions
- The .NET thread pool’s I/O completion threads are the worker team running the
GetQueuedCompletionStatusloop
With that correspondence, the Win32 picture becomes the .NET picture as it stands.
5.1. One await ReadAsync Round Trip, in Full
Part 2’s Figure 7 left a box labeled “genuine asynchronous I/O”; this time we follow its contents all the way to the post-completion continuation. In Figure 6, look separately at the thread that issues, the thread that receives the completion notification, and the place where the code after the await runs.
sequenceDiagram
participant U as Calling thread<br/>(the UI thread, for example)
participant K as Kernel<br/>(IRP issue through completion)
participant Q as The thread pool's IOCP
participant IO as I/O completion thread
participant C as Where the continuation runs
U->>K: ReadAsync issues an asynchronous read<br/>(carrying the equivalent of OVERLAPPED)
K-->>U: ERROR_IO_PENDING (returns immediately)
Note over U: await registers a continuation on the incomplete Task<br/>and gives up the thread (on a UI thread, on to the next message)
Note over K: The device is at work<br/>during this time no thread anywhere is waiting
K->>Q: Push a completion packet
Q->>IO: Wake one thread LIFO and hand it over
Note over IO: Determine the result (byte count, status)<br/>complete the Task and schedule the continuation
IO->>C: Throw it to the captured context<br/>(to the UI thread, or run on the thread pool if there is none)
Note over C: The code after the await runs
Figure 6: The whole of one await round trip. Threads are at work only during “issuing” and “after completion” — the waiting time uses zero threads.
5.2. The Precise Meaning of “an I/O Wait Does Not Consume a Thread”
No Dedicated Thread Is Placed There Just for the Waiting
What this diagram is meant to drive home is that, between issuing and completion, no thread exists in user mode or in the kernel purely to wait for that completion. Microsoft’s own async explainer (Async in depth) makes the same point about I/O-bound Tasks, walking all the way down to device drivers and interrupts.6
There are, on the other hand, moments inside the kernel where a driver hands part of the work off to a system worker thread. That is work done to push the request forward, and it is a different thing from a thread that blocks and keeps waiting for completion. “Does not consume a thread” is a statement about the waiting time.
Restated on top of what we have built up since Part 1: an IRP stays resident in the device stack as a data structure rather than as a thread (Part 1), issuing returns immediately with ERROR_IO_PENDING (Part 2), and completion arrives as a chain of events — interrupt, then completion packet (this article). The design manages to sustain the state of “waiting” without needing the expensive resource that a thread represents.
Distinguish It From CPU Work and From Fake Asynchrony
That is why an application that uses async/await correctly can sustain “10,000 I/O operations in flight at once” with a dozen-odd threads. Turn that around, though, and this property belongs only to I/O-bound Tasks. CPU work wrapped in Task.Run naturally occupies one worker thread, and the “fake asynchrony” from Section 7 of Part 2 is likewise putting a thread to sleep behind the scenes.
5.3. Where the Continuation Runs
The last arrow in Figure 6 is “once the completion has been received, where the continuation runs.” Think of whether to return to a context and where to send work that uses the CPU as two separate questions.6
flowchart TB
A["The Task completed and the continuation is to run"]
Q1{"At the point of the await, was a<br/>SynchronizationContext or a<br/>non-default TaskScheduler captured"}
Q2{"Was ConfigureAwait(false)<br/>attached"}
UI["Throw it back to what was captured<br/>e.g. run on the UI thread message loop<br/>or on that TaskScheduler"]
TP["No obligation to return to a specific place<br/>continue synchronously on the completing thread<br/>or run on a thread-pool thread"]
A --> Q2
Q2 -->|"yes"| TP
Q2 -->|"no"| Q1
Q1 -->|"yes (a WPF/WinForms UI thread, for example)"| UI
Q1 -->|"no (a console app, ASP.NET Core, and so on)"| TP
Figure 7: Where a continuation ends up. The reason you can touch the UI directly after an await is that it is being thrown back to the captured context.
The Captured Context, and the Case That Continues Synchronously
- When you
awaiton a WPF or WinForms UI thread, theSynchronizationContextis captured and the rest returns to the UI thread. That is why touching a control right after anawaitdoes not raise a cross-thread violation. The practical side of this design is covered in “WPF/WinForms async and the UI Thread on One Sheet.” - It is not only
SynchronizationContextthat gets captured: if youawaitwhile running on a non-defaultTaskScheduler, that scheduler is captured too. Where neither exists (a console app, ASP.NET Core, code already on the thread pool), there is no obligation to return to a specific place, so the continuation either runs on the thread pool or carries straight on, synchronously, on the thread that completed the Task. ConfigureAwait(false)is an explicit statement of “there is no need to return,” not a guarantee of “always moving to the thread pool.” If you await a Task that has already completed (which includes the synchronous completions we saw in Part 2), no wait occurs and execution continues on the current thread as it is. See “A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait” for how to choose between them in library code.
A Bad Example: Believing ConfigureAwait(false) Moved You
Let us confirm that last point in code. The following example was written on the assumption that ConfigureAwait(false) is an instruction to move to the thread pool.
// Bad example: the misconception that "because ConfigureAwait(false) is attached, everything after this runs on the thread pool"
private async void OnLoadClick(object sender, EventArgs e)
{
string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);
// Expected: this is the thread pool, so the UI will not freeze
// Reality: if the Task was already complete at the point of the await, no wait occurs and
// execution continues on the UI thread -> this heavy work freezes the UI
var rows = ParseHeavy(csv);
// And when it does complete asynchronously, the continuation is not the UI thread
resultLabel.Text = $"{rows.Count} rows"; // -> can raise a cross-thread exception
}
All ConfigureAwait(false) says is “there is no need to return to the captured context.” It does not specify “where it runs,” so execution may continue on the UI thread, or it may continue on an I/O completion thread or a thread-pool thread. Both being possible is why this code is broken.
A Good Example: Specify the Return to the UI and the Target for Heavy Work Separately
Split UI code from library code and write each intent out. The await that returns you to the UI and the Task.Run that pushes CPU work to the thread pool have different jobs.
// Good example: separately instruct "return to the UI or not" and "where the heavy work runs"
private async void OnLoadClick(object sender, EventArgs e)
{
// In UI code, let the capture happen (the rest returns to the UI thread)
string csv = await File.ReadAllTextAsync(path);
// If you want CPU-bound work pushed to the thread pool, say so explicitly with Task.Run
var rows = await Task.Run(() => ParseHeavy(csv));
// Definitely the UI thread here. Safe to touch controls
resultLabel.Text = $"{rows.Count} rows";
}
// In library code (code with no UI), state the opposite: "there is no need to return"
public async Task<string> ReadConfigAsync(string path)
{
string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
return text.Trim(); // does not depend on the caller's context
}
The decision splits into the following three.
| Purpose | The choice in this code |
|---|---|
| Touch the UI after an await | In UI code, let the context be captured |
| Push heavy CPU work to the thread pool | Be explicit with Task.Run |
| No need to return to the caller’s context inside a library | Stop the capture with ConfigureAwait(false) |
ConfigureAwait(false) does not specify the thread something runs on. Treat it as a library-side tool for working without depending on the caller’s context, and keep it separate from where UI work and CPU work are placed.
5.4. What Actually Clogs Things Up — Thread-Pool Starvation
A Synchronous Wait Blocks the Thread That Would Run the Continuation
If you wait synchronously inside a continuation or a worker, that thread stays occupied. Synchronous I/O, Task.Result/Wait(), and long lock waits are all this pattern.
IOCP’s own replenishment (Section 3.4) works instantly as long as spare waiting threads remain. But past the point where the spares run out lies territory in which the thread pool injects new threads only slowly.
When starvation sets in the moment load arrives — “there is a continuation to run but no thread to run it on” — the whole application bogs down.
Investigate the Available Counts Together With Where the Blocking Really Is
There are two entry points for investigating.
- Look at the availability of worker and I/O completion threads with
ThreadPool.GetAvailableThreads.4 - Grasp the real state of the thread pool and of the blocking with event tracing.
The tracing procedure is set out in “Pinpointing ‘Slow’ with PerfView and dotnet-trace.”
The principle of prevention is to keep the async path async all the way through and never mix in sync-over-async. Even if you can give up a thread while waiting on I/O, waiting synchronously inside the continuation blocks a thread all over again.
6. Summary
- IOCP is a mechanism that unifies the completion queue (FIFO) with thread-count control. Completions from many handles gather into a single port and are processed in a
GetQueuedCompletionStatusloop.17 - Because threads are released LIFO, the busier things get, the more the same thread keeps cycling, minimizing both context switches and cache misses.1
- The concurrency value caps the number of runnable threads, with the CPU count (passing 0) as the starting point. If a running thread blocks, waiting threads replenish it, but keeping completion handling short remains the cardinal rule.12
PostQueuedCompletionStatuslets your own packets flow through as well. For a new implementation, the Windows thread pool API (IOCP underneath) is the first choice.31- The .NET thread pool is a two-story structure, worker threads plus I/O completion threads, and asynchronous handles bind to the pool’s IOCP. No thread exists for an
awaitI/O wait; only the post-completion continuation rides on a thread.456 - Where a continuation goes depends on the captured context (back to the UI thread, or continuing on the thread pool).
ConfigureAwait(false)is an instruction to stop that capture.6 - What actually clogs things up is almost always thread-pool starvation caused by synchronous waits creeping in. Keep the async path async all the way through.
Next up is Part 4, “The Cache Manager — When Does Your WriteFile Actually Reach Disk?.” Part 2 said that “if it is already in the cache, the completion is synchronous,” and the cache’s shadow has flickered into view a few times in this article too. Next we face that cache itself head-on: deferred writes, read-ahead, FILE_FLAG_NO_BUFFERING, and the conditions under which “data you thought you had written vanishes on a power loss.”
Related Articles
- The Depths of Windows I/O (Part 1) — Every Read and Write Becomes an IRP: The Big Picture of the I/O System
- The Depths of Windows I/O (Part 2) — Synchronous and Asynchronous I/O: What OVERLAPPED Really Means
- A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait
- WPF/WinForms async and the UI Thread on One Sheet
- The Misconception That TCP Lets You Receive in the Same Units You Send — Designing Reception Around a Byte Stream
- Pinpointing “Slow” with PerfView and dotnet-trace — A Practical Introduction to .NET Performance Investigation
- A Practical Guide to Getting as Close to Soft Real-Time as Possible on Ordinary Windows
Related Consulting Areas
KomuraSoft LLC handles the design of Windows applications and servers that deal with large numbers of simultaneous connections and concurrent I/O, and investigates the causes of performance problems such as “the thread pool clogs up” or “we made it asynchronous but it did not get faster.”
- Windows Application Development
- Bug Investigation and Root-Cause Analysis
- Soft Real-Time Windows Application Development
- Contact Us
References
-
Microsoft Learn, I/O Completion Ports. On how I/O completion ports provide an efficient threading model for processing large numbers of asynchronous I/O requests on multiprocessor systems; how completion packets are pushed onto the port’s queue in FIFO order when asynchronous I/O completes; how the target is not limited to files on disk but includes any handle that supports overlapped I/O, such as sockets, named pipes, and mailslots; how threads waiting on the port are released in LIFO order, and how no thread switching occurs when the queue is non-empty at a concurrency value of 1; how a thread becomes associated with a port the first time it calls GetQueuedCompletionStatus, and can be associated with only one port at a time; how the concurrency value limits the number of runnable threads, with the best overall maximum being the CPU count; how a waiting thread can process a completion packet when a running thread enters a wait state for some other reason (and how a blocked thread waking up can temporarily exceed the limit); and how new server applications should first consider the Windows thread pool API (CreateThreadpoolIo, etc., which uses IOCP internally). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20
-
Microsoft Learn, CreateIoCompletionPort function. On how CreateIoCompletionPort both creates a new I/O completion port and associates a handle with an existing port; how a CompletionKey (a user-defined value) can be specified at association time and is included in the completion packet; and how NumberOfConcurrentThreads caps the number of threads that can process completion packets concurrently, with 0 using the number of processors in the system. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, PostQueuedCompletionStatus function. On how PostQueuedCompletionStatus can push an application-defined completion packet onto an I/O completion port’s queue without starting any asynchronous I/O, and how this lets a port serve as a channel for communication from other threads in the process, in addition to receiving I/O completions. ↩ ↩2 ↩3
-
Microsoft Learn, The managed thread pool. On how the .NET thread pool provides worker threads and threads for asynchronous I/O completion; how ThreadPool.GetAvailableThreads can retrieve the available counts of worker threads and I/O completion threads separately; and how thread-pool threads should not be blocked for long periods. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. On how ThreadPoolBoundHandle.BindHandle returns a ThreadPoolBoundHandle that binds an operating system handle to the system thread pool (and its I/O completion port); how low-level asynchronous I/O on a bound handle is performed together with NativeOverlapped; and how the completion of asynchronous I/O is then handled by the thread pool. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Async in depth (.NET). On how, for an I/O-bound Task, there is no dedicated thread waiting for the completion once the call has been handed to the OS (the “there is no thread” idea); how completion is signalled through device drivers and interrupts, and the registered continuation is run; how await captures the current context (such as a SynchronizationContext) by default and runs the continuation there, running on the thread pool if there is no context worth capturing; and how ConfigureAwait(false) disables that capture. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, GetQueuedCompletionStatus function. On how GetQueuedCompletionStatus retrieves one completion packet from the completion port’s queue (waiting if there isn’t one); how the retrieved result includes the bytes transferred, the CompletionKey, and the OVERLAPPED pointer; and how a FALSE return value combined with a non-NULL OVERLAPPED pointer means “the completion packet for a failed I/O operation was retrieved,” whereas a NULL OVERLAPPED pointer alone means the packet itself could not be retrieved (for example, due to a timeout). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, GetQueuedCompletionStatusEx function. On how GetQueuedCompletionStatusEx can retrieve multiple completion packets at once, and how the number of entries retrieved is returned. ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. On how FILE_SKIP_COMPLETION_PORT_ON_SUCCESS lets you choose not to push a packet to the completion port when an I/O succeeds immediately and the result is already known on the spot. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
The Depths of Windows I/O (Part 4) — Cache Manager: When Does Your WriteFile Actually Reach the Disk?
Part 4 of a diagram-led Windows Cache Manager series. It covers the cache as a file mapping, read-ahead, lazy writing, FlushFileBuffers, ...
The Depths of Windows I/O (Part 2) — Synchronous and Asynchronous I/O: What OVERLAPPED Really Means
Part 2 explains Windows synchronous and asynchronous (overlapped) I/O: FILE_FLAG_OVERLAPPED, the four notification paths, synchronous com...
The Depths of Windows I/O (Part 1) — Every Read and Write Becomes an IRP: The Big Picture of the I/O System
Part 1 of a series on Windows I/O from the ground up: the Object Manager namespace, the driver, device and file objects, the IRP lifecycl...
Handling Windows Impersonation Tokens Correctly — Borrowing Privileges per Thread and Reverting Safely
A practical guide to Windows impersonation tokens — access tokens, primary tokens, thread tokens, impersonation levels, RevertToSelf, and...
Why Arguments Break — The Rules of Windows Command-Line Arguments
Windows passes CreateProcess a single string that the receiver splits. Covers the CommandLineToArgvW, CRT, and .NET rules, ArgumentList, ...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Frequently Asked Questions
Common questions about the topic of this article.
- What is an I/O completion port (IOCP)?
- It is a Windows kernel mechanism that funnels the completion notifications of many asynchronous I/O operations into a single queue, and, in the same stroke, controls how many threads may run concurrently to process them. You create a port with CreateIoCompletionPort and associate handles such as files or sockets with it, and every time an asynchronous I/O completes, a completion packet is pushed onto the port's FIFO queue. Worker threads pull packets off the queue with GetQueuedCompletionStatus and process them. The key point is that this is not merely a notification queue: it doubles as a scheduling mechanism that keeps the number of runnable threads at or below a concurrency value. It lets you handle large numbers of concurrent I/O operations efficiently with a small number of threads, and it is the foundation under both Windows server implementations and the .NET thread pool.
- What should IOCP's concurrency value (the level of concurrency) be set to?
- Microsoft's documentation states that the best overall maximum value is the number of CPUs on the computer. Passing 0 for NumberOfConcurrentThreads in CreateIoCompletionPort uses the number of processors in the system, so 0 is the starting point if you are unsure. This value caps the number of runnable threads, not the number of waiting threads. If a running thread enters a wait state for any reason, the system wakes another waiting thread to fill the hole, so if your processing mixes in long computation or blocking, you can also choose a larger concurrency value to increase how many packets are handled at once. Ultimately, tuning it in combination with profiling is what is recommended.
- Why can we say that an async/await I/O wait does not consume a thread?
- Because between issuing the I/O and its completion there is nowhere a dedicated thread that looks after that operation. As we saw in Parts 1 and 2, an issued request flows down the device stack as an IRP, and the call returns immediately with ERROR_IO_PENDING. All await does at that point is register a continuation on the still-incomplete Task and give up the thread. While the device is doing its work as hardware, there is no thread anywhere in user mode or the kernel that merely waits. Once it completes, a completion packet is pushed onto the thread pool's IOCP, and only then does an I/O completion thread run briefly to schedule the registered continuation. In other words, a thread is used only at the moment of issuing and in the post-completion follow-up; the waiting time itself proceeds with zero threads.
- Which thread does the rest of an await (the continuation) run on?
- By default, the SynchronizationContext (or TaskScheduler) in effect at the point of the await is captured, and the continuation is thrown back onto it. If you await on a WPF or WinForms UI thread, the rest runs on the UI thread, which is why you can touch controls directly right after the await. When there is no context to capture, such as a console app, ASP.NET Core, or code already running on the thread pool, the continuation either runs on a thread-pool thread or carries straight on, on the thread that completed the Task. Adding ConfigureAwait(false) stops the capture, but that is not a guarantee of moving to the thread pool: it is an instruction that the continuation does not need to return to a specific place. If you await a Task that has already completed, no wait occurs and execution continues synchronously on the current thread. ConfigureAwait(false) is recommended in library code to avoid unnecessary round trips to the UI thread and to cut off the seeds of context dependence and deadlocks.
- What happens if you block for a long time inside an IOCP worker thread or a .NET I/O completion thread?
- It does not break instantly, but performance degrades because you have stepped outside what the mechanism assumes. IOCP wakes a waiting thread to replenish whenever a running thread enters a wait state, but every replenishment inflates the level of concurrency and increases context switches. If blocking becomes routine, packets pile up in the queue and completion processing as a whole is delayed. The same is true in .NET: waiting synchronously inside an I/O completion thread or a continuation, on synchronous I/O or on something like Task.Result, invites thread-pool starvation. The rule is to keep completion handling and continuations short and to split heavy work out elsewhere. You can observe the available worker threads and I/O completion threads with ThreadPool.GetAvailableThreads, which is useful when investigating a bottleneck.