简介:这份资源面向熟悉 C++ 并希望在不依赖 Qt 框架的前提下实现对象间松耦合通信的开发者,核心是用 Poco 网络库的事件机制模拟 Qt 信号与槽效果,并配套 CMake 构建的示例工程。包内共 43 个文件,约 384KB,以 cmake 与 make 构建脚本、cpp/c/h 源码与头文件、json 编辑器配置及少量编译产物为主,源码部分清晰划分了信号声明、模型类与主程序入口,便于对照理解事件注册、触发与回调的完整链路。工程中不包含 Poco 库,需读者自行准备依赖环境。已有 620 人学习下载,适合想借鉴事件驱动设计、降低模块耦合度或做跨框架通信方案对比的中级 C++ 学习者,可直接在示例基础上改造复用。
1. 当 Qt 不在场:用 Poco 手搓一套信号与槽
有个场景我印象很深:一个后台服务模块,界面层用的是 Qt,信号槽用得飞起,后来要把核心逻辑抽出来做成不依赖 Qt 的独立进程,编译环境里只有 Poco。代码搬过去第一件事就是编译报错——QObject、connect、emit全没了。业务逻辑里那些“数据到了通知谁”“状态变了广播一下”的耦合点,一下子没了着落。
这个项目解决的就是这件事:在纯 C++ 环境里,借助 Poco 库实现一套接近 Qt 信号与槽效果的事件通知机制。适合两类人——一类是从 Qt 迁移到无 Qt 环境、需要保留松耦合通知习惯的开发者;另一类是本来就在用 Poco 写服务端,想给模块间通信加一层类型安全的事件层的人。读完你能拿到一套可编译的骨架、一套可扩展的注册与派发设计,以及把它接进现有工程的改法。
2. 技术选型与整体架构:为什么是 Poco 而不是自己造轮子
2.1 信号槽的本质,先拆清楚再动手
Qt 的信号槽常被当成“玄学”,其实拆开就三件事:一是信号声明,本质是一个可被调用的成员函数,调用时把参数打包;二是连接建立,把“信号标识 + 接收者 + 槽函数”登记到一张表里;三是派发,信号被触发时按表找到所有接收者并逐个调用。Qt 用 moc 在编译期生成元对象代码,把这三件事做成了语言级语法糖。
脱离 Qt 之后,我们没有 moc,就得用 C++ 自己的手段补上这三块。常见做法是:用模板把槽函数的签名信息在编译期固定下来,用std::function做类型擦除存进容器,用std::shared_ptr管理连接生命周期。Poco 在这里的价值不是替我们实现信号槽——它没有这个能力——而是提供线程、互斥、原子操作、时间戳这些基础设施,让派发层在多线程下不至于翻车。
提示:不要指望 Poco 里有现成的
Signal/Slot类。Poco 的Poco::BasicEvent和Poco::Delegate能做类似的事,但接口风格和 Qt 差得远,直接套用会让调用方写得很别扭。这个项目的思路是自建一层薄封装,Poco 只做底座。
2.2 三层结构:信号对象、连接表、派发器
整体拆成三层,职责清晰,改起来不牵一发动全身。
第一层是信号对象(Signal<Args...>),模板参数就是信号携带的参数类型。它对外暴露connect和emit两个动作,内部持有一个连接容器。第二层是连接表,每个连接记录一个std::function<void(Args...)>和一个连接 ID,支持按 ID 断开。第三层是派发器,负责在emit时遍历连接表、加锁保护、逐个调用,并处理“调用过程中有人增删连接”这种经典并发问题。
目录结构大致如下,工程不大但分层明确:
poco-signal-slot/ ├── include/ │ ├── Signal.h # 信号模板类,对外主接口 │ ├── Connection.h # 连接句柄,RAII 断开 │ └── ConnectionPool.h # 连接表与线程安全派发 ├── src/ │ ├── ConnectionPool.cpp │ └── main.cpp # 示例:多线程下的事件通知 ├── tests/ │ └── signal_test.cpp └── CMakeLists.txt这个划分的好处是:Signal.h只依赖标准库和 Poco 的少量头文件,业务代码包含它就能用;ConnectionPool是唯一需要认真处理锁的地方,出问题范围可控;Connection用 RAII 管生命周期,接收者析构时自动断开,省掉一类悬空回调的血泪经验。
2.3 依赖与构建:CMake 里怎么挂 Poco
构建用 CMake,Poco 通过find_package引入。这里有个容易翻车的点:Poco 的组件是拆开的,用到线程和 Foundation 就得分别链接,只写Poco::Poco在某些发行版上会找不到目标。
cmake_minimum_required(VERSION 3.16) project(poco_signal_slot CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Poco REQUIRED COMPONENTS Foundation) add_library(signal_slot STATIC src/ConnectionPool.cpp ) target_include_directories(signal_slot PUBLIC include) # Poco::Foundation 提供互斥、原子、时间等基础设施 target_link_libraries(signal_slot PUBLIC Poco::Foundation) add_executable(demo src/main.cpp) target_link_libraries(demo PRIVATE signal_slot) enable_testing() add_executable(signal_test tests/signal_test.cpp) target_link_libraries(signal_test PRIVATE signal_slot) add_test(NAME signal_test COMMAND signal_test)COMPONENTS Foundation是必须的,Poco::Foundation这个 imported target 由 Poco 的 CMake 配置文件提供。如果你的环境里 Poco 是源码编译安装且没装 CMake 配置,就得退回find_path+find_library的老写法,这一点在跨平台构建时经常踩到。C++ 标准定在 17,是因为要用到if constexpr和结构化绑定来简化模板分支,14 也能写但代码会啰嗦不少。
3. 核心模块拆解:从 connect 到 emit 的完整链路
3.1 Signal 模板类:把参数类型钉死在编译期
信号类的核心是把“参数包”变成模板参数,这样connect时槽函数的签名能被编译器检查,传错参数类型直接编译失败,比运行期崩溃强太多。
// include/Signal.h #pragma once #include <functional> #include <cstdint> #include "ConnectionPool.h" template <typename... Args> class Signal { public: using SlotType = std::function<void(Args...)>; // 连接一个槽,返回连接 ID,用于后续断开 std::uint64_t connect(SlotType slot) { return pool_.add(std::move(slot)); } // 按 ID 断开单个连接 void disconnect(std::uint64_t id) { pool_.remove(id); } // 断开全部连接,接收者析构时常用 void disconnectAll() { pool_.clear(); } // 触发信号,参数原样转发给所有槽 void emit(Args... args) const { pool_.dispatch(args...); } std::size_t connectionCount() const { return pool_.size(); } private: ConnectionPool<Args...> pool_; };逐段看:SlotType用std::function做类型擦除,这样 lambda、成员函数绑定、普通函数指针都能塞进来,调用方不用关心槽的具体形态。connect返回uint64_t而不是裸指针,是为了让断开操作有明确句柄,避免拿着迭代器在并发场景下失效。emit声明为const,因为触发信号本身不改变信号对象的逻辑状态,连接表的修改由ConnectionPool内部的锁保护,这个const让信号可以被const引用传递,用起来更顺手。
参数传递这里有个细节:emit(Args... args)是按值接收再转发。如果参数是大对象,调用方应该传引用类型,比如Signal<const std::string&>,这样std::function内部也不会多拷贝一次。这个选择权交给使用方,比在模板里强行加完美转发更可控。
3.2 ConnectionPool:线程安全的连接表与派发
这是整个项目最需要小心的地方。派发时遍历容器,如果某个槽在回调里又去connect或disconnect,直接遍历原容器就是迭代器失效,轻则崩溃重则数据错乱。
// include/ConnectionPool.h #pragma once #include <functional> #include <unordered_map> #include <vector> #include <mutex> #include <atomic> #include <cstdint> template <typename... Args> class ConnectionPool { public: using SlotType = std::function<void(Args...)>; std::uint64_t add(SlotType slot) { std::lock_guard<std::mutex> lock(mutex_); auto id = nextId_++; slots_.emplace(id, std::move(slot)); return id; } void remove(std::uint64_t id) { std::lock_guard<std::mutex> lock(mutex_); slots_.erase(id); } void clear() { std::lock_guard<std::mutex> lock(mutex_); slots_.clear(); } std::size_t size() const { std::lock_guard<std::mutex> lock(mutex_); return slots_.size(); } void dispatch(Args... args) const { // 先在锁内拷贝一份快照,锁外再调用,避免回调里重入死锁 std::vector<SlotType> snapshot; { std::lock_guard<std::mutex> lock(mutex_); snapshot.reserve(slots_.size()); for (const auto& kv : slots_) { snapshot.push_back(kv.second); } } for (const auto& slot : snapshot) { if (slot) slot(args...); } } private: mutable std::mutex mutex_; std::unordered_map<std::uint64_t, SlotType> slots_; std::atomic<std::uint64_t> nextId_{1}; };关键点在dispatch:锁内只做快照拷贝,锁外才真正调用槽。这样即使某个槽在回调里调用connect或disconnect,也不会因为持有同一把锁而自锁,更不会让遍历中的容器被改。代价是每次派发多一次std::function拷贝,对高频信号有开销,但换来的是不会莫名其妙卡死——这个取舍在服务端场景里我认为是值得的。
nextId_用std::atomic是因为add虽然持锁,但 ID 生成放在锁外自增更省临界区,且uint64_t自增在主流平台上足够快。mutex_声明为mutable,是为了让size()和dispatch()这两个const成员能加锁。
3.3 Connection 句柄:用 RAII 管住悬空回调
裸用连接 ID 的问题是:接收者对象析构了,连接还在表里,下次emit就调到已销毁对象上,这是最典型的翻车现场。解决办法是给连接包一层 RAII 句柄,析构即断开。
// include/Connection.h #pragma once #include <cstdint> #include <functional> class Connection { public: Connection() = default; Connection(std::uint64_t id, std::function<void(std::uint64_t)> remover) : id_(id), remover_(std::move(remover)) {} Connection(const Connection&) = delete; Connection& operator=(const Connection&) = delete; Connection(Connection&& other) noexcept : id_(other.id_), remover_(std::move(other.remover_)) { other.id_ = 0; other.remover_ = nullptr; } Connection& operator=(Connection&& other) noexcept { if (this != &other) { reset(); id_ = other.id_; remover_ = std::move(other.remover_); other.id_ = 0; other.remover_ = nullptr; } return *this; } ~Connection() { reset(); } void reset() { if (id_ != 0 && remover_) { remover_(id_); } id_ = 0; remover_ = nullptr; } bool valid() const { return id_ != 0; } private: std::uint64_t id_ = 0; std::function<void(std::uint64_t)> remover_; };remover_是一个闭包,捕获了ConnectionPool的弱引用或指针,调用时执行remove(id)。拷贝构造被删除、只保留移动语义,是因为一个连接句柄对应一条连接,复制会导致两次析构两次断开。reset()里先判断id_ != 0再调用,保证幂等,移动后的源对象析构时不会误删别人的连接。
注意:
remover_里如果捕获的是ConnectionPool的裸指针,必须保证Connection的生命周期不长于ConnectionPool。工程里更稳的做法是捕获std::weak_ptr,在reset时lock()失败就跳过。这个项目为了示例简洁用了裸指针,二次开发时建议换成弱引用。
3.4 一个完整调用示例:多线程下的事件通知
把上面三块拼起来,写一个“采集线程产生数据、两个处理模块订阅”的例子,验证跨线程派发是否正常。
// src/main.cpp #include "Signal.h" #include "Connection.h" #include <thread> #include <chrono> #include <iostream> #include <vector> struct Sample { int id; double value; }; int main() { Signal<const Sample&> onSample; // 模块 A:打印 auto connA = onSample.connect([](const Sample& s) { std::cout << "[A] sample " << s.id << " = " << s.value << "\n"; }); // 模块 B:累计 double sum = 0.0; auto connB = onSample.connect([&sum](const Sample& s) { sum += s.value; }); // 采集线程:每 100ms 产生一个样本 std::thread producer([&onSample]() { for (int i = 1; i <= 5; ++i) { Sample s{i, i * 1.5}; onSample.emit(s); std::this_thread::sleep_for(std::chrono::milliseconds(100)); } }); producer.join(); std::cout << "sum = " << sum << "\n"; // connA / connB 析构时自动断开 return 0; }Signal<const Sample&>用常量引用做参数,避免每个槽都拷贝一次Sample。connA、connB是栈上的Connection对象,main返回时逆序析构,自动从连接表移除。sum被 lambda 按引用捕获,因为示例里生产线程join之后才读sum,不存在数据竞争;如果要在生产过程中读,就得给sum加锁或用原子量,这一点在真实工程里不能省。
编译运行:
mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release cmake --build . -j ./demo预期输出五行样本打印加一行sum = 22.5。如果只看到部分输出,先检查dispatch的快照逻辑是否被改坏,再看Connection是否在emit之前就被析构。
4. 避坑与排查:这套机制最容易翻车的五个地方
4.1 回调里重入导致死锁
现象:程序跑一段时间后卡死,用调试器看主线程停在std::mutex::lock。原因:早期版本在持锁状态下直接遍历slots_调用槽,某个槽内部又调用了connect,同一线程对非递归互斥量二次加锁。解决:按 3.2 的写法,锁内只拷贝快照,锁外调用。改完之后这类卡死再没出现过。
4.2 接收者析构后仍被回调
现象:偶发段错误,栈回溯落在某个已销毁对象的成员函数里。原因:连接建立后没有随接收者生命周期断开,emit时调到悬空对象。解决:把Connection作为接收者的成员变量持有,接收者析构时自动reset;或者用std::weak_ptr在槽内部先lock()再访问。血泪经验是:只要槽里捕获了this,就必须有对应的断开机制。
4.3 参数按值传递带来的隐性拷贝
现象:高频信号下 CPU 占用偏高,profile 显示大量拷贝构造。原因:信号声明成Signal<std::string>而不是Signal<const std::string&>,每次emit和每个槽调用都各拷贝一次。解决:大对象一律用常量引用做模板参数;确实需要转移所有权时用std::shared_ptr或std::unique_ptr包一层,别指望模板自动优化。
4.4 连接 ID 溢出与复用混淆
现象:长时间运行后断开操作误删了别的连接。原因:nextId_用uint32_t且回绕后与旧 ID 重复。解决:用uint64_t并把自增放在原子操作里,实际工程中回绕概率可忽略;更严谨的做法是 ID 里带一个代际计数,断开时校验代际。这个项目用了uint64_t,够用。
4.5 Poco 链接不全导致运行期符号缺失
现象:编译通过,链接报undefined reference to Poco::Mutex一类符号。原因:CMake 里只写了find_package(Poco)没指定组件,或者手动链接时漏了-lPocoFoundation。解决:显式写COMPONENTS Foundation并链接Poco::Foundation;用源码编译 Poco 时确认CMAKE_INSTALL_PREFIX下的 CMake 配置文件被正确安装,否则find_package会静默失败退到系统旧版本。
5. 二次开发:把它接进现有工程并做性能验证
拿到这套骨架之后,真正要动的地方通常不是核心派发逻辑,而是接入方式和验证手段。我一般按下面三步走。
第一步,把Signal作为业务类的成员暴露出去,而不是让业务类继承什么基类。比如一个数据源类,内部持有Signal<const Frame&> onFrame,外部通过source.onFrame.connect(...)订阅。这样业务类不背任何框架包袱,迁移成本最低。如果团队习惯了 Qt 的写法,可以再包一层宏,把emit onFrame(f)映射成onFrame.emit(f),但宏要克制,别把类型检查绕过去。
第二步,处理跨线程派发的语义。当前实现是“在 emit 的线程上同步调用所有槽”,这跟 Qt 的Qt::DirectConnection一致。如果某个槽必须在自己线程执行,就得在槽内部把任务投递到目标线程的消息队列,或者给ConnectionPool加一个可选的执行器参数。常见做法是给connect增加一个Executor接口,派发时把调用包装成任务提交,这样同步异步都能覆盖,改动集中在dispatch一处。
第三步,做压力验证。写一个基准测试,单信号挂 100 个槽,主线程每秒 emit 一万次,观察耗时和内存。下面这个片段可以直接放进tests/:
// tests/signal_test.cpp(节选) #include "Signal.h" #include <chrono> #include <iostream> int main() { Signal<int> sig; int counter = 0; for (int i = 0; i < 100; ++i) { sig.connect([&counter](int v) { counter += v; }); } constexpr int kRounds = 10000; auto start = std::chrono::steady_clock::now(); for (int i = 0; i < kRounds; ++i) { sig.emit(1); } auto cost = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - start).count(); std::cout << "rounds=" << kRounds << " cost=" << cost << "ms" << " counter=" << counter << "\n"; return counter == kRounds * 100 ? 0 : 1; }这段测试同时验证正确性(counter必须等于kRounds * 100)和性能(cost给出量级参考)。如果耗时明显偏高,优先怀疑dispatch里的快照拷贝——可以改成拷贝shared_ptr或按 ID 排序后批量处理,但别为了省拷贝把锁又加回调用路径上,那是拿稳定性换性能。
一个具体技巧:给ConnectionPool加一个“派发中”标志,dispatch期间发生的remove只做标记不立即擦除,等派发结束再统一清理。这样能省掉每次派发的全量快照,代价是连接表会短暂膨胀。我在一个每秒十万级事件的模块里用过这个改法,快照开销降了大约三成,但代码复杂度上去了,是否值得看你的信号频率。
从那以后我每次给这类事件机制做改造,都强制先跑一遍“回调里增删连接”的用例,再跑基准,两个都过了才敢合。希望帮到你。
本文还有配套的精品资源,点击获取