前言「信号与槽」signals and slots是事件驱动编程里最常见的解耦方式发送方只负责在状态变化时发出信号不关心谁在监听接收方把自己的处理函数注册为槽信号触发时被调用。Qt 把这套机制做进了语言扩展和元对象系统C# 用delegate和event关键字直接支持而C 标准库里并没有名为 signal、slot 或 delegate 的组件。这是本文首先要澄清的一点C 没有语言层面的委托关键字标准库也没有现成的信号槽类。能用的材料是std::function类型擦除的可调用对象包装、容器、互斥量和智能指针。用它们拼出一个信号槽系统并不难难的是把连接的生命周期、线程安全和重入处理正确。很多简化实现只演示了「注册 调用」一旦接收方先于发送方销毁或者槽在触发过程中注销自己就会崩溃或死锁。本文从模型讲起给出一个基于标准库的完整实现连接以 RAII 句柄表示断开是幂等的槽列表在锁内复制、锁外调用避免重入死锁连接通过weak_ptr感知信号对象的销毁不会在发送方消失后访问悬垂指针。最后用成员函数绑定和对象销毁的场景做语义推演。文中不依赖任何第三方库Qt 与 Boost.Signals2 只在对比时提及。一、模型信号、槽与连接一个最小可用的信号槽系统包含三个角色信号signal持有槽的集合提供connect、disconnect、emit三个操作。槽slot任意可调用对象签名与信号声明的参数列表一致。连接connectionconnect返回的句柄代表「这一次注册」。它需要能被单独断开并在析构时自动断开避免槽在对象销毁后仍被调用。用std::functionvoid(Args...)表示槽是最自然的选择普通函数、lambda、绑定了对象的成员函数都能装进去。信号内部用std::mapId, Slot保存Id单调递增保证每个连接可被精确定位用std::vector也可以但删除时要么留下空洞、要么打乱其他连接的索引。语义上要先定几条规则否则实现会自相矛盾问题本文的选择理由emit是同步还是异步同步依次调用行为可预测不引入线程池槽抛异常怎么办向上传播后续槽不执行与普通函数调用一致由调用方决定触发过程中注销自己本轮仍会执行完快照复制槽列表避免迭代器失效触发过程中新增槽下一轮才生效快照语义避免无限增长多线程同时emit允许但槽自身需同步信号只保护自己的列表如果业务需要异步投递正确做法是把emit的参数打包成任务投进队列由工作线程取出后调用而不是在信号内部偷偷起线程。二、线程安全的核心锁内复制锁外调用最直观的实现是在emit里持锁遍历槽列表。这个写法有一个致命问题槽在执行时可能调用同一个信号的connect或disconnect而std::mutex不可重入直接死锁即使换成递归锁槽内部也可能长时间阻塞把其他线程的注册操作全部卡住。正确模式是两段式在锁内把槽复制到一个局部容器随即释放锁。在锁外遍历副本逐个调用。复制带来两个后果必须在文档里写清楚。其一触发期间新注册的槽不会在本轮被调用其二触发期间被断开的槽如果已经在副本里本轮仍会被调用一次。这是快照语义的必然结果也是多数信号库的共同选择——它换来了「遍历不会失效」和「不会死锁」。复制std::function会调用其内部可调用对象的拷贝构造因此槽必须可拷贝。捕获了std::unique_ptr的 lambda 不能直接作为槽需要独占资源时应捕获shared_ptr或改用只移动的函数包装C23 的std::move_only_function。三、连接的生命周期weak_ptr 保护发送方最容易被忽略的是Connection析构时该做什么。如果它保存的是[this, id]并调用signal-disconnect(id)那么当信号对象先于连接销毁时这个 lambda 就持有悬垂的this。连接可能被存进某个长生命周期的容器析构时机完全不受控崩溃只是时间问题。解决办法是把信号的可变状态放进一个std::shared_ptr的State连接只持有std::weak_ptrState。信号销毁后State的强引用归零连接里的弱引用自然失效lock()返回空断开操作变成无害的空操作。这样连接可以比信号活得更久也可以安全地跨线程析构。// 适用于 C17#include cstddef#include functional#include map#include memory#include mutex#include utility#include vectorclass Connection {public:Connection() default;explicit Connection(std::functionvoid() disconnect): disconnect_(std::move(disconnect)) {}Connection(Connection other) noexcept: disconnect_(std::move(other.disconnect_)) {}Connection operator(Connection other) noexcept {if (this ! other) {release();disconnect_ std::move(other.disconnect_);}return *this;}Connection(const Connection) delete;Connection operator(const Connection) delete;~Connection() { release(); }void disconnect() { release(); }private:void release() {if (disconnect_) {auto fn std::move(disconnect_);disconnect_ nullptr;fn(); // 幂等重复调用时 disconnect_ 已为空}}std::functionvoid() disconnect_;};template typename... Argsclass Signal {public:using Slot std::functionvoid(Args...);private:struct State {std::mutex mutex;std::mapstd::size_t, Slot slots;std::size_t next_id 0;};public:Signal() : state_(std::make_sharedState()) {}Signal(const Signal) delete;Signal operator(const Signal) delete;Connection connect(Slot slot) {std::shared_ptrState state state_;std::size_t id 0;{std::lock_guardstd::mutex lock(state-mutex);id state-next_id;state-slots.emplace(id, std::move(slot));}std::weak_ptrState weak state;return Connection([weak, id]() {if (auto locked weak.lock()) {std::lock_guardstd::mutex lock(locked-mutex);locked-slots.erase(id);}});}void emit(Args... args) const {std::vectorSlot snapshot;{std::lock_guardstd::mutex lock(state_-mutex);snapshot.reserve(state_-slots.size());for (const auto entry : state_-slots) {snapshot.push_back(entry.second);}}for (const auto slot : snapshot) {if (slot) {slot(args...);}}}private:std::shared_ptrState state_;};几个设计点值得逐条说明。connect在锁内完成插入后立刻释放锁再构造返回的连接锁内不做任何用户代码调用。emit复制槽时持有锁调用时完全不持锁因此槽内部再调用connect、disconnect甚至递归emit都不会自锁。Connection的移动赋值先释放自己原有的连接再接管新的连接避免「移动后旧连接泄漏」release把std::function先移出再调用即使断开函数内部抛异常也不会重复执行。Signal禁止拷贝但它的state_是共享的如果确实需要两个句柄指向同一组槽可以显式共享状态而不是靠拷贝语义悄悄发生。当前实现里emit是const的因为它只读取state_指针并复制槽不修改Signal的成员。四、绑定成员函数与完整示例实际使用中槽大多是某个对象的方法。用 lambda 捕获shared_ptr或weak_ptr都能绑定但每次手写太啰嗦。可以提供一个辅助函数把「对象 成员函数指针」转成带生命周期检查的槽// 适用于 C17#include functional#include memorytemplate typename T, typename... Argsauto bind_weak(std::shared_ptrT object, void (T::*method)(Args...)) {std::weak_ptrT weak object;return [weak, method](Args... args) {if (auto locked weak.lock()) {(locked.get()-*method)(args...);}};}返回类型写成auto让编译器生成一个具体的 lambda 类型再由std::function的模板构造函数接收。这样参数写成const std::string时lambda 的调用签名与信号的std::functionvoid(std::string, double)仍然兼容——std::function只要求「能用这些实参调用」不要求形参类型逐字相同。(locked.get()-*method)(args...)是成员函数指针的调用语法locked.get()取得裸指针-*把成员函数指针绑定到对象上。对象销毁后lock()失败槽静默跳过不会访问已释放内存。这个辅助函数只支持非 const 成员函数、参数按值或按引用传递const 成员函数、带noexcept的函数需要额外的重载实际库会用std::invoke统一处理。下面把信号、连接和成员绑定串成一个完整例子一个温度传感器发出读数两个显示对象订阅其中一个显示对象被销毁后再次触发信号演示回调不会访问悬垂对象。// 适用于 C17#include functional#include iostream#include memory#include string#include utility// 此处省略上一节的 Connection、Signal、bind_weak 定义class Display {public:explicit Display(std::string name) : name_(std::move(name)) {}void on_temperature(const std::string room, double value) {std::cout name_ received room value \n;}private:std::string name_;};int main() {Signalstd::string, double sensor;auto first std::make_sharedDisplay(first);auto second std::make_sharedDisplay(second);Connection c1 sensor.connect(bind_weak(first, Display::on_temperature));Connection c2 sensor.connect(bind_weak(second, Display::on_temperature));sensor.emit(room, 21.5); // 两个显示对象都被调用second.reset(); // 销毁第二个显示对象sensor.emit(room, 22.0); // 第二个槽的 weak.lock() 失败安全跳过c1.disconnect(); // 主动断开第一个sensor.emit(room, 22.5); // 没有槽被调用return 0;}按代码语义推演connect两次分别得到独立 idemit复制出两个槽并依次调用second.reset()后第二个槽内部的weak_ptr失效lock()返回空函数体不执行程序不会解引用已释放的Displayc1.disconnect()把第一个槽从 map 中删除c1析构时disconnect_已被移空不会重复删除。还有一个边界没有覆盖如果Signal先于Connection销毁Connection析构时weak.lock()失败断开操作变成空操作同样安全。这正是把状态放进shared_ptr、连接只持weak_ptr的价值。常见坑点❌ 以为 C 标准库自带 signal/slot 或 delegate。✅ 标准库只有std::function等通用工具信号槽需要自己实现或使用 Qt、Boost.Signals2 等库。❌ 在持有互斥锁时调用槽函数。✅ 锁内复制槽列表锁外调用否则槽内注册/注销会死锁。❌ 直接在std::vector上遍历并在槽里erase。✅ 使用快照副本或把删除延迟到遍历结束迭代器失效是 UB。❌Connection捕获信号的this连接比信号活得久。✅ 捕获weak_ptrState锁失败即空操作绝不在连接里保存裸this。❌ 槽捕获shared_ptr造成对象与信号之间的循环引用。✅ 接收方用weak_ptr调用时lock()需要强引用时明确设计所有权。❌ 认为emit期间注销的槽一定不会被调用。✅ 快照语义下本轮可能仍被调用一次需要严格取消时在槽内自行检查状态。❌ 在槽抛出异常时假设信号会自动隔离并继续调用后续槽。✅ 本文实现让异常向上传播若要隔离需在调用处显式 try/catch 并定义策略。❌ 把emit当作异步投递认为它会立刻返回。✅ 本文的emit是同步调用异步需要额外的任务队列和线程。总结组成部分实现手段关键约束槽的存储std::functionvoid(Args...)必须可拷贝空槽需判空槽的索引std::mapId, Slotid 单调递增便于精确断开线程安全互斥量保护列表锁内复制锁外调用连接句柄RAII析构即断开移动语义断开幂等发送方生命周期weak_ptrState信号先销毁也不悬垂接收方生命周期weak_ptrTlock()对象销毁后静默跳过同步模型直接调用异步需另加队列用标准库实现信号槽代码量不大难点全在边界锁的持有时机、连接与对象的销毁顺序、触发过程中的增删。把「锁内复制、锁外调用」和「连接只持弱引用」这两条作为硬规则绝大多数崩溃和死锁都能提前避免。若项目已经引入 Qt 或 Boost直接用成熟实现即可自己写一套的价值主要在于理解这套机制为何这样设计。