workerd 类型设计与继承模式:C++ 值类型与资源类型的线程安全实践
【免费下载链接】workerdThe JavaScript / Wasm runtime that powers Cloudflare Workers项目地址: https://gitcode.com/GitHub_Trending/wo/workerd
导读
本文基于 workerd 仓库的 type-design.md 文档,系统讲解 workerd(支撑 Cloudflare Workers 的 JavaScript / Wasm 运行时)C++ 代码库中的核心类型设计规范:值类型与资源类型(Resource Types)的划分、接口与实现的继承纪律、const 语义与线程安全的协同约定,以及全局构造器、dynamic_cast、函数指针等边界规则。读完本文,你将掌握如何在 workerd 及其兄弟项目(如基于 KJ/JSG 的 C++ 服务)中设计可复制、可移动、可序列化的值类型,以及带身份、需跨线程共享的资源类型,并理解kj::Own<T>、kj::MutexGuarded<T>等 KJ 基础设施在实际代码中的落点。
一、两种类型:Value Types 与 Resource Types
workerd 的类型系统在设计层面首先回答一个根本问题:这个类型是"数据"还是"对象"?文档将 C++ 类型划分为两类,二者在可复制性、比较语义、多态手段与生命周期管理上有着截然不同的约定。
值类型(Value Types):数据
值类型是纯粹的数据载体,具备以下特征:
- 可复制 / 可移动:可以按值传递、放入容器,拷贝与移动是廉价且语义明确的;
- 按值比较:两个值类型的相等性由成员内容决定,而非对象地址;
- 可序列化:可以被编码为字节流,用于存储或跨线程/跨进程传输;
- 无虚方法:多态不通过继承实现,而是借助模板(templates)在编译期展开;
- 必须有移动构造函数:这是文档明确要求的底线,确保
kj::mv()等移动语义随处可用。
典型的例子包括kj::String、kj::Array、kj::Maybe<T>以及 workerd 中大量使用JSG_STRUCT声明的结构体。
资源类型(Resource Types):带身份的对象
资源类型是"有身份"的对象,其特征恰好与值类型互补:
- 不可复制、不可移动:对象生命周期由所有权指针管理,所有权转移通过堆上的
kj::Own<T>完成; - 使用
KJ_DISALLOW_COPY_AND_MOVE显式禁止意外的拷贝/移动,把编译期错误前置; - 按身份比较:两个资源是否相等取决于是否指向同一对象;
- 允许继承与虚方法:这是多态的主要承载者。
在 workerd 的 JSG(JavaScript glue)层,资源类型还有更具体的含义。查看 src/workerd/jsg/resource.h 的注释:
A "resource type" (in KJ parlance) is the opposite of a "value type". In JSG, a resource is a C++ class that will be wrapped and exposed to JavaScript by reference, such that JavaScript code can call back to the class's methods. This differs from, say, a struct type, which will be deeply converted into a JS object when passed into JS.
也就是说,在 JSG 语境下:资源类型会被"按引用"包装并暴露给 JavaScript,JS 侧拿到的是同一底层对象的句柄,可以调用其方法;而结构体类型在跨 JS 边界时会"深度转换"成普通 JS 对象。这直接决定了哪些类应该继承jsg::Object,哪些应该用JSG_STRUCT声明。
两者在仓库中的印证
- 资源类型示例:SharedMemoryCache 继承
kj::AtomicRefcounted(跨线程原子引用计数),并在类体内声明KJ_DISALLOW_COPY_AND_MOVE(SharedMemoryCache);(见 memory-cache.h)。它作为进程内共享缓存,同一实例可被多个 worker/isolate 访问,天然需要身份与引用计数语义。 - 同样的模式还出现在 memory-cache.h 的
ThreadUnsafeData、以及 memory-cache.h 的MemoryCacheProvider上——凡是"被共享、需要互斥保护"的对象,都通过KJ_DISALLOW_COPY_AND_MOVE杜绝拷贝。 - JSG 资源类型示例:
MemoryCache继承jsg::Object(见 memory-cache.h),通过JSG_RESOURCE_TYPE宏把read/delete方法暴露给 JS;AbortController final: public jsg::Object(见 basics.h)、Base64Module final: public jsg::Object(见 base64.h)也是同样的资源类型写法。 - 值类型示例:
CacheValueProduceResult使用JSG_STRUCT(value, expiration);声明(见 memory-cache.h),表示这是一个会被 JS 边界深度转换的值类型结构。
实践建议:新建类时先问自己——它是否需要身份?是否会被 JS 按引用暴露?是否需要跨线程共享?只要有一个"是",就走资源类型路线(堆分配 +
kj::Own+ 禁拷贝);否则优先设计成值类型。
二、继承纪律:Interface 与 Implementation 绝不混用
规则核心
文档规定:一个类要么是interface,要么是implementation,二者不能混合,否则会引发脆弱基类问题(Fragile Base Class Problem)。
- 接口(interface):没有任何数据成员,只有纯虚方法。接口是"契约"的抽象。
- 实现(implementation):没有非 final 的虚方法。实现是具体行为与状态的载体,其虚函数必须
final,从而封死被继续覆写的空间。
中间子类(intermediate subclass)可以存在——即"接口链上允许再派生出接口",但数据与非 final 虚方法的分界必须清晰。
配套约束还包括:
- 接口不应声明析构函数:纯接口没有资源需要释放,声明析构函数会强制虚析构/对象布局约定,反而带来不必要的负担。
- 多重继承被允许且鼓励:典型场景是一个实现类同时继承多个接口(interface)。这在 workerd 中非常常见。
- 实现继承可用于组合:当子类只是想复用实现且不引入额外堆分配时,实现继承是可接受的(否则优先使用
kj::Own<T>成员组合)。
仓库印证
从源码结构看,workerd 大量采用"多接口 + final 实现"的组合模式。例如:
DurableObjectTransaction final: public jsg::Object, public DurableObjectStorageOperations(见 actor-state.h)——同时继承 JSG 资源基类与存储操作接口,且为final,符合"实现类无非 final 虚方法"的纪律;CompressionStreamImpl final: public kj::Refcounted, ...(见 compression.c++)——引用计数资源 + 接口组合;ActorCallRetryState final: public kj::Refcounted(见 actor-call-retry.h)、RestoreParamsHandler: public kj::Refcounted(见 restore.h)等final实现类遍布 API 层。
项目代码评审清单 review-checklist.md 也把这条纪律列为必查项:接口中不允许数据成员、实现中不允许非 final 虚方法,中间子类例外。
三、Const 语义与线程安全:传递性 const 与锁的协同
这一节是文档的技术核心,涉及 workerd 并发模型的根基。要点如下。
1. Const 是传递的(Transitive Const)
文档要求把 const 视为传递性质:指向结构体的 const 指针,意味着其内部包含的指针所指向的数据也"事实上 const"。这防止了通过"指针成员"绕过 const 限定的漏洞,也是后文"禁止 const 拷贝构造"的动机来源。
2. const 方法必须线程安全
const方法必须支持并发调用:多个线程可以同时调用同一对象的 const 方法;- 非 const 方法要求独占访问:同一时刻只能有一个线程执行;
kj::MutexGuarded<T>恰好把这两条编译进类型系统:.lockShared()返回const T&(共享读锁,对应 const 访问);.lockExclusive()返回T&(独占写锁,对应非 const 访问)。
这条规则意味着:设计 API 时,凡是被并发访问的数据,都应通过kj::MutexGuarded<T>包裹,让"读并发、写独占"由锁类型强制,而不是靠程序员自觉。
仓库印证:SharedMemoryCache 正是这一模式的教科书案例。它的所有可变数据都放进ThreadUnsafeData,然后用kj::MutexGuarded<ThreadUnsafeData> data;包裹(见 memory-cache.h),并注释说明"每次缓存操作都需要独占锁;即使是只读操作,也要更新条目的 liveliness,因此目前也需加锁"。MemoryCacheProvider内部同样用kj::MutexGuarded<kj::HashMap<...>> caches;保护共享缓存表(见 memory-cache.h)。
kj::MutexGuarded在仓库中广泛使用,例如 actor-cache.h、async-lock-scheduler.h、basics.h 等。在 basics.c++ 中可以看到真实的加锁用法:
// 独占写(修改) auto lock = slot.lockExclusive(); // 共享读(只读检查) auto lock = pr->value.lockShared();(见 basics.c++ 与 basics.c++)
3. 指针成员的拷贝构造:T(T& other)而非T(const T& other)
这是文档中最"反直觉"的一条规则:带有指针成员的可拷贝类,其拷贝构造函数应声明为T(T& other),而不是常规的T(const T& other)。原因是:T(const T& other)会让other以 const 引用传入,从而把"传递性 const"升级到被拷贝的成员指针上,导致拷贝出来的对象内部数据意外地变成 const。用非常量引用T(T& other)则不会触发这种升级。
如果不方便手写这种拷贝构造,可以直接继承kj::DisallowConstCopy(KJ 提供的辅助基类)来禁止 const 拷贝。
4. 锁的持有时间:越短越好
文档明确要求:只为访问或修改被保护数据所需的最短时间持有锁。锁的作用是保护临界区数据的一致性,而不是充当跨操作的事务边界;把锁拖得太久会放大争用、增加死锁风险。代码注释中也有类似佐证——memory-cache.h 特意提到CacheValue使用原子引用计数,使"线程可以在不加缓存锁的情况下反序列化值,即使该条目正在被逐出",这正是为了缩短持锁时间而做的设计取舍。
四、其他硬性规则
文档还给出了一组全局适用的编码红线,与 review-checklist.md 中的评审项相互呼应。
1. 禁止全局构造器(No Global Constructors)
不要声明带动态构造器的静态/全局变量。原因是:全局对象的构造顺序在 C++ 中不可控,动态构造容易引发"静态初始化顺序惨剧"(static initialization order fiasco),并拖慢启动。全局constexpr常量是允许的——它们在编译期求值,不存在运行时构造问题。
2. 禁止用dynamic_cast做多态分派
不要写"长 if/else 链 + 逐个 cast 到派生类型"的代码。正确做法是扩展基类接口,让多态通过虚函数天然分派。
dynamic_cast仅允许用于优化或诊断场景。文档给出了一个自检判据:"如果dynamic_cast永远返回 null,这段代码是否仍然正确?"——若答案是"是",说明该 cast 只是可选优化,可以接受;若答案是"否",说明你的逻辑依赖具体类型,应该重构为接口扩展。
(补充:在依赖具体类型且明确无歧义的向下转换场景,KJ 提供 debug 检查的kj::downcast<T>,见 review-checklist.md,比裸static_cast更安全。)
3. 禁止函数/方法指针
不要使用 C 风格函数指针做回调或多态。应使用:
- 模板 + 函数对象(functor):编译期多态,零开销;
kj::Function<T>:KJ 的类型擦除回调,支持 lambda、成员函数绑定等,是 workerd 中最常见的回调类型。
仓库印证:SharedMemoryCache::Use::FallbackDoneCallback即声明为kj::Function<void(kj::Maybe<FallbackResult>, SpanBuilder&)>(见 memory-cache.h);SharedMemoryCache的AdditionalResizeMemoryLimitHandler也是kj::Function<void(ThreadUnsafeData&)>(见 memory-cache.h)。
4. 优先引用而非指针
能用引用就用引用:引用在语义上不可为空,能根除一类空指针解引用缺陷。仅当确实需要表示"可能不存在"时,才使用kj::Maybe<T&>(而不是裸的可空指针)——这条规则同样出现在评审清单中:永远不要使用可空裸指针,应使用kj::Maybe<T&>(见 review-checklist.md)。注意这里说的是"引用 vs 裸指针",与资源所有权传递(kj::Own<T>)是两回事:所有权必须用kj::Own,而借用/观察用引用或kj::Maybe<T&>。
五、与代码评审清单的衔接
本文档不是孤立的——它是 workerd C++ 开发规范体系的一部分。以下评审清单条目直接与本文规则对应,可作为设计时的自查表:
| 本文规则 | 评审清单对应项(review-checklist.md) |
|---|---|
| 禁止混合接口与实现 | "Never mixed interface and implementation classes"(接口无数据成员、实现无非 final 虚方法,中间子类 OK) |
| 禁止可空裸指针 | "Never use nullable raw pointers. Should bekj::Maybe<T&>" |
| 禁止函数指针 | 配合 "Always usekj::Function/templates" 相关约定(清单中[=]捕获亦被禁止) |
| 禁止动态构造的全局变量 | "Never use singletons or mutable globals"(配合 "No global constructors") |
| 优先引用 | 与 "Avoidstatic_castdowncasting where possible. Should bekj::downcast<T>" 及kj::Maybe<T&>规则同源 |
此外,评审清单还强调了两条与本文关联紧密的内存/异常纪律,可作为延伸阅读:
- 禁止裸
new/delete,统一使用kj::heap<T>()(见 review-checklist.md); - 禁止
throw语句,改用KJ_ASSERT/KJ_REQUIRE/KJ_FAIL_ASSERT等 KJ 断言体系(见 review-checklist.md);显式析构函数必须标注noexcept(false)。
六、小结:一个类型设计决策清单
综合本文规则,在 workerd 中设计一个新类型时可以按以下顺序决策:
- 它是数据还是对象?——可复制、按值比较、可序列化 → 值类型(模板多态 + 移动构造);带身份、跨线程共享、按引用暴露给 JS → 资源类型(
kj::Own+KJ_DISALLOW_COPY_AND_MOVE)。 - 它需要多态吗?——需要 → 走"纯接口 + final 实现"路线,接口不含数据成员、不声明析构函数,实现类没有非 final 虚方法;可用多重继承组合多个接口。
- 它会被并发访问吗?——会 → 可变状态放进结构体后用
kj::MutexGuarded<T>包裹,const 方法走lockShared(),非 const 方法走lockExclusive(),且锁只持有最小必要时间。 - 它有指针/引用成员吗?——可拷贝类用
T(T& other)拷贝构造,或继承kj::DisallowConstCopy;借用一律用引用或kj::Maybe<T&>。 - 检查红线——无动态构造的全局变量、无
dynamic_cast分派(必要时用kj::downcast)、无函数指针(用模板或kj::Function<T>)。
这套规则的价值在于:它把"线程安全""生命周期""多态边界"这些 C++ 最容易出错的问题,从"靠经验与自觉"变成了由类型系统与 KJ 基础设施(kj::Own、kj::MutexGuarded、kj::Function、kj::Maybe<T&>)强制执行的设计契约。对于任何想理解 workerd 源码结构、或为基于 KJ 的 C++ 服务贡献代码的开发者,这份文档都是必读的入门规范。
【免费下载链接】workerdThe JavaScript / Wasm runtime that powers Cloudflare Workers项目地址: https://gitcode.com/GitHub_Trending/wo/workerd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考