一:背景
1. 讲故事
在线程饥饿的场景中,我们首先要了解的就是线程是如何动态注入的?其实现如今的ThreadPool内部的实现逻辑非常复杂,而且随着版本的迭代内部逻辑也在不断的变化,有时候也没必要详细的去了解,只需在稍微宏观的角度去理解一下即可,我准备用三篇来详细的聊一聊线程注入
的流程走向来作为线程饥饿
的铺垫系列,这篇我们先从 Thread.Sleep
的角度观察线程的动态注入。
二:Sleep 角度下的动态注入
1. 测试代码
为了方便研究,我们用 Thread.Sleep
的方式阻塞线程池线程,然后观察线程的注入速度,参考代码如下:
static void Main(string[] args){for (int i = 0; i < 10000; i++){ThreadPool.QueueUserWorkItem((idx) =>{Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} -> {idx}: 这是耗时任务");Thread.Sleep(int.MaxValue);}, i);}Console.ReadLine();}
仔细观察卦中的输出,除了初始的12个线程喷涌而出,后面你会发现它的线程动态注入有时候大概是 500ms
一次,有时候会是 1000ms
一次,所以我们可以得到一个简单的结论:Thread.Sleep 场景下1s 大概会动态注入1~2
个线程。
有了这个结论之后,接下来我们探究下它的底层逻辑在哪?
2. 底层代码逻辑在哪
千言万语不及一张图,截图如下:
接下来我们来聊一下卦中的各个元素吧。
- GateThread
在 PortableThreadPool 中有一个 GateThread 类,专门掌管着线程的动态注入,默认情况下它大概是 500ms 被唤醒一次。这个是有很多逻辑源码支撑的。
private static class GateThread{public const uint GateActivitiesPeriodMs = 500;private static void GateThreadStart(){while (true){bool wasSignaledToWake = DelayEvent.WaitOne((int)delayHelper.GetNextDelay(currentTimeMs));...}}public uint GetNextDelay(int currentTimeMs){uint elapsedMsSincePreviousGateActivities = (uint)(currentTimeMs - _previousGateActivitiesTimeMs);uint nextDelayForGateActivities =elapsedMsSincePreviousGateActivities < GateActivitiesPeriodMs? GateActivitiesPeriodMs - elapsedMsSincePreviousGateActivities: 1;...}}
- SufficientDelaySinceLastDequeue
这个方法是用来判断任务最后一次出队的时间,即内部的lastDequeueTime
字段,这也是为什么有时候是1个周期(500ms),有时候是2个周期的底层原因,如果在一个周期内判断lastDequeueTime(490ms)<=500ms
,那么在下一个周期内判断最后一次出队的时间自然就是 490ms+500ms
,所以这就是为什么 Console 上显示大约 1s 的间隔的原因了,下面的代码演示了 lastDequeueTime 是如何存取的。
private static void GateThreadStart(){if (!disableStarvationDetection &&threadPoolInstance._pendingBlockingAdjustment == PendingBlockingAdjustment.None &&threadPoolInstance._separated.numRequestedWorkers > 0 &&SufficientDelaySinceLastDequeue(threadPoolInstance)){bool addWorker = false;if (addWorker){WorkerThread.MaybeAddWorkingWorker(threadPoolInstance);}}}private static bool SufficientDelaySinceLastDequeue(PortableThreadPool threadPoolInstance){uint delay = (uint)(Environment.TickCount - threadPoolInstance._separated.lastDequeueTime);uint minimumDelay;if (threadPoolInstance._cpuUtilization < CpuUtilizationLow){minimumDelay = GateActivitiesPeriodMs;}else{minimumDelay = (uint)threadPoolInstance._separated.counts.NumThreadsGoal * DequeueDelayThresholdMs;}return delay > minimumDelay;}private static void WorkerDoWork(PortableThreadPool threadPoolInstance, ref bool spinWait){bool alreadyRemovedWorkingWorker = false;while (TakeActiveRequest(threadPoolInstance)){threadPoolInstance._separated.lastDequeueTime = Environment.TickCount;if (!ThreadPoolWorkQueue.Dispatch()){}}}
- CreateWorkerThread
这个方法是用来创建线程的主体逻辑,在线程池中由上层的 MaybeAddWorkingWorker 调用,参考如下:
internal static void MaybeAddWorkingWorker(PortableThreadPool threadPoolInstance){while (toCreate > 0){CreateWorkerThread();toCreate--;}}private static void CreateWorkerThread(){Thread workerThread = new Thread(s_workerThreadStart);workerThread.IsThreadPoolThread = true;workerThread.IsBackground = true;workerThread.UnsafeStart();}
这里有一个注意点:上面的 while (toCreate > 0)
代码预示着一个周期内(500ms)可能会连续创建多个工作线程,但在饥饿的大多数情况下都是toCreate=1
的情况。
3.如何眼见为实
说了这么多,能不能用一些手段让我眼见为实呢?要想眼见为实也不难,可以用 dnspy 断点日志功能观察即可,分别在如下三个方法上下断点。
- delayHelper.GetNextDelay
在此处下断点的目的用于观察 GateThread 的唤醒周期时间,截图如下:
- SufficientDelaySinceLastDequeue
这里下断点主要是观察当前的延迟如果超过 500ms 时是否真的会通过 CreateWorkerThread 创建线程。截图如下:
- WorkerThread.CreateWorkerThread
最后我们在 MaybeAddWorkingWorker 方法的底层的线程创建方法 CreateWorkerThread 中下一个断点。
所有的埋点下好之后,我们让程序跑起来,观察 output 窗口的输出。
从输出窗口中可以清晰的看到以500ms为界限判断啥时该创建,啥时不该创建。
三:总结
可能有些朋友很感慨,线程的动态注入咋怎么慢?1s才1-2个,难怪会出现线程饥饿。。。哈哈,下一篇我们聊一聊Task.Result下的注入优化。