基于开源项目复刻的现代C++实践——OnceCallback 实战(一):动机与接口设计
基于开源项目复刻的现代C实践——OnceCallback 实战一动机与接口设计仓库已经开源仍然在持续建设中喜欢的话点个⭐相关的链接如下https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP静态网页直接阅览https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/这是笔者的老系列的一个新分支——笔者前段时间看Chrominum的C代码的时候快速的注意到了一些很不错的设计是不是很不错存在争议但是我认为值得拿过来配小屋里的各位聊聊那说啥了来来来引言说实话笔者在做异步编程的时候踩过最多的坑就是回调被多次调用。场景很经典——注册一个文件 I/O 完成的回调期望它跑一次就完事结果因为某处逻辑手滑多触发了一次回调里释放的资源被二次访问直接喜提段错误。这种 bug 的一大特点是——在测试里很难复现因为正常的异步路径往往只跑一次回调真正的触发条件是某种竞态或错误重试路径。std::function没法帮我们。它允许多次调用允许拷贝传播回调对象可以满天飞。我们需要的是一种在类型系统层面就约束住回调语义的机制——让只能调用一次这个规则变成编译器的检查项而不是程序员记忆力的事。这一篇我们从动机出发拆清楚std::function到底哪里不对然后设计我们的目标 API。下一篇再开始写代码。学习目标从一个真实的异步 bug 理解std::function在回调场景的三大缺陷掌握 Chromium OnceCallback 的设计哲学move-only 右值限定 单次消费设计出 OnceCallback 的完整公共接口从一个 bug 说起场景异步文件读取假设我们在写一个异步文件读取的封装。用户调用read_file_async(path, callback)I/O 完成后callback被触发一次传入文件内容。voidread_file_async(conststd::stringpath,std::functionvoid(std::string)callback);// 使用voidon_file_read(std::string content){process(content);// 处理内容release_resources();// 释放相关资源}read_file_async(data.txt,on_file_read);看起来没问题。但如果 I/O 子系统因为某种错误触发了重试——回调被调用了两次。release_resources()执行了两次第二次访问的是已释放的内存。段错误。在测试环境中这个重试路径永远不会被触发只有在生产环境的高并发场景下这个 bug 才会以极低的概率出现。出现了等待咱们就是准备写该死的复盘报告和哀嚎我们的年终奖完蛋了。std::function 没有帮我们问题出在哪里std::functionvoid(std::string)的类型签名里没有任何信息告诉我们这个回调应该被调用几次。类型系统没有提供约束只能靠运行时的断言——如果你有的话——或者靠程序员的纪律来保证。更糟糕的是std::function的特性让这个问题变得更难发现。它是可拷贝的意味着回调可以被复制到多个地方。如果多个执行路径同时持有同一份回调的副本竞态条件就埋伏在其中。它的operator()是const限定的——调用它不会改变std::function对象本身的状态所以你无法通过调用接口来表达调用即消费这个语义。std::function 的三大缺陷我们把问题系统化一下。std::function作为通用的可调用对象容器在设计上是成功的——但在异步回调这个特定场景下它有三个致命的问题。缺陷一可复制std::function天生支持拷贝。当你拷贝一个std::function时它内部的类型擦除机制会把存储的可调用对象也拷贝一份。在异步系统中这意味着一个回调可以被复制到任意多个地方——任务队列里一份、定时器里一份、错误处理器里一份——每份都可以独立调用。如果回调里捕获了 move-only 的资源比如std::unique_ptr拷贝直接编译失败。如果捕获的是裸指针或引用多个副本同时执行就会产生竞态。Chrome 团队的思路很直接既然异步任务回调从根本上就不应该被复制那就让它在类型层面不可拷贝。缺陷二可重复调用std::function::operator()对调用次数没有任何约束。你可以在同一个std::function上调一千次它照跑不误。但在异步回调场景里一个文件读取完成的回调被调用两次就是逻辑错误——它可能触发两次资源释放、两次状态转换、两次消息发送。这种错误在类型系统里完全检测不到。缺陷三无法表达消费语义在 Chrome 的任务投递模型中一个PostTask(FROM_HERE, callback)调用之后callback就不应该再被使用——它的所有权已经转移给了任务系统。std::function的operator()是const限定的调用它不会改变std::function对象本身的状态所以你无法通过调用接口来表达调用即消费这个语义。这三个问题归结到一点std::function的接口设计无法表达这个回调只能被调用一次调用后即失效这个约束。我们的 OnceCallback 就是为了填补这个语义空白而设计的。Chromium 的回答OnceCallback 设计哲学Chrome 的回调系统建立在一条核心原则之上消息传递优于锁序列化优于线程。在这个原则下每个投递到任务系统的回调都是一个独立的、一次性的消息。投递之后回调的所有权就从调用方转移到了任务系统执行之后回调就被销毁。没有共享没有复用没有歧义。这个哲学直接体现在OnceCallback的类型设计上三个关键约束Move-onlyOnceCallback删除了拷贝构造和拷贝赋值只保留移动操作。从类型层面保证回调在任意时刻只有一个持有者。右值限定 Run()OnceCallback::Run()只能通过右值引用调用。左值调用触发编译错误。从语法层面提醒调用方“你在消费这个回调之后别再用了。”单次消费Run()内部会通过引用计数机制销毁BindState使得后续对同一对象的任何访问都是安全的空操作。Chromium 内部架构概览Chromium 的回调系统由三个层次组成。底层是BindStateBase——类型擦除的基类带引用计数不用虚函数而是用函数指针成员来实现多态。中间层是BindStateFunctor, BoundArgs...——模板化的具体类存储真正的可调用对象和绑定参数。顶层是OnceCallbackSignature——用户直接操作的类型本质上是BindState的一个智能指针包装大小只有 8 字节。我们的实现会保留外层接口 内部存储 类型擦除的分层思路但用std::move_only_function来替代 Chromium 手写的BindState 引用计数组合用 deducing this 来替代双重重载 !sizeofhack。设计目标 API我们把目标 API 定下来再回头讨论每个设计决策。这是工程师的工作方式——先想清楚我要什么再想怎么做。构造与调用#includeonce_callback/once_callback.hppusingnamespacetamcpp::chrome;// 从 lambda 构造autocbOnceCallbackint(int,int)([](inta,intb){returnab;});// 调用必须通过右值intresultstd::move(cb).run(3,4);// result 7// 调用后 cb 被消费// std::move(cb).run(1, 2); // 运行时断言失败参数绑定// bind_once预绑定部分参数返回一个新的 OnceCallbackautoboundbind_onceint(int)([](intx,inty,intz){returnxyz;},10,20// 预绑定前两个参数);intrstd::move(bound).run(30);// r 60取消检查autocbOnceCallbackvoid(int)([](intx){/* ... */});// 检查回调是否仍然有效if(!cb.is_cancelled()){std::move(cb).run(42);}// maybe_valid乐观检查if(cb.maybe_valid()){std::move(cb).run(42);}链式组合autopipelineOnceCallbackint(int,int)([](inta,intb){returnab;}).then([](intsum){returnsum*2;});intfinal_resultstd::move(pipeline).run(3,4);// final_result 14因为 (34)*2 14接口设计决策分析为什么用 run() 而不是 operator()Chromium 用的是Run()Google 风格要求大写开头。我们用run()符合 snake_case 命名规范。更深层的原因是语义区分——operator()太通用任何可调用对象都有operator()run()明确表达了执行任务的语义在代码审查时一眼就能看出这是在消费一个 OnceCallback而不是调用一个普通函数。为什么 run() 必须通过右值这是整个设计中最关键的一点。我们用 deducing this 让编译器帮我们拦截左值调用——如果写cb.run(args)而不是std::move(cb).run(args)编译器直接报错错误信息明确告诉你该怎么做。这个机制在前置知识六里已经详细讲过了。为什么区分 is_cancelled() 和 maybe_valid()区别在于安全保证的强弱。is_cancelled()提供确定性回答——只能在回调绑定的序列上调用保证返回准确的结果。maybe_valid()提供乐观估计——可以从任何线程调用但结果可能过时。在 Chromium 的完整实现中这个区分和线程安全保证有关。我们的简化版暂时让两者语义相同但保留了接口以备后续扩展。为什么 then() 消费 *thisthen()的语义是把当前回调的执行结果传给下一个回调。这要求当前回调在then()返回的新回调中被完整捕获。如果then()不消费*this同一个回调就会同时存在于两个地方——违反 move-only 的语义约束。所以then()被声明为右值限定成员函数调用后原回调对象进入已消费状态。环境搭建开始写代码之前确认一下工具链。OnceCallback 依赖std::move_only_function和 deducing this都是 C23 特性。编译器要求GCC 13 或 Clang 17 可以完整支持上述特性。编译时加-stdc23。验证代码#includefunctional// 验证 std::move_only_function 可用static_assert(__cpp_lib_move_only_function202110L);// 验证 deducing this 可用structCheck{voidtest(thisautoself){}};intmain(){Check c;c.test();return0;}如果这段代码编译通过环境就绑了。CMake 最小配置cmake_minimum_required(VERSION 3.20) project(once_callback_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(once_callback INTERFACE) target_include_directories(once_callback INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/.. )小结这一篇我们从动机出发搞清楚了三件事。std::function在异步回调场景有三大缺陷——可复制、可重复调用、无法表达消费语义——根源在于类型系统无法约束只能调用一次。Chromium 的 OnceCallback 通过 move-only 右值限定 Run() 单次消费来填补这个语义空白。我们设计了一套目标 API包括构造与调用、参数绑定bind_once、取消检查is_cancelled/maybe_valid和链式组合then()四个核心功能。下一篇我们开始搭建核心骨架——从模板偏特化到三态管理把 OnceCallback 的类骨架搭起来。参考资源Chromium Callback 文档cppreference: std::move_only_functionP0847R7 - Deducing this 提案相关阅读第15篇第三次重构 —— if constexpr让时钟使能在编译时自动选对 - 相似度 52%第17篇C23特性收尾 —— 属性、链接与零开销抽象的最终证明 - 相似度 52%