C++信号槽与事件总线:从发布-订阅模式到轻量级实现

发布时间:2026/7/19 10:25:08
C++信号槽与事件总线:从发布-订阅模式到轻量级实现 1. 项目概述为什么我们需要自己的信号槽与事件总线在C的世界里尤其是当我们从Qt框架转向开发非GUI的后台服务、游戏引擎或高性能中间件时一个绕不开的痛点就是组件间的通信。Qt的信号槽机制固然优雅但它重度依赖其元对象系统Meta-Object System, MOC这意味着你的类必须继承QObject并且需要额外的预处理步骤。如果你的项目不想引入整个Qt的庞大生态或者你追求极致的性能与轻量那么自己动手实现一套信号槽与事件总线就成了一个非常实际的需求。这不仅仅是“再造轮子”而是一种架构上的解耦与定制。一个轻量的实现意味着你可以精确控制内存布局、线程模型和性能开销。例如在游戏服务器中一个玩家攻击事件可能需要触发伤害计算、经验获取、成就系统等多个模块的响应。如果这些模块间是硬编码的函数调用代码会变得高度耦合难以维护和扩展。而一个设计良好的事件总线可以让攻击者发出一个“攻击命中”信号其他关心此事件的模块槽函数自动、异步地处理彼此不知晓对方的存在。因此这个项目的核心价值在于为你的C项目提供一个不依赖特定框架、高性能、类型安全且易于使用的组件间通信基础设施。它适合那些对性能有要求、希望保持代码纯净、或需要在嵌入式等资源受限环境中进行模块化开发的工程师。2. 核心设计思路从Qt的优雅中汲取灵感Qt的信号槽之所以强大在于它解决了几个关键问题类型安全、松耦合、自动连接管理。我们的轻量实现也需要围绕这些核心目标展开但要去掉MOC的“魔法”用纯C主要是C11/14/17标准来实现。2.1 信号槽的核心机制拆解一个信号槽系统本质上是一个发布-订阅模式的变体。信号是事件的发布者槽是事件的订阅者。其核心数据结构是一个映射表信号 - 槽函数列表。当信号被触发发射时系统遍历对应的槽函数列表并逐一调用。Qt的实现之所以需要MOC是因为它要在编译时生成额外的代码来为每个包含signals/slots的类创建这个映射表并处理信号发射时的参数打包、跨线程传递等复杂逻辑。我们的轻量实现可以更直接信号对象每个信号是一个独立的类实例内部维护一个订阅者列表。这个列表存储的是可调用对象如函数指针、std::function、lambda表达式、成员函数绑定器等。连接Connect将某个信号与一个可调用对象槽关联起来实质上是将后者添加到前者的订阅者列表中。发射Emit调用信号的某个方法如operator()该方法会遍历其订阅者列表并调用每个可调用对象同时传递参数。断开连接Disconnect从信号的订阅者列表中移除特定的可调用对象。2.2 事件总线的设计定位事件总线Event Bus是信号槽模式的一个全局应用。你可以把它想象成一个中央交换机或消息中心。所有模块都不直接相互通信而是向事件总线发送事件或从事件总线订阅感兴趣的事件。与信号槽的区别信号槽通常是对象对对象的直接连接一对一或一对多。而事件总线是一个中心化的枢纽任何对象都可以向它发布事件任何对象也都可以订阅它上面的事件。这进一步降低了耦合度发布者甚至不知道有哪些订阅者。设计要点事件总线的核心是一个事件类型到处理器列表的全局映射。事件通常用一个类型如int枚举、std::type_index或自定义类型标签来标识。处理器Handler就是槽函数。我们的轻量实现可以将两者结合用信号对象作为具体事件的承载者用一个全局的、单例或依赖注入的事件总线来管理这些信号的注册与查找。2.3 技术选型现代C的利器为了实现轻量和类型安全我们将大量依赖现代C特性可变参数模板Variadic Templates用于支持任意数量和类型的信号参数。这是实现类型安全信号槽的基石。std::function与std::bind/ Lambda用于统一存储各种形式的可调用对象作为槽。智能指针std::shared_ptr,std::weak_ptr用于自动管理连接的生命周期防止悬空回调Dangling Callback这是手动实现中最容易出错的地方。std::tuple和参数打包/解包在信号发射时需要将参数打包存储或转发给槽函数。类型擦除Type Erasure或模板基类为了能将不同类型的信号如Signalvoid(int)和Signalvoid(std::string)以统一的方式存储在事件总线中需要一些类型擦除技术。3. 轻量信号Signal类的实现细节让我们从最核心的Signal类开始。我们将实现一个模板类它可以接受任意签名的函数。3.1 Signal类的骨架与连接管理首先我们定义一个Connection类用于代表一个连接的句柄。这个句柄主要用于后续断开连接。// connection.h #include memory #include functional class Connection { public: Connection() default; explicit Connection(std::functionvoid() disconnector) : m_disconnector(std::move(disconnector)) {} // 断开连接 void disconnect() { if (m_disconnector) { m_disconnector(); m_disconnector nullptr; } } // 判断连接是否有效 bool connected() const { return m_disconnector ! nullptr; } private: std::functionvoid() m_disconnector; };接下来是Signal类。我们将使用一个std::vector来存储槽函数每个槽函数被包装成一个std::function。为了支持任意参数Signal必须是一个模板类。// signal.h #include vector #include functional #include memory #include algorithm #include “connection.h” template typename... Args class Signal { public: using SlotType std::functionvoid(Args...); // 连接一个槽函数返回一个Connection对象用于管理生命周期 Connection connect(SlotType slot) { // 为这个连接生成一个唯一的ID这里简单使用槽函数列表的迭代器位置 // 实际项目中可能需要更稳定的标识如递增的ID。 // 我们使用一个weak_ptr来跟踪槽在列表中的位置。 auto slot_ptr std::make_sharedSlotType(std::move(slot)); m_slots.push_back(slot_ptr); // 创建一个断开器用于从列表中移除这个槽 auto disconnector [this, wp std::weak_ptrSlotType(slot_ptr)]() { if (auto sp wp.lock()) { this-disconnect(sp); } }; return Connection(disconnector); } // 发射信号调用所有连接的槽 void emit(Args... args) const { // 注意在遍历过程中槽函数可能会断开自身或其他连接。 // 为了安全我们先复制一份槽列表的智能指针。 auto slots_copy m_slots; for (const auto slot_wp : slots_copy) { if (auto slot slot_wp.lock()) { (*slot)(args...); } } // 发射后清理那些已经被释放的弱引用 cleanup(); } // 操作符重载方便使用 void operator()(Args... args) const { emit(args...); } private: // 存储槽的弱引用列表。使用weak_ptr允许槽在连接断开时被自动清理。 std::vectorstd::weak_ptrSlotType m_slots; // 断开特定槽的连接 void disconnect(std::shared_ptrSlotType slot) { m_slots.erase( std::remove_if(m_slots.begin(), m_slots.end(), [slot](const std::weak_ptrSlotType wp) { return wp.expired() || wp.lock() slot; }), m_slots.end()); } // 清理已过期的弱引用 void cleanup() { m_slots.erase( std::remove_if(m_slots.begin(), m_slots.end(), [](const std::weak_ptrSlotType wp) { return wp.expired(); }), m_slots.end()); } };关键点解析与注意事项使用std::function和std::shared_ptrstd::function可以存储任何可调用对象提供了极大的灵活性。用std::shared_ptr包装它是为了获得一个可以安全拷贝和判断生命周期的句柄。Signal内部存储的是weak_ptr这样当外部不再持有该槽的shared_ptr时即连接断开weak_ptr会过期我们可以在cleanup中安全移除。emit时的安全性在emit函数中我们首先复制了m_slots。这是至关重要的。因为槽函数在被调用时可能会执行disconnect操作直接修改正在遍历的m_slots容器会导致未定义行为迭代器失效。复制一份虽然有一定开销但保证了线程安全在单线程内和遍历稳定性。连接的生命周期管理Connection对象是一个RAII资源获取即初始化风格的句柄。当Connection对象被销毁时其析构函数应该调用disconnect吗在上面的实现中我们没有这样做因为有时我们希望连接持续存在直到显式断开或信号销毁。更常见的做法是让Connection的析构函数自动断开连接这需要将disconnector存储为Connection的成员并在析构时调用。我们的实现将选择权交给了用户通过connection.disconnect()来显式断开。线程安全性上述实现不是线程安全的。connect、emit、disconnect如果被多个线程同时调用会导致数据竞争。对于多线程环境需要在修改m_slots的地方connect,disconnect,cleanup加锁。一个简单的改进是使用std::mutex。但要注意在emit函数中加锁后调用用户槽函数如果用户槽函数内部又试图连接/断开当前信号可能会导致死锁。这通常需要更精细的设计比如使用递归锁或任务队列。3.2 支持成员函数连接上面的connect只接受std::function。在实际使用中我们经常需要连接一个对象的成员函数。我们可以提供一个重载的connect成员函数模板来实现这一点。// 在Signal类中添加 template typename T Connection connect(T* obj, void (T::*func)(Args...)) { // 使用lambda捕获对象指针和成员函数指针绑定成一个std::function return connect([obj, func](Args... args) { (obj-*func)(args...); }); } // 同样支持const成员函数 template typename T Connection connect(T* obj, void (T::*func)(Args...) const) { return connect([obj, func](Args... args) { (obj-*func)(args...); }); }注意事项这里有一个经典的陷阱lambda表达式捕获了原始指针obj。如果obj被销毁了而这个连接还没有断开那么再次发射信号时调用这个槽就会访问已释放的内存导致崩溃。这就是“悬空回调”问题。解决方案使用std::weak_ptr要求对象继承自std::enable_shared_from_this并在连接时传入obj-weak_from_this()。在lambda内部尝试lock()如果失败则跳过调用。template typename T Connection connect(std::weak_ptrT wobj, void (T::*func)(Args...)) { return connect([wobj, func](Args... args) { if (auto obj wobj.lock()) { (obj-*func)(args...); } }); }这要求对象的生命周期由shared_ptr管理增加了约束。在对象析构时自动断开这是Qt的做法。我们需要一个机制当对象obj销毁时自动断开所有与之相关的连接。这可以通过让对象继承一个特定的基类并在基类析构时通知所有连接来实现实现复杂度较高。依赖Connection对象由连接的使用者负责在对象销毁前手动调用connection.disconnect()。这是最简单但最容易出错的方式。在我们的轻量实现中为了简洁我们先采用方案3并强烈建议在代码中通过良好的RAII模式或使用方案1来管理生命周期。方案2是更健壮的选择但实现起来需要引入额外的簿记系统。4. 轻量事件总线EventBus的实现有了Signal我们可以构建事件总线。事件总线的核心是一个类型到Signal实例的映射。由于不同事件的Signal类型不同参数不同我们需要一种类型擦除的方法来存储它们。4.1 类型擦除的EventStorage我们可以定义一个非模板的基类BaseEvent然后让模板类EventArgs...继承它。事件总线存储BaseEvent的指针或智能指针。// event_bus.h #include unordered_map #include typeindex #include memory #include mutex #include “signal.h” // 事件存储基类类型擦除 class BaseEvent { public: virtual ~BaseEvent() default; // 可能需要一个通用的“发射”接口但参数类型未知所以这里留空。 // 或者我们可以通过另一个模板函数来获取具体类型的事件进行发射。 }; // 具体事件类包装了Signal template typename... Args class Event : public BaseEvent { public: SignalArgs... signal; }; // 事件总线单例模式 class EventBus { public: static EventBus instance() { static EventBus bus; return bus; } // 获取或创建特定类型事件的引用 template typename... Args SignalArgs... channel() { std::type_index typeId typeid(SignalArgs...); std::lock_guardstd::mutex lock(m_mutex); auto it m_channels.find(typeId); if (it m_channels.end()) { // 创建新的事件通道 auto event std::make_sharedEventArgs...(); m_channels[typeId] event; return event-signal; } else { // 返回已存在的事件通道 auto baseEvent it-second; // 向下转型是安全的因为我们是按typeid(SignalArgs...)存储的 auto concreteEvent std::static_pointer_castEventArgs...(baseEvent); return concreteEvent-signal; } } // 便捷函数连接到特定事件 template typename... Args Connection subscribe(std::functionvoid(Args...) handler) { return channelArgs...().connect(std::move(handler)); } // 便捷函数发布事件 template typename... Args void publish(Args... args) { channelArgs...().emit(args...); } private: EventBus() default; ~EventBus() default; EventBus(const EventBus) delete; EventBus operator(const EventBus) delete; std::unordered_mapstd::type_index, std::shared_ptrBaseEvent m_channels; std::mutex m_mutex; // 保证线程安全 };设计解析使用std::type_index作为键我们将SignalArgs...的类型信息typeid作为映射的键。这确保了每种不同参数列表的事件都有独立的通道。单例模式事件总线通常是全局唯一的所以使用单例模式很方便。你也可以通过依赖注入将其作为服务提供以方便测试。线程安全channel()、subscribe()、publish()内部使用了互斥锁保证了在多线程环境下对内部映射表的修改是安全的。但请注意这并不保证槽函数执行的线程安全。槽函数本身的线程安全性需要用户自己保证。channel()函数这是核心。它返回一个SignalArgs...的引用。用户可以通过这个引用直接连接或发射信号也可以通过subscribe和publish这两个更语义化的接口。4.2 使用示例现在我们可以像下面这样使用事件总线#include “event_bus.h” #include iostream // 定义一些事件类型通常用结构体或类来标记这里直接用参数类型区分 // 事件1玩家攻击事件参数为攻击者ID和目标ID using PlayerAttackEvent Signalint, int; // 事件2系统日志事件参数为日志消息 using SystemLogEvent Signalconst std::string; class DamageSystem { public: DamageSystem() { // 订阅玩家攻击事件 m_connection EventBus::instance().subscribeint, int( [this](int attackerId, int targetId) { onPlayerAttack(attackerId, targetId); }); } ~DamageSystem() { // 析构时断开连接可选如果Connection对象被销毁时会自动断开则不需要 // m_connection.disconnect(); } private: void onPlayerAttack(int attackerId, int targetId) { std::cout “DamageSystem: Processing attack from “ attackerId “ to “ targetId std::endl; // 计算伤害... } Connection m_connection; }; class AchievementSystem { public: AchievementSystem() { // 也订阅玩家攻击事件 m_connection EventBus::instance().channelint, int().connect( [this](int attackerId, int targetId) { onPlayerAttack(attackerId, targetId); }); } private: void onPlayerAttack(int attackerId, int targetId) { std::cout “AchievementSystem: Checking achievements for attack “ attackerId “ - “ targetId std::endl; // 检查成就... } Connection m_connection; }; int main() { DamageSystem damageSys; AchievementSystem achievementSys; // 模拟游戏逻辑玩家100攻击玩家200 std::cout “Player 100 attacks Player 200” std::endl; EventBus::instance().publishint, int(100, 200); // 输出 // DamageSystem: Processing attack from 100 to 200 // AchievementSystem: Checking achievements for attack 100 - 200 // 也可以直接通过channel获取信号并发射 EventBus::instance().channelconst std::string().emit(“Server is shutting down.”); return 0; }5. 高级特性与性能优化一个基础的实现已经完成但在生产环境中我们还需要考虑更多。5.1 连接作用域与自动管理手动管理Connection对象很麻烦。我们可以引入一个ScopedConnection在析构时自动断开连接。class ScopedConnection { public: ScopedConnection() default; explicit ScopedConnection(Connection conn) : m_conn(std::move(conn)) {} ScopedConnection(ScopedConnection other) noexcept : m_conn(std::move(other.m_conn)) {} ScopedConnection operator(ScopedConnection other) noexcept { if (this ! other) { release(); m_conn std::move(other.m_conn); } return *this; } ~ScopedConnection() { release(); } void release() { if (m_conn.connected()) { m_conn.disconnect(); } } private: Connection m_conn; // 禁止拷贝 ScopedConnection(const ScopedConnection) delete; ScopedConnection operator(const ScopedConnection) delete; };然后Signal::connect可以返回一个ScopedConnection或者提供connectScoped方法。5.2 线程模型与异步事件我们的基础实现是同步调用的emit会立即、依次、在调用者线程中执行所有槽函数。在某些场景下我们需要异步处理。跨线程事件传递一个常见的需求是在A线程发射信号在B线程执行槽函数。这需要将槽函数的调用封装成一个任务投递到目标线程的事件队列中。这需要与具体的线程库如std::thread 队列、Boost.Asio、libuv等结合。可以在Signal::connect时指定一个“执行器”Executor该执行器知道如何将调用派发到特定线程。或者在事件总线层面提供publishAsync接口它接受一个任务队列或线程池作为参数。立即发射 vs. 队列发射Qt有DirectConnection立即和QueuedConnection队列。我们可以模仿这一点在连接时指定连接类型。对于队列连接emit只是将调用请求函数对象参数存入队列由另一个线程消费。实现异步的一个简单思路伪代码template typename... Args class AsyncSignal { SignalArgs... m_signal; std::functionvoid(std::functionvoid()) m_postToQueue; // 投递函数 public: template typename Queue void connectQueued(SlotType slot, Queue queue) { m_signal.connect([slot, queue](Args... args) { queue.post([slot, args...]() { // 注意这里需要完美转发和参数拷贝/移动语义 slot(args...); }); }); } };这需要你有一个线程安全的任务队列实现。参数args...需要被捕获到lambda中这里涉及到参数的拷贝或移动需要注意性能。5.3 性能考量与优化内存分配每次connect都会在堆上分配shared_ptr和std::function。频繁连接/断开可能导致内存碎片。可以考虑使用内存池或小对象优化例如使用std::aligned_storage和placement new在Signal内部预分配槽存储空间但这会限制最大连接数。遍历开销emit时需要遍历列表并调用虚函数通过std::function。如果槽函数非常简单且数量众多这个开销可能成为瓶颈。优化方法包括使用std::vectorSlotType*存储原始函数指针或可调用对象指针但需要更复杂的生命周期管理。如果槽函数类型已知且固定可以使用std::array或特化模板来避免动态分配。锁开销多线程版本中每次connect/emit/disconnect都要加锁。对于高频事件锁竞争会成为瓶颈。可以考虑使用无锁队列、读写锁std::shared_mutexC17或线程本地存储来优化。读写锁策略connect/disconnect写操作需要独占锁emit读操作只需要共享锁。这可以显著提升多线程并发读发射的性能。mutable std::shared_mutex m_mutex; // 在connect/disconnect中 std::unique_lock lock(m_mutex); // 在emit中 std::shared_lock lock(m_mutex);6. 常见问题与实战调试技巧在实际使用自实现的信号槽/事件总线时你肯定会遇到一些坑。以下是我总结的几个典型问题及其解决方案。6.1 生命周期问题悬空指针与连接泄漏问题这是最常见也最危险的问题。对象A连接了对象B的成员函数作为槽随后对象B被销毁但连接未断开。当信号再次发射时就会调用一个无效的成员函数导致程序崩溃。解决方案首选智能指针强制要求通过std::shared_ptr和std::weak_ptr来管理对象生命周期和进行连接。在connect重载中只接受weak_ptr并在槽函数调用前检查。template typename T Connection connect(std::weak_ptrT wobj, void (T::*func)(Args...)) { return connect([wobj, func](Args... args) { if (auto obj wobj.lock()) { (obj-*func)(args...); } }); }连接跟踪器让可能被连接的对象继承一个Trackable基类。该基类在析构时遍历并断开所有指向自己的连接。这需要每个Signal维护一个反向映射从对象到连接列表实现稍复杂但更自动化。使用ScopedConnection并遵循RAII在对象的成员变量中持有与其相关的所有ScopedConnection。当对象销毁时这些ScopedConnection随之销毁自动断开连接。这要求连接在对象构造时建立并且连接生命周期与对象绑定。6.2 递归发射与死锁问题槽函数内部又发射了同一个信号或者发射了另一个信号而另一个信号的某个槽函数又试图连接/断开第一个信号可能导致无限递归或死锁如果用了互斥锁。解决方案避免在槽函数中修改同一信号的连接这是最简单的规则。如果必须修改考虑将修改操作延迟到信号发射完成后例如将要断开/连接的请求存入一个待处理队列。使用递归锁std::recursive_mutex允许同一线程多次加锁。但这会隐藏设计问题并可能降低性能。在emit内部使用副本正如我们基础实现中所做的在遍历槽函数列表前先复制一份。这样即使在遍历过程中列表被修改比如槽函数断开自身也不会影响当前的这次发射过程。但要注意新添加的连接不会在这次发射中被调用。6.3 参数拷贝与移动语义问题信号参数在发射时会被传递给每个槽函数。如果参数是大型对象多次拷贝开销很大。解决方案使用const传递只读参数这是最常用的方式避免拷贝。使用移动语义如果参数对象支持移动构造且信号发射后原始参数不再需要可以考虑使用右值引用Args...和std::forward进行完美转发。但这会使得Signal的签名变得复杂且要求所有槽函数都能接受右值引用。template typename... Args void emit(Args... args) const { auto slots_copy m_slots; for (const auto slot_wp : slots_copy) { if (auto slot slot_wp.lock()) { (*slot)(std::forwardArgs(args)...); // 完美转发 } } cleanup(); }注意完美转发后参数可能被移动后续的槽函数接收到的可能是一个被移空的对象。这通常不是我们想要的因为每个槽都应该获得参数的独立副本或引用。因此更常见的做法是让槽函数自己决定如何接收参数值、引用、常量引用而信号端始终按值或常量引用传递。对于大型对象在连接时用lambda捕获其shared_ptr是更好的选择。6.4 调试与日志问题当系统复杂时难以追踪事件的流向和连接状态。解决方案为Signal和EventBus添加调试信息例如给每个信号一个名字字符串在connect和emit时输出日志。连接计数与检查实现一个函数可以打印出事件总线上所有活跃的连接数量或者打印出某个信号的所有订阅者信息需要RTTI或存储类型信息。使用工具在Debug模式下可以在Connection对象中存储额外的上下文信息如连接创建处的文件名、行号便于追踪。6.5 与现有框架集成问题你的项目可能已经使用了其他消息或事件系统如ZeroMQ、Redis Pub/Sub、操作系统原生消息队列。解决方案事件总线可以作为适配层。为这些外部系统编写特定的“适配器”类该类订阅事件总线上的内部事件并将其转发到外部系统同时也监听外部系统的消息并将其作为事件发布到内部事件总线上。这样业务逻辑代码只与轻量的事件总线交互实现了与具体通信技术的解耦。7. 总结与扩展方向通过上述步骤我们完成了一个功能完整、类型安全、不依赖Qt MOC的C信号槽与事件总线轻量实现。它具备了核心的连接、发射、断开功能并通过事件总线提供了全局的发布-订阅机制。这个实现可以作为一个坚实的起点根据你的具体项目需求进行扩展优先级为连接添加优先级属性让槽函数按优先级顺序执行。一次性连接连接只触发一次触发后自动断开。条件连接只有在满足特定条件时才建立连接或触发槽函数。信号组合实现类似Qt的QSignalMapper或更高级的信号链式操作。性能剖析集成性能分析工具监控每个信号发射的耗时和槽函数调用次数。最后一个重要的建议是不要过度设计。先从满足当前项目需求的最小功能集开始随着需求增长再逐步添加高级特性。清晰的接口和正确的生命周期管理远比丰富的功能更重要。这个轻量实现的核心价值正是在于它给了你完全的控制权让你能够根据应用程序的特定需求对其进行裁剪和优化而不是被一个庞大框架的固定范式所束缚。