一:背景
1. 讲故事
在线程饥饿的场景中,我们首先要了解的就是线程是如何动态注入的?其实现如今的ThreadPool内部的实现逻辑非常复杂,而且随着版本的迭代内部逻辑也在不断的变化,有时候也没必要详细的去了解,只需在稍微宏观的角度去理解一下即可,我准备用三篇来详细的聊一聊线程注入
的流程走向来作为线程饥饿
的铺垫系列,这篇我们先从 Thread.Sleep
的角度观察线程的动态注入。
二:Sleep 角度下的动态注入
1. 测试代码
为了方便研究,我们用 Thread.Sleep
的方式阻塞线程池线程,然后观察线程的注入速度,参考代码如下:
C#
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 被唤醒一次。这个是有很多逻辑源码支撑的。
C#
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 是如何存取的。
C#
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 调用,参考如下:
C#
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下的注入优化。