资讯动态

C++观察者模式实战:从基础实现到线程安全与生命周期管理

发布时间:2026/10/20 18:54:37 来源:尧图企业网站定制
当年我刚开始接触设计模式的时候对“观察者模式”的理解停留在《设计模式》书里那句“定义对象间的一种一对多的依赖关系当一个对象的状态发生改变时所有依赖于它的对象都得到通知并被自动更新”。背概念很容易真到 C 项目里落地才发现这句话背后到处是坑生命周期谁管理通知时加不加锁观察者回调里又触发了同一主题的变更怎么办这篇文章我就想把这些年踩过的坑和沉淀下来的做法一次性说清楚。不论你是在做 UI 组件联动、后台数据同步、插件系统事件分发还是单纯的跨模块状态广播只要碰到“状态一变多个模块都要跟着响应”的场景观察者模式就是最自然的选择。我会从最基础的设计讲起再逐步深入线程安全、生命周期、性能优化这些工程层面的问题让你看完之后既能快速写出可用的原型也能知道如何把它打磨成能上生产环境的东西。1. 为什么需要观察者模式1.1 硬编码依赖带来的麻烦先设想一个非常常见的场景某个模块持有一份核心状态比如一个设备管理系统中“设备连接状态”从断连切换为已连接。这时候可能有三处要联动界面上的状态指示灯要变绿、日志模块要记录一条连接成功事件、资源管理器要开始加载该设备的配置信息。不引入观察者模式时最常见的做法是在状态变化的地方把三处逻辑一个一个写出来。void DeviceManager::setConnected(bool connected) { this-connected_ connected; if (connected) { uiPanel_-setStatus(UI_STATUS_OK); logger_-log(device connected); resourceLoader_-startLoading(deviceId_); } }这段代码最大的问题不是“它跑不起来”而是“它只能活一次”。第二天产品经理过来说加一个声音提示。第三天运维说连接之后要同步上报云端。第四天前端说界面指示灯改一下颜色。你会发现setConnected这个函数开始无休止地膨胀而且每次改动都要动DeviceManager本身核心逻辑被一堆边缘逻辑淹没。更难受的是模块之间的关系变成了“公开处刑”DeviceManager要认识所有的下游模块需要 include 一堆头文件编译依赖乱成一团。任何一个下游模块改变接口上游就要跟着改。这种紧耦合在当前 C 工程里是致命的特别是现代大型项目动辄几十个模块并行开发。1.2 轮询方案为什么不可取有朋友会说“我不想改DeviceManager那我让 UI、日志、资源加载这几个模块自己去查状态不就行了吗”这就是轮询方案。比如 UI 每隔 100 毫秒调用一次DeviceManager::isConnected()发现状态变了就刷新界面。轮询的问题也很明显时效性不可控。轮询间隔设短了CPU 白白空转设长了界面反应迟钝用户体验差。每个下游模块都要写一套“检查变化”的逻辑重复代码无处可藏。大量模块同时轮询同一状态时系统负载被无意义地放大问题在性能测试时才浮现。观察者模式 把“下游主动拉”变为“上游主动推”状态一变通知立即送达。通知对象精确列表不浪费资源在无关模块上。1.3 观察者模式的核心思路观察者模式其实可以用一个很生活化的比喻来讲你把一个公众号“设为星标”公众号发一篇新文章你马上就能在订阅列表里看到它你没有关注它它发多少篇文章都与你无关。用技术语言翻译一下就是主题Subject被观察的目标对象它维护一份订阅者列表并在状态变更时向所有订阅者广播通知。观察者Observer订阅了主题的对象它定义了一个接收通知的更新接口。订阅关系观察者可以随时加入attach或退出detach主题完全不需要知道观察者的具体类型只依赖其抽象接口。这带来的直接收益就是松耦合。发布者不依赖任何一个具体的下游实现下游也能独立变化。新增一个模块只需要实现观察者接口然后注册到主题上主题一行代码都不用改。2. 从零手写一个可用的 C 观察者模式2.1 定义观察者接口在 C 里观察者模式最基础的设计通常包含两个抽象的部分一个Observer接口纯虚类一个Subject主题类或者接口。先说Observer#include memory class Event { public: virtual ~Event() default; }; class ConnectionEvent : public Event { public: explicit ConnectionEvent(bool connected, int deviceId) : connected_(connected), deviceId_(deviceId) {} bool isConnected() const { return connected_; } int deviceId() const { return deviceId_; } private: bool connected_; int deviceId_; }; class Observer { public: virtual ~Observer() default; // 接收通知的入口 virtual void update(const Event event) 0; };我把Event抽象成了一个基类而不是直接在update里传一个bool或int。这么做是有原因的实战中通知事件通常会携带多种类型的参数而且事件种类还会不断扩展。如果update接口写死成void update(bool flag)将来要传deviceId、timestamp、errorMessage就全得改接口那依赖关系又变硬了。用一个Event基类包一层主题只需发布事件对象观察者根据自己的需求向下转型获取具体事件内容。虽然多了一次虚函数调用但换来的是极其稳定的接口定义。不过这里要提醒一句不要过度抽象。如果你只是在小范围内同步一个数据值非得让所有观察者都去dynamic_cast一个Event对象反而增加开发和阅读成本。接口的抽象级别要根据项目的实际复杂度来决定我后面会给出一个更轻量的std::function版方案。2.2 主题类的基本骨架接下来是Subject。它要做的核心事情只有三件管理订阅者、遍历通知、接收状态变化。#include vector #include functional #include algorithm #include mutex class DeviceManager { 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 Event event) { for (Observer* observer : observers_) { if (observer) { observer-update(event); } } } void setConnected(int deviceId, bool connected) { ConnectionEvent event(connected, deviceId); notify(event); } private: std::vectorObserver* observers_; };这个版本大概有十个正常 work 的实现是它最基础的形态但也隐藏着几个工程问题我逐个说。2.3 几个隐蔽的雷点雷点一detach里的迭代器操作std::remove配合erase是 vector 的标准删除姿势逻辑上没问题。但如果notify正在遍历observers_另一个线程或该观察者自己在update回调里调用了detachvector 的元素可能被搬动当前迭代器直接失效程序轻则漏通知重则崩溃。处理思路我放第 3 节细讲这里先记住一个结论在遍历通知期间要保证订阅者列表不被修改。雷点二观察者的指针可能悬空基础版本用的是裸指针Observer*。如果某个观察者在销毁时忘了调用detachobservers_里还留着它的地址下次notify就会通过一个悬空指针调用虚函数这是一个极其恶性的 bug崩溃不一定立刻发生可能要到下一个对《观察者列表》操作的时候才露出马脚。解决这个问题的方案一般有两种用std::weak_ptr跟踪观察者对象notify 时先lock()判断是否还活着。强约束“先 detach 再销毁”的规范配合断言在调试期暴露问题。我自己的项目里经常是两种方案同时用核心基础设施用weak_ptr保证兜底业务代码层用 detach 规范保证性能。雷点三观察者的执行顺序问题observers_是 vector遍历顺序天然是 attach 的顺序。很多人会默认“先注册的先收到通知”这确实如此。但你的设计不能依赖这个顺序——如果观察者 A 的更新逻辑依赖观察者 B 已经处理完事件那这本身就是设计缺陷应该通过数据依赖而不是注册顺序来表达。雷点四一个观察者的异常会中断整个通知链notify里是一个循环调用。如果某个observer-update()抛了异常后面的观察者就收不到通知了。在这里我强烈建议在遍历时根据业务情况包一层try/catch保证异常不会切断链路。注意如果你写的是嵌入式或性能敏感的代码catch 的成本也要考虑常常是在update接口本身约定“不得向外抛异常”然后用 RAII 统一处理。但普通业务系统中try/catch 保链路往往更值得。3. 工程化进阶生命周期、线程安全与变更管理3.1 用回调函数取代抽象基类纯虚类Observer在 C 工程里其实并不一定是最好的选择。特别是当你观察者只是一个 lambda 表达式或者一个只处理单个事件的轻量对象时为了接收通知专门写一个子类代码啰嗦又难维护——这种情况在 C11 之后非常普遍主要因为std::function的到来。于是我看到很多现代 C 实现直接使用回调注册的方式class DeviceManager { public: using EventCallback std::functionvoid(const Event); int subscribe(EventCallback cb) { std::lock_guardstd::mutex lock(mutex_); callbacks_.push_back(std::move(cb)); return nextId_; } void unsubscribe(int handle) { std::lock_guardstd::mutex lock(mutex_); callbacks_.erase( std::remove_if(callbacks_.begin(), callbacks_.end(), [handle](const auto item) { return item.handle handle; }), callbacks_.end()); } void notify(const Event event) { // 加锁期间只做拷贝不在锁内执行回调 std::vectorEventCallback snapshot; { std::lock_guardstd::mutex lock(mutex_); for (const auto item : callbacks_) { snapshot.push_back(item.cb); } } for (const auto cb : snapshot) { cb(event); } } private: struct CallbackEntry { int handle; EventCallback cb; }; std::vectorCallbackEntry callbacks_; std::mutex mutex_; int nextId_ 0; };这个版本比抽象基类版本友好不少调用方可以传入std::bind、lambda、函数指针甚至是一整个仿函数对象。订阅接口返回一个int handle取消订阅时不需要传对象指针只要记着这个编号就行非常适合基于控件或模块的系统。但要注意一个细节不要把EventCallback直接存成裸的std::function后来再按值比较来反注册。std::function的比较没有明确语义想通过“传一个等值的函数对象去取消订阅”往往等不上。用句柄handle的方式是最稳的。3.2 线程安全问题真实系统里发布状态变化的线程和观察者执行副作用的线程往往不是同一个。例如网络收包线程把数据放进缓存并触发通知UI 线程的观察者收到通知后要刷新界面。DeviceManager::notify可能被多个线程同时调用观察者在通知期间也可能被其它线程注册或注销。线程安全的经典处理策略是两步第一步在修改订阅者列表时加锁保证subscribe/unsubscribe与notify之间互斥。第二步notify时先把订阅者列表拷贝到一个局部快照在锁外执行回调避免长时间持锁导致其它线程阻塞。为什么“在锁内执行回调”是绝对的反模式因为回调里用户不知道会做什么事情如果回调里调用了同一主题的subscribe而notify还持着锁那subscribe会尝试获取同一把非递归锁只有一个互斥量的话直接std::mutex死锁。或者反过来回调里调一个很慢的接口所有想注册观察者的线程都排长队性能雪崩。拷贝快照的方法虽然每次通知都有轻微的内存分配开销但在大多数业务系统中完全可接受。如果对性能极度敏感可以尝试将通知列表做成读写锁注册和注销用写锁遍历通知用读锁这样能避免快照的内存拷贝不过实现复杂度会显著上升。我的建议是先在快照版本上优化实测不了再加复杂度不要一开始就上“唯美设计”。3.3 可重入问题的应对还有一种隐蔽的问题观察者的回调内部触发了同一个主题的状态变化从而递归调用notify。例如观察者 A 收到“设备连接”事件在回调里立刻调用deviceManager.setConnected(false)断开连接。setConnected再触发一次通知。如果观察者 A 也被注册了那 A 的update可能再次进入甚至无限递归。这种情况下快照拷贝的方案能缓解列表被修改的问题但无法阻止递归调用。应对策略通常有通过事件类型区分在回调内部检测重复事件直接返回。设置 notify 重入标志递归调用时外层循环直接跳过或排队处理。void notify(const Event event) { if (notifying_) { // 如果有重入可以选择把事件加入待处理队列或者直接返回 return; // 这是最简单粗暴的策略 } notifying_ true; // ... 正常通知流程 notifying_ false; }当然“直接返回”往往不是最好的选择它在某些场景下会丢事件。更稳妥的是用队列把重入的通知攒起来等外圈通知结束后再统一处理。复杂度会高一些但逻辑完整。3.4 批量更新与事务性通知在某些场景下一个状态变更会引发一连串小变化。比如数据同步时表内一次新增 100 条记录。如果每条记录都调用一次notify观察者会被唤醒 100 次界面刷新 100 次日志刷 100 次资源浪费严重。工程上常见的办法是引入“批处理标志”class DeviceManager { public: void beginBatch() { batchLevel_; } void endBatch() { if (--batchLevel_ 0) { // 批量变更结束统一通知一次 ConnectionEvent event(/*汇总后的最新状态*/); notify(event); } } void applyChange(std::functionvoid() change) { beginBatch(); change(); endBatch(); } };或者把接口设计成“收集变更、统一 commit”。这类设计需要结合业务实际但思路是共通的观察者模式适合通知“状态快照”不适合通知“过程细节”。如果事件过程本身很重要应当考虑专门设计增量事件类型而不是把每次微小变化都广播出去。4. 实际项目中的落地陷阱与经验4.1 典型问题速查表我在自己的 C 项目里维护过包含十几个订阅主题、上百个观察者的模块踩过的坑汇集起来就是下面这张表。新接入观察者模式遇到诡异问题的优先按这张表排查。典型症状可能原因解决方向崩溃发生在notify的 for 循环内观察者对象已销毁列表中还存有悬空指针使用weak_ptr跟踪或在析构时强制 detach观察者收不到通知注册和发布不在同一个对象实例/线程或 detach 后未重新 attach检查订阅句柄、注册时机确认主题对象唯一死锁回调里反向调用了同一主题的注册/注销接口且通知时持有锁将回调执行放到锁外快照/宽拷贝方案通知顺序出错依赖 vector 的注册顺序但新观察者提前 attach业务逻辑不要依赖顺序如必须调整注册时机或在通知前显式排序同一事件重复触发不自洽的状态循环更新增加事件去重机制或重入标志高频小通知拖慢系统频繁 notify 导致观察者反复执行重逻辑引入批量更新、合并周期、延迟通知机制动态库边界崩溃跨动态库传入Event对象时 RTTI/异常处理不匹配保持模块边界内传递避免跨模块直接传复杂异常对象优先传二进制兼容的数据4.2 调试观察者模式的心得观察者模式一旦出现问题最讨厌的地方是它的调用链被拆成了“触发—通知—回调—回调内再触发”的结构普通断点方式很难一眼看清事件从哪儿来。我的调试习惯一般是给每个订阅句柄起名字。subscribe时传入一个const char*或std::string调试名notify 时记录“当前正在通知谁”这样日志里能直观看到列表。生产代码里可以编译期用#ifdef把调试名剥离不影响运行时性能。维护notify的调用栈历史。在异常场景下把最近几笔通知的事件类型、时间戳、观察者数量写进一个环形缓冲区崩溃后配合日志能快速定位是不是某次回调又触发了新通知。用事件日志代替断点。在notify入口打印一行“[Event] DeviceManager::notify connectedtrue, observers3”在观察者回调入口打印“[Callback] UIWidget::update received”。这比条件断点好用得多尤其是在多线程场景下。重复触发问题如果一个事件被同时触发两次留意是不是出现了一张“环”A 状态变化通知 BB 根据状态变化再改 AA 又通知 B…… 这种环状同步逻辑在观察者模式里非常容易出现调试时优先打印完整的事件链路。4.3 我推荐的完整落地模板最后放一个我比较常用的、兼顾可读性和工程强度的观察者模板。它把一个主题抽象成一个小类支持事件对象、句柄注册、线程安全快照、批量更新、安全性检查适合 90% 的业务系统。它是一个骨架你可以按自己项目的情况裁剪。#include functional #include memory #include mutex #include vector #include algorithm #include map template typename EventT class EventDispatcher { public: using Handler std::functionvoid(const EventT); int subscribe(Handler handler) { std::lock_guardstd::mutex lock(mutex_); int handle nextHandle_; handlers_[handle] std::move(handler); return handle; } void unsubscribe(int handle) { std::lock_guardstd::mutex lock(mutex_); handlers_.erase(handle); } void publish(const EventT event) { std::vectorHandler snapshot; { std::lock_guardstd::mutex lock(mutex_); snapshot.reserve(handlers_.size()); for (const auto [handle, handler] : handlers_) { snapshot.push_back(handler); } } for (const auto handler : snapshot) { try { handler(event); } catch (const std::exception e) { // 记录日志但保证不中断链路 // log_exception(e.what()); } } } private: std::mutex mutex_; std::mapint, Handler handlers_; int nextHandle_ 0; };使用方式非常简洁EventDispatcherConnectionEvent dispatcher; int logHandle dispatcher.subscribe([](const ConnectionEvent e) { // 处理连接事件记录日志 }); dispatcher.publish(ConnectionEvent(true, 1001)); dispatcher.unsubscribe(logHandle);这段模板有一个隐含的取舍handlers_用的是std::map而不是std::vector原因是 map 的 key 是句柄在unsubscribe时的删除是稳定的 O(log N)不会出现 vector 删除导致句柄索引失效的问题代价是遍历时要访问 map 节点缓存不友好但在业务回调函数的开销面前这通常可以忽略。如果你偏好性能也可以用std::vectorstd::pairint, Handler和惰性删除把被标记删除的项在 notify 后再真正清除那是性能敏感场景的优化路线一般系统不必一上来就这么做。5. 写在最后的经验小结我做过一个跨平台的配置同步模块最初用抽象基类风格做了一版观察者上线后几个版本迭代下来最大感受是观察者模式真正的难点从来不是“写出来”而是把注册/注销的生命周期管理好把通知的并发模型定清楚。那套系统在早期经常出现两个问题一是模块销毁时忘了 detach导致随机崩溃二是跨线程访问同一个主题对象导致数据竞争。后来我把第三版的模板落到代码里不但在设计上强制规范化注册句柄和销毁策略还把调试日志全部引入这些坑才算被系统性地堵住。所以如果你正准备在 C 项目里引入观察者模式我的建议是第一步先把最小可运行的版本写出来跑通状态→通知→回调的主链路。第二步立刻处理生命周期——无论是weak_ptr还是有纪律的 detach必须有一个兜底策略。第三步再考虑线程安全模型——先问自己事件在哪个线程产生回调在哪个线程执行这两个答案会决定你的锁和快照方案。第四步加日志、加保护把抗风险能力做上去然后才能谈上线。这套流程我自己实践下来比每一次都从零去设计要省心得多。观察者模式不是一个需要背下来的类图而是一个需要结合业务上下文不断打磨的工程工具把它用顺了代码的可维护性会发生肉眼可见的变化。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑