找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2282

积分

0

好友

330

主题
发表于 7 天前 | 查看: 16| 回复: 0

二:为什么会容易饥饿

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)线程池队列的过程,具体步骤如下:

  1. IO线程将 event 封送到线程池的高优先级队列。
  2. Worker线程读取 event,拆解出 ValueTaskSourceAsTask(ReadAsync) 并再次排入线程池的线程本地队列。
  3. Worker线程读取 ValueTaskSourceAsTask(ReadAsync),拆解出编译器生成的状态机 <UseAwaitAsync>d__1,最终回到用户代码。

这里我姑且将其定义为三阶段。为了帮助理解,我画了一张简图来辅助说明。

.NET 8异步IO回调三阶段处理模型示意图

有了代码和图示,接下来就是眼见为实的阶段了。

2. 如何眼见为实

这个验证过程相对简单,在合适的位置埋上断点,然后观察线程调用栈即可。

  1. 观察第一阶段

自 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 方法处下断点进行观察,此时的线程栈截图如下:

.NET 8线程池高优先级入队调试截图

这里已经涉及到 ThreadPool 的深层调度机制。想深入理解这种设计背后的权衡,可以参考 基础 & 综合 板块中关于系统设计原则与优化的讨论。

  1. 观察第二阶段

当IO线程将数据丢到队列后,就需要 Worker线程 去取出来执行。这里就潜藏着一个重大隐患:如果当前系统已处于线程饥饿状态,而线程的动态注入又比较缓慢,那么这个 event 就可能无法被及时取出和处理。

根据前面的模型图,这个阶段需要从 event 中拆解出 ValueTaskSourceAsTask。这中间还涉及到 ThreadPoolBoundHandleOverlapped 的解包逻辑,本文不再赘述。我们直接在 ManualResetValueTaskSourceCore<TResult>.SignalCompletion() 方法上下断点观察。

从事件中拆解ValueTaskSourceAsTask的调试截图

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

ValueTaskSourceAsTask对象监视窗口截图

可能有朋友会疑惑: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)再次丢入了线程池的线程本地队列。这第二次入队操作,无疑进一步放大了在饥饿状态下任务无法被及时执行的风险。

  1. 观察第三阶段

数据进入队列后,需要线程池线程再次将其提取出来执行。这个逻辑相对直接:提取 ValueTaskSourceAsTask 中的延续字段 continuationObject,解构状态机,最终回到用户代码。要观察这个过程,直接在用户方法 UseAwaitAsync()await 语句之后下断点即可。

异步状态机回到用户代码的调用栈截图

3. .NET6 会这样吗

很多朋友可能会有疑问:.NET 8 是这样,那之前的版本(比如 .NET 6)也是如此吗?也有人说,我遇到的线程饥饿大多发生在处理 网络IO 时,并没有观察到 文件IO 有类似情况。

在我的实际故障分析经历中,确实绝大多数线程饥饿案例都发生在 网络IO 场景下。并且, .NET 6 和 .NET 8 在处理 网络IO 回调时的行为模式已经有了显著不同。

  1. .NET 6 倾向于由 IO线程 “一撸到底” 地执行回调。
  2. .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;
        }
    }
}
  1. .NET6 下的WinDbg观察

.NET 6网络IO回调线程标记截图

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

  1. .NET8 下的WinDbg观察

.NET 8网络IO回调线程标记截图

再看 .NET 8 的截图,线程 tid=9 后面是 (Threadpool Worker) 标记。这说明在 .NET 8 中,处理网络IO回调的线程身份已经变成了工作线程,流程变得复杂了。

三:总结

可以肯定的是,减少回调任务重新进入队列的次数,有助于尽可能避免线程饥饿。但凡事都有两面性。.NET 8 的线程池在综合性能上绝对比 .NET 6 要强悍得多,这是其架构持续演进的结果。然而,新的设计理念可能无法在100%的场景下都做到领跑,在少数特定场景(比如高并发下的严重线程饥饿)中,其表现或许反而不如 .NET 6 “IO线程一撸到底” 那种简单粗暴的方式来得直接有效。

理解这些底层机制的变迁,对于构建高性能、高可用的 后端 & 架构 至关重要,尤其是在涉及 C#/.Net 技术栈的异步与多线程编程时。希望本次探讨能为大家提供一个观察问题的角度。如果你对这类底层原理有更多兴趣,欢迎到 云栈社区 交流讨论,那里聚集了许多乐于钻研的开发者。




上一篇:C# TCP长连接实现详解:从Socket编程到完整示例代码
下一篇:SQL零基础入门指南:MySQL实战与3天掌握CRUD操作
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-10 18:36 , Processed in 0.199811 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表