解决MySql布尔型新旧版本兼容问题,采用枚举来表示布尔型的数据表。由正向工程赋值
|
# 内存分配与拷贝成本报告
> **这份报告想回答什么**:在网络库(收包 → 拆帧 → RPC 响应匹配、事件分发)里,"分配内存、触发 GC、拷贝内存"各要花多少钱?为了少拷贝而搞"池化 + 零拷贝",值不值?
>
> **数据来源**:本机 BenchmarkDotNet 实测(2026-09-13)。基准代码:`Benchmark/PacketBenchmarks/BufferCostBenchmark.cs`(通用缓冲成本)、`Benchmark/PacketBenchmarks/OwnerPacketBenchmark.cs`(所有权操作成本)。
>
> **先给结论(人话版)**:
>
> 1. **拷贝很便宜,分配才贵。** 200 字节的包拷贝一次约 3 纳秒;而"弄一块内存"越大越贵:64KB 要 1.4 微秒,128KB 起会引发"全代 GC"(整个程序停顿一下)。
> 2. **池化的借和还几乎免费**:约 8 纳秒,和数据多大无关。池化的代价不在"借",而在**借了不还**——缓冲被吊住,别人没法用。
> 3. **小帧不要陪着 8K 收包缓冲跑。** 200 字节的 RPC 帧,拷进一个"合身"的小缓冲只需要约 10 纳秒;它的 GC 账也比想象的便宜得多(第 6 章有三条路线的完整对比)。
> 4. **推荐"混合策略",不是"全零拷贝"**:小帧(1–2KB 以下)拷贝,大帧(4KB 以上)切片零拷贝,64KB 以上必须零拷贝。判据见第 6 章。
>
> **术语速查**:**池化**=缓冲用完还回去接着用,不产生垃圾;**分配**=新建一块内存,用完变成垃圾要 GC 来清;**Gen0**=小垃圾的日常清扫(不到毫秒);**Gen2 / 全代 GC**=大扫除,整个程序停顿;**LOH**=大对象区(≥85KB 的数组住这里),清理代价高;**拷贝**=把数据复制到另一块内存;**切片 / 零拷贝**=只记"从哪到哪"、不复制数据,但要占住原来那块缓冲,直到所有人放手才能复用。
---
## 1. 测试环境
| 项 | 值 |
|---|---|
| 操作系统 | Windows 10 22H2(10.0.19045.6456) |
| CPU | Intel Core i9-10900K 3.70GHz(10 物理核 / 20 逻辑核) |
| 运行时 | .NET 10.0.12(SDK 10.0.401),X64 RyuJIT x86-64-v3 |
| GC 模式 | Concurrent Workstation |
| 基准框架 | BenchmarkDotNet 0.15.8,IterationCount=20 |
## 2. 测试方法
- 7 个大小档位:**64B / 512B / 4KB / 16KB / 64KB / 128KB / 1MB**(刚好跨过 .NET"大对象"分界线 85KB 的两侧);
- 6 种操作(把现实里会写出来的几种做法都测一遍):
1. `new byte[]`——直接新建数组;
2. `ArrayPool.Rent + Return`——从池里借一块、马上还;
3. `Span.CopyTo`——内存拷贝;
4. `Buffer.BlockCopy`——内存拷贝(做对照);
5. `Span.ToArray()`——新建 + 拷贝(业务里"把数据复制走"最常见的写法);
6. `Rent + CopyTo + Return`——借池 → 拷贝 → 归还。
- 指标:Mean(单次平均耗时)、Allocated(单次操作产生多少"垃圾"内存)、Gen0/Gen1/Gen2(**每 1000 次操作**触发几次回收)。
## 3. 数据:不同大小档位的缓冲成本
| 操作 | 大小 | Mean | Gen0 | Gen1 | Gen2 | Allocated |
|---|---:|---:|---:|---:|---:|---:|
| `new byte[]` | 64 | 5.24 ns | 0.008 | – | – | 88 B |
| 池化:Rent+Return | 64 | 7.55 ns | – | – | – | – |
| 拷贝:Span.CopyTo | 64 | 1.39 ns | – | – | – | – |
| 拷贝:Buffer.BlockCopy | 64 | 2.94 ns | – | – | – | – |
| 分配+拷贝(等效 ToArray) | 64 | 7.10 ns | 0.008 | – | – | 88 B |
| 池化+拷贝+归还 | 64 | 10.37 ns | – | – | – | – |
| `new byte[]` | 512 | 18.58 ns | 0.051 | – | – | 536 B |
| 池化:Rent+Return | 512 | 8.89 ns | – | – | – | – |
| 拷贝:Span.CopyTo | 512 | 7.04 ns | – | – | – | – |
| 拷贝:Buffer.BlockCopy | 512 | 7.54 ns | – | – | – | – |
| 分配+拷贝(等效 ToArray) | 512 | 27.59 ns | 0.051 | – | – | 536 B |
| 池化+拷贝+归还 | 512 | 17.83 ns | – | – | – | – |
| `new byte[]` | 4K | 127.98 ns | 0.394 | – | – | 4,120 B |
| 池化:Rent+Return | 4K | 8.19 ns | – | – | – | – |
| 拷贝:Span.CopyTo | 4K | 44.72 ns | – | – | – | – |
| 拷贝:Buffer.BlockCopy | 4K | 43.32 ns | – | – | – | – |
| 分配+拷贝(等效 ToArray) | 4K | 180.13 ns | 0.394 | – | – | 4,120 B |
| 池化+拷贝+归还 | 4K | 44.87 ns | – | – | – | – |
| `new byte[]` | 16K | 467.60 ns | 1.565 | – | – | 16,408 B |
| 池化:Rent+Return | 16K | 7.54 ns | – | – | – | – |
| 拷贝:Span.CopyTo | 16K | 239.47 ns | – | – | – | – |
| 拷贝:Buffer.BlockCopy | 16K | 238.61 ns | – | – | – | – |
| 分配+拷贝(等效 ToArray) | 16K | 771.23 ns | 1.564 | – | – | 16,408 B |
| 池化+拷贝+归还 | 16K | 261.29 ns | – | – | – | – |
| `new byte[]` | 64K | 1,420.98 ns | 6.249 | – | – | 65,560 B |
| 池化:Rent+Return | 64K | 7.54 ns | – | – | – | – |
| 拷贝:Span.CopyTo | 64K | 1,049.57 ns | – | – | – | – |
| 拷贝:Buffer.BlockCopy | 64K | 1,044.05 ns | – | – | – | – |
| 分配+拷贝(等效 ToArray) | 64K | 2,965.17 ns | 6.249 | – | – | 65,560 B |
| 池化+拷贝+归还 | 64K | 1,179.47 ns | – | – | – | – |
| `new byte[]` | 128K | 5,090.12 ns | 41.664 | 41.664 | 41.664 | 131,110 B |
| 池化:Rent+Return | 128K | 7.54 ns | – | – | – | – |
| 拷贝:Span.CopyTo | 128K | 2,788.72 ns | – | – | – | – |
| 拷贝:Buffer.BlockCopy | 128K | 2,789.53 ns | – | – | – | – |
| 分配+拷贝(等效 ToArray) | 128K | 46,961.45 ns | 41.626 | 41.626 | 41.626 | 131,110 B |
| 池化+拷贝+归还 | 128K | 2,799.56 ns | – | – | – | – |
| `new byte[]` | 1M | 38,319.31 ns | 150.330 | 150.269 | 150.269 | 1,048,642 B |
| 池化:Rent+Return | 1M | 8.02 ns | – | – | – | – |
| 拷贝:Span.CopyTo | 1M | 30,033.44 ns | – | – | – | – |
| 拷贝:Buffer.BlockCopy | 1M | 29,914.99 ns | – | – | – | – |
| 分配+拷贝(等效 ToArray) | 1M | 118,181.54 ns | 208.862 | 208.740 | 208.740 | 1,048,665 B |
| 池化+拷贝+归还 | 1M | 29,953.29 ns | – | – | – | – |
> 原始结果:`BenchmarkDotNet.Artifacts/results/Benchmark.PacketBenchmarks.BufferCostBenchmark-report-github.md`(含 Error/StdDev)。
## 4. 关键发现
### 4.1 分配按大小涨价,池化永远是 ~8ns
这张表其实只说两件事:**池化的借还成本恒定(约 8 纳秒,跟数据多大无关)**;**新建数组则是越大的数据越贵**(而且伴生的垃圾要靠 GC 清理)。
| 大小 | 分配 | 池化 Rent+Return | 分配/池化 |
|---:|---:|---:|---:|
| 64B | 5.2 ns | 7.6 ns | **0.7×(分配更便宜)** |
| 512B | 18.6 ns | 8.9 ns | 2.1× |
| 4KB | 128 ns | 8.2 ns | 16× |
| 16KB | 468 ns | 7.5 ns | 62× |
| 64KB | 1,421 ns | 7.5 ns | 189× |
| 128KB | 5,090 ns | 7.5 ns | 679× |
| 1MB | 38,319 ns | 8.0 ns | 4,776× |
- 池化大约是"挪几个指针"的事,热池命中时便宜得可以忽略,**与大小无关**;
- 分配的成本随大小近似线性增长(清零 + GC 摊销都在里面);
- 交叉点约在 **256–512B**:小于这个尺寸,`new byte[]` 反而更快;大于这个尺寸,池化明显更优。
### 4.2 85KB 是一道悬崖:进了大对象区,就要全代 GC
.NET 对 ≥85KB 的数组另眼相待(放进大对象区 LOH),**清理它们往往要触发 Gen2 全代 GC——相当于让整个程序停顿一下的大扫除**。这就是"分配"最吓人的一面:
| 大小 | 单次分配 | 分配+拷贝 | GC(每千次操作) | 换算 |
|---:|---:|---:|---|---|
| 64KB | 1.42 µs | 2.97 µs | Gen0 ×6.2 | 每 ~160 条一次 Gen0 |
| 128KB | 5.09 µs | **46.96 µs** | Gen2 ×41.7 | **每 ~24 条一次全代 GC** |
| 1MB | 38.3 µs | **118.18 µs** | Gen2 ×150.3 | **每 ~6.7 条一次全代 GC** |
- 128KB 规模下,"分配+拷贝"比"池化+拷贝"慢 **17 倍**(46.96µs vs 2.80µs);1MB 下 3.9 倍(118µs vs 30µs,此时拷贝占主导);
- 更可怕的是 GC:分配路径的回收是**全代回收**(Gen0=Gen1=Gen2 计数相同)——128KB 时**每 24 条消息就停顿一次**,1MB 时每 6.7 条一次。这才是"零 GC"优化真正要守的阵地:不是单次快慢,而是整机吞吐和尾延迟。
### 4.3 拷贝成本线性增长,带宽 30–70GB/s
| 大小 | Span.CopyTo | 等效带宽 |
|---:|---:|---:|
| 64B | 1.39 ns | ~46 GB/s |
| 512B | 7.04 ns | ~73 GB/s |
| 4KB | 44.72 ns | ~92 GB/s(L1/L2 驻留) |
| 16KB | 239.5 ns | ~68 GB/s |
| 64KB | 1,050 ns | ~62 GB/s |
| 128KB | 2,789 ns | ~47 GB/s |
| 1MB | 30.0 µs | ~35 GB/s |
`Span.CopyTo` 与 `Buffer.BlockCopy` 基本同速(差异 <5%),实现选择无实质影响。
### 4.4 池化的边际成本近乎为零
`池化+拷贝+归还 ≈ 拷贝 + 0~10ns`:4KB 时 44.87ns ≈ 44.72ns;1MB 时 29.95µs ≈ 30.03µs。
→ 决定"零拷贝"收益的是**拷贝时间本身**,获取/归还缓冲的开销可以忽略(极小的包除外)。
### 4.5 对小包,"省下一次拷贝"本身省不了几个钱
64B 拷贝 1.4ns、512B 7ns、4KB 45ns;而网络库绕不开的"包对象、所有权转移"操作本身就要 10–40 纳秒(见第 5 章)。
→ 对 ≤1KB 的小帧,零拷贝相比拷贝**只省几纳秒**,却要付出"吊住整块收包缓冲"的代价(第 6 章细算)。
→ 小包真正要防的是**垃圾总量**:拷贝+新建是每消息 88–1,536B,吞吐上来会线性放大。GC 的账要按"每秒新增多少垃圾、活多久"来算,而不是简单地"拷不拷"。
---
## 5. OwnerPacket 所有权操作成本(引用计数内核实测)
这一章回答:"管理所有权"这件事本身要花多少钱?——结论先行:**和数据多大无关,都是几十纳秒的量级**。同一环境(见第 1 章),对重构后的 `OwnerPacket` 做微基准(`OwnerPacketBenchmark`),三个档位(64B/1KB/8KB)结果完全一致:
| 操作 | Mean | Gen0 | Allocated |
|---|---:|---:|---:|
| 构造+释放 | 13.2 ns | 0.003 | 32 B |
| GetSpan / GetMemory / TryGetArray | 16.0–16.3 ns | 0.003 | 32 B |
| 借阅切片(不变更所有权) | 14.2 ns | 0.009 | 96 B |
| **移动切片+释放** | **33.0 ns** | 0.012 | 128 B |
| **共享切片+双方释放** | **39.7–42.2 ns** | 0.012 | 128 B |
| 链式移动切片+释放(两段) | 113.4–118.9 ns | 0.024 | 256 B |
| Resize | 6.0–6.5 ns | 0.005 | 48 B |
| Indexer 读 / 写 | 0.78–1.13 ns | – | – |
> 历史对照(同机、.NET 10.0.4、重构前实现):构造+释放 6.7 ns。
> 分配口径说明:`构造+释放` 仅 32 B/op,对应引用计数对象(`ArrayOwner`);节点本身(48 B)在未逃逸的局部场景被 .NET 10 JIT 的逃逸分析/对象栈分配优化消除(参见 .NET 10 性能博文 “Object Stack Allocation”)。未被消除的形态(返回值/穿透调用)按节点 48 B 计。
> **2026-09-14 更新**:引入析构兜底后本基线已变化——构造+释放 13.2 → 60 ns(32 → 80 B),切片与多线程同样上移;分项实测见第 8 章。
### 5.1 移动切片:恒定 ~33ns,与大小无关
移动切片 = 1 次节点分配(48 B)+ 引用零成本移交 + 后续释放(含池化 Rent/Return ~8ns)。对比拷贝的交叉点:
| 大小 | 拷贝 | 移动切片 | 对比 |
|---:|---:|---:|---|
| 64B | 1.4 ns | 33 ns | 拷贝便宜 23× |
| 512B | 7.0 ns | 33 ns | 拷贝便宜 4.7× |
| 4KB | 44.7 ns | 33 ns | 切片开始便宜 |
| 16KB | 239 ns | 33 ns | 切片便宜 7× |
| 128KB | 2.79 µs | 33 ns | 切片便宜 85× |
### 5.2 共享切片:移动 + ~7ns(一次原子对)
`Share`(每段 AddRef + 双方释放)实测 39.7–42.2ns,仅比移动多 ~7ns。**共享并不贵**:对 ≥4KB 数据,共享比拷贝便宜;但对 ≤512B,共享(~40ns)远贵于拷贝(1–7ns)——**小包不要为共享而共享**。
### 5.3 引用计数的固定税
- 每"拥有包"多出 +32 B 堆分配(就是那个引用计数对象);
- 构造+释放 6.7 → 13.2 ns(每个包多 6.5ns);
- 换来的东西:移动时不需要任何原子操作、能安全共享(`Share`)、重复释放不出错(幂等)、错误语义统一;
- `Dispose` / 借用视图没有额外成本;索引器(~1ns)没有变慢。
---
## 6. 网络库实战:小帧到底该拷贝,还是零拷贝?
> 这一章直接回答实践里绕不开的抉择:收包 → 拆帧 → RPC 响应匹配 / 事件分发,**上层会持有多久不可控**,该"全面池化 + 尽量共享不拷贝",还是"部分拷贝"?
### 6.1 两条路线的性格
- **零拷贝(切片 / 共享)**:不花时间、不产生垃圾;但帧走到哪,**整块 8K 收包缓冲就被"吊住"到哪**——池里的这块缓冲在所有人放手前不能复用。
- **拷贝**:花一点点时间(小帧几纳秒)、产生一点点垃圾;换来一个"合身"的独立小缓冲,**8K 收包缓冲当场还回池里**。
对到现有代码上:接收层手里的 8K 是一个循环复用的池化缓冲(`SocketSetting.BufferSize = 8 * 1024`);**事件里同步读完、不认领(`Take`)时,缓冲下一轮直接接着用,零开销**——这一步已经是最优。真正要做取舍的,是"数据要带出本轮"(跨回调、跨 await、进队列、RPC 响应匹配)的那一刻:**带整块 8K 走,还是把这一帧拷到合身的小缓冲里带走**。
### 6.2 算一笔 200 字节 RPC 帧的账
场景:一帧 200B 从 8K 缓冲里解析出来,要交给上层(持有时长不确定)。三条路线:
| 路线 | 做法 | 时间成本 | GC 垃圾 | 每帧内存占用 | 风险 |
|---|---|---|---|---|---|
| A. 新建数组 + 拷贝 | `new byte[200]` + CopyTo | ≈ 12 ns | ≈ 224 B/帧 | 224 B | 无 |
| B. **池化小缓冲 + 拷贝**(推荐) | 从池里借一个 256B 桶 + CopyTo | ≈ 16–22 ns | **0**(热池) | 256 B | 无 |
| C. 零拷贝切片 / 共享 | `Slice` / `Share` 直接交出去 | ≈ 33–40 ns | 0(热池) | **8 KB(被吊住)** | 池子被吊空后,每帧要新分配 8KB → 垃圾是 A 的 37 倍;慢消费者会让内存膨胀 |
三个结论:
1. **时间上,C 并不比 A/B 快。** 200B 的拷贝只有约 3ns;而"所有权转移"操作本身要 33–40ns(见第 5 章)。"零拷贝省时间"要到几 KB 以上的帧才成立。
2. **GC 上,A 的 224B/帧很便宜。** 224B × 10 万帧/秒 ≈ 22 MB/s,大约每秒触发 2–5 次 Gen0;Gen0 就是"小垃圾桶边扫边丢",每次不到 1 毫秒。**"拷贝会造成不划算的 GC"这个担心,在十万帧/秒以下不成立**;真的在意,就升级到 B(池化小缓冲),GC 直接归零。
3. **C 的代价不在时间、也不在垃圾,而在"吊住 8KB"。** RPC 一问一答时一次通常只收到一帧,8K 里只有 200B 有用(利用率 2.5%)。按这个最坏口径算:**只要约 3% 的接收在池里拿不到缓冲(被迫新建),C 产生的垃圾就反超拷贝方案**;池子被吊得越紧,反超越多。
### 6.3 怎么选:一张表
| 情形 | 建议 | 理由 |
|---|---|---|
| 帧在 2KB 以下(大多数 RPC) | **拷贝**(优先池化小缓冲,`new` 也可) | 拷贝只要 1–7ns,约等于白送;不会吊住 8K |
| 帧在 4KB 以上 | **零拷贝切片 / 共享** | 拷贝 45ns–1µs 开始变贵;吊住的缓冲与帧同量级,不吃亏 |
| 帧在 64KB 以上 | **必须零拷贝 + 池化** | 拷贝是 µs 级且触发 LOH / 全代 GC,分配是灾难 |
| 长持有(事件订阅、消息队列、IPC 转发) | 大帧零拷贝共享;小帧照拷 | 拷贝路径的内存占用 ≈"真正在途的数据量",可控;零拷贝长持有会让"接收缓冲 × 积压数"无限膨胀 |
| 帧在本次回调里就能消费完(同步链路) | 什么都不用做 | 吊住的时间就是几个纳秒,切片、拷贝都行;**一旦要跨回调 / 跨 await / 进队列,小帧就拷贝** |
2–4KB 之间属于模糊地带,默认先拷(更稳),阈值做成配置项,等压测数据出来再调。
一句话记忆:**小帧拷、大帧切;拷贝要挑"合身"的,切片交给引用计数。**
> 顺带一提:把 RPC 连接的收包缓冲从 8K 改小(2–4K)也能减轻"吊住"的浪费,但会牺牲大包吞吐;相比之下"小帧拷贝"更直接,两者不冲突。
### 6.4 两个常问的问题
**Q1:既然要拷,为什么建议池化小缓冲,而不是直接 `new`?**
时间与 GC 二选一:`new` 一次付清约 12ns,外带 224B/帧垃圾;池化约 16–22ns(借 8ns + 还 8ns),垃圾为 0。十万帧/秒以下 `new` 完全够用;再往上建议池化。`OwnerPacket` 本来就按"最接近的桶"从 `ArrayPool` 借(200B → 256B 桶),拷进去 = 天然池化,不需要新造机制。
**Q2:上层怎么用内存我们控制不了,怎样选不后悔?**
小帧拷贝路线的最坏情况是"多花几纳秒";小帧零拷贝路线的最坏情况是"池子被吊空 → 垃圾暴涨 + 内存膨胀"。**在不确定上层用法的前提下,小帧拷贝是"不后悔"的选择**;等压测证明某类连接(例如纯大帧的流式连接)值得零拷贝,再按连接类型放开阈值。事件层现成的"拷走"路子是 `ReceivedEventArgs.GetBytes()`。
### 6.5 别忘了真正的大头
如果上层拿到帧马上要反序列化成对象(JSON / 二进制),**一次反序列化的分配远大于 224B 的拷贝**——先看业务侧的对象分配,往往比在字节层抠零拷贝更值得。字节层零拷贝最大的受益者是"字节被原样搬运 / 缓存"的场景:消息队列、IPC 转发、大帧流式、多消费者共享。
---
## 7. 总结
**按数据规模的优化决策表**(拷贝 / 移动 / 共享均为实测值;数值为加析构兜底前的 9/13 基线,新值见第 8 章):
| 规模 | 拷贝 | 移动切片 | 共享切片 | 结论 |
|---:|---:|---:|---:|---|
| ≤512B | 1–7 ns | ~33 ns | ~40 ns | 零拷贝不省时间(切片比拷贝贵 5–20 倍);要防的是"吊住收包缓冲";**不要为共享而共享**。 |
| 1–16KB | 7–240 ns | ~33 ns | ~40 ns | 池化 + 切片全面胜出(拷贝 45–240ns 都比切片 33ns 贵;垃圾还归零)。 |
| 64KB | ~1.05 µs | ~33 ns | ~40 ns | 必须池化 + 零拷贝:分配 1.42µs(比池化贵 190 倍)、每千次 Gen0 ×6.2;切片省 µs 级。 |
| ≥128KB | 2.8–30 µs | ~33 ns | ~40 ns | 数量级收益:分配进大对象区(128KB 分配+拷贝 47µs、每千次 Gen2 ×41.7);切片/共享恒定 ~33–40ns。 |
**分界点很好记**:拷贝速度约 30–70GB/s(1KB 约 16ns,1MB 约 30µs);切片 / 共享恒定 33–40ns。两者在 **2–4KB** 附近打平——比它小,拷贝更快更省心;比它大,零拷贝净赚。
**值得吗?**
- **≤512B 同步链路**:零拷贝的时延收益趋近于零;算清"吊住 8K"的账后,拷贝(或池化拷贝)通常更优。
- **1KB–64KB 每消息路径**:池化 + 移动切片双赢(时延与 GC 都好)。
- **≥64KB 大帧**:必须池化 + 零拷贝(否则 LOH 风暴:47–118µs 加全代 GC)。
- **多消费者共享(跨 await / 多订阅)**:引用计数共享是保持零拷贝的正解,成本仅 ~40ns/次,远低于拷贝(≥4KB 时);小包仅在语义需要双持时共享,否则拷贝更便宜。
**复现命令**:
```bash
# 通用缓冲成本
dotnet run -c Release --project Benchmark -- --filter "*BufferCostBenchmark*"
# OwnerPacket 操作成本
dotnet run -c Release --project Benchmark -- --filter "*OwnerPacketBenchmark*"
# 析构兜底成本(A/B 对照 + 泄漏批 + 反汇编 + Server GC 对照)
dotnet run -c Release --project Benchmark -- --filter "*Finalizer*"
# Server GC 多线程对照(真实类)
dotnet run -c Release --project Benchmark -- --filter "*OwnerPacketConcurrencyServerGc*"
```
---
## 8. 析构兜底成本实测(2026-09-14 增测)
> 背景:`OwnerPacket` 引入“漏释放兜底”(析构函数 + `GC.SuppressFinalize`)后,**正确使用路径也要为每个句柄付一笔固定税**。本章分项实测这笔税,并对照“漏释放时兜底把账找回来”的效果。第 5/6/7 章的数值均为**加析构之前**(2026-09-13)的基线。
>
> 基准代码:`Benchmark/PacketBenchmarks/FinalizerCostBenchmark.cs`(受控 A/B + 泄漏批 + 反汇编 + Server GC 对照,新增);真实类对照用 `OwnerPacketBenchmark.cs`。
### 8.1 受控 A/B:同样的句柄,只差一个析构函数
两个迷你类镜像 `OwnerPacket` 的结构(`ArrayPool` 租借 + 引用计数 owner + `Dispose`/`SuppressFinalize`),唯一差异是实验组多一个析构函数:
| 场景 | 无析构 | 带析构 | 差异 |
|---|---:|---:|---:|
| 单线程 构造+释放(Workstation GC) | 15.4 ns | 58.0 ns | **+42.6 ns/op(×3.8)** |
| 单线程 构造+释放(Server GC) | 18.1 ns | 47.5 ns | **+29.5 ns/op(×2.6)** |
| 4 线程各 1000 次(Workstation GC) | 23.9 µs | 658.1 µs | **×27.5**(每 op 6.0 → 164.5 ns) |
| 4 线程各 1000 次(Server GC) | 26.4 µs | 75.5 µs | ×2.9(每 op 6.6 → 18.9 ns) |
- 单线程的 “+30~43 ns” 是**终结合同**的固定税:每次分配向终结队列登记(`GC_ALLOC_FINALIZE` → `CFinalize::RegisterForFinalization`,带队列锁),外加每次 `Dispose` 的 `GC.SuppressFinalize`(对象头置位)。**正确释放的路径也要付。**
- **多线程差异巨大**:Workstation GC 全局只有一条终结登记队列,4 线程即放大到每 op 164.5 ns;Server GC 每堆一条队列(登记队列随分配堆分离),每 op 仅 18.9 ns——**差 8.7 倍**。
- 反汇编组(`FinalizerAsmBenchmark`)确认:两种形态的节点均落堆(`CORINFO_HELP_NEWSFAST` 均存在),本形态下栈分配对照不可见;**带析构类型被 JIT 栈分配永久排除**(`canAllocateOnStack` 对含析构类型返回 false)。
### 8.2 漏释放场景:兜底的代价与收益
每批丢弃 1000 个句柄(模拟漏 `Dispose`),随后两次强制完整回收 + 等待终结线程排空:
| 指标 | 无析构 | 带析构 | 说明 |
|---|---:|---:|---|
| 批耗时 | 224.2 µs | 792.4 µs | 兜底方多付 **+568 ns/句柄**(复活 + 终结线程 + 多一轮 GC;不占业务线程) |
| 每批分配量 | 1,078.77 KB | 424.44 KB | 兜底方**把约 654 KB 缓冲拉回了池**——无兜底时这些池缓冲永久流失 |
### 8.3 真实类实测:现行 `OwnerPacket`
| 操作 | 9/13 基线(无析构) | 现行(含析构) | 差异 |
|---|---:|---:|---|
| 构造+释放 | 13.2 ns / 32 B | **60.1–61.1 ns / 80 B** | +47 ns(×4.6);节点 48 B 落堆(基线中该项曾被栈分配消除) |
| 无主源切片+双方释放 | 14.2 ns / 96 B(旧“借阅视图”形态,未释放) | 100.1–101.0 ns / 96 B | 两个句柄均付终结合同 |
| 共享切片+双方释放 | 39.7–42.2 ns / 128 B | 124.5–126.2 ns / 128 B | +85 ns ≈ 2 × 42.6(两个句柄) |
| 切片+遗弃(未释放,对照) | – | 252.6–278.9 ns / 96 B,**Gen1/Gen2 不为零** | 遗弃句柄走“复活 + 终结线程”;方差 ±10–20 ns(终结线程背压) |
多线程构造+释放(1024B、每线程 1000 次;每格为“墙钟均值(单 op 摊销)”,1/4/16/32 线程):
| 线程数 | Workstation 基线(无析构) | Workstation 现行 | **Server 现行** |
|---:|---:|---:|---:|
| 1 | 13.66 µs(13.7 ns) | 65.09 µs(65.1 ns) | **54.37 µs(54.4 ns)** |
| 4 | 26.01 µs(6.5 ns) | 607.60 µs(151.9 ns) | **82.79 µs(20.7 ns)** |
| 16 | 58.82 µs(3.7 ns) | 4,304.67 µs(269.0 ns) | **278.92 µs(17.4 ns)** |
| 32 | 103.35 µs(3.2 ns) | 15,122.36 µs(472.6 ns) | **485.35 µs(15.2 ns)** |
> Workstation 基线(10.0.4 运行时、本机)是“线程越多、单 op 越快”(并行扩展);现行 Workstation 是“线程越多、单 op 越贵”(32 线程 472.6 ns/op),增量全部来自全局终结登记队列的锁串行化。**Server GC 基本消除该退化**(32 线程 15.2 ns/op,仍有并行收益)——生产上是否踩这个坑,取决于 GC 配置:控制台 / Windows 服务程序默认 Workstation,需显式 `<ServerGarbageCollection>true</ServerGarbageCollection>`。
### 8.4 一句话结论
正常路径每句柄 **+30 ns(Server GC,无竞争)~+43 ns(Workstation)**;Workstation 多线程最差可达 **150–470 ns/op**(4–32 线程),Server GC 下基本无放大。漏释放场景兜底每句柄多付 **+568 ns**(不占业务线程)并回收约 65% 的缓冲分配流量。按“每包 2–5 个句柄”折算:10 万包/秒的链路多付约 6–21 ms/s(单核 0.6%–2.1%),Workstation + 高并发最坏情形会再放大数倍到数十倍不等。**保留 / 条件编译 / 移除的取舍,取决于对热路径税的容忍度、GC 配置现状与外部调用方漏释放的概率。**
> **2026-09-14 决定**:采用方案 B——`#if DEBUG || OWNERPACKET_FINALIZER`:开发期保留兜底并在命中时输出 `XTrace` 告警;发布版默认不编译析构(零热路径税);下游如需生产兜底可自定义 `OWNERPACKET_FINALIZER` 符号开启。
> 原始结果:`BenchmarkDotNet.Artifacts/results/`(`FinalizerCostBenchmark` / `FinalizerCostServerGcBenchmark` / `FinalizerLeakBenchmark` / `FinalizerAsmBenchmark` / `OwnerPacketBenchmark` / `OwnerPacketConcurrencyBenchmark` / `OwnerPacketConcurrencyServerGcBenchmark` 的 `-report-github.md`)。
---
## 9. Echo 链路基准重跑:响应句柄释放修复前后(2026-09-15 增测)
> 背景:2026-09-15 全量验证会话重跑 `LengthFieldCodecEchoBenchmark` / `StandardCodecEchoBenchmark`(Release、Server GC、`WarmupCount=2 / IterationCount=5`)时,发现两组基准的 Allocated ≈ **65 KB/消息**(恰为一个 64 KB 接收缓冲)且跨并发档恒定、Gen0 偏高——这是"缓冲池失血"的特征信号。
>
> 根因(基准脚本缺陷,非框架问题):脚本 `await client.SendMessageAsync(payload)` 后**丢弃响应句柄不释放**。响应负载是等待方拥有的共享切片,不释放则引用计数不归零、缓冲永不回池(发布版无析构兜底,句柄随 GC 回收、池缓冲被绕过)→ 接收层每轮换缓冲都新分配 64 KB。
>
> 修复:`Benchmark/NetBenchmarks/LengthFieldCodecEchoBenchmark.cs`、`StandardCodecEchoBenchmark.cs` 三处 await(逐包、滑动窗口、排空)后补 `rs.TryDispose()`。对比口径:同机(i9-10900K)、同 Job、同运行时(.NET 10.0.12);修复前跑与修复后跑为同日两次独立完整跑,机器状态一致。
### 9.1 LengthFieldCodec Echo 前后对比
| 场景 | 并发 | Mean 前 | Mean 后 | 延迟变化 | Allocated 前 | Allocated 后 |
|------|-----:|--------:|--------:|---------:|-------------:|-------------:|
| 逐包 | 1 | 50.588 µs | 48.253 µs | -4.6% | 86.5 KB | 1008 B |
| 逐包 | 4 | 14.564 µs | 13.851 µs | -4.9% | 65.01 KB | 1008 B |
| 逐包 | 16 | 7.775 µs | 6.852 µs | -11.9% | 65.01 KB | 1008 B |
| 逐包 | 64 | 5.529 µs | 4.526 µs | -18.1% | 65.01 KB | 1008 B |
| 逐包 | 256 | 5.817 µs | 4.763 µs | -18.1% | 65.01 KB | 1009 B |
| 逐包 | 1024 | 8.104 µs | 6.253 µs | -22.8% | 65.01 KB | 1014 B |
| 滑动窗口 | 1 | 50.414 µs | 15.455 µs | **-69.3%** | 43.71 KB | 814 B |
| 滑动窗口 | 4 | 10.044 µs | 3.887 µs | **-61.3%** | 161.85 KB | 851 B |
| 滑动窗口 | 16 | 6.195 µs | 4.159 µs | -32.9% | 20.06 KB | 6.69 KB |
| 滑动窗口 | 64 | 3.692 µs | 4.064 µs | +10.1%(噪声) | 6.34 KB | 5.04 KB |
| 滑动窗口 | 256 | 3.654 µs | 3.361 µs | -8.0% | 8.79 KB | 2.61 KB |
| 滑动窗口 | 1024 | 4.165 µs | 3.377 µs | -18.9% | 9.88 KB | 2.62 KB |
### 9.2 StandardCodec Echo 前后对比
| 场景 | 并发 | Mean 前 | Mean 后 | 延迟变化 | Allocated 前 | Allocated 后 |
|------|-----:|--------:|--------:|---------:|-------------:|-------------:|
| 逐包 | 1 | 52.135 µs | 49.889 µs | -4.3% | 28.55 KB | 1568 B |
| 逐包 | 4 | 14.589 µs | 13.154 µs | -9.8% | 65.56 KB | 1568 B |
| 逐包 | 16 | 7.573 µs | 6.419 µs | -15.2% | 65.56 KB | 1568 B |
| 逐包 | 64 | 5.630 µs | 5.410 µs | -3.9% | 65.56 KB | 1568 B |
| 逐包 | 256 | 5.736 µs | 5.438 µs | -5.2% | 65.56 KB | 1569 B |
| 逐包 | 1024 | 7.717 µs | 6.265 µs | -18.8% | 65.56 KB | 1573 B |
| 滑动窗口 | 1 | 11.897 µs | 5.414 µs | **-54.5%** | 40.54 KB | 1270 B |
| 滑动窗口 | 4 | 4.504 µs | 4.107 µs | -8.8% | 41.44 KB | ≈0 |
| 滑动窗口 | 16 | 3.526 µs | 3.268 µs | -7.3% | 12.25 KB | 1314 B |
| 滑动窗口 | 64 | 3.323 µs | 3.185 µs | -4.2% | 5.42 KB | 1248 B |
| 滑动窗口 | 256 | 3.109 µs | 3.059 µs | -1.6% | 7.09 KB | 1206 B |
| 滑动窗口 | 1024 | 3.446 µs | 3.160 µs | -8.3% | 13.71 KB | 1276 B |
### 9.3 分析
- **分配量**:逐包场景全部回落到 **~1 KB/消息**(修复前恒为 65–86 KB,即每消息一个 64 KB 缓冲的幽灵分配);滑动窗口回落到 **0.8–6.7 KB**(修复前 6.3–161.9 KB),中并发档残留的 2.6–6.7 KB 为窗口补发路径的临时对象与队列开销。**判定信号:Allocated ≈ 接收缓冲大小且跨并发档恒定 = 缓冲池失血**;写消费 `SendMessageAsync` 的基准/测试时必须释放响应句柄("共享≠免释放")。
- **延迟**:低并发滑动窗口档改善最大(LengthField C=1 **-69%**、Standard C=1 **-54%**;LengthField C=4 -61%)——原瓶颈是 64 KB 缓冲的分配/零化与 GC 抖动,而非网络路径本身;高并发逐包档 -18%~-23%(C=64/256/1024)。个别档位(LengthField 滑动 C=64)前后互见 ±10% 波动,属跨批次噪声。
- **GC**:LengthField 逐包 C=1 的 Gen0 从 6.94 降至 0.015(每千次操作);Standard 逐包 C=1 从 8.88 降至 0.031。
- **架构印证**:修复后接收层"共享切片 + 轮末 RefCount 裁决 + 句柄回挂复用"按设计生效——合规用法下每消息仅 ~1 KB 常规分配(含每消息 32 B 负载新建),池化效率符合 §6 预期。
### 9.4 附:OwnerPacketBenchmark 现行基线(2026-09-15 重跑,33 项)
> §8.3 中"现行(含析构)"列为方案 B 落地前(析构无条件编译)的实测;方案 B 落地后 Release 默认无析构,现行基线如下(各尺寸档 64/1024/8192 B 均值基本恒定):
| 操作 | Mean | Allocated |
|------|-----:|----------:|
| 构造+释放 | 19.0–19.1 ns | 80 B |
| 无主源切片+双方释放 | 20.0–21.9 ns | 96 B |
| 共享切片+双方释放 | 42.1–43.4 ns | 128 B |
| 链式共享切片+释放 | 132.4–134.5 ns | 256 B |
| 切片+遗弃(未释放对照) | 13.6 ns | 96 B |
| Resize | 5.9 ns | 48 B |
| Indexer 读 / 写 | 0.92 / 0.77 ns | - |
与 §8.3 对照:构造+释放 60→19 ns、共享切片+双方释放 126→42 ns、切片遗弃 279→13.6 ns——**方案 B 的"Release 零析构税"在真实类上兑现**(决策见 §8.4);`切片+遗弃` 不再触发 Gen1/Gen2。
### 9.5 复现命令
```bash
dotnet run -c Release --project Benchmark -- --filter "*LengthFieldCodecEchoBenchmark*"
dotnet run -c Release --project Benchmark -- --filter "*StandardCodecEchoBenchmark*"
dotnet run -c Release --project Benchmark -- --filter "*OwnerPacketBenchmark*"
```
> 原始结果:`BenchmarkDotNet.Artifacts/results/`(`LengthFieldCodecEchoBenchmark` / `StandardCodecEchoBenchmark` / `OwnerPacketBenchmark` 的 `-report-github.md`;修复前数据为本次会话首跑,文件已被覆盖,数值引自跑分记录)。
## 10. 数据管道(Pipe)分配基线(2026-09-26 增测)
> 目的:为"Pipe 是否值得引入段对象池、写缓冲租借下限"提供数据依据。Job 设置 `warmupCount:2, iterationCount:10`,三档大小 × 三条路径。
### 10.1 数据
| 路径 | 帧大小 | Mean | Allocated | 说明 |
|------|-------:|-----:|----------:|------|
| 包级回环 `Append`→`ReadAsync`→`AdvanceTo` | 64 B / 4 KB / 64 KB | 89 / 88 / 87 ns | **96 B / 帧** | 与帧大小无关:≈ 1 个序列段对象 + 1 次 `ArrayPacket` 视图经 `IPacket` 入参的装箱 |
| 写入缓冲回环 `WriteAsync`→`ReadAsync`→`AdvanceTo` | 64 B / 4 KB / 64 KB | 169 / 233 / 1 360 ns | **136 B / 次提交** | 与帧大小无关:封口时 `OwnerPacket` + `ArrayOwner` 各一个;耗时随大小线性(拷贝成本,4 KB→64 KB 即 1.1 µs) |
| `FlushAsync(waitForResume:true)` 未暂停快路径 | — | 28 ns | **0 B** | 与《数据管道Pipe》声称的"约 28ns、零分配"吻合 |
> 计时受同机并行任务影响(同一工作区另有测试运行),**分配量为确定性数据可用,纳秒值仅供量级参考**。
### 10.2 决策:两项优化都不做(先量化,不先优化)
| 候选优化 | 数据支持 | 决定 |
|---|---|---|
| **段对象池**(复用 `PacketSequenceSegment`,对标 BCL 的 SPSC 段队列) | 至多省下 96 B/帧中的序列段部分(接口入参装箱省不掉) | **不做**。回收安全性过不了关:本库消费推进以**字节计数**为主、窗口在每次追加时重建,无法像 BCL 那样用 `SequencePosition` + `_lastExaminedIndex` 判定"读者是否还持有该段";误回收是静默脏数据,代价远大于 96 B/帧 |
| **写缓冲租借下限**(`Rent(Math.Max(sizeHint, 4096))`) | 不减少 136 B/次提交的分配,只把"租 8 字节"变成"租 4 KB" | **不做**。每次封口都会换新缓冲(`_writeBuffer = null`),加大下限拿不到复用收益,反而在小提交场景浪费内存 |
| 封口缓冲复用(封口后不置 null,改用共享切片交出) | 可省下 136 B/次提交 | **待议**:需把"所有权转移"改为"引用计数共享",触及所有权契约,属大版本窗口决策 |
### 10.3 复现命令
```bash
dotnet run -c Release --project Benchmark -- --filter "*PipeBenchmark*"
```
> 原始结果:`BenchmarkDotNet.Artifacts/results/`(`PipeBenchmark` 的 `-report-github.md`)。
|