【.NET并发编程 - 19】性能优化实战:零拷贝与内存优化
19. 性能优化实战:零拷贝与内存优化
本章 GitHub 仓库:csharp-concurrency-cookbook ⭐
欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。
🎯 本章导读
📌 本文目标:掌握 .NET 高性能编程的核心技术——ValueTask、Span、Memory、ReadOnlySequence、ArrayPool,能够编写零拷贝、低分配的高性能代码。
不知道各位有没有遇到过这样的情况:代码写得很漂亮,逻辑也没问题,但一上生产环境,性能就不尽如人意——CPU 占用不高,但吞吐量就是上不去;内存一直在增长,GC 频繁触发,每次 GC 都会导致毫秒级的暂停......
今天我们就来聊聊如何用 .NET 的现代化高性能 API,把你的代码性能提升一个档次。这不是什么玄学优化,而是微软官方在 .NET Core / .NET 5+ 中大力推广的"零拷贝"和"低分配"编程模式。
在前面的章节中,我们学习了异步编程(第 3-9 章)、并行处理(第 10 章)、限流控制(第 18 章)。这些技术让我们的代码能够更高效地利用系统资源。但是,还有一个常被忽略的性能瓶颈——内存分配与 GC。
你可能会说:"内存分配有什么大不了的?现在内存又不贵。"
但现实是残酷的:在高并发场景下(比如每秒处理 10 万个请求),每个请求多分配 1KB 内存,就意味着每秒分配 100MB!这会导致:
- GC 频繁触发(每秒数十次)
- 每次 GC 暂停 5-20ms(Stop-The-World)
- 吞吐量下降 30-50%
- 响应时间飙升
这就是为什么 ASP.NET Core、EF Core、Kestrel 等微软官方框架的源码中,大量使用了本章要讲的技术。
0️⃣ 故事开始:一次性能优化之旅
疫情期间,我接了一个“私活”,帮朋友负责的一个高频交互系统的进行性能优化。系统的核心功能是解析上游推送的市场数据(多租户模式,日常均值在每秒1万条左右,峰值超过每秒 5 万+ 条消息),提取关键信息,然后再转发给下游服务进行消费处理。
初版代码写得很"优雅",用了很多我们熟悉的 .NET API:
public async Task<MarketData> ParseMessageAsync(Stream stream)
{
// 读取消息体
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
byte[] data = ms.ToArray();
// 解析 JSON
string json = Encoding.UTF8.GetString(data);
var message = JsonSerializer.Deserialize<MessageDto>(json);
// 提取时间戳(格式:2021-12-20 14:30:25.123)
string timestamp = message.Timestamp;
string date = timestamp.Substring(0, 10);
string time = timestamp.Substring(11, 12);
return new MarketData
{
Date = date,
Time = time,
Price = message.Price
};
}
看起来没什么问题对吧?逻辑清晰,代码简洁。
性能灾难
但上线后,系统的表现让人大跌眼镜:
- 吞吐量:目标 3 万 QPS,实际只能跑到 1 万不到
- CPU:只用了 40%,明明还有余力
- 内存:疯狂增长,每分钟增加 500MB
- GC:Gen0 每秒触发 50+ 次,Gen1 每秒 5+ 次
- 延迟:P99 延迟从预期的 5ms 飙升到 50ms
我们用 dotMemory 分析了内存分配,发现了惊人的事实:
每个请求分配了 6 次内存!
MemoryStream初始分配:256 字节MemoryStream扩容 2 次:512 字节 + 1024 字节ms.ToArray():1024 字节(拷贝)Encoding.UTF8.GetString():1024 字节(字符串)Substring(0, 10):10 字节(日期字符串)Substring(11, 12):12 字节(时间字符串)
5 万 QPS × 6 次分配 × 平均 1KB = 每秒分配 300MB!
GC 根本回收不过来,Gen0 的回收频率达到了极限。
优化之路
经过一番研究和重构,我们引入了这些技术:
- ArrayPool:复用缓冲区,不再每次分配
- Span:零拷贝切片,不再 Substring
- Memory + PipeReader:流式读取,不再一次性加载
- ValueTask:缓存场景零分配
最终效果:
- 吞吐量:从1万提升到 接近6 万
- 内存分配:减少 95%(从每秒 300MB 降到 25MB)
- GC:Gen0 从 50 次/秒降到 5 次/秒
- 延迟:P99 从 50ms 降到 3ms
这就是零拷贝和低分配编程的威力。
今天这一章,我就把这套优化技术系统地讲给你听。
1️⃣ 为什么需要性能优化?三个现实问题
在深入具体技术之前,我们先搞清楚三个问题:
问题 1:内存分配的代价比你想象的大
很多开发者觉得:"new 一个对象能花多少时间?"
真相:在高并发场景下,内存分配的代价非常昂贵。
内存分配的隐性成本:
| 步骤 | 耗时 | 说明 |
|---|---|---|
| 1. 查找空闲内存 | 5-10ns | 扫描 Gen0 堆 |
| 2. 初始化内存 | 5-10ns | 清零内存(安全要求) |
| 3. 更新 GC 元数据 | 5-10ns | 记录分配信息 |
| 4. 触发 GC(可能) | 1-20ms | Gen0 回收暂停 |
| 总计 | 15-30ns + GC暂停 | 看似很快,但架不住次数多 |
案例:每秒 10 万次分配,每次 1KB
- 直接成本:10 万 × 30ns = 3ms
- GC 成本:假设每秒触发 50 次 Gen0(每次 5ms)= 250ms
- 总计:每秒浪费 253ms(占用 25% 的 CPU 时间)
这还没算 Gen1、Gen2 的回收,以及 GC 暂停导致的延迟抖动。
问题 2:拷贝数据的代价比你想象的大
案例:处理 1KB 的 HTTP 请求体
传统做法:
byte[] buffer1 = new byte[1024]; // 分配
await stream.ReadAsync(buffer1); // 读取
byte[] buffer2 = new byte[1024]; // 又分配
Array.Copy(buffer1, buffer2, 1024); // 拷贝!
string text = Encoding.UTF8.GetString(buffer2); // 又分配
问题:
- 分配了 3 次(2 次数组 + 1 次字符串)
- 拷贝了 1024 字节(内存带宽消耗)
- 增加了 GC 压力
零拷贝做法:
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024); // 租借
try
{
await stream.ReadAsync(buffer.AsMemory()); // 读取
var span = buffer.AsSpan(0, bytesRead); // 零拷贝切片
string text = Encoding.UTF8.GetString(span); // 直接解码
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // 归还
}
效果:
- 只分配 1 次(字符串)
- 零数据拷贝
- 缓冲区复用,GC 压力归零
问题 3:GC 暂停是延迟抖动的元凶
在第 4 章中,我们讲过 async/await 的性能开销。但还有一个更隐蔽的性能杀手——GC 暂停(Stop-The-World)。
GC 工作流程:
- 检测到内存不足(Gen0 满了)
- 暂停所有线程(STW)
- 标记-清除-压缩
- 恢复线程
典型 GC 暂停时间:
- Gen0:0.5-5ms
- Gen1:5-20ms
- Gen2:20-200ms(如果有大对象堆碎片,可能更长)
实际影响:
假设你的 API 平均响应时间是 10ms,但:
- 每秒触发 50 次 Gen0 GC(每次 2ms)
- 每秒触发 5 次 Gen1 GC(每次 10ms)
结果:
- 50% 的请求会被 GC 延迟 2ms
- 5% 的请求会被 GC 延迟 10ms
- P50 延迟:12ms(+20%)
- P99 延迟:20ms(+100%)
用户感知:你的 API"偶尔"会卡顿。
解决方案:减少分配,降低 GC 频率。
现在我们知道了为什么要优化内存分配,接下来就逐一学习这些优化技术。
2️⃣ ValueTask:减少 Task 分配的杀手锏
在08章里面,单独有一个章节讲过ValueTask,但是这些知识点真的很重要,一旦用错,可能会引发更大的性能问题,所以我觉得很有必要再强调一遍。
2.1 ValueTask 解决了什么问题?
还记得第 4 章讲过的 async/await 性能开销吗?我们说过,每次调用 async 方法都会分配一个 Task 对象。
问题场景:高频缓存查询
public async Task<User> GetUserAsync(int userId)
{
// 大部分情况缓存命中(同步完成)
if (_cache.TryGetValue(userId, out var user))
{
return user; // 编译器生成:return Task.FromResult(user);
// ❌ 问题:即使同步完成,仍分配 Task 对象!
}
// 少数情况缓存未命中,查数据库
user = await _database.GetUserAsync(userId);
_cache[userId] = user;
return user;
}
分析:
- 假设缓存命中率 90%
- 每秒 10 万次调用
- 90% 的调用(9 万次)都是同步完成,但仍分配了 Task 对象
- 每秒浪费 9 万次堆分配!
2.2 ValueTask 的魔法:同步完成路径零分配
public async ValueTask<User> GetUserAsync(int userId)
{
// 缓存命中:零分配!
if (_cache.TryGetValue(userId, out var user))
{
return user; // ✅ ValueTask<User> 是值类型,栈分配
}
// 缓存未命中:内部仍使用 Task
user = await _database.GetUserAsync(userId);
_cache[userId] = user;
return user;
}
效果:
- 缓存命中(90%):零堆分配
- 缓存未命中(10%):仍使用 Task(与传统方式一致)
- 每秒减少 9 万次分配!
2.3 ValueTask 的工作原理
ValueTask
// 简化示意
public readonly struct ValueTask<T>
{
private readonly object _obj; // 可能是 Task<T>,也可能是 IValueTaskSource
private readonly T _result; // 同步完成时的结果
private readonly short _token; // 状态标记
// 同步完成:直接返回结果
public ValueTask(T result)
{
_result = result;
_obj = null;
_token = 0; // 表示同步完成
}
// 异步完成:包装 Task<T>
public ValueTask(Task<T> task)
{
_obj = task;
_result = default;
_token = 1; // 表示异步
}
}
关键点:
- 同步完成时,
ValueTask<T>只包含结果值,不分配 Task - 异步完成时,
ValueTask<T>包装一个 Task,与传统方式一致 ValueTask<T>本身是值类型(struct),在栈上分配
2.4 ValueTask 的使用限制(重要!)
ValueTask 不是 Task 的完全替代品,有以下限制:
❌ 限制 1:只能 await 一次
var task = GetUserAsync(1);
await task; // ✅ 第一次 await:正确
await task; // ❌ 第二次 await:运行时异常!
原因:ValueTask 可能被复用,多次 await 会导致未定义行为。
❌ 限制 2:不能并发 await
var task = GetUserAsync(1);
await Task.WhenAll(task.AsTask(), task.AsTask()); // ❌ 错误!
❌ 限制 3:不能直接用于 Task.WhenAll/WhenAny
var vt1 = GetUserAsync(1);
var vt2 = GetUserAsync(2);
// await Task.WhenAll(vt1, vt2); // ❌ 编译错误
// ✅ 需要转换为 Task
await Task.WhenAll(vt1.AsTask(), vt2.AsTask());
2.5 何时使用 ValueTask?
✅ 推荐使用场景:
- 高频调用:每秒 10 万+ 次
- 同步完成占比高:> 50%(如缓存命中率高)
- 性能敏感的库代码:如 ASP.NET Core、EF Core
❌ 不推荐使用场景:
- 需要多次 await 同一个结果
- 需要使用 Task.WhenAll/WhenAny
- 低频调用的方法(Task 分配开销可忽略)
- 业务代码(优先可读性)
📌 经验法则:
- 应用代码:默认用 Task
,够用且简单 - 库代码:性能敏感场景考虑 ValueTask
- 有疑问时:先用 Task
,性能问题再优化
2.6 实战案例:分布式缓存查询
public class UserService
{
private readonly IMemoryCache _cache;
private readonly IDatabase _database;
// ✅ 使用 ValueTask 优化高频缓存查询
public async ValueTask<User> GetUserAsync(int userId)
{
// 本地缓存命中:同步完成,零分配
if (_cache.TryGetValue($"user:{userId}", out User user))
{
return user;
}
// 本地缓存未命中,查数据库
user = await _database.GetUserAsync(userId);
// 写入缓存
_cache.Set($"user:{userId}", user, TimeSpan.FromMinutes(5));
return user;
}
}
效果对比(100 万次调用,90% 缓存命中):
| 实现方式 | 分配次数 | GC Gen0 | 吞吐量 |
|---|---|---|---|
| Task |
1,000,000 | ~8,000 | 120k req/s |
| ValueTask |
100,000 | ~800 | 500k req/s |
提升:分配减少 90%,吞吐量提升 4 倍!
3️⃣ Span:栈分配与零拷贝的利器
3.1 Span 解决了什么问题?
场景:解析日期字符串 "2026-07-11"
传统做法:
string input = "2026-07-11";
string year = input.Substring(0, 4); // 分配新字符串!
string month = input.Substring(5, 2); // 分配新字符串!
string day = input.Substring(8, 2); // 分配新字符串!
int yearValue = int.Parse(year);
问题:
- 调用 3 次
Substring,分配 3 个新字符串 - 每次分配约 20-30 字节(字符串对象 + 字符数据)
- 高频场景(解析日志、CSV)下,GC 压力巨大
Span 做法:
ReadOnlySpan<char> input = "2026-07-11";
ReadOnlySpan<char> year = input.Slice(0, 4); // 零分配!
ReadOnlySpan<char> month = input.Slice(5, 2); // 零分配!
ReadOnlySpan<char> day = input.Slice(8, 2); // 零分配!
int yearValue = int.Parse(year); // Span 直接支持解析
效果:
- 零堆分配!
Slice只是指针偏移,不复制数据- 所有操作在栈上完成
3.2 Span 的本质:ref struct
Span
public readonly ref struct Span<T>
{
private readonly ref T _pointer; // 指向数据的引用
private readonly int _length; // 长度
// 切片操作:零拷贝
public Span<T> Slice(int start, int length)
{
return new Span<T>(_pointer + start, length);
// 只是指针偏移,不复制数据!
}
}
关键特性:
- ref struct:只能在栈上分配,不能装箱
- 零拷贝切片:
Slice只改变指针和长度 - 统一抽象:可以指向数组、栈内存(stackalloc)、非托管内存
3.3 Span 的使用场景
场景 1:字符串解析(避免 Substring)
// 解析 CSV:name,age,city
string csv = "Alice,30,Beijing";
// ❌ 传统做法
string[] parts = csv.Split(','); // 分配数组 + 3 个字符串
// ✅ Span 优化
ReadOnlySpan<char> span = csv;
int comma1 = span.IndexOf(',');
int comma2 = span.Slice(comma1 + 1).IndexOf(',') + comma1 + 1;
ReadOnlySpan<char> name = span.Slice(0, comma1);
ReadOnlySpan<char> age = span.Slice(comma1 + 1, comma2 - comma1 - 1);
ReadOnlySpan<char> city = span.Slice(comma2 + 1);
// 零分配!
场景 2:数组切片(避免复制)
int[] numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// ❌ 传统做法:复制数据
int[] segment = new int[5];
Array.Copy(numbers, 2, segment, 0, 5);
// ✅ Span 优化:零拷贝
Span<int> segment = numbers.AsSpan(2, 5);
// segment 指向 numbers[2..7],不复制!
场景 3:栈上分配缓冲区(stackalloc)
// ❌ 传统做法:堆分配
byte[] buffer = new byte[256];
// ✅ Span 优化:栈分配
Span<byte> buffer = stackalloc byte[256];
// 分配在栈上,不触发 GC!
⚠️ 注意:stackalloc 只适合小缓冲区(< 1KB),栈空间有限(Windows 默认 1MB)。
3.4 Span 的限制
❌ 限制 1:不能跨 await 边界
public async Task ProcessAsync(Span<byte> buffer)
{
await Task.Delay(100); // ❌ 编译错误!
// Span 是 ref struct,不能跨 await
Process(buffer);
}
原因:await 会将方法编译成状态机,状态机会将局部变量保存到堆上,但 Span 是栈分配,不能装箱。
解决方案:使用 Memory
❌ 限制 2:不能作为类的字段
public class MyClass
{
private Span<byte> _buffer; // ❌ 编译错误!
// Span 只能是方法内局部变量
}
3.5 实战案例:高性能日志解析
// 日志格式:2024-12-20 14:30:25 [INFO] User login: user@example.com
public static LogEntry ParseLog(string logLine)
{
ReadOnlySpan<char> log = logLine;
// 查找分隔符
int firstSpace = log.IndexOf(' ');
int secondSpace = log.Slice(firstSpace + 1).IndexOf(' ') + firstSpace + 1;
int leftBracket = log.IndexOf('[');
int rightBracket = log.IndexOf(']');
// 零拷贝提取
var date = log.Slice(0, firstSpace);
var time = log.Slice(firstSpace + 1, secondSpace - firstSpace - 1);
var level = log.Slice(leftBracket + 1, rightBracket - leftBracket - 1);
return new LogEntry
{
Date = date.ToString(), // 只在最后转字符串
Time = time.ToString(),
Level = level.ToString()
};
}
性能对比(100 万次解析):
| 实现方式 | 分配次数 | GC Gen0 | 耗时 |
|---|---|---|---|
| Split + Substring | ~4M | ~1000 | 2000ms |
| Span.Slice | ~3M | ~0 | 500ms |
提升:性能提升 4 倍,GC 压力归零!
4️⃣ Memory:可存储的 Span,支持异步
4.1 Memory 解决了什么问题?
Span
场景:异步读取文件
// ❌ Span 不能跨 await
public async Task ProcessAsync(Span<byte> buffer)
{
await Task.Delay(100); // 编译错误!
Process(buffer);
}
// ✅ Memory 可以跨 await
public async Task ProcessAsync(Memory<byte> buffer)
{
await Task.Delay(100); // ✅ 正确
Process(buffer.Span); // 获取 Span 进行实际操作
}
4.2 Memory 与 Span 的关系
Memory
- 普通结构体(不是 ref struct)
- 可以作为字段、可以装箱
- 可以跨 await 边界
- 通过
.Span属性获取 Span
Span
- ref struct
- 只能作为局部变量
- 不能跨 await
- 性能最优(栈分配)
使用规则:
- 同步方法:优先用 Span
- 异步方法:必须用 Memory
- 公共 API:同时提供两个重载
4.3 Memory 与 ArrayPool 配合
public async Task<string> ReadFileAsync(string path)
{
// 租借缓冲区
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
// 转换为 Memory
Memory<byte> memory = buffer.AsMemory();
await using var fs = File.OpenRead(path);
int bytesRead = await fs.ReadAsync(memory); // 可以跨 await
// 获取 Span 进行操作
return Encoding.UTF8.GetString(memory.Span.Slice(0, bytesRead));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
4.4 MemoryPool:Memory 的对象池
// 使用 MemoryPool
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> memory = owner.Memory;
// 异步操作
await ProcessAsync(memory);
// using 结束,自动归还
MemoryPool vs ArrayPool:
- ArrayPool:返回
byte[] - MemoryPool:返回
IMemoryOwner<T>,包装了 Memory - 适用场景:异步场景优先 MemoryPool
5️⃣ ReadOnlySequence:处理分段数据
5.1 ReadOnlySequence 解决了什么问题?
场景:网络数据可能跨越多个缓冲区
假设你用 PipeReader 读取 HTTP 请求,数据可能分散在多个内存块中:
[缓冲区1: "GET /api"] [缓冲区2: "/users HT"] [缓冲区3: "TP/1.1\r\n"]
传统做法:
// ❌ 合并到单个数组(分配+拷贝)
byte[] allData = new byte[totalLength];
int offset = 0;
foreach (var segment in segments)
{
segment.CopyTo(allData.AsSpan(offset));
offset += segment.Length;
}
string text = Encoding.UTF8.GetString(allData);
ReadOnlySequence 做法:
// ✅ 零拷贝遍历分段数据
ReadOnlySequence<byte> sequence = ...; // 从 PipeReader 获取
if (sequence.IsSingleSegment)
{
// 单段优化路径:零拷贝
string text = Encoding.UTF8.GetString(sequence.FirstSpan);
}
else
{
// 多段路径:逐段处理
foreach (var segment in sequence)
{
Process(segment.Span);
}
}
5.2 ReadOnlySequence 的核心特性
- 统一抽象:可以表示单段或多段数据
- 零拷贝遍历:不需要合并到单个数组
- 与 PipeReader 配合:处理网络数据的标准方式
5.3 实战案例:HTTP 请求解析
public async Task ParseHttpRequestAsync(PipeReader reader)
{
while (true)
{
ReadResult result = await reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
// 查找请求行结束符 \r\n
var position = buffer.PositionOf((byte)'\n');
if (position.HasValue)
{
// 提取请求行(零拷贝切片)
var requestLine = buffer.Slice(0, position.Value);
// 解析
string line = Encoding.UTF8.GetString(requestLine);
var parts = line.Split(' ');
Console.WriteLine($"方法: {parts[0]}, 路径: {parts[1]}");
// 推进读取位置
reader.AdvanceTo(buffer.GetPosition(1, position.Value));
break;
}
if (result.IsCompleted) break;
}
}
性能对比(10 万次解析):
| 实现方式 | 分配次数 | GC Gen0 | 吞吐量 |
|---|---|---|---|
| 合并到数组 | ~300k | ~800 | 50k req/s |
| ReadOnlySequence(单段) | 0 | 0 | 200k req/s |
| ReadOnlySequence(多段) | ~100k | ~200 | 150k req/s |
6️⃣ ArrayPool:数组复用的强大武器
6.1 ArrayPool 解决了什么问题?
场景:临时缓冲区的频繁分配
// ❌ 传统做法:每次分配新数组
for (int i = 0; i < 1000; i++)
{
byte[] buffer = new byte[4096]; // 每次分配!
ProcessData(buffer);
// buffer 离开作用域,等待 GC
}
// 分配了 1000 次,总共 4MB!
ArrayPool 优化:
// ✅ 租借-归还模式
for (int i = 0; i < 1000; i++)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096); // 租借
try
{
ProcessData(buffer);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // 归还
}
}
// 复用同一个缓冲区,零额外分配!
6.2 ArrayPool 的使用规则
-
租借大小:可能比请求的大
byte[] buffer = ArrayPool<byte>.Shared.Rent(100); Console.WriteLine(buffer.Length); // 可能是 128(2 的幂次) // 使用时注意:只用需要的部分 var usableSpan = buffer.AsSpan(0, 100); -
归还策略:敏感数据要清空
// 敏感数据(密码、密钥) ArrayPool<byte>.Shared.Return(buffer, clearArray: true); // 普通数据 ArrayPool<byte>.Shared.Return(buffer, clearArray: false); -
不要长期持有:用完立即归还
// ❌ 错误:长期持有 byte[] buffer = ArrayPool<byte>.Shared.Rent(4096); _field = buffer; // 不要这样做! // ✅ 正确:用完归还 byte[] buffer = ArrayPool<byte>.Shared.Rent(4096); try { ProcessData(buffer); } finally { ArrayPool<byte>.Shared.Return(buffer); }
6.3 自定义对象池
// 池化 StringBuilder
public class StringBuilderPool
{
private readonly Stack<StringBuilder> _pool = new();
private readonly object _lock = new();
public StringBuilder Rent()
{
lock (_lock)
{
if (_pool.Count > 0)
return _pool.Pop();
}
return new StringBuilder();
}
public void Return(StringBuilder sb)
{
sb.Clear();
if (sb.Capacity > 4096) return; // 容量过大不回收
lock (_lock)
{
_pool.Push(sb);
}
}
}
7️⃣ 综合实战:零拷贝 HTTP 解析器
现在把所有技术综合起来,实现一个高性能的 HTTP 请求体解析器。
7.1 传统实现(多次分配)
public async Task<string> ReadBodyAsync(Stream stream)
{
// ❌ 分配 MemoryStream
using var ms = new MemoryStream();
// ❌ 可能多次扩容分配
await stream.CopyToAsync(ms);
// ❌ ToArray() 又分配一次
return Encoding.UTF8.GetString(ms.ToArray());
}
问题:分配 3-5 次,GC 压力大。
7.2 优化方案 1:ArrayPool + Span
public async Task<string> ReadBodyAsync(Stream stream)
{
// ✅ 使用 ArrayPool
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
int bytesRead = await stream.ReadAsync(buffer.AsMemory());
// ✅ 使用 Span,零拷贝解码
return Encoding.UTF8.GetString(buffer.AsSpan(0, bytesRead));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
效果:只分配 1 次(字符串),缓冲区复用。
7.3 优化方案 2:PipeReader + ReadOnlySequence(推荐)
public async ValueTask<string> ReadBodyAsync(PipeReader reader)
{
ReadResult result = await reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
string body;
// 🔥 单段优化路径:零拷贝
if (buffer.IsSingleSegment)
{
body = Encoding.UTF8.GetString(buffer.FirstSpan);
}
else
{
// 多段路径:使用 ArrayPool
byte[] temp = ArrayPool<byte>.Shared.Rent((int)buffer.Length);
try
{
buffer.CopyTo(temp);
body = Encoding.UTF8.GetString(temp.AsSpan(0, (int)buffer.Length));
}
finally
{
ArrayPool<byte>.Shared.Return(temp);
}
}
reader.AdvanceTo(buffer.End);
return body;
}
7.4 带缓存的完整示例
public class HighPerformanceHttpParser
{
private readonly Dictionary<string, (string body, DateTime timestamp)> _cache = new();
// 🔥 使用 ValueTask 优化缓存命中场景
public async ValueTask<HttpResponse> FetchAsync(string url)
{
// 检查缓存(同步完成路径)
if (_cache.TryGetValue(url, out var cached))
{
if (DateTime.UtcNow - cached.timestamp < TimeSpan.FromSeconds(30))
{
return new HttpResponse(cached.body, FromCache: true);
}
}
// 缓存未命中
var body = await FetchFromNetworkAsync(url);
_cache[url] = (body, DateTime.UtcNow);
return new HttpResponse(body, FromCache: false);
}
private async Task<string> FetchFromNetworkAsync(string url)
{
// 模拟网络请求
await Task.Delay(50);
// 使用 Pipe 进行零拷贝解析
var pipe = new Pipe();
await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"Response from {url}"));
await pipe.Writer.CompleteAsync();
return await ParseResponseAsync(pipe.Reader);
}
private async ValueTask<string> ParseResponseAsync(PipeReader reader)
{
// ... 使用 ReadOnlySequence 解析(代码同上)
}
}
7.5 性能对比总结
| 实现方式 | 分配次数 | GC Gen0 | 吞吐量 |
|---|---|---|---|
| 传统方式(Stream) | ~5M | ~50k | 1.2k req/s |
| ArrayPool + Span | ~1M | ~10k | 4.8k req/s |
| Pipe + ReadOnlySequence(单段) | ~0 | ~0 | 8.5k req/s |
| Pipe + ReadOnlySequence(多段) | ~1M | ~5k | 7.2k req/s |
综合效果:
- 内存分配:减少 95%+
- GC 压力:减少 99%+
- 吞吐量:提升 5-10 倍
8️⃣ BenchmarkDotNet:量化你的优化效果
纸上得来终觉浅,绝知此事要测量。所有的优化都需要用数据说话。
8.1 安装 BenchmarkDotNet
dotnet add package BenchmarkDotNet
8.2 编写 Benchmark
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser] // 诊断内存分配
public class StringParsingBenchmark
{
private const string Input = "2026-07-11";
[Benchmark(Baseline = true)] // 基准方法
public int ParseWithSubstring()
{
string year = Input.Substring(0, 4);
return int.Parse(year);
}
[Benchmark]
public int ParseWithSpan()
{
ReadOnlySpan<char> input = Input;
ReadOnlySpan<char> year = input.Slice(0, 4);
return int.Parse(year);
}
}
// 运行 Benchmark
BenchmarkRunner.Run<StringParsingBenchmark>();
8.3 查看报告
本人电脑真实运行数据(部分):
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26200.8655)
13th Gen Intel Core i7-13700KF, 1 CPU, 24 logical and 16 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.926.27113), X64 RyuJIT AVX2
DefaultJob : .NET 10.0.9 (10.0.926.27113), X64 RyuJIT AVX2
字符串解析性能测试
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
|------------------- |---------:|---------:|---------:|------:|-------:|----------:|------------:|
| ParseWithSubstring | 34.70 ns | 0.084 ns | 0.078 ns | 1.00 | 0.0061 | 96 B | 1.00 |
| ParseWithSpan | 21.21 ns | 0.094 ns | 0.083 ns | 0.61 | - | - | 0.00 |
数组切片性能测试
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------------- |---------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| SliceWithArrayCopy | 5.063 ns | 0.0297 ns | 0.0594 ns | 1.00 | 0.02 | 0.0041 | 64 B | 1.00 |
| SliceWithSpan | 3.443 ns | 0.0467 ns | 0.0390 ns | 0.68 | 0.01 | - | - | 0.00 |
ValueTask vs Task 性能测试
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
|----------------- |----------:|----------:|----------:|------:|-------:|----------:|------------:|
| GetWithTask | 3.7263 ns | 0.0129 ns | 0.0114 ns | 1.00 | 0.0046 | 72 B | 1.00 |
| GetWithValueTask | 0.7908 ns | 0.0162 ns | 0.0135 ns | 0.21 | - | - | 0.00 |
对比字符串操作 vs Span 操作
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------------- |-----------:|-----------:|-----------:|-----------:|------:|--------:|-------:|----------:|------------:|
| ParseWithSubstring | 109.407 ns | 10.2839 ns | 30.3222 ns | 132.959 ns | 1.09 | 0.46 | 0.0372 | 584 B | 1.00 |
| ParseWithSpan | 5.358 ns | 0.0338 ns | 0.0316 ns | 5.363 ns | 0.05 | 0.02 | - | - | 0.00 |
解读:
- Mean:平均执行时间(Span 快 3.5 倍)
- Gen0:GC 第 0 代回收次数(Span 零回收)
- Allocated:分配的内存(Span 零分配)
8.4 常见陷阱
⚠️ 必须在 Release 模式下运行:
dotnet run -c Release
⚠️ 避免死代码消除:
// ❌ 错误:编译器可能优化掉
[Benchmark]
public void TestMethod()
{
int result = Compute(); // 结果未使用,可能被优化
}
// ✅ 正确:返回结果
[Benchmark]
public int TestMethod()
{
return Compute();
}
9️⃣ 最佳实践总结
9.1 技术选择指南
| 场景 | 推荐技术 | 原因 |
|---|---|---|
| 高频缓存查询 | ValueTask | 同步完成路径零分配 |
| 字符串解析 | Span | 零拷贝切片 |
| 数组切片 | Span | 避免复制 |
| 异步方法参数 | Memory | 可跨 await |
| 网络数据处理 | ReadOnlySequence | 处理分段数据 |
| 临时缓冲区 | ArrayPool | 复用内存 |
9.2 优化顺序
- 先优化算法:O(n²) → O(n log n) 的收益远大于内存优化
- 再减少分配:用 BenchmarkDotNet 量化
- 最后零拷贝:Span/Memory/ReadOnlySequence
9.3 何时优化?
✅ 值得优化的场景:
- 高频调用(每秒 10 万+ 次)
- GC 成为瓶颈(dotnet-counters 观察到频繁 GC)
- 延迟敏感(P99 > 100ms)
- 吞吐量不足(CPU 未满载但 QPS 上不去)
❌ 不值得优化的场景:
- 低频调用(每秒 < 100 次)
- CPU 是瓶颈(100% 占用)
- 业务代码(可读性优先)
9.4 可读性 vs 性能
经验法则:
- 应用代码:可读性优先,Task/string.Split 够用
- 库代码:性能优先,ValueTask/Span
- 热路径:性能优先(通过 Profiler 识别)
- 冷路径:可读性优先
🎯 总结
本章我们学习了 .NET 高性能编程的五大核心技术:
- ValueTask:减少 Task 分配,适用于高频+同步完成占比高的场景
- Span
:栈分配+零拷贝切片,字符串/数组处理利器 - Memory
:可跨 await 的 Span,异步编程必备 - ReadOnlySequence
:处理分段数据,网络编程标配 - ArrayPool
:租借-归还模式,减少 GC 压力
综合使用:
- 内存分配减少 95%+
- GC 压力减少 99%+
- 吞吐量提升 5 -10 倍
关键要点:
- 不是所有代码都需要优化,用 BenchmarkDotNet 量化收益
- 优先优化算法,再优化内存分配
- 可读性和性能需要权衡,热路径才需要极致优化
记住,过早优化是万恶之源,但了解这些技术,能让你在需要时知道如何突破性能瓶颈。
编程是门手艺活,性能优化更是。希望你在实践中不断积累经验,成为一名真正的高性能编程专家!

浙公网安备 33010602011771号