写 ASP.NET Core 时大多数人关注的是业务逻辑浏览器发起请求Controller处理最后返回 JSON。中间件、Filter、依赖注入DI这些内容大家也都比较熟悉但还有一个容易被忽略的问题这段代码到底运行在哪个线程上线程是谁创建的await数据库查询时线程去哪了为什么 CPU 和内存都不高请求响应却越来越慢这些问题其实都和 ASP.NET Core 的底层执行模型有关。这篇文章就沿着一次 HTTP 请求的生命周期把 Kestrel、线程池、async/await 以及线程池饥饿串起来讲清楚。上一篇聊的是 async 为什么不会一直占着线程这一篇则继续回答另一个问题线程从哪里来又为什么会不够用。HTTP 请求是谁执行的直觉里的链路浏览器 → Controller → 返回实际少了一步。Kestrel 收到请求后不会自己执行业务代码而是把处理工作交给 CLR 的调度中心——我们常说的线程池Thread Pool浏览器 ↓ Kestrel 收到 HTTP ↓ 线程池取出一名工人Worker Thread ↓ Middleware 管道 ↓ Controller / Action ↓ 返回 Response工人回池里待命Controller 并不是自己在跑。是线程池里的某条工作线程正在执行你的 Middleware 和 Action。这一点想通后面 async 为什么能提并发、.Result为什么会拖垮服务都有落脚点。先把线程池想成一支施工队不必一上来就背ThreadPool、Hill Climbing这些词。可以先把 CLR 线程池想成工地上的施工队活来了HTTP 请求、Task.Run、定时器回调…… ↓ 先排队 ↓ 哪个工人闲着哪个上 ↓ 干完不回家回到队里等下一单这名工人就是ThreadPool Worker Thread。施工队不必每个请求现招一个人new Thread()招人、辞退都贵每人还要占一块栈内存默认大约 1MB。预先养一队人复用才撑得住 Web 的高并发。关键限制整个进程通常只有这一支施工队。HTTP 请求、Task.Run、很多定时器回调默认都往同一个池子里排。一个模块把工人占光或堵住别的模块也得干等。跟完一次请求同步写法[HttpGet({id})]publicIActionResultGet(intid){varorder_db.Orders.Find(id);// 同步查库returnOk(order);}HTTP 请求到达 ↓ Kestrel ↓ 线程池 Worker #12 接单 ↓ Middleware → Controller.Get ↓ Worker #12 发起 SQL原地阻塞等数据库 ↓ 等待期间 #12 什么别的活也干不了 ↓ 数据返回Worker #12 继续写出 Response ↓ Worker #12 回池并发 500 个这样的同步请求就可能需要 500 条线程同时堵在等数据库上——施工队很快不够用新请求只能在门口排队。await 时工人去哪了换成 asyncpublicasyncTaskIActionResultGet(intid){varorderawait_db.Orders.FindAsync(id);returnOk(order);}HTTP 请求到达 ↓ 线程池 Worker #12 接单 ↓ Middleware → Controller.Get ↓ 执行到 await FindAsyncSQL 交给数据库Worker #12 释放回池 ↓ #12 可以去处理别的 HTTP 请求了 ↓ 数据库完成通过 I/O 完成端口IOCP通知运行时 ↓ 线程池再派一名工人可能是 #12也可能是 #27执行 await 之后的代码 ↓ 返回 Responseasync 没有消灭线程池而是让工人在等 I/O 的时候不必傻站着。几十个工人就能轮转几百个挂起中的请求。这和 async 那篇是同一套故事的两面那边讲 Task 与挂起这边讲工人从池里来、用完还回去。为什么线程会不够施工队的人数不是固定的CLR 会根据负载调节但有上限。线程不够通常不是机器核数太少而是工人被占住不还publicIActionResultGet(){returnOk(_service.GetDataAsync().Result);// 占着工人等 Task}工人在池子线程上调用.Result/.Wait()等于占着岗位干等。并发一上来空闲工人归零新请求只能排队——表现就是延迟慢慢变大CPU 却不高。线上真正把服务拖慢的往往是大量请求在池子线程上同步等待.Result、.GetAwaiter().GetResult()Thread.Sleep同步HttpClient、StreamReader.ReadToEnd()以为用了异步其实把所有逻辑塞进Task.Run和 HTTP 请求抢同一批工人ThreadPool Starvation系统为什么越来越慢ThreadPool Starvation线程池饥饿就是任务还在源源不断地来但长时间拿不到空闲工人。门口排队的活越积越多大部分请求还行偶尔有一批特别慢而 CPU 利用率并不夸张。几种典型场面在工人线程上同步阻塞 I/OvardataGetDataAsync().Result;一条线程被占住就少一条线程服务其他请求。大量 CPU 密集活丢进池子awaitTask.Run(()HeavyCompute());Task.Run会从施工队里领一名工人专门跑这段计算计算没完之前工人不还。请求一多工人都去算数了等 I/O 的请求排成长队。在池子线程里再等池子线程awaitTask.Run(()otherTask.Wait());工人 A 等工人 BB 可能也在等 A——死锁式饥饿。排查时看阻塞栈、线程池计数器、dotnet-trace先找代码里的同步等待而不是先调配置。施工队怎么补人Work Stealing 与 Hill Climbing工人不够时CLR 会尝试加人但不会无限加——有MaxThreads上限到了就只能排队。Work Stealing为什么池子调度快除了复用线程.NET 线程池还有一个关键机制Work Stealing工作窃取。每个 Worker 有自己的本地队列活来了优先塞给刚交活的那个工人减少锁竞争。本队列为空时工人会去别的 Worker 队列尾部偷任务Worker1 ── Queue1 Worker2 ── Queue2 Worker3 ── Queue3 ↓ Worker3 闲下来了 ↓ 从 Queue1 尾部 Steal 一个任务这是 TPLTask.Run、Parallel.For在线程池上效率高的原因之一不是只有一个全局大队列大家抢。Hill Climbing加线程的依据CLR 内部用Hill Climbing等策略根据吞吐量反馈慢慢调节工人数量在MinThreads和MaxThreads之间找平衡。不必深究算法细节记住用途即可池子自己决定何时加人、何时减人开发者日常不用手算该开几条线程。Task.Run 不是异步是换一名工人干同步活很多人以为Task.Run和GetAsync是一类东西——不是。awaitTask.Run(()Compute());Task.Run ↓ 从线程池领一名工人 ↓ 这名工人同步执行 Compute() ↓ 执行完工人回池Task.Run没有创造 I/O 异步。它只是把一段同步工作交给施工队里的另一名工人去做本质上是线程切换不是 IOCP 那套非阻塞等待。源码路径可以简化为Task.Run(...) ↓ Task.Factory.StartNew / InternalStartNew ↓ TaskScheduler.Default ↓ ThreadPoolTaskScheduler ← Default 就是它 ↓ ThreadPool.UnsafeQueueUserWorkItem所以TaskScheduler.Default不是什么神秘调度器就是ThreadPoolTaskScheduler最终排队进同一个 CLR 线程池。I/O 用GetAsync/FindAsyncCPU 密集或改不了的阻塞 API才在边界上用Task.Run隔离。长时间占线的活后台监听、消费循环可以用TaskCreationOptions.LongRunning提示单独开线程避免占死池子里的工人Task.Factory.StartNew(LongLoop,TaskCreationOptions.LongRunning);为什么微软不建议你调线程池ThreadPool.SetMinThreads、SetMaxThreads能调但文档和实战经验都指向同一件事绝大多数时候问题在代码不在池子配置。99% 的饥饿是工人被同步阻塞占住不是 MinThreads 少了一个零.Result.Wait()Thread.Sleep 同步 HttpClient先把 API 改成全链路 async去掉阻塞等待比调参数管用得多。只有监控明确显示刚启动或扩容后短时间排队明显、且代码侧已排查过才考虑适度调高MinThreads。SetMinThreads不是立刻创建那么多人调太高还会浪费空闲线程的栈内存。SetMaxThreads更要慎动限制不当等于亲手制造排队。一个请求完整经历了什么把这篇和 async、Middleware 放在一起一条链是这样的HTTP 请求 ↓ Kestrel ↓ 线程池派出 Worker ↓ Middleware 管道异常、路由、认证…… ↓ Controller Action ↓ await SqlConnection / EF / HttpClient ↓ Worker 释放回池async I/O 等待 ↓ 数据库 / 网络完成IOCP 通知运行时 ↓ 线程池再派 Worker 执行续体 ↓ Result → Formatter → Response ↓ Worker 回池等待下一单若其中某处写成.Result或同步 I/OWorker 就会在原地阻塞施工队被占满后面的请求只能在门口等——这就是线程池视角下的性能事故。一张表记住线程池概念要点ThreadPool Worker执行 Controller、Middleware、Task.Run 的工作线程进程内单池HTTP、Task.Run、多数回调共用同一 CLR 线程池async I/O等待期间释放 Worker工人可去接别的请求ThreadPool Starvation工人被占满新任务长时间排队Work Stealing本地队列 偷任务降低调度开销Hill ClimbingCLR 自动调节工人数有 Min/Max 边界Task.Run领一名 Worker 跑同步代码不是 I/O 异步TaskScheduler.Default即 ThreadPoolTaskScheduler最容易搞混的几个问题问题一句话回答Controller 跑在哪线程池 Worker 上不是 Kestrel 自己跑业务代码。await 时线程去哪了回线程池I/O 完成后池子再派 Worker 跑续体。每个 Task 有独立池吗没有进程内共用一个 CLR 线程池。Task.Run是异步吗不是是把同步工作交给池里另一名 Worker。TaskScheduler.Default是什么ThreadPoolTaskScheduler底层进线程池队列。饥饿常见原因池子线程上.Result、同步 I/O、CPU 活占满工人。Work Stealing 解决什么多 Worker 本地队列 偷任务提高调度效率。该不该调SetMinThreads先修阻塞代码仅刚启动后排队明显、且监控证实时才考虑。官方文档Thread pool