1. 为什么线程池不是“高级技巧”,而是每个C#开发者绕不开的生存技能
我带过不少刚从学校出来的实习生,也帮不少转行的朋友做过技术辅导。几乎所有人第一次写多线程代码时,都会本能地 new Thread(() => { /* 做点事 */ }).Start() —— 看起来干净利落,逻辑清晰。但只要项目上线跑上一两周,服务器内存就悄悄涨了30%,CPU偶尔飙高,日志里开始出现“Thread was aborted”或者“Out of memory”这类让人头皮发紧的提示。这时候我才告诉他们:你写的不是并发程序,你是在给操作系统发“招工启事”,而且还是不签劳动合同、不交社保、干完就拉黑的那种。
线程池之所以重要,根本原因在于它直面了一个被教科书长期忽略的残酷现实: 线程不是免费的,它是有重量的资源实体 。在Windows + .NET环境下,每个托管线程默认会向操作系统申请 1MB的私有堆栈空间 (注意,是私有,不能共享),这个空间在x64系统下甚至可能达到2MB。这不是虚拟地址空间的预留,而是实打实的物理内存或页面文件占用。更关键的是,线程创建本身就有开销:内核对象初始化、TLS(线程本地存储)结构分配、上下文切换准备、CLR线程对象构造……这一套流程下来,实测平均耗时在 300–800微秒 之间。听起来不多?那我们来算一笔账:如果你的Web API每秒要处理500个请求,每个请求都开一个新线程,那一秒就要创建500次线程,光是创建开销就吃掉150–400毫秒的CPU时间——这还没算销毁、GC压力和上下文切换的损耗。而线程池把这500个任务塞进20个复用线程里执行,开销直接归零。
我把线程池比作城市里的“市政环卫外包队”,这个类比不是为了好听,而是因为它精准揭示了三层设计哲学:第一层是 责任隔离 ——你只管扔垃圾(提交任务),清扫、调度、人员增减、绩效考核全由外包公司(ThreadPool Manager)负责;第二层是 弹性伸缩 ——早高峰垃圾量暴增,外包公司自动加派3辆清运车(扩容线程);深夜垃圾锐减,它悄悄让2辆车收工回家(回收空闲线程);第三层是 成本封顶 ——合同里白纸黑字写着“最多配10辆车”,哪怕垃圾山堆成珠峰,它也不会无限制招人(受MaxThreads限制)。这种机制天然规避了“线程爆炸”(Thread Explosion)和“线程泄漏”(Thread Leak)两大经典陷阱。很多老项目线上OOM,查到最后根源不是业务代码有Bug,而是某个定时器每分钟new一个Thread去轮询数据库,三年没重启,线程数早已突破2000——而线程池会强制把你锁死在安全水位线下。
所以别再把它当成“进阶内容”放在《多线程系列之三》这种位置了。它应该是你写第一个异步方法前就必须刻进肌肉记忆的常识。今天这篇文章,我就以一个在金融交易系统、高并发API网关、实时数据管道里摸爬滚打十年的.NET老兵身份,带你把线程池从“知道有这回事”变成“闭着眼都能调优”的手艺活。不讲虚的原理图,不堆抽象概念,只聊你明天上班就会遇到的真实场景、踩过的坑、抄过来就能用的配置和参数。
2. 线程池的底层骨架:CLR如何管理这支“市政环卫队”
要真正用好线程池,必须掀开CLR的盖子,看清它的调度引擎怎么转。很多人以为ThreadPool.QueueUserWorkItem就是往队列里塞个委托完事,其实背后是一套精密的三层协作机制: 任务队列(Work Queue)→ 线程调度器(Thread Scheduler)→ 线程实例(Thread Instance) 。这三者的关系,决定了你代码是飞一般丝滑,还是卡得像PPT翻页。
先说最常被误解的“队列”。ThreadPool内部维护的不是单个FIFO队列,而是 一个全局队列(Global Queue)+ 每个线程一个本地队列(Local Queue) 。当你调用QueueUserWorkItem,任务首先进入全局队列;而工作线程在空闲时,会优先从自己的本地队列取任务(这是为了减少锁竞争,提升缓存局部性);如果本地队列空了,它才会去全局队列“抢活干”。这个设计直接导致一个关键现象: 任务提交顺序 ≠ 执行顺序 。比如你连续提交Task A、B、C,它们可能被分发到不同线程的本地队列,而线程1执行A,线程2执行B,线程3执行C,谁先完成取决于各自线程负载,而非提交先后。这点在需要严格时序的场景(如订单状态流转)必须警惕,不能依赖提交顺序做业务假设。
再看调度器的核心算法—— 启发式自适应调节(Heuristic Adaptive Scaling) 。它不像某些框架那样简单粗暴地“CPU利用率>70%就加线程”,而是基于三个动态指标做决策:
- 当前活跃线程数 (正在执行任务的线程)
- 挂起等待线程数 (已唤醒但因I/O或锁阻塞而暂停的线程)
- 队列积压深度 (全局队列+所有本地队列的任务总数)
当调度器发现:活跃线程数 < 当前负载所需,且队列积压持续超过500ms,它才会触发扩容。扩容也不是一次加10个,而是按
指数退避策略
:首次扩容加1个,若2秒后仍积压,再加2个,再等2秒……直到达到
ThreadPool.GetMaxThreads()
设定的上限。这个过程全程无锁,靠CAS(Compare-And-Swap)原子操作保证线程安全。我曾经在一个实时风控服务里把MaxThreads设为50,结果在流量突增时,线程数在3秒内从12平稳爬升到48,没有一次抖动——这就是启发式算法的威力。反观那些手动new Thread的代码,扩容是“硬编码式”的,要么永远固定10个,要么自己写个笨重的线程池,结果就是流量低谷时浪费资源,高峰时雪崩。
最后是线程实例的生命周期管理。每个线程池线程都是 后台线程(Background Thread) ,这意味着:当主线程(或所有前台线程)退出时,这些线程会被强制终止,不会阻止进程退出。这是双刃剑——好处是避免“孤儿线程”拖垮进程;坏处是你不能在其中执行必须完成的清理工作(如写日志、释放非托管资源)。更隐蔽的坑是: 线程池线程无法设置名称(Name属性为null) 。这在调试时简直是噩梦。想象一下,生产环境CPU飙高,你用PerfView抓到一个叫“Thread #1234”的线程占了90%时间,却完全不知道它在跑哪个业务逻辑。我的解决方案是在任务委托开头强制打日志:“[ThreadID: {Thread.CurrentThread.ManagedThreadId}] Starting OrderValidationTask”,用ManagedThreadId作为唯一标识,配合Serilog的Structured Logging,把线程ID嵌入日志上下文,排查效率提升3倍不止。
提示:线程池线程的堆栈大小是固定的1MB(x64下2MB),无法像手动创建的线程那样通过
new Thread(..., stackSize)自定义。如果你的任务需要大量递归或超大局部变量,务必评估是否适合放入线程池——否则可能触发StackOverflowException,且异常堆栈极难定位。
3. 实战中的三种主流接入方式:选对路子,少走五年弯路
在.NET生态里,调用线程池有至少五种写法,但真正值得你投入精力掌握的只有三种: Task-based(推荐)、ThreadPool.QueueUserWorkItem(兼容旧代码)、BeginInvoke/EndInvoke(历史遗留) 。其他如BackgroundWorker、WCF异步调用等,本质都是对这三者的封装。我见过太多团队因为选错入口,导致代码可维护性断崖式下跌。下面用真实业务场景拆解每种方式的适用边界和致命陷阱。
3.1 Task-based:现代.NET开发的黄金标准
这是.NET 4.0之后的首选方案,也是微软官方力推的模式。它的核心优势不是语法糖,而是 统一的异步抽象层(TAP - Task-based Asynchronous Pattern) 。看这个典型电商下单场景:
public async Task<OrderResult> ProcessOrderAsync(OrderRequest request)
{
// 步骤1:校验库存(IO密集型,用await释放线程)
var stockCheck = await _inventoryService.CheckStockAsync(request.ItemId);
// 步骤2:生成订单(CPU密集型,用Task.Run切到线程池)
var orderTask = Task.Run(() => _orderGenerator.GenerateOrder(request));
// 步骤3:发送通知(IO密集型,继续await)
var notifyTask = _notificationService.SendEmailAsync(request.UserId, "OrderCreated");
// 等待所有并行任务完成
await Task.WhenAll(orderTask, notifyTask);
return new OrderResult { OrderId = orderTask.Result.Id };
}
这里的关键洞察是:
await不是魔法,它背后是线程池的精密调度
。当
CheckStockAsync
遇到await时,当前线程池线程会立即返回线程池,去处理其他任务;等IO完成,回调会再次从线程池中借一个线程来执行后续代码。而
Task.Run
则是明确告诉CLR:“这段CPU密集型代码,请务必给我一个线程池线程来跑”。这种混合使用,让IO和CPU任务各得其所,资源利用率拉满。
但新手常犯的错误是滥用
Task.Run
。比如把一个纯内存计算的
List.Sort()
包进
Task.Run
,结果反而增加线程切换开销。我的经验法则是:
只有当方法执行时间 > 50ms,且确定是CPU-bound(非IO、非锁等待)时,才用Task.Run
。判断方法很简单:在Visual Studio中用“诊断工具”窗口观察CPU和“.NET ThreadPool”计数器,如果Task.Run后CPU使用率没升反降,说明你在制造瓶颈。
3.2 ThreadPool.QueueUserWorkItem:轻量级任务的快刀手
当你需要执行一个 无返回值、无异常传播需求、纯粹的后台作业 时,这是最轻量的选择。比如日志异步刷盘、监控指标上报、缓存预热。它的优势在于零开销:不创建Task对象,不涉及状态机,直接把委托丢进队列。
// 安全的日志刷盘(避免阻塞主线程)
public void AsyncFlushLog(string message)
{
ThreadPool.QueueUserWorkItem(_ => {
try
{
// 注意:这里不能访问UI控件!
File.AppendAllText("app.log", $"[{DateTime.Now}] {message}\n");
}
catch (Exception ex)
{
// 必须自行捕获,否则异常会静默丢失!
_logger.Error(ex, "Failed to flush log");
}
});
}
这里埋着两个深坑:第一, 异常静默丢失 。QueueUserWorkItem不会捕获委托内的异常,一旦抛出,线程池会终止该线程,且不通知调用方。所以必须在委托内部用try-catch兜底。第二, 无法获取执行结果 。如果你需要知道“日志是否成功写入”,就必须自己实现回调机制,比如传入一个Action 参数。我通常会封装一层:
public static void QueueWithCallback<T>(WaitCallback work, object state, Action<T> callback)
{
var wrapperState = new { Work = work, State = state, Callback = callback };
ThreadPool.QueueUserWorkItem(_ => {
try
{
var s = (dynamic)_;
s.Work(s.State);
s.Callback(default(T)); // 简化版,实际需泛型约束
}
catch (Exception ex)
{
s.Callback(default(T)); // 或传递ex
}
}, wrapperState);
}
3.3 BeginInvoke/EndInvoke:历史包袱的正确打开方式
这是.NET 1.1时代的产物,现在基本只存在于老系统维护中。它的价值不在于性能,而在于
与旧式异步编程模型(APM)的兼容性
。比如你对接一个老旧的SOAP Web Service,它只提供
BeginGetData
和
EndGetData
方法,你就不得不走这条路。
// 调用老式Web Service
public void CallLegacyService()
{
var service = new LegacyWebService();
service.BeginGetData("param", ar => {
try
{
// 回调中必须调用EndInvoke才能获取结果和释放资源
string result = service.EndGetData(ar);
Console.WriteLine($"Got: {result}");
}
catch (Exception ex)
{
// EndInvoke会抛出原始异常
_logger.Error(ex, "Legacy service failed");
}
}, null);
}
最大陷阱是
忘记调用EndInvoke
。这会导致Web Service的底层连接句柄无法释放,最终耗尽连接池。我见过最惨的案例:一个银行系统每分钟调用100次老接口,但忘了EndInvoke,三天后所有HTTP请求都卡在“Connecting”状态。解决方案是:
把BeginInvoke和EndInvoke写在同一作用域,用using或try-finally确保EndInvoke必执行
。不过更建议的做法是,用
Task.Factory.FromAsync
包装成Task,然后用现代async/await语法消费:
var task = Task.Factory.FromAsync(
service.BeginGetData,
service.EndGetData,
"param",
null);
string result = await task; // 现代语法,异常自动传播
注意:所有这三种方式创建的线程,
Thread.CurrentThread.IsThreadPoolThread都返回true。但Thread.CurrentThread.Name永远为null——这是CLR的硬性限制,别试图hack。
4. 线程池的调优实战:从“能跑”到“跑得稳、跑得省”的七步法
线程池不是设个参数就一劳永逸的黑盒。我在某支付平台做性能调优时,曾把一个TPS 200的订单服务优化到TPS 1200,核心动作就是对线程池的七次精准干预。下面这套方法论,是我从上百个生产事故中提炼出的“保命清单”。
4.1 第一步:基线测量——别猜,用数据说话
在调优前,必须建立当前性能基线。我用三个免费工具组合拳:
-
PerfView
:抓取5分钟的ETW事件,重点关注
ThreadPool.ThreadCount、ThreadPool.QueueLength、ThreadPool.CompletedWorkItems计数器。 -
dotnet-counters
:实时监控
System.Runtime指标,特别是thread-pool-queue-length和thread-pool-thread-count。 -
Windows性能监视器(PerfMon)
:添加
.NET CLR Networking和.NET CLR Memory计数器,观察线程数与GC压力的相关性。
关键阈值红线:
-
ThreadPool.QueueLength持续 > 100 → 任务积压严重,需扩容 -
ThreadPool.ThreadCount频繁触达GetMaxThreads()→ 线程池已达容量极限 -
thread-pool-thread-count在低负载时 >GetMinThreads()的2倍 → 存在线程泄漏
有一次,我发现一个后台服务在凌晨3点(业务低谷)线程数稳定在80,而
GetMinThreads()
返回20。这明显异常——线程池会在空闲60秒后自动回收线程,不可能长期维持高位。最终定位到是某个Timer回调里用了
lock
锁住了静态资源,导致线程被阻塞无法返回线程池。
4.2 第二步:合理设置MinThreads——给线程池一个“保底工资”
ThreadPool.SetMinThreads(20, 20)
这行代码,我敢说90%的.NET开发者都写错过。误区在于:认为“设得越大,并发越高”。真相是:
MinThreads是线程池的“最低保障线”,不是“最高限额”
。它告诉CLR:“即使现在一个任务都没有,也请至少保留20个线程随时待命”。这对突发流量至关重要。
但设太高会吃内存。20个线程 × 1MB = 20MB私有堆栈,这还只是堆栈,不包括托管堆开销。我的经验值:
- Web API服务:MinWorkerThreads = CPU核心数 × 2(例如8核设16)
- 后台计算服务:MinWorkerThreads = CPU核心数 × 4(计算密集型需更多并行)
- 高IO服务(如文件服务器):MinWorkerThreads = CPU核心数 × 1(IO线程大部分时间在等,不需要太多)
设置后必须验证:用
ThreadPool.GetMinThreads(out _, out _)
确认生效,并观察
ThreadPool.ThreadCount
是否稳定在该值附近。
4.3 第三步:谨慎调整MaxThreads——给线程池一条“高压线”
SetMaxThreads
是双刃剑。设太低,流量高峰时任务排队,响应延迟飙升;设太高,内存爆满,GC风暴频发。我的原则是:
MaxThreads ≤ (可用物理内存GB × 100) / 1MB
。例如一台16GB内存的服务器,理论最大线程数 ≈ 1600,但实际我会设为800——留足内存给应用程序和GC。
更关键的是,
永远不要在代码里硬编码SetMaxThreads
。它应该由部署环境决定。我的做法是:在
appsettings.json
中配置:
"ThreadPoolSettings": {
"MinWorkerThreads": 32,
"MaxWorkerThreads": 512,
"MinCompletionPortThreads": 4,
"MaxCompletionPortThreads": 64
}
然后在
Program.cs
中读取并设置:
var config = builder.Configuration.GetSection("ThreadPoolSettings");
ThreadPool.SetMinThreads(
config.GetValue<int>("MinWorkerThreads"),
config.GetValue<int>("MinCompletionPortThreads"));
ThreadPool.SetMaxThreads(
config.GetValue<int>("MaxWorkerThreads"),
config.GetValue<int>("MaxCompletionPortThreads"));
提示:
CompletionPortThreads专用于IOCP(I/O Completion Ports)线程,如Socket、File IO。它的Min/Max设置逻辑与WorkerThreads独立,但同样重要。对于高IO服务,CompletionPortThreads的Min值应设为CPU核心数,避免IO回调排队。
4.4 第四步:识别并消灭“线程饥饿”——那个偷偷吃掉你性能的幽灵
线程饥饿(Thread Starvation)是最难诊断的性能问题之一。现象是:CPU使用率很低(<30%),但请求延迟极高,线程池队列长度持续上涨。根本原因不是线程不够,而是 线程被长时间阻塞,无法返回线程池 。
常见阻塞源:
-
同步IO调用
:
File.ReadAllText()、WebClient.DownloadString()(非async版本) -
死锁
:
task.Wait()或task.Result在UI线程或ASP.NET同步上下文中调用 -
长时锁
:
lock块内执行了网络调用或复杂计算
诊断方法:用PerfView抓取“Thread Time”视图,找出那些
Thread State
长时间为
Wait:WrUserRequest
或
Wait:WrAlertable
的线程,然后看它们的调用栈。我曾在一个报表服务里发现,一个
lock(_cacheLock)
块里调用了
HttpClient.GetAsync()
,导致整个线程池被锁住——因为HttpClient的同步方法会阻塞线程,而lock又阻止其他线程进入。解决方案:用
await httpClient.GetAsync().ConfigureAwait(false)
,并确保lock块内只做内存操作。
4.5 第五步:善用IO线程池——别让CPU线程干IO的活
.NET线程池实际包含两类线程: Worker Threads(CPU线程) 和 Completion Port Threads(IO线程) 。前者处理计算型任务,后者专为异步IO回调服务。混淆二者是重大失误。
典型错误:用
Task.Run(() => File.ReadAllBytes("hugefile.dat"))
读大文件。这会让一个宝贵的CPU线程去干IO的活,而IO线程池却闲着。正确姿势是:
所有IO操作,必须用async/await原生支持的方法
:
// ❌ 错误:用CPU线程干IO活
var bytes = await Task.Run(() => File.ReadAllBytes("data.bin"));
// ✅ 正确:用IO线程池,CPU线程全程不阻塞
using var stream = File.OpenRead("data.bin");
var bytes = await stream.ReadAllBytesAsync(); // .NET 5+ 内置方法
ReadAllBytesAsync
的底层是
FileStream.ReadAsync
,它利用Windows的IOCP机制,由Completion Port Threads处理回调,CPU线程在await期间完全释放。实测对比:读取1GB文件,前者CPU占用率峰值95%,后者稳定在5%以下。
4.6 第六步:监控与告警——让线程池自己开口说话
生产环境必须建立线程池健康度监控。我在Prometheus+Grafana中配置了三个核心告警规则:
-
ThreadPool_QueueLength > 200 for 2m:任务积压,需扩容或检查下游依赖 -
ThreadPool_ThreadCount == ThreadPool_MaxThreads for 5m:线程池已满,存在严重瓶颈 -
ThreadPool_CompletedWorkItems_per_second < 10 and ThreadPool_QueueLength > 50:线程池“假死”,可能有未捕获异常或死锁
告警消息模板:
【紧急】服务[OrderService]线程池告警:队列长度=320,线程数=512/512。可能原因:1. 数据库连接池耗尽 2. 外部API超时未设限 3. 代码中存在lock死锁。请立即检查
dotnet-counters --process-id XXX输出。
4.7 第七步:优雅降级——当线程池真的扛不住时
再完美的调优也无法应对所有极端情况。必须设计降级策略:
-
熔断机制
:当
ThreadPool.QueueLength持续>500,自动开启熔断,返回503 Service Unavailable,避免雪崩。 -
任务拒绝策略
:自定义
ThreadPool包装类,在QueueUserWorkItem前检查队列长度,超限时抛出RejectedExecutionException,由上层捕获并降级(如写入本地队列稍后重试)。 - 动态权重调整 :在微服务架构中,将线程池配置与服务实例权重绑定。当某实例线程池压力过大,注册中心自动降低其权重,流量逐步切走。
我在一个实时竞价系统中实现了动态权重:每个服务实例每10秒上报
ThreadPool.QueueLength
,注册中心根据该值动态调整其在负载均衡器中的权重。效果是:当某台机器因磁盘IO慢导致线程池积压,它的流量权重在30秒内从100降到20,故障自动隔离。
5. 线程池的暗礁与避坑指南:那些文档里不会写的血泪教训
写了十年多线程代码,我总结出一份“线程池死亡黑名单”,全是踩过坑、修过半夜bug后刻进DNA的经验。这些细节,官方文档不会提,但它们往往决定你的服务是坚如磐石,还是风中残烛。
5.1 “ThreadPool.SetMinThreads失效”的真相
很多人抱怨“我明明调了SetMinThreads(100, 100),但线程数就是上不去”。真相是:
SetMinThreads只影响后续的线程创建,对已存在的线程无效
。它就像给招聘网站设个“最低入职人数”,但不会把现有员工辞退再重新招。所以,必须在应用启动最早期调用,最好在
Main
方法第一行,或
Program.cs
的
builder.Host.ConfigureServices
之前。如果在中间件或控制器里调用,大概率已经晚了——线程池早已按默认值(通常是CPU核心数)初始化完毕。
5.2 Timer回调的线程池陷阱
System.Threading.Timer
的回调默认在线程池线程中执行,这很危险。因为Timer回调是周期性的,如果回调方法执行时间超过Timer间隔,就会发生
回调堆积
。比如Timer设为1秒,但回调里有个
Thread.Sleep(2000)
,那么下一秒就会有第二个线程池线程进来执行,以此类推,线程数指数级增长。
解决方案只有两个:
-
用
lock或SemaphoreSlim串行化回调 (适合轻量回调) -
改用
System.Timers.Timer并设置AutoReset = false,在回调末尾手动Start()(推荐,可控性强)
private readonly Timer _timer = new Timer();
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
public void StartTimer()
{
_timer.Interval = 1000;
_timer.Elapsed += async (s, e) => {
await _semaphore.WaitAsync(); // 确保同一时间只有一个回调执行
try
{
await DoWorkAsync(); // 你的业务逻辑
}
finally
{
_semaphore.Release();
}
};
_timer.Start();
}
5.3 ASP.NET Core中的线程池“双重代理”危机
在ASP.NET Core中,Controller Action默认运行在
ASP.NET请求线程
(由Kestrel管理),而不是线程池线程。但当你在Action里调用
await Task.Run(...)
,就形成了“请求线程 → 线程池线程 → 请求线程”的双重代理。这会导致:
- 上下文切换开销翻倍
-
HttpContext在Task.Run内部不可用(因为它是请求线程的专属资源) -
如果Action标记了
[DisableRequestSizeLimit],Task.Run里访问HttpContext.Request.Body会抛出ObjectDisposedException
正确姿势:
所有IO操作用原生async方法,CPU密集型才用Task.Run,且绝不访问HttpContext
。如果必须访问,用
AsyncLocal<T>
在调用前捕获关键数据:
[HttpPost]
public async Task<IActionResult> Upload([FromForm] IFormFile file)
{
// 捕获文件名等必要信息
var fileName = file.FileName;
var userId = User.Identity.Name;
await Task.Run(() => {
// 在这里处理文件,但只能用fileName、userId等捕获的值
ProcessLargeFile(fileName, userId);
});
return Ok();
}
5.4 异常处理的“三明治法则”
线程池任务中的异常处理,必须遵循“三明治”结构:
-
外层
:
try-catch捕获所有异常,防止线程终止 - 中层 :记录结构化日志(含线程ID、任务ID、输入参数)
- 内层 :根据异常类型决定重试、降级或告警
ThreadPool.QueueUserWorkItem(_ => {
var taskId = Guid.NewGuid();
try
{
_logger.Information("Task {TaskId} started", taskId);
DoRiskyWork();
_logger.Information("Task {TaskId} completed", taskId);
}
catch (HttpRequestException ex)
{
// 可重试异常:记录并重试
_logger.Warning(ex, "Task {TaskId} failed with HTTP error, retrying", taskId);
RetryLater(taskId, ex);
}
catch (Exception ex)
{
// 不可重试异常:告警并停止
_logger.Error(ex, "Task {TaskId} failed fatally", taskId);
AlertCriticalFailure(taskId, ex);
}
});
5.5 单元测试中的线程池模拟
测试线程池代码最头疼的是“不可预测性”。我的方案是:
永远不要在单元测试中直接调用ThreadPool
,而是抽象出
IThreadPoolExecutor
接口:
public interface IThreadPoolExecutor
{
void QueueUserWorkItem(WaitCallback callback, object state);
}
public class ThreadPoolExecutor : IThreadPoolExecutor
{
public void QueueUserWorkItem(WaitCallback callback, object state)
=> ThreadPool.QueueUserWorkItem(callback, state);
}
// 测试用模拟实现
public class MockThreadPoolExecutor : IThreadPoolExecutor
{
public List<(WaitCallback, object)> Invocations { get; } = new();
public void QueueUserWorkItem(WaitCallback callback, object state)
=> Invocations.Add((callback, state));
}
这样,测试时注入
MockThreadPoolExecutor
,可以精确断言“任务是否被提交”、“提交的参数是否正确”,而无需担心线程调度的不确定性。
注意:线程池的终极奥义,不是让它“跑得多快”,而是让它“跑得有多稳”。我见过太多团队追求极致吞吐,把MaxThreads设到2000,结果GC停顿时间从10ms飙到500ms,用户体验断崖式下跌。真正的高手,懂得在吞吐、延迟、内存、稳定性之间找那个微妙的平衡点——而这个点,永远藏在你亲手测量的数据里,不在任何一篇博客的结论中。
466

被折叠的 条评论
为什么被折叠?



