一:背景
1. 讲故事
前面二篇我们聊到了 Thread.Sleep
和 Task.Result
场景下的线程注入逻辑,在线程饥饿的情况下注入速度都不是很理想,那怎么办呢?有没有更快的注入速度,这篇作为 动态注入 的终结篇,我个人总结如下两种方法,当然可能有更多的路子,知道的朋友可以在下面留言。
二:提高注入速度的两种方法
1. 降低GateThread的延迟时间
上一篇跟大家聊过 Result 默认情况下GateThread每秒会注入4个,底层逻辑是由 Blocking.MaxDelayMs=250ms
变量控制的,言外之意就是能不能减少这个变量的值呢?当然可以的,这里我们改成 100ms,参考代码如下:
static void Main(string[] args) { AppContext.SetData("System.Threading.ThreadPool.Blocking.MaxDelayMs", 100); for (int i = 0; i < 10000; i++) { ThreadPool.QueueUserWorkItem((idx) => { Console.WriteLine($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")} -> {idx}: 这是耗时任务"); try { var client = new HttpClient(); var content = client.GetStringAsync("https://youtube.com").Result; Console.WriteLine(content.Length); } catch (Exception ex) { Console.WriteLine(ex.Message); } }, i); } Console.ReadLine(); }
现在我们还是用上一篇的方法在如下三个方法 HasBlockingAdjustmentDelayElapsed,PerformBlockingAdjustment,CreateWorkerThread
上埋日志断点,埋好之后运行程序观察。
从卦中的输出结果看,注入速度明显快了很多,判断阈值也从 250ms 变成了 100ms,每秒能注入7~8
个线程,所以这是一个简单粗暴的提速方法。
2. 提高 MinThreads 的阈值
看过上两篇的朋友应该知道,我用过 喷涌而出
四个字来形容前 12个线程,这里的12是因为我的机器是 12 核,言外之意就是为什么要设置12呢?我能不能给它提升到 120,1200甚至更高的 12000 呢?这样线程的注入速度不是更快吗?有了这个想法赶紧上一段代码,参考如下:
static void Main(string[] args) { ThreadPool.SetMinThreads(10000, 10); 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(); }
从卦中看,直接秒了这个 10000 个任务,但不要忘了你的程序此时有1w个线程,如果是32bit程序大概率因为虚拟地址不足直接崩了,如果是 64bit 可能也会导致非常可观的内存占用。
有些人可能对底层逻辑感兴趣,我特意花了点时间绘了一张图来描述底层的运转逻辑。
之所以能快速的产生新线程,核心判断条件是 numProcessingWork <= counts.NumThreadsGoal
,我们设置的 MinThread=10000 最后给到了 NumThreadsGoal 字段,所以现有线程数不超过 10000 的话,就会不断的调用 CreateWorkThread
产生新的工作线程。
接下来我们再聊一下 SetMinThreads 这里面的坑吧,如果你将刚才的 ThreadPool.SetMinThreads(10000, 10);
改成 ThreadPool.SetMinThreads(10000, 10000);
的话,将不会有任何效果,截图如下:
为什么会出现这样的情况呢?这得从源码上找答案,参考代码如下:
public class PortableThreadPool { private short _minThreads; private short _maxThreads; private short _legacy_maxIOCompletionThreads; private const short DefaultMaxWorkerThreadCount = MaxPossibleThreadCount; private const short MaxPossibleThreadCount = short.MaxValue; private PortableThreadPool() { _minThreads = HasForcedMinThreads ? ForcedMinWorkerThreads : (short)Environment.ProcessorCount; _maxThreads = HasForcedMaxThreads ? ForcedMaxWorkerThreads : DefaultMaxWorkerThreadCount; _legacy_maxIOCompletionThreads = 1000; } } public bool SetMinThreads(int workerThreads, int ioCompletionThreads) { if (workerThreads < 0 || ioCompletionThreads < 0) { return false; } bool flag = false; bool flag2 = false; this._threadAdjustmentLock.Acquire(); if (workerThreads > (int)this._maxThreads) { return false; } if (ioCompletionThreads > (int)this._legacy_maxIOCompletionThreads) { return false; } }
从卦中代码可以看到 ioCompletionThreads 默认最大值为 1000,如果你设置的值大于 1000 的话,那前面的 workerThreads 等于白设置了。。。这就很无语了。。。 如果参数有误,你完全可以抛出一个异常来告诉我,,,而不是偷偷的掩埋错误信息,导致程序出现了我意想不到的行为。。。
为了凑篇幅,我再说一个有意思的参数 DebugBreakOnWorkerStarvation,它可以用来捕获 线程饥饿
的第一现场,底层逻辑是C#团队在代码里埋了一个钩子,参考如下:
private static void GateThreadStart() { bool debuggerBreakOnWorkStarvation = AppContextConfigHelper.GetBooleanConfig("System.Threading.ThreadPool.DebugBreakOnWorkerStarvation", false); while (counts.NumProcessingWork < threadPoolInstance._maxThreads && counts.NumProcessingWork >= counts.NumThreadsGoal) { if (debuggerBreakOnWorkStarvation) { Debugger.Break(); } } }
这个 Debugger.Break();
发出的 int 3 信号,我们可以用 VS,DnSpy,WinDbg 这样的调试器去捕获,参考代码如下:
static void Main(string[] args) { AppContext.SetSwitch("System.Threading.ThreadPool.DebugBreakOnWorkerStarvation", true); 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(); }
三:总结
我们聊到了两种提升线程注入的方法,尤其是第二种让人意难平,面对上游洪水猛兽般的对线程池进行DDOS攻击,下游的线程不顾一切,倾家荡产的去承接,这是一种明知不可为而为之的悲壮之举。