【.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 次内存!

  1. MemoryStream 初始分配:256 字节
  2. MemoryStream 扩容 2 次:512 字节 + 1024 字节
  3. ms.ToArray():1024 字节(拷贝)
  4. Encoding.UTF8.GetString():1024 字节(字符串)
  5. Substring(0, 10):10 字节(日期字符串)
  6. 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 工作流程

  1. 检测到内存不足(Gen0 满了)
  2. 暂停所有线程(STW)
  3. 标记-清除-压缩
  4. 恢复线程

典型 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 是一个联合体(discriminated union):

// 简化示意
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?

推荐使用场景

  1. 高频调用:每秒 10 万+ 次
  2. 同步完成占比高:> 50%(如缓存命中率高)
  3. 性能敏感的库代码:如 ASP.NET Core、EF Core

不推荐使用场景

  1. 需要多次 await 同一个结果
  2. 需要使用 Task.WhenAll/WhenAny
  3. 低频调用的方法(Task 分配开销可忽略)
  4. 业务代码(优先可读性)

📌 经验法则

  • 应用代码:默认用 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 是一个特殊的结构体(ref struct):

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);
		// 只是指针偏移,不复制数据!
	}
}

关键特性

  1. ref struct:只能在栈上分配,不能装箱
  2. 零拷贝切片Slice 只改变指针和长度
  3. 统一抽象:可以指向数组、栈内存(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 很强大,但不能跨 await 边界,不能作为字段。这在异步编程中是个大问题。

场景:异步读取文件

// ❌ 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 的核心特性

  1. 统一抽象:可以表示单段或多段数据
  2. 零拷贝遍历:不需要合并到单个数组
  3. 与 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 的使用规则

  1. 租借大小:可能比请求的大

    byte[] buffer = ArrayPool<byte>.Shared.Rent(100);
    Console.WriteLine(buffer.Length); // 可能是 128(2 的幂次)
    
    // 使用时注意:只用需要的部分
    var usableSpan = buffer.AsSpan(0, 100);
    
  2. 归还策略:敏感数据要清空

    // 敏感数据(密码、密钥)
    ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
    
    // 普通数据
    ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
    
  3. 不要长期持有:用完立即归还

    // ❌ 错误:长期持有
    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 优化顺序

  1. 先优化算法:O(n²) → O(n log n) 的收益远大于内存优化
  2. 再减少分配:用 BenchmarkDotNet 量化
  3. 最后零拷贝: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 高性能编程的五大核心技术:

  1. ValueTask:减少 Task 分配,适用于高频+同步完成占比高的场景
  2. Span:栈分配+零拷贝切片,字符串/数组处理利器
  3. Memory:可跨 await 的 Span,异步编程必备
  4. ReadOnlySequence:处理分段数据,网络编程标配
  5. ArrayPool:租借-归还模式,减少 GC 压力

综合使用

  • 内存分配减少 95%+
  • GC 压力减少 99%+
  • 吞吐量提升 5 -10 倍

关键要点

  • 不是所有代码都需要优化,用 BenchmarkDotNet 量化收益
  • 优先优化算法,再优化内存分配
  • 可读性和性能需要权衡,热路径才需要极致优化

记住,过早优化是万恶之源,但了解这些技术,能让你在需要时知道如何突破性能瓶颈。

编程是门手艺活,性能优化更是。希望你在实践中不断积累经验,成为一名真正的高性能编程专家!

posted @ 2026-07-12 21:55  呆萌哈士奇  阅读(96)  评论(0)    收藏  举报