聊一聊 C#线程池 的线程动态注入 (上)

摘要:在线程饥饿的场景中,我们首先要了解的就是线程是如何动态注入的?其实现如今的ThreadPool内部的实现逻辑非常复杂,而且随着版本的迭代内部逻辑也在不断的变化,有时候也没必要详细的去了解,只需在稍微宏观的角度去理解一下即可,我准备用三篇来详细的聊一聊

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

这里下断点主要是观察当前的延迟如果超过 500ms 时是否真的会通过 CreateWorkerThread 创建线程。截图如下:

WorkerThread.CreateWorkerThread

最后我们在 MaybeAddWorkingWorker 方法的底层的线程创建方法 CreateWorkerThread 中下一个断点。

所有的埋点下好之后,我们让程序跑起来,观察 output 窗口的输出。

从输出窗口中可以清晰的看到以500ms为界限判断啥时该创建,啥时不该创建。

可能有些朋友很感慨,线程的动态注入咋怎么慢?1s才1-2个,难怪会出现线程饥饿。。。哈哈,下一篇我们聊一聊Task.Result下的注入优化。

来源:opendotnet

相关推荐