一:背景

1. 讲故事

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

二:为什么会容易饥饿

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

  1. IO线程 封送 event 到 线程池队列。
  2. Worker线程 读取 event 拆解出 ValueTaskSourceAsTask(ReadAsync) 再次入 线程池队列。
  3. Worker线程 读取 ValueTaskSourceAsTask(ReadAsync) 拆解出编译器生成的状态机<UseAwaitAsync>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, 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 上下断点即可,然后观察线程栈,截图如下:

  1. 观察第二阶段

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

按照模型图描述,这个阶段是从 event 中拆解出 ValueTaskSourceAsTask,这中间还涉及到了 ThreadPoolBoundHandleOverlapped 的解包逻辑,我在上篇聊一聊 C#异步中的Overlapped是如何寻址的和大家聊过,这里就不赘述了,接下来在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) 丢入了线程池的线程本地队列,可二次丢入又放大了饥饿的风险。

  1. 观察第三阶段

数据进了队列之后,需要线程池线程再次提取,这个逻辑就比较简单了,提取 ValueTaskSourceAsTask 中的延续字段 continuationObject 来解构状态机最终回到用户代码,要想观察直接在用户方法 UseAwaitAsync() 的 await 之后下一个断点即可。

3. .NET6 会这样吗

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

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

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

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


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观察

从卦中可以看到 tid=10 后面有一个 Threadpool Completion Port标记,这就表明确实是 IO线程 一撸到底。

  1. .NET8 下的WinDbg观察

从卦中可以看到 tid=9 后面是 Threadpool Worker标记,这就说明复杂了哈。。。

三:总结

可以肯定的是减少callback重入队列次数可以尽可能的避免线程饥饿,但怎么说呢?.NET8的线程池综合性能绝对比 .NET6 要强悍的多,但.NET8中的设计理念可能也不能达到100%的全域领跑,可能在某些1%的场景下还不如 .NET6 的简单粗暴。

为什么 .NET8线程池 容易引发线程饥饿的更多相关文章

  1. 【温故而知新-万花筒】C# 异步编程 逆变 协变 委托 事件 事件参数 迭代 线程、多线程、线程池、后台线程

    额基本脱离了2.0 3.5的时代了.在.net 4.0+ 时代.一切都是辣么简单! 参考文档: http://www.cnblogs.com/linzheng/archive/2012/04/11/2 ...

  2. C#线程篇---线程池如何管理线程(6完结篇)

    C#线程基础在前几篇博文中都介绍了,现在最后来挖掘一下线程池的管理机制,也算为这个线程基础做个完结. 我们现在都知道了,线程池线程分为工作者线程和I/O线程,他们是怎么管理的? 对于Microsoft ...

  3. Java多线程系列--“JUC线程池”01之 线程池架构

    概要 前面分别介绍了"Java多线程基础"."JUC原子类"和"JUC锁".本章介绍JUC的最后一部分的内容——线程池.内容包括:线程池架构 ...

  4. Java多线程系列--“JUC线程池”02之 线程池原理(一)

    概要 在上一章"Java多线程系列--“JUC线程池”01之 线程池架构"中,我们了解了线程池的架构.线程池的实现类是ThreadPoolExecutor类.本章,我们通过分析Th ...

  5. Java多线程系列--“JUC线程池”03之 线程池原理(二)

    概要 在前面一章"Java多线程系列--“JUC线程池”02之 线程池原理(一)"中介绍了线程池的数据结构,本章会通过分析线程池的源码,对线程池进行说明.内容包括:线程池示例参考代 ...

  6. Java多线程系列--“JUC线程池”04之 线程池原理(三)

    转载请注明出处:http://www.cnblogs.com/skywang12345/p/3509960.html 本章介绍线程池的生命周期.在"Java多线程系列--“基础篇”01之 基 ...

  7. Java多线程系列--“JUC线程池”05之 线程池原理(四)

    概要 本章介绍线程池的拒绝策略.内容包括:拒绝策略介绍拒绝策略对比和示例 转载请注明出处:http://www.cnblogs.com/skywang12345/p/3512947.html 拒绝策略 ...

  8. 通过设置线程池的最小线程数来提高task的效率,SetMinThreads。

    http://www.cnblogs.com/Charltsing/p/taskpoolthread.html task默认对线程的调度是逐步增加的,连续多次运行并发线程,会提高占用的线程数,而等若干 ...

  9. 内存池、进程池、线程池介绍及线程池C++实现

    本文转载于:https://blog.csdn.net/ywcpig/article/details/52557080 内存池 平常我们使用new.malloc在堆区申请一块内存,但由于每次申请的内存 ...

  10. Executors提供的四种线程池和自定义线程池

    JAVA并发编程——EXECUTORS 线程池的思想是一种对象池的思想,开放一块内存空间,里面存放了众多(未死亡)的线程,池中线程执行调度由池管理器来处理.当有线程任务时,从池中取一个,执行完毕,对象 ...

随机推荐

  1. Web渗透10_CSRF SSRF

    1 CSRF漏洞 CSRF 跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CS ...

  2. C++之OpenCV入门到提高003:矩阵的掩膜(Mask)处理

    一.介绍 今天是这个系列<C++之 Opencv 入门到提高>得第三篇文章.今天这篇文章也不难,主要介绍如何使用 Opencv 对图像进行掩膜处理,提高图像的对比度.在这个过程中,我们可以 ...

  3. html换行的方法

    1.使用<br> 1 <html> 2 <body> 3 <p> 4 春眠不觉晓,<br />处处闻啼鸟.<br />夜来风雨声 ...

  4. 解决DDD最大难题-如何划分领域

    本文书接上回<反DDD模式之"复用">,关注公众号(老肖想当外语大佬)获取信息: 最新文章更新: DDD框架源码(.NET.Java双平台): 加群畅聊,建模分析.技术 ...

  5. 本文是第一篇在GitHub仓库中撰写的.md格式的blog文件

    正文内容: 具体内容,只是未来测试,给出福利: 模板格式: title: 博文标题 description: 博文摘要 #多个标签请使用英文逗号分隔或使用数组语法 tags: 标签1, 标签2 #多个 ...

  6. .NET Core 委托底层原理浅谈

    简介 .NET通过委托来提供回调函数机制,与C/C++不同的是,委托确保回调是类型安全,且允许多播委托.并支持调用静态/实例方法. 简单来说,C++的函数指针有如下功能限制,委托作为C#中的上位替代, ...

  7. Ymodem协议详解

    Xmodem.Ymodem和Zmodem协议是最常用的三种通信协议. Xmodem协议是最早的,传输128字节信息块. Ymodem是Xmodem的改进版协议,具有传输快速稳定的优点.它可以一次传输1 ...

  8. Air780E软件指南:UDP应用示例

    一.UDP概述 UDP(用户数据报协议,UserDatagramProtocol)是一种无连接的.不可靠的传输层协议,主要用于实现网络中的快速通讯.以下是UDP通讯的主要特点: 1.1 无连接通讯: ...

  9. 联邦学习开山之作Communication-Efficient Learning of Deep Networks from Decentralized Data

    1 介绍 1.1 背景 越来越多的手机和平板电脑成为许多人的主要计算设备.这些设备上强大的传感器(包括摄像头.麦克风和GPS),加上它们经常被携带的事实,意味着它们可以访问前所未有的大量数据,其中大部 ...

  10. 记录个Java/Groovy的小问题:空字符串调用split函数返回非空数组

    问题复现 最近写了一个groovy替换程序增量流水线脚本(会Java也能看懂),示意脚本如下: //获取文件列表方法 def listFiles(folder) { def output = sh(s ...