写 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 时工人去哪了
换成 async:
publicasyncTask<IActionResult>Get(intid){varorder=await_db.Orders.FindAsync(id);returnOk(order);}HTTP 请求到达 ↓ 线程池 Worker #12 接单 ↓ Middleware → Controller.Get ↓ 执行到 await FindAsync:SQL 交给数据库,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/O
vardata=GetDataAsync().Result;一条线程被占住,就少一条线程服务其他请求。
大量 CPU 密集活丢进池子
awaitTask.Run(()=>HeavyCompute());Task.Run会从施工队里领一名工人专门跑这段计算,计算没完之前工人不还。请求一多,工人都去算数了,等 I/O 的请求排成长队。
在池子线程里再等池子线程
awaitTask.Run(()=>otherTask.Wait());工人 A 等工人 B,B 可能也在等 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 一个任务这是 TPL(Task.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/FindAsync;CPU 密集或改不了的阻塞 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/O,Worker 就会在原地阻塞,施工队被占满,后面的请求只能在门口等——这就是线程池视角下的性能事故。
一张表记住线程池
| 概念 | 要点 |
|---|---|
| ThreadPool Worker | 执行 Controller、Middleware、Task.Run 的工作线程 |
| 进程内单池 | HTTP、Task.Run、多数回调共用同一 CLR 线程池 |
| async I/O | 等待期间释放 Worker,工人可去接别的请求 |
| ThreadPool Starvation | 工人被占满,新任务长时间排队 |
| Work Stealing | 本地队列 + 偷任务,降低调度开销 |
| Hill Climbing | CLR 自动调节工人数,有 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