我们写了近 700 个 sealed,最在意的却不是性能
「什么时候用 partial?什么时候用嵌套类?怎么消除虚调用开销?」——这是 C# 团队规范里的老三样,教科书答案也都能背:partial 拆大文件、嵌套类做封装、sealed 帮 JIT 去虚。
但真到写一个几十万行的存储引擎时,这三个问题的答案会长得很不一样。这篇把 TC.Tier 里的做法摊开——它们最后其实指向同一件事。
partial:不是”拆大文件”,是可见性管控
先看一个例子。段表由 7 个 partial 文件组成,类头注释本身就是一份”文件目录”:
SegmentTable.cs —— 字段 + 构造 + 释放 + 双尾水位操作
SegmentTable.Addressing.cs —— 地址算术 + 边界只读
SegmentTable.Segment.cs —— 段操作(查询/建段/收缩/紧凑/替换)
SegmentTable.Checkpoint.cs —— 持久化
SegmentTable.Lease.cs —— 租约(806 行)
SegmentTable.ExtentLease.cs—— 区间租约
SegmentTable.Diagnosis.cs —— 诊断
拆分的动机,一个重构提交里说得直白:“partial 类本就为拆分而设”。但重点不在这里——partial 是纯编译期概念,运行时零成本,这不用论证。真正需要它的是别的东西:
可见性管控。 当一个能力需要访问基类的私有 IO 原语或内部字段时,用 partial 文件 + 嵌套类,就不需要把任何成员提升到 internal——同程序集的其他类依然碰不到它们。设计稿把这叫做”格式管控铁律”:
IO 原语与水位全保持
private protected,同程序集非子类/非嵌套的类碰不到——调用方无法绕过编解码层直接裸写格式。
把成员从 private protected 提到 internal 也能让代码跑起来,但那样同程序集任意类都能调——格式管控就失效了。partial + 嵌套的组合,换来的是”既要跨文件组织,又要零可见性泄露”。
反面教材也是自己人写的。 早期一次重构的评审文档里,作者给当时的 partial 判了刑:
partial 文件职责混乱(主文件杂物间、句柄文件夹带建段扫段、读写路径挤在一起)
还有个更实在的观察:段表的租约文件拆完之后仍有 806 行。partial 治的是”文件职责”,治不了”上帝类”——如果一类能力本身就巨大,拆文件只是把它藏进了多个文件里。
嵌套类:一个判断标准,两种范式
什么时候用嵌套类?仓库给了一个二值判断标准,简单到可以贴在墙上:
| 能力是否访问基类私有成员? | 代码结构 |
|---|---|
| 是(写主载体 / 读写水位 / 调私有 IO 原语) | partial + 嵌套类 |
| 否(自管载体缓冲、纯字节读写、委托接口) | 独立顶级类(实现接口,构造注入) |
理由一句话:嵌套类天然访问包含类的所有成员(含 private),不需要把任何成员提升到 internal——可见性零泄露。
三种典型的嵌套形态,各有各的用处:
一、泛型嵌套抽象基类——默认实现嵌套在基类里,子类专属版本嵌套在实现类里、只覆写钩子点。完整状态机放基类,子类只填格式。
二、私有接口 + 两个私有 sealed 实现——镜像帧的校验和计算器:
private interface IMirrorFrameCrc { void Append(ReadOnlySpan<byte> data); ulong Finalize(); }
private sealed class Crc64FrameCrc : IMirrorFrameCrc { ... } // 默认
private sealed class Crc32CFrameCrc : IMirrorFrameCrc { ... } // 另一算法
private 接口 = 只有本类内部能实现和引用;两个实现都是 sealed(下面讲为什么这很重要)。调用点只有两处、编译期可见——JIT 去虚的命中率极高。
三、节点类的布局技巧——跳表队列的节点里,最底层的边内联成字段、更高层才用数组:
结构优化:底层边内联(约一半节点只有这一条边——省一次数组分配)
嵌套在这里的作用是:这个节点类型只服务于宿主,没必要暴露,还顺手做了内存布局优化。
一条教训值得单独抄下来:“不要照抄其他基类”。设计稿说得很具体:Log 有”写入主设备的元数据”这种模式(需要嵌套以访问主设备私有成员),Blob 没有——所以两者的工厂形态不同(一边必须由实现类提供,一边只是基类默认实现)。差异源于分析,不是照抄。
诚实说明:仓库里没有”嵌套类避免泛型实例化爆炸”和”嵌套只是编译期命名空间”这类论证——嵌套的正当性一律落在可见性和去虚上。
一个视图类的满分案例
整个”嵌套 + 封装”思路最漂亮的一个落地是段的只读视图。
问题:段表的查询接口原本返回可变的段对象引用。任何拿到引用的调用方都能直接调段的方法改状态——完全不经过段表。而段表是段生命周期事件的源头:状态绕过它改了,事件(段满/段回收/段替换)就不会触发,上层的租约协议、压缩、恢复全部建立在错误的前提上。
解法:对外只给一个 70 行的值类型视图。
public readonly struct SegmentView // 几个只读标量 + 派生属性
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private SegmentView(Segment seg) { /* 拷贝快照字段 */ }
public static implicit operator SegmentView(Segment seg) => new(seg);
}
段表对外的入口返回视图(不存在时返回一个零分配的 Hollow 哨兵);真正的段引用只经私有的原始访问器在内部流转。
这个重构的验收标准特别值得学——它是一条可以机械验证的架构断言:
全项目搜索外部(非段表自身)对段实例方法的调用,应该为零。
一个设计目标被写成了可以搜索验证的条件,这比”架构更清晰”之类的表述有用得多。
顺带纠一个常见误解:视图是快照语义(构造时拷贝,之后不反映段的后续变更),这是有意接受的——评审曾建议加版本号做惰性校验,最后裁定用文档声明替代。“对象引用悬挂”是另一个独立问题,解法也不同(压缩时”同对象换内脏”、引用恒稳)。两件事不要混为一谈。
去虚化(上):JIT 已经帮你做了一半
先说结论:.NET 8 的 JIT 已经能自动消除大部分接口分派的成本——前提是你把实现类标成 sealed。
实测数据(设备 IO 路径,页缓存命中 4KB 块):
| 方法 | 平均耗时 | 比值 |
|---|---|---|
| 接口分派读 | 5.97 µs | 1.00 |
| 具体类型直调读 | 6.27 µs | 1.05 ± 0.09 |
统计上没有差异。 报告结论是:.NET 8 运行时的 JIT 对 sealed 类的接口调用做了”受保护去虚化”(GDV)——运行时自动把接口分派降级成直接调用。
另一份旁证更极端:FASTER 自己的基准里,“虚方法版 vs 去虚化版”跑出来是 4.40 ns vs 4.41 ns——1.00×。
所以第一步简单到只有两个字:sealed。规范原文写得很直白:
实现类全部
sealed——JIT 对 sealed 类型做去虚化,接口/虚方法调用编译为直接调用直至内联。这是”接口可替换 + 热路径零虚分发”设计的性能前提。
有一条代码注释是唯一点名 GDV 的:
单接口实现 + GDV → JIT 内联为直接调用,零间接开销。
去虚化(下):JIT 帮不了的那一半
但有三件事 JIT 替你做不了。
一、abstract 的中间转发层。 基类声明 abstract、派生类覆写——这层转发是 JIT 无法自动消除的。做法是:在意这层开销的设备直接覆写跨段入口,把”地址解析 + 分派 + 物理 IO”打平进一个方法体。
二、内联提示的分寸。 [MethodImpl(AggressiveInlining)] 全仓 450 处,但规范给了明确的判断标准:
热路径(IO 循环内 / 校验和 / 头部读写 / 缓冲拷贝)且方法体短小 → 标注;冷路径(构造/释放/编排)或方法体大 → 不标(避免调用方代码膨胀)。
还有个反向技巧很妙:抛异常的辅助方法用”不返回 + 不内联”标记把 throw 隔离出去——不是为了这个异常方法快,而是为了让调用它的热路径方法能被 JIT 内联。
三、物理消除接口。 这是最硬的一刀:把结构层基类持有的引擎字段,从接口类型改成具体类型。
// 改之前
private protected readonly IStorageEngine _engine;
// 改之后(具体类型是 internal sealed)
private protected readonly StorageEngine _engine;
七个结构基类(Ring / Log / Mirror / Metadata / Snapshot / SortedIndex / ProbingIndex)全部这么改了。接口面直接消失——不是”JIT 去虚”,而是根本没有虚可分。
这里有个很值钱的反转。 早期性能报告在评估这件事时,白纸黑字写着”不做”:
上层字段改具体类型(彻底消除接口分派)| 高侵入,破坏抽象;JIT 已 GDV 自动消除 | 不做(GDV 已够)
一个月后,结构层还是改了。同一件事,在设备层判定为”不值得”(抽象价值高、GDV 已够),在结构层判定为”值得”(热路径更密、引擎类型本就是 internal sealed、抽象由别的机制承担)。取舍随层而变——这是比结论本身更重要的东西。
最后诚实声明:C# 11 的静态抽象接口成员(static abstract interface members)在本项目零使用;函数指针(delegate*)只有 8 处、全部用于原生库绑定、与去虚无关。别把这两条算在本项目头上。
源生成器:第三条路
还有一条路是在编译期把”调用什么”直接定死,让运行时没有任何可虚的东西。
用属性标注的二进制布局结构体,由源生成器在编译期生成读写与校验代码——字段偏移、对齐、嵌套全部编译期确定、布局可审计。
键类型走的是”封闭薄类”:
/// [RingKey(typeof(T))] 封闭薄类——开放泛型内核的编译期封闭,消费面只见本类型。
public sealed class RingOfXxx : BlittableRing<Xxx> { ... }
开放泛型的构造函数是 protected internal,消费面只经生成的一组 sealed 封闭类——想绕过它直接继承开放泛型是个编译错误。于是”泛型实例化集合”从”消费者随手 new”收口成”编译期确定且可审计”。
这条路上最狠的不是工具,是制度化:禁止运行时反射不是建议,是一条有编号的分析器规则,写了就报;热路径另有专门的分配纪律规则;验收面是”用 AOT 发布探针项目并成功”——生成物零反射的硬验收。
收束:热路径零接口虚分发
把三件事拼起来,会看到它们其实是同一个问题的三张脸。
项目有一条铁律:
★★ 热路径零接口虚分发:可变接口只进冷路径,热路径 100% non-virtual 实例方法(编译期静态分派)。 热路径:Ring 的分配/封口、Blob 的读写、Log 的追加、Index 的查找; 冷路径:全量恢复、全量扫描、后台巡检、检查点。
而张力在于:整个项目的卖点之一是”140+ 接口注入:编解码/策略/恢复/游标/协调全可替换”——处处是接口调用。可替换性和零虚分发天生对立。
三件套就是用来化解这个张力的:
sealed—— 让 JIT 去虚(免费的那一半);- 具体类型字段 + non-virtual 热路径 —— 让 JIT 没得可虚(要动手的那一半);
- partial + 嵌套 —— 在不提升任何可见性的前提下,把上面这些组织起来。
而边界的粒度感也值得学:日志的记录布局方法是抽象的(“模板方法,虚调用一次”)——那是每次批量操作一次的频率,可以接受;而追加是每次操作的核心路径,必须 non-virtual。“虚调用一次”和”虚调用每次”是两个量级的事情——分清这个,比”一律消灭虚调用”更实用。
最后一句:那份证明”GDV 已经够了”的基准,本身就是为这个决策而建的——先测,再优化。它推翻的正是”接口分发一定慢”的直觉。
本文事实来自 TC.Tier 的代码组织规范、基类扩展范式设计稿、设备性能报告与仓库统计。
评论