作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!
- cnblogs博客
- zhihu
- Github
- 公众号:一本正经的瞎扯
![]()
在压测一个 asp.net 实现的服务器时,发现第一次压测和第二次压测性能差距较大。
以下是我的分析,以及询问 ChatGPT 得到的答案:
C# 中基于 asp.net 的框架做了一个 web 服务。编译时 -p:PublishAot=false。
压测发现,第一次压测单核 qps 为 15156.5; 第二次压测为 48923.7
猜测是 dotnet runtime 内部使用了类似 PGO 的技术,导致预热后的性能更好。请帮我分析一下这个现象。
这个现象非常像 .NET 的“预热效应”,但不应该只归因于 PGO。
从 15156 qps → 48923 qps,提升约 3.23 倍,幅度比较大,通常是多个因素叠加:
第一次压测:冷启动 / 冷 JIT / Tier0 代码 / 连接池未热 / ThreadPool 未扩容 / 路由未缓存 / HTTP/2 或 TLS 状态未热第二次压测:JIT 完成 / Tier1 优化代码生效 / Dynamic PGO 生效 / 连接复用 / ThreadPool 稳定 / 缓存命中
1. PublishAot=false 意味着你在跑 JIT 模式
你编译时:
-p:PublishAot=false
说明不是 NativeAOT,而是普通 CoreCLR JIT 模式。
ASP.NET Core 服务启动后,很多方法不会在启动时全部编译,而是在第一次执行到某个方法时才 JIT 编译。
所以第一次压测期间,CPU 里有一部分时间花在:
IL → native code JIT 编译
而第二次压测时,大量热点路径已经编译好了,所以 QPS 会明显上升。
这点是最基础的冷启动差异。
2. Tiered Compilation 是核心原因之一
.NET 默认启用 Tiered Compilation。
它大概是这样的:
第一次调用某个方法:先快速生成低优化代码,称为 Tier0方法被频繁调用后:后台重新编译成高度优化代码,称为 Tier1
Tier0 的目标是:
尽快启动,快速生成机器码
Tier1 的目标是:
运行更快,做更多优化
所以第一次压测期间,很多请求路径可能还在跑 Tier0 代码。
第二次压测时,关键路径已经变成 Tier1 代码,所以吞吐明显提高。
可以把它理解成:
第一次压测:边跑边优化
第二次压测:已经优化完再跑
3. Dynamic PGO 也可能参与了
你猜的 PGO 是有道理的。现代 .NET 运行时默认支持 Dynamic PGO。
它会在运行时收集热点路径信息,例如:
某个虚方法实际最常调用哪个具体类型
某个 interface call 实际指向哪个实现
某个分支哪边更常走
某些泛型/委托/循环的实际行为
然后 JIT 可以据此重新生成更激进的 Tier1 代码,例如:
interface IFoo
{int Run();
}IFoo foo = new RealFoo();
foo.Run();
冷的时候可能是普通 interface dispatch。
热了之后,如果 runtime 发现这里 99% 都是 RealFoo,可能会做类似:
if (foo.GetType() == typeof(RealFoo))直接调用 RealFoo.Run()
elsefallback 到 interface dispatch
这样可以减少间接调用,提高 inline 机会。
在 ASP.NET Core 场景里,Dynamic PGO 可能优化:
中间件 pipeline
Endpoint routing
JSON 序列化路径
日志判断路径
DI 调用路径
泛型集合访问
Kestrel 请求处理路径
所以你的猜测方向是对的:PGO 可能是原因之一,但它不是唯一原因。
4. ASP.NET Core 本身也有大量应用级预热
除了 runtime,ASP.NET Core 框架自身也会在第一次请求时初始化很多东西。
例如:
Endpoint 路由匹配结构
Controller / Minimal API delegate
模型绑定缓存
JSON 序列化 metadata
反射结果缓存
表达式树编译结果
DI 解析路径缓存
日志 category/cache
TLS/HTTP 连接状态
如果你的接口返回 JSON,那么 System.Text.Json 第一次序列化某个类型时,也可能建立 metadata cache。
例如:
public record UserDto(long Id, string Name);
第一次序列化 UserDto 时,runtime 会准备类型元数据、属性访问器等。
第二次开始就大量复用缓存。
5. ThreadPool 也可能是关键因素
第一次压测时,ThreadPool 可能还没有达到稳定线程数量。
尤其如果你的接口中有:
await
Task.Run
同步阻塞
.Result
.Wait()
数据库/Redis/HTTP IO
日志 IO
那么第一次压测可能经历 ThreadPool hill-climbing 扩容过程。
大概是:
初始 worker threads 较少
压测突然打进来
请求排队
ThreadPool 逐步判断是否需要增加线程
吞吐逐渐上升
第二次压测时,线程池已经处于更合适的状态,所以 QPS 会更高。
不过你说的是“单核 QPS”,如果确实严格限制到 1 个 CPU core,那么 ThreadPool 扩容的影响可能小一些,但仍然会影响调度、队列和异步 continuation。
6. 连接复用也可能造成巨大差异
压测工具的行为也很重要。
比如使用:
wrk
bombardier
h2load
ab
hey
第一次压测时可能包含:
TCP 连接建立
TLS 握手
HTTP/2 连接初始化
连接池建立
客户端侧连接预热
服务端 socket accept 路径预热
第二次压测时,如果客户端和服务端连接状态、内核缓存、端口状态、HTTP/2 stream 状态更稳定,吞吐可能明显上升。
不过如果你的两次压测是两个完全独立进程,连接复用影响会小一些;如果是同一个压测进程多轮跑,影响可能更明显。
7. 这个差距是否正常?
15k → 49k qps,差距偏大,但在下面条件下是合理的:
压测时间较短,例如 5s、10s
服务刚启动后立刻压测
接口路径涉及较多 framework pipeline
Dynamic PGO / Tiered JIT 尚未完成
第一次压测包含 JIT 和缓存初始化
单核限制导致 JIT 开销更明显
如果你第一次压测时间很短,例如:
wrk -t1 -c100 -d10s
那第一次结果非常容易被冷启动污染。
更合理的 benchmark 应该是:
启动服务
先 warmup 30s ~ 120s
丢弃 warmup 结果
再正式压测 30s ~ 120s
重复多轮取稳定值
8. 建议你做几个对照实验
实验 1:关闭 Tiered Compilation
DOTNET_TieredCompilation=0
再压测。
如果第一次和第二次差距明显缩小,说明 Tiered JIT 是主要因素之一。
实验 2:关闭 Dynamic PGO
DOTNET_TieredPGO=0
再压测。
如果第二次 QPS 明显下降,说明 Dynamic PGO 对你的服务路径有贡献。
实验 3:打开 QuickJitForLoops
通常现代 .NET 默认策略已经比较合理,但你可以显式观察:
DOTNET_TC_QuickJitForLoops=1
它影响循环方法是否先 quick JIT。
实验 4:延长第一次压测时间
例如:
wrk -t1 -c64 -d10s http://127.0.0.1:5000/
wrk -t1 -c64 -d60s http://127.0.0.1:5000/
wrk -t1 -c64 -d120s http://127.0.0.1:5000/
观察第一次压测内部是不是逐渐升高。
如果 120 秒的第一次压测平均 QPS 接近第二次,说明主要是预热问题。
实验 5:使用 warmup 脚本
例如:
# warmup
wrk -t1 -c64 -d60s http://127.0.0.1:5000/# real benchmark
wrk -t1 -c64 -d60s http://127.0.0.1:5000/
只记录第二段。
9. 可以用 EventPipe / dotnet-trace 直接验证
你可以抓 runtime 事件,看 JIT 和 Tiered Compilation 行为。
例如:
dotnet-trace collect \-p <pid> \--providers Microsoft-Windows-DotNETRuntime:0x10:4
0x10 是 JIT keyword。
也可以更粗暴一点收 runtime profile:
dotnet-trace collect \-p <pid> \--profile cpu-sampling \--duration 00:00:30 \-o trace.nettrace
然后转 speedscope:
dotnet-trace convert trace.nettrace --format speedscope
在第一次压测期间,如果看到很多类似:
JIT
Compiler
clrjit
System.Private.CoreLib
Microsoft.AspNetCore.*
System.Text.Json.*
第二次明显减少,就能验证 JIT/预热因素。
10. 也可以用 counters 看 ThreadPool / JIT
dotnet-counters monitor -p <pid> System.Runtime
重点看:
% Time in GC
ThreadPool Thread Count
ThreadPool Queue Length
JIT Method Count
Time spent in JIT
GC Heap Size
Allocation Rate
如果第一次压测时:
JIT Method Count 持续增长
Time spent in JIT 增长明显
ThreadPool Queue Length 偏高
那基本就坐实了。
11. 对生产环境的建议
如果你的目标是稳定低延迟/高 QPS,建议做显式 warmup。
例如服务启动后主动请求关键 endpoint:
app.Lifetime.ApplicationStarted.Register(() =>
{_ = Task.Run(async () =>{using var client = new HttpClient();for (int i = 0; i < 10000; i++){await client.GetAsync("http://127.0.0.1:5000/your-hot-path");}});
});
但更好的做法是在外部 readiness 流程中做:
启动服务
warmup 热路径
readiness probe 才返回 ready
开始接真实流量
不要让真实用户流量承担 JIT / PGO / cache 初始化成本。
结论
你的判断基本正确,但更完整的解释是:
第一次压测低:冷 JIT + Tier0 代码 + runtime/framework 缓存初始化 + ThreadPool/连接预热第二次压测高:Tier1 optimized code + Dynamic PGO 优化 + framework cache 命中 + 运行状态稳定
其中最值得重点验证的是:
Tiered Compilation
Dynamic PGO
ASP.NET Core 路由/JSON/DI 缓存
ThreadPool 状态
压测工具连接复用
建议你先做这两个环境变量对照:
DOTNET_TieredCompilation=0
DOTNET_TieredPGO=0
再配合:
dotnet-counters monitor -p <pid> System.Runtime
基本就能判断到底是 JIT/TieredPGO 主导,还是 ThreadPool/连接/框架缓存主导。

