二:为什么会容易饥饿
1. 测试代码
为了方便讲述异步回调的路径,这里我用简单的 FileStream 的异步读取来演示,当然实际的场景更多的是网络IO。我先上一个 .NET6 和 .NET8 的对比,先看一下参考代码。
internal class Program
{
static void Main(string[] args)
{
UseAwaitAsync();
Console.ReadLine();
}
static async Task<string> UseAwaitAsync()
{
string filePath = @"D:\dumps\trace-1\GenHome.DMP";
Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} 请求发起...");
using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 16, useAsync: true))
{
byte[] buffer = new byte[fileStream.Length];
int bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length);
string content = Encoding.UTF8.GetString(buffer, 0, bytesRead);
var query = $"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} 获取到结果:{content.Length}";
Console.WriteLine(query);
return query;
}
}
}
代码中 await 之后的回调,很多人可能会想当然地认为是 IO线程 一撸到底,其实在 .NET 8 中并非如此。它会经历两次进入(Enqueue)线程池队列的过程,具体步骤如下:
- IO线程将 event 封送到线程池的高优先级队列。
- Worker线程读取 event,拆解出
ValueTaskSourceAsTask(ReadAsync) 并再次排入线程池的线程本地队列。
- Worker线程读取
ValueTaskSourceAsTask(ReadAsync),拆解出编译器生成的状态机 <UseAwaitAsync>d__1,最终回到用户代码。
这里我姑且将其定义为三阶段。为了帮助理解,我画了一张简图来辅助说明。

有了代码和图示,接下来就是眼见为实的阶段了。
2. 如何眼见为实
这个验证过程相对简单,在合适的位置埋上断点,然后观察线程调用栈即可。
- 观察第一阶段
自 C# 重写 ThreadPool 之后,底层会使用一个单独的线程来轮询 IO完成端口队列(GetQueuedCompletionStatusEx),其内部实现可以简化理解为如下逻辑:
internal sealed class PortableThreadPool
{
private unsafe void Poll()
{
int num;
while (Interop.Kernel32.GetQueuedCompletionStatusEx(this._port, this._nativeEvents, 1024, out num, -1, false))
{
for (int i = 0; i < num; i++)
{
Interop.Kernel32.OVERLAPPED_ENTRY* ptr = this._nativeEvents + i;
if (ptr->lpOverlapped != null)
{
this._events.BatchEnqueue(new PortableThreadPool.IOCompletionPoller.Event(ptr->lpOverlapped, ptr->dwNumberOfBytesTransferred));
}
}
this._events.CompleteBatchEnqueue();
}
ThrowHelper.ThrowApplicationException(Marshal.GetHRForLastWin32Error());
}
}
从代码可以看出,一旦 GetQueuedCompletionStatusEx 获取到了数据,就会开始封送 event,并将其投送到线程池的 高优先级队列 中。我们可以在 UnsafeQueueHighPriorityWorkItemInternal 方法处下断点进行观察,此时的线程栈截图如下:

这里已经涉及到 ThreadPool 的深层调度机制。想深入理解这种设计背后的权衡,可以参考 基础 & 综合 板块中关于系统设计原则与优化的讨论。
- 观察第二阶段
当IO线程将数据丢到队列后,就需要 Worker线程 去取出来执行。这里就潜藏着一个重大隐患:如果当前系统已处于线程饥饿状态,而线程的动态注入又比较缓慢,那么这个 event 就可能无法被及时取出和处理。
根据前面的模型图,这个阶段需要从 event 中拆解出 ValueTaskSourceAsTask。这中间还涉及到 ThreadPoolBoundHandleOverlapped 的解包逻辑,本文不再赘述。我们直接在 ManualResetValueTaskSourceCore<TResult>.SignalCompletion() 方法上下断点观察。

上图调试信息中的 _continuationState 就是最终拆解出来的 ValueTaskSourceAsTask(ReadAsync),其详细属性如下图所示:

可能有朋友会疑惑:ReadAsync 方法明明返回的是 Task<int>,怎么就变成了 ValueTaskSourceAsTask 呢?这是因为在 ReadAsync 的底层实现中,做了一个从 ValueTask<int> 到 Task<int> 的转换。相关代码如下:
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
ValueTask<int> valueTask = this.ReadAsync(new Memory<byte>(buffer, offset, count), cancellationToken);
if (!valueTask.IsCompletedSuccessfully)
{
return valueTask.AsTask();
}
return this._lastSyncCompletedReadTask.GetTask(valueTask.Result);
}
无论如何,代码确实清晰地将数据(ValueTaskSourceAsTask)再次丢入了线程池的线程本地队列。这第二次入队操作,无疑进一步放大了在饥饿状态下任务无法被及时执行的风险。
- 观察第三阶段
数据进入队列后,需要线程池线程再次将其提取出来执行。这个逻辑相对直接:提取 ValueTaskSourceAsTask 中的延续字段 continuationObject,解构状态机,最终回到用户代码。要观察这个过程,直接在用户方法 UseAwaitAsync() 的 await 语句之后下断点即可。

3. .NET6 会这样吗
很多朋友可能会有疑问:.NET 8 是这样,那之前的版本(比如 .NET 6)也是如此吗?也有人说,我遇到的线程饥饿大多发生在处理 网络IO 时,并没有观察到 文件IO 有类似情况。
在我的实际故障分析经历中,确实绝大多数线程饥饿案例都发生在 网络IO 场景下。并且, .NET 6 和 .NET 8 在处理 网络IO 回调时的行为模式已经有了显著不同。
- .NET 6 倾向于由 IO线程 “一撸到底” 地执行回调。
- .NET 8 则改为需要 Worker线程 来做二次处理。
说了这么多,我们来看一个 网络IO 的示例代码,并对比观察 .NET 6 和 .NET 8 在处理回调路径上的差异:
internal class Program
{
static async Task Main(string[] args)
{
var task = await GetContentLengthAsync("http://baidu.com");
Console.ReadLine();
}
static async Task<int> GetContentLengthAsync(string url)
{
using (HttpClient client = new HttpClient())
{
var content = await client.GetStringAsync(url);
Debug.WriteLine($"线程编号:{Environment.CurrentManagedThreadId}, content.length={content.Length}");
Debugger.Break();
return content.Length;
}
}
}
- .NET6 下的WinDbg观察

从上图可以看到,线程 tid=10 后面带有 (Threadpool Completion Port) 标记,这明确表明在 .NET 6 中,是 IO线程 完整地执行了回调逻辑。
- .NET8 下的WinDbg观察

再看 .NET 8 的截图,线程 tid=9 后面是 (Threadpool Worker) 标记。这说明在 .NET 8 中,处理网络IO回调的线程身份已经变成了工作线程,流程变得复杂了。
三:总结
可以肯定的是,减少回调任务重新进入队列的次数,有助于尽可能避免线程饥饿。但凡事都有两面性。.NET 8 的线程池在综合性能上绝对比 .NET 6 要强悍得多,这是其架构持续演进的结果。然而,新的设计理念可能无法在100%的场景下都做到领跑,在少数特定场景(比如高并发下的严重线程饥饿)中,其表现或许反而不如 .NET 6 “IO线程一撸到底” 那种简单粗暴的方式来得直接有效。
理解这些底层机制的变迁,对于构建高性能、高可用的 后端 & 架构 至关重要,尤其是在涉及 C#/.Net 技术栈的异步与多线程编程时。希望本次探讨能为大家提供一个观察问题的角度。如果你对这类底层原理有更多兴趣,欢迎到 云栈社区 交流讨论,那里聚集了许多乐于钻研的开发者。