.NET Core 线程池(ThreadPool)底层原理浅谈
简介
上文提到,创建线程在操作系统层面有4大无法避免的开销。因此复用线程明显是一个更优的策略,切降低了使用线程的门槛,提高程序员的下限。
.NET Core线程池日新月异,不同版本实现都有差别,在.NET 6之前,ThreadPool底层由C++承载。在之后由C#承载。本文以.NET 8.0.8为蓝本,如有出入,请参考源码.
ThreadPool结构模型图
眼见为实
https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.cs
上源码 and windbg
internal sealed partial class ThreadPoolWorkQueue
{
internal readonly ConcurrentQueue<object> workItems = new ConcurrentQueue<object>();//全局队列
internal readonly ConcurrentQueue<object> highPriorityWorkItems = new ConcurrentQueue<object>();//高优先级队列,比如Timer产生的定时任务
internal readonly ConcurrentQueue<object> lowPriorityWorkItems =
s_prioritizationExperiment ? new ConcurrentQueue<object>() : null!;//低优先级队列,比如回调
internal readonly ConcurrentQueue<object>[] _assignableWorkItemQueues =
new ConcurrentQueue<object>[s_assignableWorkItemQueueCount];//CPU 核心大于32个,全局队列会分裂为好几个,目的是降低CPU核心对全局队列的锁竞争
}
ThreadPool生产者模型
眼见为实
public void Enqueue(object callback, bool forceGlobal)
{
Debug.Assert((callback is IThreadPoolWorkItem) ^ (callback is Task));
if (_loggingEnabled && FrameworkEventSource.Log.IsEnabled())
FrameworkEventSource.Log.ThreadPoolEnqueueWorkObject(callback);
#if CORECLR
if (s_prioritizationExperiment)//lowPriorityWorkItems目前还是实验阶段,CLR代码比较偷懒,这一段代码很不优雅,没有连续性。
{
EnqueueForPrioritizationExperiment(callback, forceGlobal);
}
else
#endif
{
ThreadPoolWorkQueueThreadLocals? tl;
if (!forceGlobal && (tl = ThreadPoolWorkQueueThreadLocals.threadLocals) != null)
{
tl.workStealingQueue.LocalPush(callback);//如果没有特殊情况,默认加入本地队列
}
else
{
ConcurrentQueue<object> queue =
s_assignableWorkItemQueueCount > 0 && (tl = ThreadPoolWorkQueueThreadLocals.threadLocals) != null
? tl.assignedGlobalWorkItemQueue//CPU>32 加入分裂的全局队列
: workItems;//CPU<=32 加入全局队列
queue.Enqueue(callback);
}
}
EnsureThreadRequested();
}
细心的朋友,会发现highPriorityWorkItems的注入判断哪里去了?目前CLR对于高优先级队列只开放给内部,比如timer/Task使用
当使用ThreadPool.QueueUserWorkItem添加任务时,forceGlobal=ture.也就是默认进入全局队列。
也包括如下这个方法
ThreadPool.UnsafeQueueUserWorkItem,Task.Factory.StartNew,TaskCreationOptions.PreferFairness,Task.Yield
其它情况下任务会进入本地队列中。
本地队列的好处(源于大模型)
-
减少线程竞争
- 在多线程环境下,多个线程同时访问共享资源(如全局队列)时,需要进行同步操作以避免数据不一致。例如,使用锁来确保同一时间只有一个线程能够访问和修改队列。而线程池中的本地队列是与每个工作线程相关联的,每个线程主要处理自己本地队列中的任务。这就意味着线程在访问本地队列时不需要与其他线程进行竞争,减少了因同步操作而带来的开销。
- 比如,在一个高并发的Web服务器应用中,有多个请求需要处理。如果所有请求都放在一个全局队列中,当多个线程同时尝试获取任务时,就会频繁地争夺锁,导致性能下降。而本地队列可以让每个线程独立地从自己的队列中获取任务,避免这种过度竞争。
-
提高缓存利用率(数据局部性)
- 当任务在本地队列中时,由于任务更倾向于在同一个线程中执行,这有助于利用数据的局部性。对于一些需要频繁访问的数据(如缓存数据),如果任务在本地线程处理,这些数据更有可能已经在该线程的缓存中,从而减少了从主存或其他共享存储中读取数据的次数。
- 例如,在一个数据处理应用程序中,线程可能会对本地缓存中的数据进行一系列操作。如果任务在本地队列并且在本地线程执行,每次访问缓存数据的延迟会更低,因为缓存数据可能已经在CPU缓存或者线程本地的高速缓存中,这大大提高了数据访问的速度,进而提高了任务执行的效率。
-
工作窃取机制的基础
- 本地队列是工作窃取机制的重要基础。工作窃取允许一个空闲的线程从其他繁忙线程的本地队列中“窃取”任务来执行。当一个线程完成了自己本地队列中的所有任务后,它可以查看其他线程的本地队列,找到有任务的队列并从中窃取任务。
- 这种机制能够有效地平衡线程之间的工作负载。例如,在一个并行计算任务中,部分线程可能因为分配的任务较简单而提前完成,此时通过工作窃取,这些线程可以从其他任务较多的线程那里获取任务继续执行,避免了某些线程过度繁忙而其他线程闲置的情况,提高了整个线程池的资源利用率和任务处理效率。
-
降低上下文切换成本
- 线程在处理本地队列中的任务时,由于不需要频繁地切换到其他线程的任务(相比之下,从全局队列获取任务可能会导致更频繁的线程切换),减少了上下文切换的次数。上下文切换涉及到保存当前线程的执行状态(如寄存器的值、栈信息等),并加载新线程的执行状态,这是一个相对复杂且消耗资源的过程。
- 例如,在一个计算密集型的任务场景中,如果任务都在本地队列中,线程可以在较长时间内专注于自己的任务,减少了因频繁切换任务而导致的上下文切换开销,使得线程能够更高效地利用CPU时间来完成任务。
ThreadPool消费者模型
当线程池Dequeue时,会优先检查本地队列(LIFO),如果为空就查询高优先级队列,再检查全局队列,再检查低优先级队列。
如果检查完低优先级队列还是为空,那么它会"窃取"其它线程的本地队列。
需要注意一点的是,当窃取其它线程的任务时,它会使用FIFO顺序来访问,被窃取的线程还是不变使用LIFO。这样一个从头部读取(被窃取的线程),一个从尾部读取(窃取的线程)。无需同步锁,减少冲突
眼见为实
public object? Dequeue(ThreadPoolWorkQueueThreadLocals tl, ref bool missedSteal)
{
// Check for local work items
object? workItem = tl.workStealingQueue.LocalPop();
if (workItem != null)
{
return workItem;
}
// Check for high-priority work items
if (tl.isProcessingHighPriorityWorkItems)
{
if (highPriorityWorkItems.TryDequeue(out workItem))
{
return workItem;
}
tl.isProcessingHighPriorityWorkItems = false;
}
else if (
_mayHaveHighPriorityWorkItems != 0 &&
Interlocked.CompareExchange(ref _mayHaveHighPriorityWorkItems, 0, 1) != 0 &&
TryStartProcessingHighPriorityWorkItemsAndDequeue(tl, out workItem))
{
return workItem;
}
// Check for work items from the assigned global queue
if (s_assignableWorkItemQueueCount > 0 && tl.assignedGlobalWorkItemQueue.TryDequeue(out workItem))
{
return workItem;
}
// Check for work items from the global queue
if (workItems.TryDequeue(out workItem))
{
return workItem;
}
// Check for work items in other assignable global queues
uint randomValue = tl.random.NextUInt32();
if (s_assignableWorkItemQueueCount > 0)
{
int queueIndex = tl.queueIndex;
int c = s_assignableWorkItemQueueCount;
int maxIndex = c - 1;
for (int i = (int)(randomValue % (uint)c); c > 0; i = i < maxIndex ? i + 1 : 0, c--)
{
if (i != queueIndex && _assignableWorkItemQueues[i].TryDequeue(out workItem))
{
return workItem;
}
}
}
#if CORECLR
// Check for low-priority work items
if (s_prioritizationExperiment && lowPriorityWorkItems.TryDequeue(out workItem))
{
return workItem;
}
#endif
// Try to steal from other threads' local work items
{
WorkStealingQueue localWsq = tl.workStealingQueue;
WorkStealingQueue[] queues = WorkStealingQueueList.Queues;
int c = queues.Length;
Debug.Assert(c > 0, "There must at least be a queue for this thread.");
int maxIndex = c - 1;
for (int i = (int)(randomValue % (uint)c); c > 0; i = i < maxIndex ? i + 1 : 0, c--)
{
WorkStealingQueue otherQueue = queues[i];
if (otherQueue != localWsq && otherQueue.CanSteal)
{
workItem = otherQueue.TrySteal(ref missedSteal);
if (workItem != null)
{
return workItem;
}
}
}
}
return null;
}
什么是线程饥饿?
线程饥饿(Thread Starvation)是指线程长时间得不到调度(时间片),从而无法完成任务。
- 线程被无限阻塞
当某个线程获取锁后长期不释放,其它线程一直在等待 - 线程优先级降低
操作系统锁竞争中,高优先级线程,抢占低优先级线程的CPU时间 - 线程在等待
比如线程Wait/Result时,线程池资源不够,导致得不到执行
眼见为实
@一线码农 使用大佬的案例
https://www.cnblogs.com/huangxincheng/p/15069457.html
https://www.cnblogs.com/huangxincheng/p/17831401.html
windbg sos bug依旧存在
大佬的文章中,描述sos存在bug,无法显示线程堆积情况
经实测,在.net 8中依旧存在此bug
99851个积压队列,没有显示出来
ThreadPool如何改善线程饥饿
CLR线程池使用爬山算法来动态调整线程池的大小来来改善线程饥饿的问题。
本人水平有限,放出地址,有兴趣的同学可以自行研究
https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.HillClimbing.cs
ThreadPool如何增加线程
在 PortableThreadPool 中有一个子类叫 GateThread,它就是专门用来增减线程的类
其底层使用一个while (true) 每隔500ms来轮询线程数量是否足够,以及一个AutoResetEvent来接收注入线程Event.
如果不够就新增
《CLR vir C#》 一书中,提过一句 CLR线程池每秒最多新增1~2个线程。结论的源头就是在这里
注意:是线程池注入线程每秒1~2个,不是每秒只能创建1~2个线程。OS创建线程的速度块多了。
眼见为实
眼见为实
static void Main(string[] args)
{
for (int i = 0;i<=100000;i++)
{
ThreadPool.QueueUserWorkItem((x) =>
{
Console.WriteLine($"当前线程Id:{Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(int.MaxValue);
});
}
Console.ReadLine();
}
可以观察输出,判断是不是每秒注入1~2个线程
Task
不用多说什么了吧?
Task的底层调用模型图
Task的底层实现主要取决于TaskSchedule,一般来说,除了UI线程外,默认是调度到线程池
眼见为实
Task.Run(() => { { Console.WriteLine("Test"); } });
其底层会自动调用Start(),Start()底层调用的TaskShedule.QueueTask().而作为实现类ThreadPoolTaskScheduler.QueueTask底层调用如下。
可以看到,默认情况下(除非你自己实现一个TaskShedule抽象类).Task的底层使用ThreadPool来管理。
有意思的是,对于长任务(Long Task),直接是用一个单独的后台线程来管理,完全不参与调度。
Task对线程池的优化
既然Task的底层是使用ThreadPool,而线程池注入速度是比较慢的。Task作为线程池的高度封装,有没有优化呢?
答案是Yes
当使用Task.Result时,底层会调用InternalWaitCore(),如果Task还未完成,会调用ThreadPool.NotifyThreadBlocked()来通知ThreadPool当前线程已经被阻塞,必须马上注入一个新线程来代替被阻塞的线程。
相对每500ms来轮询注入线程,该方式采用事件驱动,注入线程池的速度会更快。
眼见为实
点击查看代码
static void Main(string[] args)
{
var client = new HttpClient();
for(int i = 0; i < 100000; i++)
{
ThreadPool.QueueUserWorkItem(x =>
{
Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} -> {x}: 这是耗时任务");
try
{
var content = client.GetStringAsync("https://youtube.com").Result;
Console.WriteLine(content);
}
catch (Exception)
{
throw;
}
});
}
Console.ReadLine();
}
其底层通过AutoResetEvent来触发注入线程的Event消息
结论
多用Task,它更完善。对线程池优化更好。没有不使用Task的理由