Worker versus completion-port threads, IOCP on Windows, epoll on Linux, how async I/O completes without a thread waiting, and what GetAvailableThreads really reports.
Worker threads run CPU work and async continuations. I/O completion threads exist to receive notifications that an asynchronous I/O operation finished. On Windows the OS mechanism is an I/O completion port; on Linux .NET uses epoll (kqueue on macOS) with a small number of event-loop threads. In modern .NET those completions are picked up by a few poller threads and the continuation is normally queued to the worker pool, so the worker count and queue length are what matter. No thread waits while the I/O is in flight: that is the whole point of async I/O.
"Worker threads run CPU work,
Task.Rundelegates, request processing andawaitcontinuations. I/O completion threads exist to receive the operating system's notification that an asynchronous I/O operation has finished. The split comes from .NET Framework on Windows, where a separate group of threads waited on the process's I/O completion port. The key idea is that while async I/O is in flight, no thread is waiting: the operation lives in the OS and the device. When it completes, a runtime thread picks up the notification and queues the continuation to the worker pool. On Linux, sockets use epoll with a few event-loop threads, so the 'completion port threads' number is essentially irrelevant; and since .NET 7, Windows also handles completions with a few poller threads that dispatch to workers. So in practice, diagnosis is about worker threads. One Linux caveat: async regular-file I/O is served by pool threads doing blocking reads, so it isn't threadless."
ThreadPool.GetMinThreads(out int workers, out int completionPorts);
ThreadPool.GetAvailableThreads(out int availWorkers, out int availIo);
| Number | Historical meaning | Meaning in .NET 7+ |
|---|---|---|
| Worker threads | Threads running queued work items | Same, and this is what matters |
| Completion port threads | Windows threads blocked in GetQueuedCompletionStatus running I/O callbacks |
Mostly legacy; completions are handled by a few pollers and dispatched to workers |
await socket.ReceiveAsync(...)| Step | Thread involved? |
|---|---|
1. Your code calls ReceiveAsync; the runtime issues a non-blocking OS operation |
Your thread, briefly |
| 2. Data not ready → an incomplete task is returned; your method's continuation is registered | Your thread returns to the pool |
| 3. Waiting for data | No thread |
| 4. OS signals completion (IOCP packet / epoll readiness) | A poller/event thread wakes |
| 5. Runtime completes the operation and queues the continuation | Event thread, briefly |
6. Code after await runs |
A worker thread |
| Windows | Linux | macOS | |
|---|---|---|---|
| Sockets | I/O completion ports (overlapped I/O) | epoll via SocketAsyncEngine (non-blocking sockets, a few event threads) |
kqueue |
Regular files (FileStream async) |
Overlapped I/O (true async) | Blocking reads/writes on pool threads | Blocking on pool threads |
| Pipes / named pipes | IOCP | epoll | kqueue |
| Where continuations run | Worker pool | Worker pool | Worker pool |
| Question | Answer |
|---|---|
| Should I tune completion port minimum threads? | Almost never on .NET 7+, and it does nothing useful on Linux |
| Why can one server hold 50,000 pending requests? | No thread is held while I/O waits |
| My service is starving: which pool? | The worker pool: look at queue length and blocked worker stacks |
| Is heavy async file I/O free on Linux? | No: each in-flight operation occupies a pool thread while the kernel call runs; bound concurrency |
| Can CPU work in a completion delay other I/O? | Yes, if it runs inline on an event thread; keep completions short and let await dispatch to workers |
Our [gRPC gateway] on Linux containers had periodic timeouts under load. An earlier "fix" had raised the completion-port minimum, with no effect. I pointed out that on Linux socket completions come from epoll event threads and continuations run on worker threads, so that setting couldn't help. dotnet-counters showed the worker queue length rising to [hundreds] during spikes with moderate CPU. A trace showed workers blocked in synchronous File.ReadAllText loading response templates on every request. I cached the templates at startup, made the remaining file reads async with a bounded SemaphoreSlim, and removed the completion-port override. Timeouts stopped, and we documented that only worker metrics matter for our Linux services.
Checks a deep understanding of async I/O and platform differences.
Green flags
Red flags
An IOCP is a kernel queue of completion packets. Handles (sockets, files opened with overlapped I/O) are associated with the port. When an overlapped operation finishes, the kernel posts a packet; a thread blocked in GetQueuedCompletionStatus dequeues it.
ReadAsync -> ReadFile(OVERLAPPED) returns ERROR_IO_PENDING
... device works ... (no thread)
kernel -> posts completion packet to IOCP
poller -> GetQueuedCompletionStatusEx dequeues batch
-> completes the Task / IValueTaskSource
-> continuation queued to worker pool
In .NET Framework, a pool of "I/O threads" ran the callbacks directly. Since .NET 7, the managed portable thread pool handles IOCP with a few poller threads that dequeue completions in batches and dispatch the work to workers.
SocketAsyncEngineepoll is a readiness model, not a completion model: it tells you a socket can be read or written without blocking.
ReceiveAsync -> try recv() non-blocking
-> data available: complete synchronously (no wait at all)
-> EAGAIN: register interest, return pending
epoll thread -> epoll_wait() reports socket readable
-> performs recv(), completes the operation
-> continuation queued to worker pool
SocketAsyncEngine instances (each with an epoll thread), scaled with processor count.DOTNET_SYSTEM_NET_SOCKETS_INLINE_COMPLETIONS) can run continuations directly on epoll threads; it can reduce latency for some workloads but risks stalling the event loop, so it's an expert-only setting.Regular files are always "ready" from epoll's perspective, so readiness doesn't help. .NET's FileStream/RandomAccess async APIs on Unix run the blocking pread/pwrite calls on pool threads. That's correct and usually fast (page cache), but:
io_uring for true async file I/O, but .NET's file stack doesn't use it by default; check the release notes of your runtime version before relying on it.| Model | 10,000 concurrent slow downstream calls |
|---|---|
| Thread-per-request, blocking I/O | 10,000 threads → ~GBs of stack reservation, massive context switching |
| Async I/O | A few dozen threads; 10,000 small pending-operation objects |
Context at the await |
Continuation runs on |
|---|---|
No SynchronizationContext (ASP.NET Core, console, workers) |
Worker pool |
UI SynchronizationContext |
UI thread (unless ConfigureAwait(false)) |
Custom TaskScheduler |
That scheduler |
So CPU-heavy code after an await competes for worker threads, never for I/O pollers.
| Version | Change |
|---|---|
| .NET Framework | Separate IOCP thread group with its own min/max |
| .NET Core 2.1 | Managed SocketsHttpHandler; epoll-based sockets on Linux |
| .NET 6 | FileStream rewrite; much faster async file I/O on Windows; portable pool for workers |
| .NET 7 | Portable thread pool handles IOCP on Windows (poller threads) |
| .NET 8–10 | Same model; worker metrics are the main health signal |
stream.Read inside Task.Run and watch thread count and queue length climb.File.ReadAllBytesAsync calls on large files on a slow volume and watch thread count; repeat on Windows.GetAvailableThreads on Linux and Windows; observe that the I/O number doesn't track real activity.dotnet-trace with the Microsoft-Windows-DotNETRuntime:ThreadPool keyword and see worker vs I/O events.using System.Net.Sockets;
using System.Threading;
ThreadPool.GetAvailableThreads(out int workers, out int io);
Console.WriteLine($"Available workers {workers}, completion port {io}");
using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
var clients = new List<Task>();
for (int i = 0; i < 200; i++)
{
clients.Add(Task.Run(async () =>
{
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", port);
var buffer = new byte[1];
// Pending read: no thread is blocked while waiting.
await client.GetStream().ReadAsync(buffer).AsTask()
.WaitAsync(TimeSpan.FromSeconds(2))
.ContinueWith(_ => { });
}));
}
await Task.Delay(500);
Console.WriteLine($"200 pending reads, pool threads = {ThreadPool.ThreadCount}");
await Task.WhenAll(clients);
What happens: "async just moves the wait to a background thread". Why it is wrong: the operation is pending in the OS; no thread waits. Do instead: explain that only the completion and the continuation briefly need a thread.
What happens: SetMinThreads(workers, 500) on a Linux service.
Why it is wrong: Linux uses epoll threads; continuations run on workers. The setting has no effect.
Do instead: look at worker queue length and blocked worker stacks.
What happens: thousands of concurrent file reads on Linux "because it's async". Why it is wrong: each in-flight file operation occupies a pool thread. Do instead: bound concurrency for heavy file work and measure.
What happens: old APM/EAP-style callbacks do parsing inline.
Why it is wrong: it can delay other completions on the same event thread.
Do instead: use await, which dispatches continuations to workers; keep callbacks tiny.
Task.Run and calling it asyncWhat happens: Task.Run(() => stream.Read(buffer)).
Why it is wrong: a worker thread now blocks for the whole I/O.
Do instead: use the real async API (ReadAsync).
What happens: an environment variable from a blog post is set in production. Why it is wrong: continuations run on epoll threads; blocking there stalls all sockets on that engine. Do instead: leave defaults unless you've benchmarked and audited the continuation code.
GetAvailableThreads as a health metricWhat happens: dashboards show "available I/O threads".
Why it is wrong: the I/O number is mostly meaningless on modern .NET and Linux.
Do instead: use dotnet.thread_pool.queue.length, thread count and completed items.
FileOptions.Asynchronous and expecting gains on LinuxWhat happens: the flag is added everywhere for "performance". Why it is wrong: on Linux it doesn't change the underlying blocking call; it can add overhead. Do instead: measure; on Linux, the async API still helps by keeping the caller non-blocking, but not by saving threads.
What happens: .Result on an async socket call "because the I/O itself is async".
Why it is wrong: the calling worker thread blocks for the full duration anyway.
Do instead: await end to end.
What happens: candidates describe await as starting a thread.
Why it is wrong: it misses why async scales.
Do instead: describe pending OS operations, completion notification, and continuation scheduling.
I treat the worker/IOCP split as mostly historical. What I explain in an interview is the mechanism: async I/O releases the thread, the OS reports completion, and the continuation is just another work item. Diagnosis is then always about worker threads.
| Question | Senior answer |
|---|---|
| Do I tune completion port threads? | Almost never in .NET 10 |
| Why is async I/O scalable? | No thread is held while waiting |
| Where does code after await run? | Worker pool, unless a context captures it |
| Is file async free on Linux? | No, it uses pool threads |
A system call is how a user-mode program asks the kernel to do something privileged: read a file, send data, allocate memory, create a thread. The program puts arguments in registers and executes a special instruction (syscall on x64); the CPU switches to kernel mode, the kernel validates the request, does the work and returns. In .NET, File.ReadAllBytesAsync goes through FileStream/RandomAccess and a SafeFileHandle to ReadFile on Windows or pread on Linux; Socket.SendAsync uses I/O completion ports on Windows and epoll on Linux. Each call costs hundreds of nanoseconds to microseconds, more with security mitigations, so thousands of tiny calls add up. Buffering, batching and async I/O keep the count down.
The ThreadPool is a process-wide set of reusable background threads that run queued work items: Task.Run, async continuations, timer callbacks and ASP.NET Core requests. Work goes into a global queue or, when queued from a pool thread, into that thread's local queue, and idle threads steal from others. The pool starts up to minimum threads (default: processor count) immediately on demand; above that it adds threads gradually, using a hill-climbing algorithm that measures throughput plus a starvation detector that injects threads when queued work is not progressing. That gradual injection is why blocking pool threads causes latency spikes.
How to tell it (STAR)
Our [gateway] had timeouts and someone had already raised the completion port threads. I explained that on Linux socket completions use epoll and the continuations run on worker threads, so that setting could not help. The worker queue was growing, and a trace showed synchronous file reads per request. Caching those files fixed the timeouts.
Last updated 31 Oct 2026.