很多 .NET 性能问题,根源不在于"不知道某个优化技巧",而在于优化了不该优化的地方 。有人一听说 Span<T> 快,就把所有字符串处理改一遍;有人觉得 Task.Run 能提速,就给每个数据库查询套一层。结果 CPU 没降,线程池先撑不住了。
这篇文章想讲清楚一件事:.NET 性能优化的核心思路,是先识别真正的瓶颈,再选择对路的工具和写法。
第一步:别猜,先看
性能优化最容易犯的错误是凭直觉改代码。你觉得慢的地方,往往不是真正吃资源的地方。
微软官方推荐了几类诊断工具,按场景选就行:
开发阶段,直接用 Visual Studio 自带的性能探查器。CPU 使用率、内存分配、热路径调用栈都能可视化,够用且方便。
生产环境或压测环境 ,用 dotnet-counters 实时看关键指标。一条命令就能监控 GC 次数、线程池线程数、异常率这些核心数据。比如你怀疑线程池被阻塞了,看 ThreadPool Thread Count 是不是在持续爬升,比翻代码猜快得多。
需要深入调用栈,Windows 上用 PerfView,Linux 上用 PerfCollect 采集后在 PerfView 里分析。PerfView 是 .NET 团队自己做的,对 GC 和线程池行为的解读最准确。
记住一个原则:没有数据支撑的优化,都是赌博。先跑一轮诊断,拿到 CPU 热点和 GC 数据,再决定往哪个方向使劲。
第二步:异步的正确用法,和最常见的误用
.NET 服务端性能的头号杀手,不是算法不够快,而是线程被白白占着。
ASP.NET Core 的线程池线程数量有限。每个请求进来,占用一个线程。如果这个线程在等数据库返回、等 HTTP 响应、等文件读完,期间什么都干不了,那并发一上来,线程池很快就被占满,新请求只能排队。表现就是:CPU 不高,但接口响应时间飙升。
正确做法是让异步 IO 真正异步。用 ToListAsync() 而不是 ToList(),用 GetAsync() 直接 await 而不是 .Result。这样线程发起 IO 请求后立刻归还线程池,去处理别的请求,IO 完成后再拿一个线程继续后续逻辑。异步省的不是时间,是线程占用。
这里有一个极其常见的反模式:
csharp
// 错误:用 Task.Run 包装同步数据库调用
var users = await Task.Run(() => dbContext.Users.ToList());
这不会让查询变快。它只是把"当前线程阻塞"变成了"另一个线程池线程阻塞",还多了一次无谓的调度开销。EF Core 的同步查询不会因为套了 Task.Run 就变成异步 IO。正确写法是直接用 ToListAsync()。
同样,Task.Run 也不该用来包装 HttpClient 调用,_httpClient.GetAsync(url).Result 这种"异步转同步再转异步"的写法,只会增加死锁风险和调度开销。
Task.Run 真正适合的场景是 CPU 密集型工作,而且不能无限并发,否则会把线程池打爆。批量计算、图像处理这类任务,更好的做法是放进后台队列或独立 Worker 服务里控制并发度。
还有一个隐蔽的陷阱:async void。在 ASP.NET Core 里,async void 方法无法被框架等待,请求可能在方法执行到第一个 await 时就返回了,后续代码访问已经回收的 HttpContext 会直接让进程崩溃。除非是 UI 事件处理器,否则永远用 async Task。
第三步:GC 不是敌人,但大对象是
.NET 的垃圾回收器是它相对 Java 的一大优势,大部分时候你不需要操心内存释放。但有一种情况需要警惕:频繁分配大对象。
大于约 85KB 的对象会进入大对象堆(LOH)。LOH 的回收成本远高于第 0 代和第 1 代,而且容易产生内存碎片。如果你在热路径里反复创建大数组、大字符串,或者把大集合缓存在单例里永不释放,GC 的压力会传导成接口的延迟抖动。
几个实际的做法:
- 大数组用
ArrayPool<T>池化,用完归还,别每次都new byte[1024 * 1024]。 - 返回集合时考虑分页。一次性返回十万条记录,序列化本身就会产生大量分配,还容易触发 LOH 分配。
- 用
IAsyncEnumerable<T>做流式返回,而不是先把所有数据ToList()再返回。
另外,IEnumerable<T> 作为 Action 返回值时有个坑:序列化器会同步遍历整个集合。如果数据量大且来自异步源,直接用 IAsyncEnumerable<T>,或者先 ToListAsync() 再返回,避免在序列化阶段阻塞线程。
第四步:缓存要用对地方
"加缓存"几乎是条件反射式的优化手段,但缓存用错比不用更糟。
缓存适合的场景是:数据访问成本高 + 变更频率低 + 可以容忍短暂的数据陈旧。比如配置信息、热门商品列表、首页聚合数据。
缓存不该用的场景同样明确:分页列表、实时仪表盘、用户个性化数据。这些数据缓存命中率低,还容易让用户看到过期内容。更隐蔽的代价是内存膨胀:你把大量不常重用的数据塞进缓存,GC 的第 2 代回收频率上升,反而拖慢整体性能。
如果确定要用,优先用 IMemoryCache(单机)或 IDistributedCache(多实例)。设置合理的过期时间,并且监控缓存命中率。一个命中率低于 50% 的缓存,基本是在帮倒忙。
第五步:数据访问,少即是多
大部分 ASP.NET Core 应用的性能瓶颈最终都指向数据库。这里有几个见效快且不容易出错的做法:
只查需要的列。 dbContext.Users.ToList() 会把整张表所有字段拉回来。如果页面只需要 Id 和 Name,用 .Select(u => new { u.Id, u.Name }) 投影,网络传输和反序列化的开销都会降一个量级。
只读查询关掉跟踪。 EF Core 默认会跟踪查询返回的实体,以便后续更新。纯展示场景用 .AsNoTracking(),省掉快照维护的开销。
避免 N+1。 在循环里逐个查关联数据是经典性能杀手。用 .Include() 或者一次性批量查询,把多次往返合并成一次。
最后一句
.NET 性能优化没有"一招鲜"。Span<T>、ValueTask、stackalloc 这些工具在特定场景下确实很强,但它们解决的是微观层面的分配问题。如果你的应用瓶颈在数据库查询或者线程池阻塞,用再多 Span<T> 也救不回来。
顺序永远是:先测量,找到热路径,再针对性修改,改完再测量验证。跳过第一步的优化,大概率是在给编译器添乱。