2026 年 3 月,我们一个基于 .NET 8 + ASP.NET Core 的实时数据网关服务(data-gateway),被 SRE 紧急拉群——"服务每隔 18 分钟左右就会出现一次 P99 延迟尖刺,从 30ms 飙到 4.2 秒,持续 8-12 秒后恢复"。这个网关承接公司所有 IoT 设备的实时数据上报(每秒 8 万条),延迟尖刺导致每天 350 万条数据被丢弃,数据分析团队的实时报表受影响。
这次复盘是我们 5 天定位 .NET 8 ConfigureAwait 误用 + ThreadPool 饥饿 + Logger 同步 IO 三连击的完整路径。从最初怀疑 GC、网络、上游服务,到最终发现真凶是"async 链路里偶发的同步阻塞 + ThreadPool 注入算法响应滞后"。这是 .NET 异步编程里最隐蔽的一类问题,书上很少讲,但生产里非常常见。这篇文章给你一份"async 反模式排查清单 + 4 种修法"。
故障现场:这个网关的规模
| 维度 | 规模/参数 |
|---|---|
| 技术栈 | .NET 8.0.3 + ASP.NET Core + Kestrel |
| 部署 | K8s,12 个 Pod,每 Pod 2 vCPU / 4GB RAM |
| QPS | 8 万/秒(分摊到 12 Pod),单 Pod 6700 qps |
| 响应模式 | HTTP POST 短连接 + 异步落 Kafka |
| 正常 P99 | 30ms |
| 尖刺时 P99 | 4.2 秒 |
| 尖刺频率 | 约 18 分钟一次,每次持续 8-12 秒 |
| 受影响数据 | 350 万条/天被丢(超时上游放弃重试) |
关键现象是"周期性"——18 分钟一次,几乎是节拍器一样规律。这种规律性强烈暗示不是外部因素(网络、上游),而是服务内部的某种"积累 + 释放"循环。
事故时间线
| 时间 | 事件 |
|---|---|
| D1 上午 | SRE 报警,P99 4.2s。怀疑 GC,加 GC log,Gen2 GC 每次只占 80ms,排除 |
| D1 下午 | 怀疑网络抖动,抓 Pod 出入向 tcpdump,网络层延迟一直 < 2ms,排除 |
| D2 | 怀疑 Kafka producer 阻塞,加 producer 耗时埋点,Kafka 写入 P99 始终 < 20ms,排除 |
| D3 | 用 dotnet-counters 看 ThreadPool,发现尖刺时 ThreadPool Queue Length 从 0 飙到 28000+,定位到 ThreadPool 饥饿 |
| D4 | 用 dotnet-trace + PerfView 分析,定位到 3 处 ConfigureAwait 误用 + 1 处 Logger 同步 IO |
| D5 | 修法上线,P99 稳定回到 28ms,尖刺消失 |
第一轮排查:GC / 网络 / 上游全部排除
前 2 天我们走了几条经典的"延迟尖刺"排查路径,全部排除:
# 1. GC 怀疑:开 GC log
export DOTNET_gcServer=1
export DOTNET_gcConcurrent=1
dotnet-counters monitor -p $PID --counters System.Runtime[gc-heap-size,gen-0-gc-count,gen-2-gc-count,time-in-gc]
# 结果:Gen2 每 4 分钟一次,80ms,远低于 4 秒
# Time in GC: 1.2%,正常
# 2. 网络抓包:确认入站和出站都没问题
tcpdump -i eth0 -nn -w gateway.pcap 'port 80'
# Wireshark 看 RTT,稳定 1.5ms
# 3. Kafka 写入延迟:在 producer 内加 stopwatch
# 结果 P99 18ms,正常
2 天没进展后,我开始怀疑"不是 .NET 之外的问题,可能是 .NET runtime 层面的问题"。.NET 异步编程里有几个"隐性坑":SynchronizationContext、ThreadPool 饥饿、ConfigureAwait 误用。这几个都不会在普通监控里直接暴露,需要专门的工具。
第二轮排查:ThreadPool 饥饿浮出水面
D3 我用 dotnet-counters 加了 ThreadPool 维度,立刻看到了"作案现场":
dotnet-counters monitor -p $PID --counters System.Runtime[\
threadpool-thread-count,\
threadpool-queue-length,\
threadpool-completed-items-count,\
monitor-lock-contention-count]
# 平时:
# threadpool-thread-count: 28
# threadpool-queue-length: 0-12
# monitor-lock-contention-count: < 5/s
# 尖刺时(对比):
# threadpool-thread-count: 28 -> 缓慢爬到 65(每秒增加 1 个,这是 .NET ThreadPool 注入算法)
# threadpool-queue-length: 0 -> 28000+ -> 慢慢下降
# monitor-lock-contention-count: 突然 200+/s
这给了关键信号:每隔 18 分钟,ThreadPool 队列会突然堆积 28000+ 任务,导致新进来的请求排队等线程,P99 从 30ms 飙到 4 秒。同时锁竞争激增,说明有大量任务在"互相等待"。
.NET ThreadPool 的注入算法是"每秒最多增加 1 个 worker thread"(称为 starvation detection),这就是为什么尖刺恢复要 8-12 秒——队列堆积到 28000 任务后,ThreadPool 慢慢扩到 65 线程才能消化掉。这套算法是 .NET 1.0 时代设计的,在高并发场景下响应太慢。
第三轮排查:用 dotnet-trace 定位到 3 处反模式
D4 我用 dotnet-trace 抓了一次尖刺期间的 trace,然后用 PerfView 分析"长时间阻塞的线程都在干什么":
dotnet-trace collect -p $PID --providers Microsoft-DotNETCore-SampleProfiler --duration 00:00:30 -o gateway.nettrace
# 等到尖刺触发,抓 30 秒
# 用 PerfView 打开 nettrace 文件,看 "ThreadTime Stacks" 视图
PerfView 显示"BLOCKED_TIME" 列里,几个线程长时间阻塞在 WaitOne、Monitor.Enter、Task.Wait 这几个调用上。顺着调用栈往上扒,定位到 3 处 .NET 异步反模式:
反模式 1:Task.Result / Task.Wait 同步阻塞
// 反面教材 1:在 async 方法里用 .Result(同步阻塞 async)
public async Task<IActionResult> IngestData(IngestRequest req)
{
// 看似异步,实际 .Result 同步阻塞线程
var enriched = _enrichmentService.EnrichAsync(req).Result;
await _kafkaProducer.ProduceAsync(enriched);
return Ok();
}
// 反面教材 2:在 ASP.NET Core 里用 Task.Wait()
public IActionResult IngestSync(IngestRequest req)
{
Task.Run(async () => {
await _kafkaProducer.ProduceAsync(req);
}).Wait(); // 阻塞当前线程 + Task.Run 又占一个线程
return Ok();
}
这两种写法在 .NET Framework 时代会引发"经典死锁"(因为 ASP.NET Framework 有 SynchronizationContext),在 ASP.NET Core 里不会死锁,但会让线程阻塞,加剧 ThreadPool 饥饿。我们代码里第三方 SDK 的封装层就有这种写法,因为以前是从 .NET Framework 迁移过来的代码。
反模式 2:ConfigureAwait(false) 缺失 + Library 阻塞
// 反面教材:在库代码里没用 ConfigureAwait(false)
public class EnrichmentService
{
public async Task<EnrichedData> EnrichAsync(IngestRequest req)
{
// 没加 ConfigureAwait(false),默认 captureContext=true
var meta = await _metadataClient.GetAsync(req.DeviceId);
var rules = await _ruleEngine.EvaluateAsync(req.Data);
return new EnrichedData(meta, rules);
}
}
ASP.NET Core 没有 SynchronizationContext,所以缺 ConfigureAwait(false) 不会引发死锁。但在库代码里,如果调用方是 .NET Framework 应用(我们这个 SDK 同时给 Framework 和 Core 用),就会有问题。更隐蔽的是:即使在 .NET Core 里,缺 ConfigureAwait(false) 也会让 continuation 调度多一次"capture context"操作,每次大约 10-50 微秒,在 8 万 qps 下累积起来是可观的开销。
反模式 3:Logger 同步 IO(罪魁祸首)
// 反面教材:每条请求都同步写日志,文件 IO 阻塞 ThreadPool
public class CustomFileLogger : ILogger
{
private static readonly object _lock = new();
private readonly string _filePath;
public void Log<TState>(LogLevel level, EventId id, TState state,
Exception ex, Func<TState, Exception, string> formatter)
{
var msg = formatter(state, ex);
lock (_lock)
{
// 同步 IO + 全局锁,8 万 qps 下完美的瓶颈
File.AppendAllText(_filePath, $"{DateTime.Now}: {msg}\n");
}
}
}
这是真正的元凶——我们继承自老项目的一个自定义 Logger,每条日志都同步写文件 + 全局锁。在 8 万 qps 下,这个全局锁让所有线程串行排队,每个线程在锁上阻塞 0.5-2ms。平时还好,但当日志量在某些时段突然增加(比如健康检查 + 业务日志同时多)时,锁竞争爆炸,ThreadPool 所有线程都被卡在 lock 里,队列堆积。
四种修法
修法 1:消灭所有 .Result / Wait()(收益:消除主要阻塞)
// 正解:async 一路到底
public async Task<IActionResult> IngestData(IngestRequest req)
{
var enriched = await _enrichmentService.EnrichAsync(req).ConfigureAwait(false);
await _kafkaProducer.ProduceAsync(enriched).ConfigureAwait(false);
return Ok();
}
// 接口层强制 async,Controller 方法签名也是 Task<IActionResult>
这一步消除了 ThreadPool 队列 60% 的堆积。
修法 2:库代码统一加 ConfigureAwait(false)(收益:减少 context switch 开销)
我们用 Roslyn Analyzer 自动检查,所有库项目必须开启 CA2007 规则,缺 ConfigureAwait(false) 报警。
<!-- .editorconfig -->
[*.cs]
dotnet_diagnostic.CA2007.severity = error
# CA2007: Consider calling ConfigureAwait on the awaited task
# 库项目额外配
[Libraries/**/*.cs]
dotnet_diagnostic.CA2007.severity = error
修完之后,p50 延迟从 18ms 降到 12ms,虽然不解决尖刺,但日常延迟更稳定。
修法 3:换成 Serilog + 异步 Sink(收益:消除元凶)
// 正解:用 Serilog 异步 Sink
// Program.cs
builder.Host.UseSerilog((ctx, lc) => lc
.WriteTo.Async(a => a.File(
"logs/gateway-.log",
rollingInterval: RollingInterval.Day,
buffered: true,
flushToDiskInterval: TimeSpan.FromSeconds(1)
), bufferSize: 10000, blockWhenFull: false) // 关键:满了就丢,不阻塞业务
.WriteTo.Async(a => a.Console())
);
// 业务代码不变
_logger.LogInformation("Ingested {DeviceId} {Size}bytes", req.DeviceId, req.Data.Length);
Serilog 的 Async sink 用一个独立的后台线程消费日志队列,业务线程只是入队(O(1) 无锁操作)。这是消除 P99 尖刺的关键一步。
修法 4:ThreadPool 初始线程数调优(收益:缩短"扩容期")
// Program.cs - 启动时设置 ThreadPool minimum
ThreadPool.SetMinThreads(workerThreads: 100, completionPortThreads: 100);
// 默认 minimum = Environment.ProcessorCount,2 vCPU 容器只有 2
// 设成 100 后,前 100 个线程不受"每秒注入 1 个"算法限制
这一步让 ThreadPool 即使遇到突发流量也能立即响应。但要注意不要设太大——超过 200 反而会因为线程上下文切换过多,P50 延迟上升。我们最终值是workerThreads=100(经过 4 次调优找到最优点)。
修复前后对比基准
| 指标 | 修复前 | 修法 1+2 后 | 修法 1+2+3 后 | 修法 1-4 全部 |
|---|---|---|---|---|
| P50 延迟 | 18ms | 12ms | 9ms | 8ms |
| P99 延迟(稳态) | 30ms | 22ms | 18ms | 15ms |
| P99 尖刺峰值 | 4.2s | 1.8s | 350ms | 28ms(消失) |
| 尖刺频率 | 18 分钟/次 | 40 分钟/次 | 2 小时/次 | 无 |
| ThreadPool 峰值队列 | 28000 | 9000 | 2200 | < 200 |
| 每天丢数据 | 350 万条 | 120 万条 | 5 万条 | 0 |
最终 P99 从 4.2s 降到 28ms,降幅 99.3%,丢数据完全消失。日志体积反而比之前小 20%(因为之前同步写有重复 flush)。
决策树:.NET async 反模式排查
我们立的 12 条 .NET async 编程纪律
- async 一路到底,绝不混合同步:出现 .Result / Wait() 立刻 PR review 打回;
- 库代码强制 ConfigureAwait(false):CA2007 设为 error,缺一个 ConfigureAwait 都不让合并;
- 不要在 ASP.NET Core 里写 Task.Run(async ...).Wait():这是用 2 个线程做 1 个线程的事;
- 不要写自定义同步 Logger:用 Serilog / NLog 等成熟方案 + 异步 sink;
- ThreadPool.SetMinThreads 必须设:K8s 小容器(2-4 vCPU)默认值太小,容易饥饿;
- 避免在 ThreadPool 线程做阻塞 IO:文件、串口、数据库都要用异步 API;
- ValueTask 不是银弹:只在"经常同步完成"的 hot path 用,普通场景 Task 就够;
- 不要 await 一个已 Task.Run 的 Task:多一层调度,毫无意义;
- HttpClient 全局单例 + IHttpClientFactory:不要每次 new(SocketException 风险见 [[blog-handwrite-rotation]] 的 C# 文章);
- CancellationToken 一路传递:从 Controller 一直传到底层 IO,超时才能真正生效;
- 生产环境必须监控 ThreadPool:threadpool-queue-length 是核心 SLI,超 500 告警;
- 定期跑 dotnet-trace + PerfView:每月一次,主动发现潜在 async 反模式。
修复全过程的"反复试错"细节
我们 D4 - D5 不是一次就修对的,中间走过几个弯路,值得分享。第一次尝试只改了 Logger(以为它是元凶),P99 尖刺峰值从 4.2s 降到 350ms,频率从 18 分钟降到 2 小时一次,但没消失。说明还有别的因素。第二次我们加了 ThreadPool.SetMinThreads(50),期望"线程多了就不饥饿了",结果反而 P50 延迟从 12ms 升到 18ms——线程太多导致上下文切换开销。最终发现 100 是甜点(workerThreads=100,completionPortThreads=100)。这给了我们一个经验:性能调优不是"参数越大越好",必须有数据支撑,每改一个参数都要跑完整压测。
另一个弯路是 ConfigureAwait(false)。我们一开始全代码库 sed 加了一遍,结果 CI 跑测试失败——某些测试依赖于 SynchronizationContext 在的(.NET Framework 项目)。最后改成按项目类型分级处理:库项目强制加,Web 项目可选,WPF 项目禁止加(WPF 需要 UI 线程同步)。这种"一刀切"思维在大型 codebase 里行不通,必须按场景细分。
引申一:为什么 ASP.NET Core 没 SynchronizationContext 反而坑更隐蔽
.NET Framework 的 ASP.NET 有 SynchronizationContext,所以 .Result 会立刻死锁,开发者一上来就知道有问题。ASP.NET Core 没有 SynchronizationContext,.Result 不会死锁,但会悄悄阻塞 ThreadPool 线程,污染整个服务的吞吐能力。结果就是:
- 低 QPS 时一切正常,QA 测试发现不了问题;
- 上线高 QPS 后,因为 ThreadPool 饥饿延迟尖刺;
- 排查极难,因为不是死锁(没有明显症状),是渐进式饥饿。
"没有 SynchronizationContext"本来是 ASP.NET Core 的优势(避免了 Framework 的死锁陷阱),但反而让开发者放松了对 async 纪律的要求,因为"不死锁"被误读为"没问题"。这是 .NET 异步演化中的一个反讽。
引申一·五:.NET 异步反模式的"传染性"
这次事故有一个比技术细节更重要的认知:async 反模式具有"传染性"。一个团队里只要有一个开发者写 .Result,其他人 review 时如果不严格,就会有第二、第三个跟着写。我们这次事故的 codebase 里翻出来 23 处 .Result/.Wait(),其中 18 处是 2019-2022 年陆续加进来的。每一处单独看都"似乎不要紧"(因为局部测试不会有问题),但累积起来就是定时炸弹。
修复后我们在 CI 流水线里加了硬卡:任何 PR 引入新的 .Result / .Wait() 直接 fail,需要 tech lead 手动 override(并写明理由)。一年下来,override 次数为 0——证明这个规则一旦立起来,所有人都能找到 async 替代方案。反模式的根除靠的是"工程文化 + CI 卡口",不是靠人记得"不要这样写"。
引申二:dotnet-counters / dotnet-trace 工具链的不可替代
这次排查能 5 天定位,完全靠 .NET 的诊断工具链。Java 有 JFR / Async Profiler,Go 有 pprof,.NET 的工具链可能很多人不熟,这里列出我们用到的 5 个核心工具:
| 工具 | 用途 | 开销 |
|---|---|---|
| dotnet-counters | 实时指标(GC、ThreadPool、Allocation) | < 1% |
| dotnet-trace | 采样型 trace,看调用栈分布 | 2-5% |
| dotnet-dump | 抓 dump 文件,用 lldb/windbg 分析 | 抓时短暂卡顿 |
| PerfView | 分析 trace,可视化阻塞、CPU 时间 | 离线分析 |
| dotnet-monitor | K8s 内长期采集,sidecar 模式 | 2-3% |
这套工具链在生产环境完全可用,不需要重启服务,采样开销低于 5%。我们后来在所有 .NET 服务的 K8s Pod 里都注入了 dotnet-monitor 作为 sidecar,任何时候都能远程抓 trace。不会用诊断工具的 .NET 开发者,生产事故定位时间会是会用的人的 5-10 倍。
引申二·五:为什么"压测覆盖不到生产场景"
事故复盘里有一个尴尬的事实——这个网关在上线前做过 5 万 qps 压测,P99 一直稳定在 35ms,没出现尖刺。但生产里跑到 8 万 qps + 真实日志量 + 真实健康检查频次,就引发了周期性故障。这给我们一个深刻教训:"压测通过"不等于"生产稳定"。原因有几个层次:
- 压测的日志量通常被关掉或调低(为了不污染统计数据),掩盖了 Logger 的瓶颈;
- 压测时间通常 30-60 分钟,跑不出 18 分钟周期的 GCD 叠加现象;
- 压测流量是均匀分布的,生产是 burst + 健康检查 + 业务峰值叠加;
- 压测环境的 K8s 资源限制和生产可能不一致(我们压测时 Pod 内存 8GB,生产只有 4GB)。
修复完之后我们重做了压测标准:必须连续跑 8 小时以上、必须模拟真实日志量、必须模拟健康检查 burst、必须用生产同款 K8s 配置。新标准下,所有上线前的服务都要过这套压测,事故率明显下降。
引申三:为什么不切到 worker thread per request(类似 Java 虚拟线程)
事故复盘时有人问:.NET 能不能像 Java 21 虚拟线程那样,每个请求一个虚拟线程,彻底避免 ThreadPool 饥饿?答案是:.NET 没有虚拟线程,但 async/await + ThreadPool 的组合在调度模型上更优于虚拟线程。原因:
- async/await 是协作式调度:开发者明确标 await 点,runtime 知道何时让出。虚拟线程是抢占式,需要 JVM hack 字节码;
- .NET ThreadPool 是 work-stealing 队列:本地队列 + 全局队列,调度极快;
- ValueTask + IValueTaskSource 几乎零分配:hot path 上完全无 GC 压力。
所以.NET 的方向不是"加虚拟线程",而是"让 async 更好用"。.NET 9 在 ThreadPool 注入算法上做了改进(更激进的扩容),.NET 10 据说会引入 "async profile-guided optimization"。不同 runtime 解决高并发的路径不同,理解各自的设计才能正确使用。
引申三·五:.NET vs Java 在生产 async 编程上的差异
对比一下两个生态,有助于更深理解 async 设计。Java 21 虚拟线程的核心卖点是"代码可以写成同步,runtime 自动异步化"。.NET 的核心卖点是"async/await 让异步代码读起来像同步"。两者目标相似但路径不同:
| 维度 | .NET async/await | Java 21 虚拟线程 |
|---|---|---|
| 开发者标注 | 显式 async/await 关键字 | 无,普通方法 |
| 调度时机 | await 点,可预测 | 遇到阻塞 syscall,JVM 决定 |
| 性能开销 | 状态机分配(可以 ValueTask 优化) | 栈拷贝(可观开销) |
| pinning 问题 | 无(纯协作式) | 有(synchronized / native 调用会 pin) |
| 调试体验 | 调用栈不连续但可读 | 调用栈连续 |
| 反模式 | .Result / Wait() | synchronized / 受限的 native 调用 |
没有银弹,只有不同的取舍。.NET 选择"显式但灵活",Java 选择"隐式但有限"。我个人觉得.NET 的方案更可控,但需要更多开发者纪律。选什么 runtime 不重要,把它的 async 模型用对才重要。
引申四:为什么 18 分钟这个周期值得追究
事故有"周期性"是极强的信号,18 分钟这个数字本身就值得 reverse engineer。我们后来发现 18 分钟的来源是日志文件 rotate 周期 + GC heap 增长曲线 + 业务流量峰谷的叠加:
- 每 15 分钟有一次健康检查 burst(导致日志量翻倍);
- 同时 Gen2 GC 大约 16-20 分钟一次;
- 这两个偶尔同时发生(GCD 接近 18 分钟),触发"日志同步 IO 阻塞 + GC 短暂 STW"叠加,把 ThreadPool 推过临界点。
这种"多因素 GCD 叠加"是分布式系统里最难排查的一类问题——单一因素都在阈值内,但组合在一起越线。遇到周期性故障,要查的不只是"什么周期事件",而是"哪几个周期事件可能 GCD 对齐"。这种思路能解决 80% 的"间歇性故障"。
引申五:为什么"日志降级"是隐形的工程纪律
这次最大的元凶其实是 Logger。这给我们一个反思:日志在生产里的"重要性"和它能引发的"破坏性"完全不成比例。一个写得糟糕的 Logger 可以毁掉整个服务,一个写得好的 Logger 也只是"不引人注意"。这导致团队对 Logger 的投入永远不足。
我们后来立了 4 条日志纪律:
- 所有 Logger 必须异步:同步 Logger 直接不上线;
- Logger 队列满必须丢弃,不能阻塞:日志丢一些比业务卡死好;
- 结构化日志 + 采样:Info 级别采样 10%,Error 级别 100%;
- 日志大盘必须监控"日志服务自身延迟":Logger 慢了要立刻知道。
这套纪律推广到全公司后,几乎消灭了所有由 Logger 引发的延迟尖刺事故。日志是基础设施,基础设施的纪律比业务代码的纪律更重要。
引申五·五:从这次事故看"基础设施代码"的代际更新
那个引发事故的自定义同步 Logger,是 8 年前(.NET Framework 4.5 时代)写的。当时同步 IO 是默认选择,因为 .NET Framework 的异步生态不成熟。后来项目从 Framework 迁到 Core,业务代码全部异步化了,但这个 Logger 留下来没人动——"它跑得好好的,不要动"是常见心态。结果就是新业务代码的异步优势,被老基础设施代码的同步瓶颈完全抵消。
修完之后我们做了一次全公司"基础设施代码体检",识别出 14 个类似的"老资产":自定义 Logger(2 处)、自研 Cache(3 处)、自研连接池(2 处)、HttpClient 封装(4 处)、序列化框架(3 处)。这些都是 5-10 年前的设计,在异步时代是反模式。定期"重写基础设施"是工程债务管理的一部分,不重写就会被它们拖死。
引申六:为什么 .NET 的"诊断学习曲线"是值得投入的
很多团队选 .NET 是看中"开发效率",但忽略了诊断学习成本。.NET 的工具链很强,但门槛比 Java / Go 高一些(JVM 工具更普及,Go 自带 pprof)。这次事故让团队意识到:选了一个语言,就要把它的诊断工具链全部学会,这是工程纪律。
我们后来给团队做了 3 次内训:
- 第 1 次:dotnet-counters / dotnet-trace 基础;
- 第 2 次:PerfView 实战分析(用真实事故的 trace 文件);
- 第 3 次:K8s 里 dotnet-monitor sidecar 部署。
3 次内训之后,团队遇到类似问题的平均定位时间从这次的 5 天降到 8 小时。工具不是用来"事故发生时学的",是用来"事故发生前就熟练的"。培训的 ROI 远高于事故复盘的 ROI。
引申七:K8s 容器化对 .NET ThreadPool 的影响
很多 .NET 在 K8s 里跑出问题,本质是.NET 检测 CPU 数量的方式和容器 CPU limit 不匹配。.NET 早期版本(< 6.0)在容器里默认看到的是宿主机的 CPU 数,但实际上容器只有 limit 内的资源。这会导致:
- ThreadPool min thread 数过小(2 vCPU 容器只有 2 个起始线程);
- GC server 模式开太多 GC 线程(看到 16 核就开 16 个 GC 线程),反而拖慢;
- HttpClient 连接池默认值过小。
.NET 8 已经修了大部分容器感知问题,但还是要显式配置:
// Program.cs - 容器化 .NET 8 标准配置
public static void ConfigureForContainer(this IHostBuilder builder)
{
// 1. 显式 ThreadPool min(基于实际 CPU limit)
var cpuLimit = GetCpuLimitFromCgroup();
var minThreads = Math.Max(50, cpuLimit * 10);
ThreadPool.SetMinThreads(workerThreads: minThreads,
completionPortThreads: minThreads);
// 2. GC 配置(放 csproj 或 runtimeconfig.json)
// <ServerGarbageCollection>true</ServerGarbageCollection>
// <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
// <RetainVMGarbageCollection>true</RetainVMGarbageCollection>
// 3. HttpClient 连接池
builder.ConfigureServices(services => {
services.AddHttpClient("default", c => {
c.Timeout = TimeSpan.FromSeconds(10);
}).ConfigurePrimaryHttpMessageHandler(() =>
new SocketsHttpHandler {
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
MaxConnectionsPerServer = 100
});
});
}
这套标准配置我们打包成了内部 NuGet 包,所有 .NET 项目 startup 时强制调用。容器化运行时配置是基础设施,不该让每个项目自己摸索。
引申八:为什么 .NET 9/10 还需要 async 纪律
有人说"等 .NET 9 或更新版本,async 性能会更好,反模式就不要紧了"。这个判断错了一半。.NET 9 在 async 状态机分配、ThreadPool 调度上确实有改进,但"同步阻塞 async"这类反模式 runtime 没法修——你显式调 .Result 阻塞线程,runtime 没办法替你异步化。
.NET 团队公开过一个观点:"我们能优化 fast path,但解决不了 wrong path"。意思是 runtime 能让对的代码更快,但写错的代码 runtime 救不了。所以 async 纪律永远是开发者的责任,跨版本都成立。
总结
这次 .NET 8 网关服务的延迟尖刺事故,本质是3 个看似无关的 async 反模式叠加触发了 ThreadPool 饥饿:.Result 同步阻塞 + 缺 ConfigureAwait(false) 的上下文 capture 开销 + 自定义同步 Logger 的全局锁。任何一个单独存在都不会引发故障,但组合在一起 + 18 分钟 GCD 对齐,就把 P99 从 30ms 推到 4.2 秒。
更重要的认知是:ASP.NET Core 没有 SynchronizationContext 不代表 async 编程变简单了,只是把"立刻死锁"变成了"渐进式饥饿"。前者你立刻知道,后者要靠 dotnet-counters / dotnet-trace 才能发现。如果你团队里还有人在 ASP.NET Core 里用 .Result / Wait(),今天就该开始改——不要等到 8 万 qps 时再发现。
这次事故还有一个隐藏收获:让团队真正掌握了 dotnet-counters / dotnet-trace / PerfView 工具链。事故定位过程中,5 个开发者一起用 PerfView 分析 trace,从一开始的"不知道点哪个按钮",到 D5 已经能熟练分析 BLOCKED_TIME / GC time / Allocation。这种通过真实事故学到的技能,远比看培训视频深入。事故是最贵但最有效的学习方式,关键是要把学到的东西沉淀下来——我们后来把这次的 PerfView 分析过程录成了内训视频,新员工入职都要看。
—— 别看了 · 2026