The Depths of Windows I/O (Part 3) — I/O Completion Ports (IOCP) and the .NET Thread Pool: The Basement Under async/await

· Updated: · · Windows, Win32, I/O, IOCP, Asynchronous, Thread Pool, .NET, C#

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. PostQueuedCompletionStatus lets 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 await I/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.

The IOCP model (asynchronous I/O)Completion queue(completion notifications from every connection gather here)Worker 1Worker 2Workers are few, roughly the CPU countOne thread per connection (synchronous I/O)Thread 1 waiting on a read for connection 1Thread 2 waiting on a read for connection 2Thread 3 waiting on a read for connection 3...threads grow with the connection countmost are just asleep, waiting on I/O

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

Worker threadsI/O completion portAssociated handles (any number of them)controlsWaiting in GetQueuedCompletionStatusWaiting in GetQueuedCompletionStatusCompletion packet queue (FIFO)packet = bytes transferred +CompletionKey + OVERLAPPED pointerConcurrency controlrunnable threads at or below the limitFileSocketNamed pipe

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

Worker threadThe port's queue (FIFO)Kernel (IRP completion)Worker threadThe port's queue (FIFO)Kernel (IRP completion)Waiting in GetQueuedCompletionStatusLook at the packet and run completion handling(run the continuation, issue the next I/O, and so on)If packets remain in the queuereceive the next one without waitingPush a completion packet(byte count / CompletionKey / OVERLAPPED)Wake one waiting thread and hand it overFinish processing and call GetQueuedCompletionStatus again

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

  1. Receive one packet with GetQueuedCompletionStatus.
  2. Run the completion handling for the operation it received.
  3. When that is done, call GetQueuedCompletionStatus again.

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.

Waiting threads (a LIFO stack)P1, P2 and P3 all go toThread A first if it is freeOnly when A is busyRarely wakes at allThread A (ran until just now, warm)Thread B (asleep for a while)Thread C (asleep the whole time)Queue holds P1, P2, P3 in FIFO order

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 GetQueuedCompletionStatus receives 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.

belowat the capA packet arrives at the queueIs the number of runnable threadsbelow the concurrency valueWake a waiting thread and let it processWake no one and leave it in the queue(a running thread will come and take it)A running thread entereda wait state for some other reasonWake enough waiting threads to make upfor the drop in the runnable count

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 APICreateThreadpoolIo 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, OVERLAPPED is 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 GetQueuedCompletionStatus loop

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.

Where the continuation runsI/O completion threadThe thread pool's IOCPKernel(IRP issue through completion)Calling thread(the UI thread, for example)Where the continuation runsI/O completion threadThe thread pool's IOCPKernel(IRP issue through completion)Calling thread(the UI thread, for example)await registers a continuation on the incomplete Taskand gives up the thread (on a UI thread, on to the next message)The device is at workduring this time no thread anywhere is waitingDetermine the result (byte count, status)complete the Task and schedule the continuationThe code after the await runsReadAsync issues an asynchronous read(carrying the equivalent of OVERLAPPED)ERROR_IO_PENDING (returns immediately)Push a completion packetWake one thread LIFO and hand it overThrow it to the captured context(to the UI thread, or run on the thread pool if there is none)

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

yesnoyes (a WPF/WinForms UI thread, for example)no (a console app, ASP.NET Core, and so on)The Task completed and the continuation is to runAt the point of the await, was aSynchronizationContext or anon-default TaskScheduler capturedWas ConfigureAwait(false)attachedThrow it back to what was capturede.g. run on the UI thread message loopor on that TaskSchedulerNo obligation to return to a specific placecontinue synchronously on the completing threador run on a thread-pool thread

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 await on a WPF or WinForms UI thread, the SynchronizationContext is captured and the rest returns to the UI thread. That is why touching a control right after an await does 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 SynchronizationContext that gets captured: if you await while running on a non-default TaskScheduler, 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.

  1. Look at the availability of worker and I/O completion threads with ThreadPool.GetAvailableThreads.4
  2. 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 GetQueuedCompletionStatus loop.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
  • PostQueuedCompletionStatus lets 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 await I/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.”

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.”

References

  1. 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

  2. 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

  3. 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

  4. 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

  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

  6. 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

  7. 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

  8. Microsoft Learn, GetQueuedCompletionStatusEx function. On how GetQueuedCompletionStatusEx can retrieve multiple completion packets at once, and how the number of entries retrieved is returned.  2

  9. 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. 

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

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.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog