专栏名称: dotNET跨平台
专注于.NET Core的技术传播。在这里你可以谈微软.NET,Mono的跨平台开发技术。在这里可以让你的.NET项目有新的思路,不局限于微软的技术栈,横跨Windows,Linux 主流平台
目录
相关文章推荐
重庆日报  ·  创意视频|新春第一会 绘出新重庆新篇章 ·  18 小时前  
重庆日报  ·  创意视频|新春第一会 绘出新重庆新篇章 ·  18 小时前  
重庆市文化和旅游发展委员会  ·  重庆春节哪里人气最旺?这些地方排名前5→ ·  3 天前  
重庆市文化和旅游发展委员会  ·  重庆春节哪里人气最旺?这些地方排名前5→ ·  3 天前  
第1眼新闻  ·  启动黄色预警!洪崖洞景区发布最新公告 ·  4 天前  
第1眼新闻  ·  启动黄色预警!洪崖洞景区发布最新公告 ·  4 天前  
51好读  ›  专栏  ›  dotNET跨平台

为什么 .NET8线程池 容易引发线程饥饿

dotNET跨平台  · 公众号  ·  · 2025-01-08 08:00

正文

一:背景

1. 讲故事

最近时间相对比较宽裕,多写点文章来充实社区吧,这篇文章主要还是来自于最近遇到的几例线程饥饿 (Task.Result) 引发的一系列的反思和总结,我觉得.NET8容易引发饥饿的原因,更多的在于异步回调之后底层会反复的将结果丢到线程池所致,因为数据进线程池容易,再用线程到池中去捞就没有那么简单了,可能今天的话题比较有争议,当然我个人的思考也不见得一定对,算是给大家提供一个角度吧,话不多说,开干!

二:为什么会容易饥饿

1. 测试代码

为了方便讲述异步回调的路径,这里我用简单的 FileStream 的异步读取来演示,当然实际的场景更多的是网络IO,最后我再上一个 .NET6 和 .NET8 的对比,先看一下参考代码。


    internal class Program
    {
        static void Main(string[] args)
        {
            UseAwaitAsync();

            Console.ReadLine();
        }

        static async Task<stringUseAwaitAsync()
        {
            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线程 一撸到底,其实在.NET8中并不是这样的,它会经历两次 Enqueue 到线程池,步骤如下:

  1. IO线程 封送 event 到 线程池队列。
  2. Worker线程 读取 event 拆解出 ValueTaskSourceAsTask(ReadAsync) 再次入 线程池队列。
  3. Worker线程 读取 ValueTaskSourceAsTask(ReadAsync) 拆解出编译器生成的状态机 d__1 ,回到用户代码。

这里我姑且定义成三阶段吧,可能有些朋友有点模糊,我画一张简图给大家辅助一下。

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

2. 如何眼见为实

这个相对来说比较简单,在合适的位置埋上断点,然后观察线程栈即可。

  1. 观察第一阶段

自 C# 重写了ThreadPool之后,底层会用一个单独的线程轮询IO完成端口队列(GetQueuedCompletionStatusEx),参考代码如下:


    internal sealed class PortableThreadPool
    {
        private unsafe void Poll()
        {
            int num;
            while (Interop.Kernel32.GetQueuedCompletionStatusEx(this._port, this._nativeEvents, 1024out num, -1false))
            {
                for (int i = 0; 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 上下断点即可,然后观察线程栈,截图如下:

  1. 观察第二阶段

当IO线程将数据丢到队列之后,接下来就需要用 Worker线程 去取了,这里就有了一个重大隐患,这个隐患在于如果当前存在线程饥饿,而线程的动态注入又比较慢,所以这个event存在不能及时取出来的情况。

按照模型图描述,这个阶段是从 event 中拆解出 ValueTaskSourceAsTask,这中间还涉及到了 ThreadPoolBoundHandleOverlapped 的解包逻辑,我在上篇聊一聊 C#异步中的Overlapped是如何寻址的和大家聊过,这里就不赘述了,接下来在 ManualResetValueTaskSourceCore .SignalCompletion() 上下一个断点观察。

上面卦中的 _continuationState 就是最终拆解的 ValueTaskSourceAsTask(ReadAsync),截图如下:

有些朋友可能会有疑惑, ReadAsync 返回的是 Task ,怎么就变成了 ValueTaskSourceAsTask 呢?这是因为 ReadAsync 的底层做了一个 ValueTask -> Task 的转换,参考代码如下:


        public override Task<intReadAsync(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 会这样吗

很多朋友可能会说 .NET8 是这样,那之前的版本也是这样吗?也有一些朋友可能会说,我的饥饿发生在 网络IO ,并没有看到类似 文件IO 的情况。

在我的dump分析之旅中,确实几乎所有的饥饿都发生在 网络IO 上,并且 .NET6 和 .NET8 在 网络IO 上的行为已经完全不一样了。

  1. .NET6 是IO线程 一撸到底。
  2. .NET8 则需要 Worker线程 做二次处理。

说了这么多,我们上一个 网络IO 的例子,然后观察 .NET6 和 .NET8 在处理回调上的不同,参考代码如下:







请到「今天看啥」查看全文