最近被一个朋友问住:他维护的 C++ 项目里,订单状态一变,就能看到五六个模块的调用链写死在同一个 if 分支里,加一个“关心这件事”的模块,就得改原业务代码,测一次回归能改出一身冷汗。我告诉他,这种场景就该让观察者模式上场了。观察者模式在 C++ 里几乎是一门基本功,面试八股考它,实际项目里也在无处不在的地用着,但它远不止“一个回调列表”那么简单。从我自己的实战经历来看,把观察者模式真正用好,至少要搞明白它的生命周期管理、线程安全、事件设计,以及“什么时候不该用”。
这篇就围绕着我在 C++ 项目里使用观察者模式的完整思路做一次拆解,从最经典的 C++98 写法讲到现代 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 教科书版本:从 C++98 说起
很多教材讲观察者模式,用的都是纯虚接口的方式。在 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::vector<Observer*> observers_; };subscribe 对应上面的 attach,unsubscribe 对应 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 告别抽象基类
进入 C++11 时代后,我的观察者模式实现基本抛弃了抽象基类,改用 std::function。为什么?因为抽象基类有一个比较尴尬的限制:一个类如果已经继承了业务基类,再想去监听事件,就得再从 Observer 继承一份,C++ 的多继承用起来要小心翼翼地处理命名冲突。而 std::function 加 lambda 的方式可以完全绕开继承体系,让任何可调用对象直接成为观察者。
using Handler = std::function<void(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::vector<Handler> 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_ptr<Observer>& 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::vector<std::weak_ptr<Observer>> observers_; };观察者对象则继承 enable_shared_from_this,用 shared_from_this() 把自己注册进去:
class UserPanel : public std::enable_shared_from_this<UserPanel> { 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_guard<std::mutex> lock(mutex_); Token id = nextToken_++; handlers_[type].push_back({id, std::move(handler)}); return id; } void unsubscribe(Token token) { std::lock_guard<std::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_map<EventType, std::vector<Entry>> 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_ptr<Observer>& observer) { std::lock_guard<std::mutex> lock(mutex_); observers_.push_back(observer); } void notify() { std::vector<std::shared_ptr<Observer>> snapshot; { std::lock_guard<std::mutex> lock(mutex_); snapshot = observers_; } for (auto& observer : snapshot) { if (observer) { observer->update(); } } } private: std::mutex mutex_; std::vector<std::shared_ptr<Observer>> observers_; };这样做的好处是:锁的持有时间极短,只够复制一个 vector。回调在锁外执行,观察者即使想在线程安全的前提下再次调用 subscribe、unsubscribe、notify,都不会和当前锁产生冲突。快照的代价是可能有一个观察者刚退订,却还收到一次通知,这种“最终一致”的语义在绝大多数业务场景是可以接受的。
我在网络服务里就是这么处理的,压测下来效果很稳。热点不是锁本身,反而是回调里如果做了磁盘 IO 或网络请求,整个线程会被拖慢,这是后续要去优化的事件分发策略。
4.3 如果不想阻塞发布者:异步事件队列
有些场景里,发布事件的动作发生在网络线程或 UI 事件线程,而观察者要处理逻辑可能很耗时。比如登录成功后要写数据库、要调外部接口发邮件。如果同步 notify,发布者线程会被这些耗时的观察者回调卡住,导致后续网络消息不能及时处理。
解决方式是把事件投递到一个队列里,发布者只管把事件 push 进去就返回,由一个专门的事件循环线程来统一消费并触发观察者:
class AsyncEventCenter { public: void publishAsync(Event evt) { { std::lock_guard<std::mutex> lock(queueMutex_); queue_.push(std::move(evt)); } condition_.notify_one(); } void run() { while (running_) { Event evt; { std::unique_lock<std::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::queue<Event> queue_; std::condition_variable condition_; std::atomic<bool> 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::function<void(const Event&)>; using Token = uint64_t; Token subscribe(EventType type, Handler handler) { std::lock_guard<std::mutex> lock(mutex_); Token token = nextToken_++; handlers_[type].push_back({token, std::move(handler)}); return token; } void unsubscribe(Token token) { std::lock_guard<std::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::vector<Handler> snapshot; { std::lock_guard<std::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_guard<std::mutex> lock(queueMutex_); queue_.push(event); } condition_.notify_one(); } void runEventLoop() { while (running_) { Event event; { std::unique_lock<std::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_map<EventType, std::vector<Entry>> handlers_; std::atomic<Token> nextToken_{1}; std::mutex queueMutex_; std::queue<Event> queue_; std::condition_variable condition_; std::atomic<bool> running_{true}; };这段代码里最核心的两个设计点在 publish 和 publishAsync 的分工。publish 先把订阅列表拷贝出来,再在锁外执行回调,这样退订或继续订阅都不会影响本次通知的安全性。publishAsync 则只是把事件放进队列,由另外的线程调用 runEventLoop 来真正执行 publish,因此回调永远不会阻塞发布者线程。
5.3 测试代码与预期输出
下面是一段完整的测试 main:
int 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++ -std=c++17 -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 调用处都留日志,事件名规范得像接口文档一样清晰,定期检查“有发布无订阅”的事件类型。如果你决定在项目里引入观察者模式,不妨从小范围开始,先让两个模块解耦跑通,再慢慢推广。这套东西看着简单,但生命周期、线程安全和调试成本,每一项都需要在真实场景里重新打磨一遍。