ARTICLE DETAIL

资讯详情

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

C++观察者模式实战:从事件解耦到信号槽与生命周期管理

C++观察者模式实战:从事件解耦到信号槽与生命周期管理 最近有个项目要做一个怪物受击反馈系统一开始很顺利后来当血条UI、音效、成就解锁、任务追踪都要监听怪物掉血这个事件时我发现自己把Monster类的构造函数改得面目全非。其实怪物只应该关心自己掉血这件事至于谁在看、看完要干什么它根本不应该知道。把一线项目里这套东西的来龙去脉讲清楚包括怎么实现、怎么在万亿次回调和生命周期里活下来是这篇文章的目的。如果你是正在准备C面试的开发者或者想在项目里正经引入事件机制的工程师下面的内容可以直接拿去参考。观察者模式在C里一直是老生常谈但多数教材只讲了一个接口、一个列表、一个循环的三件套。实际项目里观察者模式牵扯到的问题远不止这些回调顺序、容器选择、线程安全、悬挂指针、递归重入每一个都足以让程序在线上崩得莫名其妙。本文会用实战视角把这套东西完整拆一遍。1. 为什么需要它从一对多通知到解耦的实际场景1.1 一段反模式代码是怎么长大的设想一下你现在正在开发一个RPG游戏怪物的掉血逻辑最开始写得很直接void Monster::TakeDamage(int damage) { hp_ - damage; hpBar_-Update(hp_); // 更新血条 audio_-PlaySound(hurt); // 播放掉血音效 achievement_-OnTakeDamage(this); // 达成条件判断 task_-OnMonsterHurt(this); // 日常任务追踪 }这段代码在第一个版本里完全没问题。但需求一多你很快就会遇到几个躲不开的痛点Monster这个类必须认识所有跟掉血有关的系统哪怕跟战斗毫无关系的成就系统、任务系统都要被它直接依赖。每次加一个新监听者都得改动TakeDamage违反开闭原则。你想在测试环境里模拟一次掉血事件但构造Monster时必须连UI、音频、任务一起初始化单元测试根本没法写。这就是经典的耦合膨胀。观察者模式把广播事件和接收事件拆开Monster只管发出我掉血了这个信号所有对该信号感兴趣的系统自己决定要不要订阅。怪物根本不认识它们。1.2 观察者模式和发布-订阅的一点区别严格来说观察者模式通常指Subject持有Observer的强引用或弱引用Observer直接注册到Subject上而发布-订阅模式往往多了一个中间的消息通道发布者和订阅者互相不持有对方。实际项目里这两种东西经常混着用没有必要过度咬文嚼字。你需要记住的核心是事件源和事件接收方解耦并且是一对多关系。C项目里的事件系统、信号槽、消息总线本质上都是观察者模式的变体。理解了观察者模式这三个核心问题——回调怎么组织、生命周期怎么管理、线程安全怎么处理——你就能看懂绝大多数C事件框架的设计思路。2. 经典实现接口、容器与虚函数的三方配合2.1 最小可读的接口模式教科书里最常见的观察者模式写法是这样的class IObserver { public: virtual ~IObserver() default; virtual void OnNotify(int eventId, const std::string eventData) 0; }; class Subject { public: void Attach(IObserver* observer) { observers_.push_back(observer); } void Detach(IObserver* observer) { auto it std::remove(observers_.begin(), observers_.end(), observer); observers_.erase(it, observers_.end()); } void Notify(int eventId, const std::string eventData) { for (IObserver* observer : observers_) { observer-OnNotify(eventId, eventData); } } private: std::vectorIObserver* observers_; };代码本身很简单但有几个实现细节值得展开说说。这也是面试官最爱追问的点。2.2 容器选型为什么vector反而是默认选择很多初学者会问既然有增删为什么不用list这里涉及缓存局部性、遍历频率和观察者数量三个因素。容器插入/删除遍历迭代器失效情况适用场景vector末尾O(1)中间O(n)连续内存遍历极快中间插入/删除使后续迭代器失效观察者数量不大遍历是主要操作listO(1)分散内存遍历慢仅被删除的迭代器失效频繁随机增删观察者很多deque两端O(1)中间O(n)缓存较好中间操作使迭代器失效需要两端增删实际项目里大多数Subject的观察者数量都很小十个以内很常见。遍历通知是最高频路径而Attach/Detach是低频操作所以vector是默认选择。即便删除是O(n)在n很小的情况下几乎可以忽略。不要为了理论上的O(1)删除把高频遍历变成缓存不友好的指针跳转。C20以后还可以用std::erase_if来简化删除逻辑std::erase_if(observers_, [observer](IObserver* p) { return p observer; });代码意图更清晰也不用再记remove-erase成语。2.3 回调过程中修改容器vector的迭代器失效陷阱这里是我见过最多翻车现场的地方。假设观察者A的OnNotify里调用了subject.Detach(observerB)那在Notify的for循环里observers_容器发生了中间删除当前迭代器及其后所有迭代器都会失效。等循环下一次取iterator时行为未定义程序随时可能崩溃。这不是理论问题。在真实UI系统里一个按钮事件很可能触发另一个控件的销毁而那个控件恰好也是同一Subject的观察者。解决办法一般有三种思路思路一延迟删除。不在回调里真正删除而是把要删除的观察者放到一个pendingRemove列表里Notify遍历结束后统一清理。思路二拷贝快照。Notify开始时先拷贝一份观察者列表然后遍历快照。这样即使回调里改了原容器遍历也不会崩溃。但语义上有个变化新注册的观察者本次事件可能收不到因为拷贝时机已经固定了。思路三用list或deque替代vector这类容器在删除当前节点时只会让被删节点自身的迭代器失效循环里通过it erase(it)的形式继续遍历是安全的。我个人在小项目里偏向思路一因为它好理解行为也可预期。2.4 异常处理一个回调抛异常整个通知链就断了再细想一步如果observerB.OnNotify里抛了一个异常Notify循环会直接终止observerC之后的所有观察者都收不到事件。这在代码里是个隐蔽坑。一个现实处理方式是在通知循环中保护每个回调void Subject::Notify(int eventId, const std::string eventData) { for (IObserver* observer : observers_) { try { observer-OnNotify(eventId, eventData); } catch (const std::exception e) { // 记录日志继续通知下一个观察者 } } }这样单个观察者的异常不会拖垮整个事件广播。但要注意这不是万能方案如果某个观察者失败意味着业务流程中断那捕获异常后需要进一步处理错误状态而不是简单吞掉。事件系统和错误处理的关系需要结合具体业务设计。2.5 顺序问题永远不要依赖默认顺序标准里vector的遍历顺序当然是从前往后但观察者模式不应该对外保证回调顺序。为什么因为Attach的顺序并不等于业务上想要的顺序。比如UI刷新和成就检查谁先谁后都有可能。如果业务确实依赖顺序设计上就应该把优先级作为观察者的一部分在Attach时做有序插入而不是等出bug了再回头找是哪个观察者先注册的。3. 现代C升级std::function lambda 的信号槽实现3.1 为什么要放弃继承式接口接口模式的问题是侵入性太强观察者必须继承IObserver必须实现OnNotify哪怕你只关心一个事件也得实现整个接口。现代C给了另一个选择用std::function存储回调用lambda或函数对象绑定任意可调用对象。观察者和被观察者之间不再存在接口继承关系只剩一个最简单的函数回调关系。一个极简的信号槽可以长这样template typename... Args class Signal { public: using Callback std::functionvoid(Args...); int Connect(Callback cb) { handlers_.push_back(std::move(cb)); return static_castint(handlers_.size()) - 1; } void Disconnect(int token) { if (token 0 token static_castint(handlers_.size())) { handlers_[token] nullptr; } } void Emit(Args... args) { for (auto cb : handlers_) { if (cb) { cb(args...); } } } private: std::vectorCallback handlers_; };使用起来就比接口模式轻量得多Signalint onDamage; int conn onDamage.Connect([](int damage) { std::cout 受到伤害: damage std::endl; }); onDamage.Emit(25); onDamage.Disconnect(conn);注意这里Disconnect的实现是先置空槽位这样token对应的下标不会因为中间删除而漂移。代价是vector内部会出现空档。如果连接和断开极其频繁需要定期压缩或改用其他容器但大多数场景下这个代价可以接受。3.2 std::function的性能到底行不行接手过性能优化的人多半会警惕std::function。它内部通过类型擦除保存可调用对象调用点时通常相当于一次间接跳转跟虚函数调用级别差不多。相比手写函数指针它多了点存储开销但换来了巨大的灵活性能绑定成员函数、绑定lambda、绑定带状态的函数对象。在高频热点路径上比如每帧几十万次的事件广播应该先profile再下结论。我见过有些项目把std::function换成手写模板回调后性能提升明显但也有项目发现问题根本不在std::function而在事件系统本身的设计——比如每秒钟构造了海量临时事件对象。用数据说话别凭印象优化。3.3 两种风格的取舍维度接口模式std::function信号槽侵入性必须继承IObserver无侵入事件参数接口固定通常为基类参数模板任意参数类型安全订阅者识别通过对象指针识别通过token识别生命周期管理手动Attach/Detach可用Connection对象管理调试体验回调函数名字明确在std::function内打断点略绕我的个人选择是如果事件参数结构稳定、观察者本身有对象身份需要管理用接口模式如果只是一些松散的UI刷新、状态同步用std::function信号槽。一个项目里完全可以把两种混着用不存在必须统一的强制规定。4. 线程安全跨线程通知的真实玩法与锁的陷阱4.1 教科书式加锁为什么是雷区很多人处理线程安全的第一反应是给容器加一把mutexvoid Subject::Notify(...) { std::lock_guardstd::mutex lock(mutex_); for (auto* observer : observers_) { observer-OnNotify(...); } }这在低并发、短回调的场景下能跑但两个问题会很快浮现。第一个是死锁。如果某个观察者的回调里调用了同一个Subject的Attach或Detach这两个操作又需要获取同一把mutex时就形成了对非递归锁的重入直接死锁。这类问题在复杂的UI事件链中非常常见一个控件在收到事件后动态创建一个订阅者而这个订阅者的注册动作又跟通知共用一把锁。第二个是长尾阻塞。持锁遍历意味着整个遍历期间其他线程都无法注册或注销观察者。如果某个回调执行了IO操作或者用了大量时间其他线程只能在锁这里排队观察者模式精心设计的解耦效果瞬间变成串行瓶颈。4.2 分发表快照先拷贝再通知一个简单有效的升级是把锁的粒度控制在拷贝分发表范围内通知过程不持锁void Subject::Notify(...) { std::vectorIObserver* snapshot; { std::lock_guardstd::mutex lock(mutex_); snapshot observers_; } for (IObserver* observer : snapshot) { observer-OnNotify(...); } }好处是锁只保护容器本身线程等待时间很短回调里再调Attach/Detach也不会死锁。代价是通知期间新注册的观察者在本次广播里收不到事件快照语义被固定在了通知发起时刻。如果你的业务对一次广播必须包含所有最新观察者有强要求这个方法就有语义偏差。4.3 真正的跨线程方案事件队列快照方案的终极短板是观察者本身是否线程安全你怎么保护如果观察者B的生命周期只存在于线程2而线程1在遍历快照时调用了B的OnNotify这本身就跨线程访问了B。要彻底解决这个问题教科书式的锁不够用更实用的做法是事件队列任意线程调用Notify时不直接执行回调而是把事件丢进一个队列。事件实际处理循环只在一个固定线程里。处理线程除了执行回调还可以顺带处理Attach/Detach请求所有对容器的修改都在单线程内完成。这其实就是很多游戏引擎和UI框架的做法玩家的输入事件、网络消息、逻辑事件都先投递然后由主循环统一派发。跨线程操作被收拢成了一个线程安全地往队列里加东西的问题而队列的并发安全比回调注册表简单得多。class EventQueue { public: void Post(std::functionvoid() event) { std::lock_guardstd::mutex lock(mutex_); queue_.push(std::move(event)); cv_.notify_one(); } void Process() { std::functionvoid() event; while (true) { { std::lock_guardstd::mutex lock(mutex_); if (queue_.empty()) break; event std::move(queue_.front()); queue_.pop(); } event(); // 这里不在锁内执行 } } private: std::mutex mutex_; std::queuestd::functionvoid() queue_; std::condition_variable cv_; };如果要让Process阻塞等待可以配合条件变量核心思想不变回调永远只在事件循环线程里执行跨线程安全就变成了对队列加锁这个足够简单的问题。4.4 小延伸裸指针地址作为订阅者身份其实是个雷如果观察者系统需要支持按订阅者身份断开很多人直接拿observer指针当作身份ID。这在大多数时候能用但有一个类似ABA问题的陷阱observerA析构后内存被释放新创建的对象B恰好占据同一个地址。如果系统里还留着指向该地址的旧注册项B就会被误认为A导致B无辜收到不属于自己的事件。所以设计注册表时订阅者ID最好用独立分配的长整型id或者用shared_ptr/weak_ptr这种能识别资源生命周期的句柄千万不要裸用地址。这个坑在长连接服务里特别容易踩到因为对象创建和销毁特别频繁内存地址复用概率相当高。5. 悬挂指针与生命周期观察者模式最易翻车的现场5.1 两个方向的生命周期错位观察者模式里有两个对象Subject被观察者和Observer观察者。它们的析构顺序一旦错位就会出事故。方向一Subject先析构。Observer还存着指向Subject的引用或指针下次事件触发时访问已经释放的内存直接未定义行为。很多事件总线是全局单例生命周期比所有观察者都长所以这个方向在全局总线场景下不常见但局部Subject场景非常常见比如某个UI窗口关闭时把内部的Subject销毁了而外部控件还持有它的订阅关系。方向二Observer先析构。这更经典。Subject的观察者列表里还存着一个已经析构的对象指针下次Notify遍历到它就调用悬空对象的虚函数崩溃几乎是必然的。我在早期项目里真翻过这个车一个角色死亡后被销毁但伤害事件广播里还残留它的引用游戏在下一次AOE伤害时直接闪退。5.2 weak_ptr方案让Subject检查观察者是否还活着要解决方向二自然是让Subject不持裸指针而是持weak_ptr。观察者对象本身用shared_ptr管理并继承enable_shared_from_this。class IObserver : public std::enable_shared_from_thisIObserver { public: virtual ~IObserver() default; virtual void OnNotify(...) 0; }; class Subject { public: void Attach(const std::shared_ptrIObserver observer) { observers_.emplace_back(observer); } void Notify(...) { for (auto it observers_.begin(); it ! observers_.end();) { if (auto observer it-lock()) { observer-OnNotify(...); it; } else { // 观察者已经析构顺手清理 it observers_.erase(it); } } } private: std::vectorstd::weak_ptrIObserver observers_; };这段代码最大的好处是observer被析构时不需要主动DetachSubject在通知时通过weak_ptr::lock自动发现它已经死了并清理掉。注册和注销的逻辑都变得很省心。但要注意lock在调用OnNotify之外还需要额外考虑如果观察者的唯一引用就是Subject里的weak_ptr那在通知过程中不持有shared_ptr的话观察者就根本活不到通知执行那一刻。上面的代码在OnNotify期间用shared_ptr观察者对象让它在回调生命周期内稳定存活。这是对回调执行期间不要销毁订阅者对象的一种保护但仍挡不住回调内部主动释放最后一个强引用的行为。这种情况需要约定规范不能全靠语言特性兜底。5.3 Connection对象用RAII把断开和生命周期绑定weak_ptr方案要求观察者必须是shared_ptr管理的对象这在很多轻量场景下有点重。另一个思路是Connection对象connect时返回一个代表连接所有权的句柄句柄析构时自动断开。class Connection { friend class Signal; public: Connection() default; Connection(const Connection) delete; Connection operator(const Connection) delete; Connection(Connection other) noexcept : subject_(std::move(other.subject_)), token_(other.token_) { other.token_ -1; } ~Connection() { Disconnect(); } void Disconnect() { if (auto subject subject_.lock()) { subject-Disconnect(token_); } subject_.reset(); token_ -1; } private: Connection(const std::weak_ptrSignal subject, int token) : subject_(subject), token_(token) {} std::weak_ptrSignal subject_; int token_; };Signal::Connect返回一个Connection调用方把它作为成员变量保存即可。当观察者本身被析构时它的成员Connection随之析构Disconnect自动触发Signal里的槽位被清理。这个方案的一个亮点是Connection内部持有的是Signal的weak_ptr所以如果Signal先析构Connection的析构也只是发现weak_ptr已经失效然后安静复位。这同时解决了方向一的问题。现在很多C信号槽库就是这么设计连接对象生命周期的。使用时把它们存成容器成员但Connection通常不可拷贝如果观察者需要支持移动就要谨慎处理Connection的移动语义。我建议把它放进一个unique_ptr或者合适的智能指针容器里避免直接管理复杂生命周期。5.4 递归通知与重入保护还有一个容易被忽略的坑观察者A的回调里触发了同一Subject的另一个Notify如果两条通知链之间互相影响就可能出现递归嵌套甚至死循环。比如Subject收到攻击事件时通知了观察者AA在回调里又触发攻击事件于是又通知A无限循环。常用的轻量保护是给Subject加一个notifying_标志void Signal::Emit(...) { if (notifying_) { pendingEvents_.push_back(...); // 把新事件入队 return; } notifying_ true; for (auto cb : handlers_) { if (cb) cb(...); } notifying_ false; // 处理重入累积的事件 while (!pendingEvents_.empty()) { auto e std::move(pendingEvents_.front()); pendingEvents_.pop(); // 递归调用Emit但这在notifying_判断后仍可能继续累积 // 所以需要让重入事件在notifying_ false后再统一处理 } }其实这段代码在重入累积和统一处理的措辞上需要更细腻的表述但你完全可以在项目里采用更保守的策略检测到重入时直接断言或记录错误因为大多数情况下重入通知本身就是设计错误。简单的notifying_标志 报错比复杂的事件队列处理更容易落地。6. 实战复盘我在项目中用观察者模式的取舍与记录6.1 什么时候真的值得用它观察者模式不是万能的它解决的是对象之间一对多动态通知的问题。我实际项目里最典型的应用有三处跨模块事件网络层收到消息后广播给UI、逻辑、统计分析等多个模块各模块互不感知。UI更新数据模型发生变化时多个视图控件需要刷新这些控件可能是动态创建和销毁的用观察者订阅数据模型的变化再合适不过。游戏事件伤害、死亡、道具获取这类战斗内事件监听方五花八门观察者模式能避免战斗核心逻辑反向依赖玩法系统。反过来有些场景我就不用观察者模式。比如两个模块之间存在稳定的、固定的一对一调用关系直接用函数调用反而更清晰硬套观察者模式只会增加追踪难度。再比如每秒执行百万次的热点更新里虚函数分派或std::function间接调用带来的开销可能完全不可接受这时候直接耦合加上成员函数内联调用才是正确选择。6.2 一个真实线上教训全局事件总线的性能塌方有一次我维护的服务里大规模使用了全局事件总线所有模块的消息全部通过观察者广播出去。功能上是爽了但压测时发现CPU占用高得离谱。逐个排查后发现几个问题全局总线上的观察者数量极多每次广播都遍历一个很大的列表哪怕其中大多数观察者对当前事件毫无兴趣。事件对象在广播过程中频繁构造析构加上std::function的拷贝堆分配压力很大。有些观察者在回调里继续广播新事件形成了一条很长的调用链栈上和堆上都在膨胀。后来做的优化方向是给事件按ID分组每个事件ID对应一个小vector广播时只遍历与该事件ID相关的观察者。同时事件对象改用池化分配避免高频构造析构。改造后这一块的CPU占用明显下降。这个经历给我的教训是观察者模式适合做架构解耦但真正的高频路径上要让广播数据和目标列表足够收敛别把所有事件都塞进一个万能总线里反复遍历。6.3 团队落地时我会定的几条回调守则最后分享我们项目里给所有写回调的人立下的几条约定它们在实战中帮我挡掉过很多个bug回调里不做耗时操作尤其不做IO、网络请求和复杂计算。回调应该把WorkItem丢进后面的调度队列然后立刻返回。不在回调里销毁触发事件的那个对象自身。如果确实要销毁用defer机制延迟到当前通知链结束。不在回调里随意触发同主题的嵌套通知。如果必须确保Subject有重入保护。所有断开操作都用Connection/Token不使用裸指针Detach。裸指针Detach在多线程环境下很容易造成重复释放或悬空注册。观察者的生命周期短于Subject时优先weak_ptr方案Subject生命周期短于观察者时必须用Connection这类弱引用句柄。这些守则不说能让代码变得多高级但至少保证大多数人在协同时不会踩进最深的坑里。6.4 从这往哪个方向继续深入如果看完文章想继续实践我建议不要只是抄代码可以做两个小实验来加深理解。第一个实验给信号槽加入回调优先级字段实现一个按优先级有序执行的Emit。第二个实验把所有回调丢进线程池模拟多线程事件处理然后观察快照拷贝和事件队列两种方案在行为上的差异。做完这两个实验你对观察者模式在真实项目里的适应范围和性能边界会有一个远比读书深刻的理解。至于我自己每次重写事件系统时都会把生命周期安全和重入安全放在接口设计的第一位在此基础上再谈性能和扩展。观察者模式本身不难难的是在项目里约束出它的使用边界和生命周期规则。这套从接口到信号槽、从线程到生命周期的实践路径算是比较贴近生产环境的一条参考路线。
返回列表