十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++观察者模式实战:生命周期、线程安全与事件中心设计

C++观察者模式实战:生命周期、线程安全与事件中心设计 最近被一个朋友问住他维护的 C 项目里订单状态一变就能看到五六个模块的调用链写死在同一个 if 分支里加一个“关心这件事”的模块就得改原业务代码测一次回归能改出一身冷汗。我告诉他这种场景就该让观察者模式上场了。观察者模式在 C 里几乎是一门基本功面试八股考它实际项目里也在无处不在的地用着但它远不止“一个回调列表”那么简单。从我自己的实战经历来看把观察者模式真正用好至少要搞明白它的生命周期管理、线程安全、事件设计以及“什么时候不该用”。这篇就围绕着我在 C 项目里使用观察者模式的完整思路做一次拆解从最经典的 C98 写法讲到现代 C 的 std::function 弱回调写法再到线程安全、异步事件中心最后会附上一个可以直接编译运行的轻量级事件中心代码。适合准备 C 面试的开发也适合正在被耦合代码折磨、想动手重构团队的工程师。1. 观察者模式到底在解决什么问题1.1 耦合爆炸一个订单状态变化引出的需求先看一个非常典型的业务场景。你的系统里有一个订单模块订单从“待支付”变成“已支付”后库存系统要把货预占掉短信服务要发一条通知财务系统要记一笔账大数据模块要打一个埋点。最直接的写法是把这些调用全部写到订单状态更新的函数里void OrderService::markPaid(OrderId id) { // 更新订单状态 inventoryService_.reserve(id); smsService_.send(id, 支付成功); financeService_.record(id); analyticsService_.track(id); }这段代码看起来挺直白但问题藏在“需求变更”里。今天加一个优惠券模块明天加一个风控通知每来一个需求都要打开 OrderService 改这个函数。改多了以后订单模块会越来越臃肿订单变成什么状态、别人怎么响应全被硬编码绑定在一起。更麻烦的是你无法在不影响订单逻辑的情况下动态决定“哪些模块要响应这件事”。观察者模式的核心思路就是反转这种依赖订单状态变化时它不需要知道谁会关心这件事只需要对外喊一声“我变了”。谁关心谁自己来订阅。这就是发布-订阅思想的雏形也是这个设计模式最能打的地方。1.2 拆解三个核心角色观察者模式里有三个关键角色搞清楚了它们代码才能写得清爽Subject被观察者它持有观察者列表提供 subscribe、unsubscribe 的方法并在状态变化时触发 notify。Observer观察者它注册到 Subject 上当 Subject 通知事件时收到回调执行自己的业务逻辑。Event事件通知时携带的数据可以只是一个标志也可以是一整个结构体。这三个角色合起来实现了“一对多通知”的效果。一个 Subject 有多个 Observer 关注它Subject 不关心 Observer 的具体类型Observer 也不关心 Subject 内部是怎么管理的。它们只通过 subscribe / notify 这条线联系其他一概不管。用生活里的例子类比会特别顺手公众号平台就是 Subject读者的微信就是 Observer文章推送就是事件。你关注一个公众号它发文章时你会收到通知但公众号并不需要关心你是什么性格、住在哪个城市、用什么方式读完文章。这就是解耦。1.3 什么时候该用什么时候别硬用观察者模式不是万能药它的适用面其实有边界。我用过几次“过度设计”之后才慢慢总结出判断标准该用的场景很清晰一类事件会被多个独立模块关心而且这些模块可能会动态增减。比如 UI 框架里的按钮点击事件、游戏里的成就解锁事件、网络服务里的用户状态变更都属于典型的观察者场景。不该用的场景也很明显如果一对一调用直接调用函数反而更清晰如果通知链路非常深A 通知 B、B 通知 C、C 通知 D这种“观察者套观察者”的写法会让静态代码检查形同虚设如果你对性能有极致要求并且事件频率高到了百万级每秒那观察者模式中 std::function 拷贝和锁竞争的开销可能就不划算。另一个容易被忽视的缺点是可读性观察者模式把显式的调用关系变成了隐式的注册关系。你在 IDE 里点一下“查看引用”可能什么都看不到因为真正的响应关系发生在运行时。所以团队里用这个模式一定要配合日志和命名规范不然维护起来就是灾难。2. 经典 C 实现继承、虚函数与手写管理2.1 教科书版本从 C98 说起很多教材讲观察者模式用的都是纯虚接口的方式。在 C 世界里这就是最经典的实现。先把 Observer 定义成一个抽象基类class Observer { public: virtual ~Observer() default; virtual void update(const std::string eventName) 0; }; class Subject { public: void attach(Observer* observer) { observers_.push_back(observer); } void detach(Observer* observer) { observers_.erase( std::remove(observers_.begin(), observers_.end(), observer), observers_.end()); } void notify(const std::string eventName) { for (Observer* observer : observers_) { observer-update(eventName); } } private: std::vectorObserver* observers_; };subscribe 对应上面的 attachunsubscribe 对应 detach。detach 里用了 remove-erase 惯用法这是 C 里从 vector 中删除指定元素的标准姿势。整个模式的核心就是Subject 依赖的是 Observer 这个抽象接口而不是某个具体业务类。这就是依赖倒置原则的体现——高层模块不依赖低层模块二者都应该依赖抽象。2.2 手工管理带来的内存与生命周期问题经典的纯虚接口写法虽然思想纯粹但在实战中有一个非常头疼的难题生命周期。Subject 里存的是裸指针这意味着你必须保证 Observer 对象释放之前它已经从 Subject 里 detach 掉了。一旦顺序倒过来Observer 先析构Subject 后在某个事件驱动下调用 notify那这个循环访问的就是一块已经释放的内存轻则读到脏数据重则直接崩溃。我在早期项目里就踩过这个坑。一个网络模块和一个 UI 模块相互订阅窗口关闭时 UI 模块被析构但网络模块还在某个后台线程里偶尔触发事件。结果就是崩溃现场飘忽不定只有用 AddressSanitizer 编译一遍才能看到“heap-use-after-free”的报错。排查到最后解决的方案虽然只是给 UI 模块的析构函数里补了一个 detach但那个过程让人印象极深。另一个常见问题则是忘记 delete。很多新手用 new 创建 Observer 并 attach 之后就再也不管释放的事。现在 C 项目一般来说不推荐裸 new除非你确定自己能把生命周期管明白。2.3 遍历中更新列表迭代器失效经典写法里还有一个隐藏陷阱在 update 回调里修改观察者列表。假设某个 Observer 在 update 方法里调用 subject-detach(this)而 notify 正在遍历 observers_ 这个 vector那么这次删除操作会导致迭代器失效接下来的遍历就会读取到被破坏的内存。这个问题有几种修复方式。最简单的做法遍历时不直接操作原容器而是先复制一份观察者快照在快照上遍历。后面我们在现代 C 实现里也会继续沿用这个思路因为快照法可以同时避免很多重入问题。如果你不想复制也可以在回调中只标记需要删除的观察者等 notify 遍历结束之后再统一清理。这种“延迟回收”策略在游戏引擎的事件系统里很常见能减少内存分配次数代价是代码复杂度上升。3. 现代 C 实战升级std::function 与弱引用3.1 用 std::function 告别抽象基类进入 C11 时代后我的观察者模式实现基本抛弃了抽象基类改用 std::function。为什么因为抽象基类有一个比较尴尬的限制一个类如果已经继承了业务基类再想去监听事件就得再从 Observer 继承一份C 的多继承用起来要小心翼翼地处理命名冲突。而 std::function 加 lambda 的方式可以完全绕开继承体系让任何可调用对象直接成为观察者。using Handler std::functionvoid(const EventData); class EventSource { public: void subscribe(Handler handler) { handlers_.push_back(std::move(handler)); } void notify(const EventData data) { for (auto handler : handlers_) { handler(data); } } private: std::vectorHandler handlers_; };std::function 能接收普通函数、lambda、函数对象和 std::bind 绑定出来的成员函数灵活性比纯虚接口高一个量级。比如在 UI 层你可以直接写一个 lambda 捕获当前窗口对象然后把它订阅到一个按钮点击事件上button-onClick.subscribe([this](const ClickEvent e) { this-openDialog(e.position); });这种写法非常自然代码读起来和常见 C 框架的事件接口很接近。3.2 生命周期再思考weak_ptr 是靠谱的解法但 std::function 没有自动解决生命周期问题。上面的 lambda 捕获了 this如果窗口对象已经销毁而按钮还在这个 lambda 里被调用那依然是一颗雷。这里我强烈推荐弱回调方案。核心做法观察者对象用 shared_ptr 管理Subject 容器里存 weak_ptr。每次通知前调用 lock() 判断观察者是否还活着class Subject { public: void subscribe(const std::shared_ptrObserver observer) { observers_.push_back(observer); } void notify() { for (auto weak observers_.begin(); weak ! observers_.end();) { if (auto observer weak-lock()) { observer-update(); weak; } else { weak observers_.erase(weak); // 顺带清理已失效的观察者 } } } private: std::vectorstd::weak_ptrObserver observers_; };观察者对象则继承 enable_shared_from_this用 shared_from_this() 把自己注册进去class UserPanel : public std::enable_shared_from_thisUserPanel { public: void registerOn(Subject subject) { subject.subscribe(shared_from_this()); } void update() { /* ... */ } };这里用 weak_ptr 而不是 shared_ptr 的原因有两个。第一weak_ptr 不会延长观察者的生命周期避免一个已经不再需要的对象因为订阅关系而一直活在内存里。第二它能避免循环引用观察者持有 Subject 的 shared_ptr、Subject 又持有观察者的 shared_ptr就会形成环导致引用计数永远归不了零。weak_ptr 在这条链路上主动“断开”了一边。3.3 事件类型别拿字符串硬拼很多初学者会拿字符串当事件名比如 notify(user_login)。字符串的好处是灵活、不用提前定义类型但它的问题也很明显拼写错误要到运行时才暴露而且字符串比较在性能敏感路径上也有额外开销。在 C 里我建议用枚举类型enum class EventType { UserLogin, UserLogout, OrderPlaced, ConfigUpdated, };枚举的好处是编译期检查和 IDE 补全都能派上用场。如果事件需要携带很多额外数据就单独封装一个 Event 结构体避免把所有参数都塞到函数签名里。遇到确实需要在运行时动态扩展事件类型的场景也可以给 Event 加一个 type 字段再用 std::variant 或 std::any 存储 payload但这是我最后才会考虑的方案因为它会把类型安全重新丢掉复杂度也上去了。3.4 取消订阅std::function 的弱点很多人会在实现 unsubscribe 的时候卡住怎么把一个 lambda 从 vector 里删掉麻烦在于 std::function 没有 operator也就是说你不能拿一个“等值的函数对象”当作 key 去容器里查找并删除。解决办法是记录连接标识也就是订阅时返回一个 token。这个 token 可以是一个自增整数也可以是封装了 ID 的 RAII 对象class EventBus { public: using Token uint64_t; Token subscribe(EventType type, Handler handler) { std::lock_guardstd::mutex lock(mutex_); Token id nextToken_; handlers_[type].push_back({id, std::move(handler)}); return id; } void unsubscribe(Token token) { std::lock_guardstd::mutex lock(mutex_); for (auto [type, list] : handlers_) { std::erase_if(list, [token](const Entry e) { return e.token token; }); } } private: struct Entry { Token token; Handler handler; }; std::mutex mutex_; std::unordered_mapEventType, std::vectorEntry handlers_; Token nextToken_ 1; };这个设计在后续的“轻量级事件中心”案例里会继续用到。核心思路就是订阅关系本身是有身份的记住这个身份比记住“哪个回调函数”要可靠得多。4. 多线程环境下的观察者模式4.1 三个典型坑把观察者模式往多线程一放坑就成倍增加。我总结了最常见的三个第一个是数据竞争。一个线程在 subscribe另一个线程在 notify两个线程同时对 observers_ 这个 vector 执行 push_back 和遍历轻则行为未定义重则马上崩溃。第二个是死锁。如果在 notify 持锁期间执行回调而回调内部又调用了 subscribe 或 unsubscribe那同一把非递归锁被同一个线程重复获取程序会直接死在锁上。就算你用递归互斥锁暂时逃过一死回调里修改容器结构也会让之前正在遍历的迭代器失效照样崩。第三个是观察者对象自身状态在多线程下不一致。即使事件中心线程安全observer 内部的业务数据如果没加保护在多个线程同时处理事件时依然会出问题。4.2 先加锁再从快照里通知我推荐的标准解法是“锁只保护容器结构不保护回调执行”。也就是 notify 时先把观察者列表打包成一个快照然后释放锁最后在锁外遍历快照class SafeSubject { public: void subscribe(const std::shared_ptrObserver observer) { std::lock_guardstd::mutex lock(mutex_); observers_.push_back(observer); } void notify() { std::vectorstd::shared_ptrObserver snapshot; { std::lock_guardstd::mutex lock(mutex_); snapshot observers_; } for (auto observer : snapshot) { if (observer) { observer-update(); } } } private: std::mutex mutex_; std::vectorstd::shared_ptrObserver observers_; };这样做的好处是锁的持有时间极短只够复制一个 vector。回调在锁外执行观察者即使想在线程安全的前提下再次调用 subscribe、unsubscribe、notify都不会和当前锁产生冲突。快照的代价是可能有一个观察者刚退订却还收到一次通知这种“最终一致”的语义在绝大多数业务场景是可以接受的。我在网络服务里就是这么处理的压测下来效果很稳。热点不是锁本身反而是回调里如果做了磁盘 IO 或网络请求整个线程会被拖慢这是后续要去优化的事件分发策略。4.3 如果不想阻塞发布者异步事件队列有些场景里发布事件的动作发生在网络线程或 UI 事件线程而观察者要处理逻辑可能很耗时。比如登录成功后要写数据库、要调外部接口发邮件。如果同步 notify发布者线程会被这些耗时的观察者回调卡住导致后续网络消息不能及时处理。解决方式是把事件投递到一个队列里发布者只管把事件 push 进去就返回由一个专门的事件循环线程来统一消费并触发观察者class AsyncEventCenter { public: void publishAsync(Event evt) { { std::lock_guardstd::mutex lock(queueMutex_); queue_.push(std::move(evt)); } condition_.notify_one(); } void run() { while (running_) { Event evt; { std::unique_lockstd::mutex lock(queueMutex_); condition_.wait(lock, [this] { return !queue_.empty() || !running_; }); if (!running_) break; evt std::move(queue_.front()); queue_.pop(); } dispatch(evt); } } private: void dispatch(const Event evt) { /* 触发观察者 */ } std::mutex queueMutex_; std::queueEvent queue_; std::condition_variable condition_; std::atomicbool running_{true}; };异步分发的核心价值在于所有观察者回调都集中在同一个事件循环线程里执行天然避开了多线程同时修改观察者内部状态的问题。UI 框架和游戏主循环几乎都是这个思路因为单线程执行回调能省掉一整套锁设计。代价是事件从发布到处理存在延迟而且你需要在系统退出时显式 stop 并等待事件循环退出否则可能会出现事件还没处理完、对象已经析构的问题。4.4 轻量优化按线程隔离如果你的系统对性能要求很高但大部分事件又只会在固定线程上产生可以考虑按线程隔离。做法是用 thread_local 存一份当前线程的事件管理器同一个线程内发布和通知都不需要加锁跨线程的事件才投递到目标线程队列。这种方式在游戏引擎里常见比如渲染线程的事件只在渲染线程处理逻辑线程的事件只在逻辑线程处理。它能把锁竞争压到最低但也要付出设计和调试上的额外成本。对一般项目我建议先用 4.2 和 4.3 的方案足够支撑大多数业务场景。5. 实战案例从零写一个轻量级事件中心5.1 需求与整体设计为了把这些思路串起来我写一个可以直接放进项目里用的小型事件中心。需求如下支持按事件类型订阅和取消订阅支持同步 publish 和异步 publishAsync线程安全多个生产者线程能安全地发布事件代码量可控不引入第三方依赖我模拟一个常见的业务场景用户登录时日志模块要记录、邮件模块要发问候、风控模块要做一个简单检查。后续登录退订一个模块验证退订逻辑是否好使。整体设计上我用 unordered_map 以事件类型为 key每个类型对应一个 vector 的订阅条目。订阅条目包含 token 和 handler。这样能保持同一类型下多个观察者的注册顺序发布时按顺序触发。5.2 核心代码实现完整可编译代码如下#include atomic #include condition_variable #include functional #include iostream #include mutex #include queue #include string #include thread #include unordered_map #include vector enum class EventType { UserLogin, OrderPlaced, }; struct Event { EventType type; int userId 0; std::string message; }; class EventCenter { public: using Handler std::functionvoid(const Event); using Token uint64_t; Token subscribe(EventType type, Handler handler) { std::lock_guardstd::mutex lock(mutex_); Token token nextToken_; handlers_[type].push_back({token, std::move(handler)}); return token; } void unsubscribe(Token token) { std::lock_guardstd::mutex lock(mutex_); for (auto [type, entries] : handlers_) { entries.erase( std::remove_if(entries.begin(), entries.end(), [token](const Entry e) { return e.token token; }), entries.end()); } } void publish(const Event event) { std::vectorHandler snapshot; { std::lock_guardstd::mutex lock(mutex_); auto it handlers_.find(event.type); if (it ! handlers_.end()) { for (auto entry : it-second) { snapshot.push_back(entry.handler); } } } for (auto handler : snapshot) { handler(event); } } void publishAsync(const Event event) { { std::lock_guardstd::mutex lock(queueMutex_); queue_.push(event); } condition_.notify_one(); } void runEventLoop() { while (running_) { Event event; { std::unique_lockstd::mutex lock(queueMutex_); condition_.wait(lock, [this] { return !queue_.empty() || !running_; }); if (!running_) break; event std::move(queue_.front()); queue_.pop(); } publish(event); } } void stop() { running_ false; condition_.notify_all(); } private: struct Entry { Token token; Handler handler; }; std::mutex mutex_; std::unordered_mapEventType, std::vectorEntry handlers_; std::atomicToken nextToken_{1}; std::mutex queueMutex_; std::queueEvent queue_; std::condition_variable condition_; std::atomicbool running_{true}; };这段代码里最核心的两个设计点在 publish 和 publishAsync 的分工。publish 先把订阅列表拷贝出来再在锁外执行回调这样退订或继续订阅都不会影响本次通知的安全性。publishAsync 则只是把事件放进队列由另外的线程调用 runEventLoop 来真正执行 publish因此回调永远不会阻塞发布者线程。5.3 测试代码与预期输出下面是一段完整的测试 mainint main() { EventCenter center; auto logToken center.subscribe(EventType::UserLogin, [](const Event e) { std::cout [log] user login: e.userId \n; }); center.subscribe(EventType::UserLogin, [](const Event e) { std::cout [mail] send welcome mail to e.userId \n; }); center.subscribe(EventType::OrderPlaced, [](const Event e) { std::cout [order] e.message \n; }); center.publish({EventType::UserLogin, 1001, }); center.publish({EventType::UserLogin, 1002, }); center.unsubscribe(logToken); std::cout after unsubscribe log module:\n; center.publish({EventType::UserLogin, 1003, }); std::thread loopThread([] { center.runEventLoop(); }); center.publishAsync({EventType::OrderPlaced, 0, order-2024-001}); std::this_thread::sleep_for(std::chrono::milliseconds(50)); center.stop(); loopThread.join(); return 0; }预期输出为[log] user login: 1001 [mail] send welcome mail to 1001 [log] user login: 1002 [mail] send welcome mail to 1002 after unsubscribe log module: [mail] send welcome mail to 1003 [order] order-2024-001注意异步事件在另一个线程里执行它比主线程的 publish 可能晚一点所以我们在主线程 sleep 了 50 毫秒等待事件循环消费完。在实际项目中不能用 sleep 来等待事件完成应该用更可靠的同步机制比如等待队列清空再退出否则测试可能不稳定。我在这里只做演示图省事用了 sleep。编译命令g -stdc17 -O2 -pthread main.cpp -o event_demo5.4 工程化改进方向这个事件中心很轻量但放在真实项目里我还会根据情况做几个增强。第一个是 RAII 退订。subscribe 返回 token如果调用方忘记退订token 就会变成悬空标记还是会有隐患。可以让 subscribe 返回一个 Subscription 对象析构时自动调用 unsubscribe这样局部变量生命周期结束就自动退订和 lock_guard 一个思路。第二个是优先级。如果想保证某些观察者先执行可以在 Entry 里加 priority 字段通知前按优先级排序。第三个是事件元信息比如事件发生时间戳、来源线程 ID这对排查线上问题很有帮助。最后一个建议是如果项目里已经有 Boost.Signals2 这样的成熟信号槽库直接用它的 connection 机制管理生命周期会省很多事情。但如果是像我现在演示的这种三五十个事件、几十个订阅者的规模自己维护这套代码完全够用还不用引入额外依赖。6. 常见问题与排查技巧实录6.1 悬垂指针与生命周期错乱现象是程序不是必崩而是偶尔崩崩溃位置在 notify 的回调调用处。用 AddressSanitizer 一查十有八九是 heap-use-after-free。根本原因是观察者对象已经释放但 Subject 里还存着裸指针。排查思路是给订阅和退订都加日志观察是否所有对象都在析构时正确退订。修复手段有优先级第一选择是用 shared_ptr weak_ptr 的弱回调方案从根上避免悬垂第二选择是坚持裸指针但所有观察者析构函数里必须显式 unsubscribe这是最容易漏掉的点。注意如果观察者是栈对象不能直接塞给 weak_ptr。这时候要么让对象改用 shared_ptr 管理要么老老实实在析构函数里 unsubscribe。6.2 在 notify 循环里 unsubscribe 崩溃如果在某个观察者的 update 回调里调用了 unsubscribe而 notify 正在遍历原容器删除元素会直接让迭代器失效回调循环就会踩到非法内存。崩溃栈往往指向 notify 的 for 循环那行。解决办法就是前面反复说的快照法遍历之前拷贝一份观察者列表遍历只在快照上进行。这也是为什么我推荐统一把“事件分发”做成“先快照再回调”的模式。顺手提一句如果快照里的观察者已经退订了它依然会被调用一次这是语义上需要接受的小瑕疵。6.3 死锁与重入回调内部如果调用了同一个 Subject 的 subscribe、unsubscribe 或 notify而且发布者在持锁遍历就可能出现两种结果一种是重入同一把非递归锁程序直接挂死另一种是修改了正在遍历的容器导致崩溃。多线程环境更复杂可能一个线程持锁在 notify另一个线程在 subscribe 等锁锁粒度大了就变成性能瓶颈。排查死锁用 gdb 打开线程列表看到几个线程都卡在 mutex 相关的栈上就要警惕是不是锁内回调导致的。修复手段始终是释放锁之后再调用业务回调让锁的职责尽量单一。6.4 std::function 的取消订阅难题很常见的一个问题是我订阅的时候传的是一个 lambda退订的时候想再传同一个 lambda 给 unsubscribe但 std::function 不支持相等比较编译不过。正确姿势是返回 token。token 可以理解成一份订阅关系的“身份证”退订时拿着身份证来办注销业务而不是拿一个人的照片来找人。真实项目里我建议把 token 封装到 RAII 对象里避免裸 token 被人乱用。6.5 性能问题事件风暴下的卡顿事件中心本身的性能瓶颈通常是三个地方std::function 拷贝带来的动态内存分配、容器锁竞争、回调本身耗时。先说 std::function 拷贝发布时把 handler 拷贝进快照每次 notify 都会触发一次拷贝如果事件频率高可以考虑存储 shared_ptr 或者用索引方式避免复制。锁竞争在大流量下也很可观尤其 publishAsync 和 subscribe 都在争同一把队列锁。轻量级优化是改用无锁队列或者按事件类型拆锁每个类型一把锁减少锁冲突。最后也是最容易忽略的回调本身才是大头。如果回调里有慢速 IO优化事件中心性能没有意义应该去优化回调的逻辑或改成异步处理。注意先压测确认瓶颈再优化不要一上来就换无锁队列。很多项目的事件频率根本到不了无锁队列的收益线引入复杂实现反而得不偿失。6.6 调试技巧让观察者模式不再像黑洞观察者模式最让人头疼的就是运行时调用链不透明。我的习惯是在事件中心里维护一个事件名称映射表把 EventType 转成字符串每次 publish、subscribe、unsubscribe 都打一条日志包含 token、事件名、线程 ID 和时间戳。上线初期保留全量日志一旦出问题按 token 检索就能还原出这条事件被谁订阅、谁发起、谁收到、谁退订的完整链路。如果在回调里发现某个观察者的内部状态不符合预期可以在它自己的回调函数入口和出口分别打耗时和结果日志。事件量大的时候不需要全打可以做一个开关只在排障时打开。配合 ASan 和 ThreadSanitizer 一起用基本能覆盖观察者模式下 90% 的疑难杂症。说回实践层面我在这类事件系统上踩过几次坑之后最大的体会是观察者模式最怕的不是写不出来而是滥用。一个服务里十几个事件类型真正有人订阅的可能就五六个剩下的都成了永远不响的空炮。所以我后来习惯在每个 subscribe 和 publish 调用处都留日志事件名规范得像接口文档一样清晰定期检查“有发布无订阅”的事件类型。如果你决定在项目里引入观察者模式不妨从小范围开始先让两个模块解耦跑通再慢慢推广。这套东西看着简单但生命周期、线程安全和调试成本每一项都需要在真实场景里重新打磨一遍。
返回列表