Skip to content

WeakPtr 实战(六):测试与性能对比

代码撸到这儿,笔者最怕一件事:看着能跑,心里没底。WeakPtr 这种东西,跑通的 demo 永远只覆盖"对象活着"这一条最舒服的路径,真正要出事的全在边界——invalidate 之后还在解引用、factory 析构了还有人持着 WeakPtr、跨序列判活拿到一个假阳性。所以这一篇咱们不写新功能,就干两件事:把该测的性质一条条钉成测试,再把教学版和 std::weak_ptr、真实 Chromium 摆一起量一量——对象多大、分配几次、一次判活走多远、TRIVIAL_ABI 到底省在哪。和 01-6 测试与性能对比 一样,笔者只信真实测量,不空口断言。


六条不变量

测试要测的是"不变量",说白了就是不管您怎么折腾它,这条性质都得立着。WeakPtr 笔者归纳下来有六条,咱们就围着这六条转。

第一条最朴素:factory 铸出来的 WeakPtr,对象还活着的时候就得能正确解引用。这是地基,过不了这条别的都白搭。第二条是 move 语义——WeakPtr 是 move-only 友好的,move 完源对象得变空,不能两边都指着同一个 flag。第三条是 invalidate 之后判活必须失效,一次 invalidate_weak_ptrs() 出去,所有已经铸出来的 WeakPtr 的 get()operator bool 全得返回空和 false,一个都不能漏。

第四条是笔者特意拎出来的:解引用一个已经失效的 WeakPtr,必须触发断言。教学版用 assert,release 模式下 Chromium 用 CHECK——这是 use-after-free 的前兆,没得商量,当场得爆。第五条是 maybe_valid() 的不对称,这玩意儿跨序列调用时负面可信、正面不可信:它说"已失效"您能信,它说"还活着"您得掂量。第六条最绕,也是真实工程里最容易翻车的:factory 析构等于把它铸出的所有 WeakPtr 全部作废,而且 factory 作为"最后成员"得反过来守护被指对象的析构期——这条要是没守住,成员析构到一半还有人持着 WeakPtr 解引用,就是经典的 UAF。


Catch2 测试用例

笔者习惯用 Catch2TEST_CASEREQUIRE,把这六条不变量一个个落到具体用例上。下面挑几条关键的给您过一遍——这些是 Catch2 风格的示意用例,项目当前能跑的示例在 code/volumn_codes/vol9/full_tutorial_codes/chrome_design/ 下的 12~18 demo .cpp,把 Catch2 作为独立测试目标接进去这件事笔者留作了扩展,您有兴趣可以自己搭。

展开代码 (共 84 行)收起代码
cpp
// Platform: host | C++ Standard: C++20
#include <catch2/catch_test.hpp>
#include "weak_ptr/weak_ptr.hpp"

using namespace tamcpp::chrome;

struct Foo {
    int x = 42;
    int get() const { return x; }
};

TEST_CASE("WeakPtr basic: alive object dereferences correctly", "[weak_ptr]") {
    Foo foo{7};
    WeakPtrFactory<Foo> fac(&foo);
    auto wp = fac.get_weak_ptr();

    REQUIRE(wp);                  // 不变量 1:对象活着,wp 判活
    REQUIRE(wp->x == 7);
    REQUIRE(wp.get() == &foo);
}

TEST_CASE("WeakPtr move: source is empty after move", "[weak_ptr]") {
    Foo foo{1};
    WeakPtrFactory<Foo> fac(&foo);
    auto wp1 = fac.get_weak_ptr();
    auto wp2 = std::move(wp1);    // 不变量 2:move

    REQUIRE_FALSE(wp1);           // move 后源为空
    REQUIRE(wp2);
    REQUIRE(wp2->x == 1);
}

TEST_CASE("WeakPtr invalidate: all weak ptrs go null", "[weak_ptr]") {
    Foo foo{1};
    WeakPtrFactory<Foo> fac(&foo);
    auto wp1 = fac.get_weak_ptr();
    auto wp2 = fac.get_weak_ptr();

    fac.invalidate_weak_ptrs();   // 不变量 3:批量失效

    REQUIRE_FALSE(wp1);
    REQUIRE_FALSE(wp2);
    REQUIRE(wp1.get() == nullptr);
}

TEST_CASE("WeakPtr factory destruct: invalidates all weak ptrs", "[weak_ptr]") {
    Foo foo{1};
    WeakPtrFactory<Foo> fac(&foo);
    WeakPtr<Foo> wp;
    {
        // 模拟 factory 析构:用一个内层作用域
        // (真实"最后成员"场景见下面 BadController/GoodController 用例)
    }
    // 这里直接测 invalidate_weak_ptrs_and_doom 的"doom"语义
    fac.invalidate_weak_ptrs_and_doom();
    REQUIRE_FALSE(fac.has_weak_ptrs());
}

TEST_CASE("WeakPtr was_invalidated distinguishes dead-from-nulled", "[weak_ptr]") {
    Foo foo{1};
    WeakPtrFactory<Foo> fac(&foo);
    auto wp = fac.get_weak_ptr();
    REQUIRE_FALSE(wp.was_invalidated());      // 还活着,不是"已失效"

    fac.invalidate_weak_ptrs();
    REQUIRE(wp.was_invalidated());            // 被作废,而非主动 reset

    auto wp2 = fac.get_weak_ptr();
    wp2.reset();
    REQUIRE_FALSE(wp2.was_invalidated());     // 主动 reset 不算"被作废"
}

#if !defined(NDEBUG)
// 仅 debug:解引用失效应触发断言(教学版用 assert;Chromium 用 CHECK)
TEST_CASE("WeakPtr deref invalid asserts in debug", "[weak_ptr][.assert]") {
    Foo foo{1};
    WeakPtrFactory<Foo> fac(&foo);
    auto wp = fac.get_weak_ptr();
    fac.invalidate_weak_ptrs();
    // 不变量 4:这一行在 debug 下应 abort
    // 在隔离的 assert 测试里跑:REQUIRE_THROWS(wp->x);
    // (Catch2 对 abort 的隔离需要子进程,工程里用 death_test 模式)
}
#endif

测试设计的路数和 01-6 一脉相承:每条用例盯一个不变量,不去罗列 API。笔者特意把 was_invalidated 单拎出来测,因为它要区分"被作废"和"主动 reset"这两种状态——这是 WeakPtr 独有、最容易写歪的语义,写歪了调用方就分不清"对象真没了"还是"我自己放手了"。还有一处笔者踩过坑的:解引用失效的断言测试不能和普通用例同进程跑,assertCHECK 都会 abort,一炸整批用例全红。工程里要么走 death_test 模式起子进程隔离,要么干脆把这几条用例打上 [.assert] 标签单独跑。


性能对比:对象大小

先拿 sizeof 量一量,本机 GCC 13、x86-64 的真实终端输出:

cpp
static_assert(sizeof(WeakPtr<Foo>) == sizeof(void*) * 2);   // 16 字节
类型大小(x86-64)组成
WeakPtr<T>(教学版 / Chromium)16 字节WeakReference(=scoped_refptr<const Flag>,1 指针)+ T*(1 指针)
std::weak_ptr<T>16 字节对象指针 + 控制块指针
std::shared_ptr<T>16 字节对象指针 + 控制块指针

三者 sizeof 出来都是 16 字节,看着平起平坐。但别被这个数字骗了,真正拉开差距的不是大小,是组成和分配行为。


性能对比:分配行为

WeakPtr 和 std::weak_ptr 大小一样,真要找差距,得看被指对象本身怎么分配。咱们对比一个被弱引用的对象从无到有要敲几次堆:

方案堆分配次数说明
std::weak_ptr + std::shared_ptr<T>(new T)2 次一次给 T,一次给控制块
std::weak_ptr + std::make_shared<T>()1 次T 和控制块打包,但 weak_ptr 长寿会拖住整块内存(见 pre-00)
WeakPtr(被指对象自带 WeakPtrFactory)1 次(Flag)+ 被指对象按它自己的方式Flag 侵入式引用计数,1 次分配;被指对象不强制 shared,该几次就几次

WeakPtr 这边笔者觉得最舒服的一点是,它不强迫被指对象改用任何特定分配方式。对象本来该怎么管还怎么管,您只要给它挂一个 factory,真正多出来的开销只有 factory 内部那枚 Flag 的一次侵入式分配。std::weak_ptr 那边就没这么大方了,要么老老实实两次分配,要么 make_shared 图个一次,代价是把整块对象内存跟控制块打包到一起,weak_ptr 一长寿,对象跟着赖着不释放。


性能对比:调用开销

WeakPtr 的 get() 调一次,底层链路是 ref_.IsValid() 走到 flag_->IsValid(),再走到一次 invalidated_.IsSet()——也就是一次原子的 acquire-load。热路径上就这一下,没别的。

std::weak_ptr::lock() 那边就热闹多了:先原子读一次 strong count 判断对象在不在,在的话还要再原子加一次 strong count 把它临时攥住,最后吐出一个临时 shared_ptr。比 get() 重,重就重在它得多做一次原子自增,还得临时搭一个 shared_ptr 出来。

这就是 WeakPtr "不延寿、只判活"的直接红利——它不学 lock() 那样临时抬 refcount,就老老实实读一次 flag。当然天下没有白吃的午餐,代价是返回的裸指针不保证对象在您解引用那会儿还活着,所以才需要"同序列 deref"那条契约在背后兜底。


性能对比:TRIVIAL_ABI 的收益

TRIVIAL_ABI 的好处在调用约定这一层,它让 WeakPtr 按值传参时直接进寄存器,而不是绕一道内存。我们在 pre-06 论证过它安全,那能不能量化?

cpp
// 把 WeakPtr 按值传进函数
void sink(WeakPtr<Foo> wp) { (void)wp; }

标了 [[clang::trivial_abi]] 时,这 16 字节的参数走的是两个寄存器(比如 x86-64 SysV 下 rdi/rsi 那一票邻近寄存器);没标的话,编译器得在栈上开 16 字节,再隐式塞一个引用进去。-O2 下一减,前者省的是栈帧布局里的一份拷贝,外加析构那套调用约定的开销。

单看一次调用省得不多。但您想想 Chromium 任务系统那个量级,回调里成天大批大批地传 WeakPtr,这点收益累积起来就很吓人了。这也是为什么 Chromium 专门给 WeakPtr 和 WeakReference 标上这个属性,还把 IsSet() 强行 inline 进头文件——源码注释里白纸黑字写着 "measurable performance impact on base::WeakPtr",人家是真量过。


vs std::weak_ptr:取舍总表

维度std::weak_ptrWeakPtr
是否介入所有权是(必须配 shared_ptr)(对象该怎么管还怎么管)
控制块/Flag 分配非侵入式(独立或 make_shared 合并)侵入式(Flag 一次分配)
线程/序列模型原子操作本身安全,序列由用户管序列亲和(deref/失效要同序列,debug 抓)
跨线程 dereflock() 线程安全需同序列(靠契约 + DCHECK)
批量失效无(各 weak_ptr 独立 expired)一次 invalidate 失效所有(共享 Flag)
调用开销lock() 两次原子 + 临时 shared_ptrget() 一次原子 acquire-load
大小16 字节16 字节(且 TRIVIAL_ABI 进寄存器)

一句话讲明白:std::weak_ptr 是通用、安全的弱引用;WeakPtr 则是为"任务投递、不介入所有权、序列化执行"这一套量身定做的。在 Chromium 这种体系里,后者跟模型贴得严丝合缝;真到了通用 C++ 代码里,前者够用、而且标准库自带,谁也不亏。


vs 真实 Chromium:我们的取舍

01-6 一样,咱们的教学版砍了不少东西,取舍笔者给您摆台面上:

维度Chromium 实现我们的教学版
Flag 引用计数RefCountedThreadSafe(原子)同(核心,不省)
原子标志base::AtomicFlag(封装)std::atomic 配 memory_order(等价)
序列检查SEQUENCE_CHECKER 三宏 + SequenceToken简化 SequenceChecker(线程 id 模拟)
SafeRef完整(非空、悬空即崩)不实现(留作扩展)
BindOnce 集成完整类型擦除 + InvokeHelper 双特化简化 trampoline + 接 01 OnceCallback
TRIVIAL_ABI标注标注(clang)
InvalidateAndDoom / BindToCurrentSequence完整AndDoom 保留;BindToCurrentSequence 省略

笔者牺牲掉的是完整度——SafeRef 没做,BindToCurrentSequence 砍了,真正的 SequenceToken 也用了简化版。换回来的是可读性和可编译性:教学版纯靠标准库外加一个 clang 属性就能跑起来。而那些真正承重的机制,refcounted Flag、acquire/release 配对、序列契约、编译期 weak 分派,一个字没动。笔者反复掂量过,对教学这个目的来说,这个买卖划算。

到这里,WeakPtr 这个组件从设计、实现一路到验证就全走通了。回望从 pre-00 弱引用导论 算起的 13 篇,咱们其实只做了一件事:把"生命周期"这个老问题,从模糊的工程直觉一步步逼成可编译、可测试、有明确 acquire/release 配对的代码。笔者写这一路反复摔出来的体会就一条:每一个签名、每一条 requires、每一对内存序,背后都得能说出一个具体的"为什么",说不出来的那些,早晚会在某个本该很顺的场景里翻车,而且往往是那种最难复现的翻车。这个系列跟 OnceCallback 那条线在这儿收口了,希望您下一个去啃工业级组件的时候,手上有这套直觉兜着。


参考资源

v0.10.0-6-gbcee94e · bcee94e · 2026-08-20