简介这份资源面向熟悉 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 三层结构信号对象、连接表、派发器整体拆成三层职责清晰改起来不牵一发动全身。第一层是信号对象SignalArgs...模板参数就是信号携带的参数类型。它对外暴露connect和emit两个动作内部持有一个连接容器。第二层是连接表每个连接记录一个std::functionvoid(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构建用 CMakePoco 通过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_pathfind_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::functionvoid(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: ConnectionPoolArgs... pool_; };逐段看SlotType用std::function做类型擦除这样 lambda、成员函数绑定、普通函数指针都能塞进来调用方不用关心槽的具体形态。connect返回uint64_t而不是裸指针是为了让断开操作有明确句柄避免拿着迭代器在并发场景下失效。emit声明为const因为触发信号本身不改变信号对象的逻辑状态连接表的修改由ConnectionPool内部的锁保护这个const让信号可以被const引用传递用起来更顺手。参数传递这里有个细节emit(Args... args)是按值接收再转发。如果参数是大对象调用方应该传引用类型比如Signalconst 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::functionvoid(Args...); std::uint64_t add(SlotType slot) { std::lock_guardstd::mutex lock(mutex_); auto id nextId_; slots_.emplace(id, std::move(slot)); return id; } void remove(std::uint64_t id) { std::lock_guardstd::mutex lock(mutex_); slots_.erase(id); } void clear() { std::lock_guardstd::mutex lock(mutex_); slots_.clear(); } std::size_t size() const { std::lock_guardstd::mutex lock(mutex_); return slots_.size(); } void dispatch(Args... args) const { // 先在锁内拷贝一份快照锁外再调用避免回调里重入死锁 std::vectorSlotType snapshot; { std::lock_guardstd::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_mapstd::uint64_t, SlotType slots_; std::atomicstd::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::functionvoid(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::functionvoid(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() { Signalconst 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; }Signalconst Sample用常量引用做参数避免每个槽都拷贝一次Sample。connA、connB是栈上的Connection对象main返回时逆序析构自动从连接表移除。sum被 lambda 按引用捕获因为示例里生产线程join之后才读sum不存在数据竞争如果要在生产过程中读就得给sum加锁或用原子量这一点在真实工程里不能省。编译运行mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPERelease 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 显示大量拷贝构造。原因信号声明成Signalstd::string而不是Signalconst 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作为业务类的成员暴露出去而不是让业务类继承什么基类。比如一个数据源类内部持有Signalconst 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() { Signalint 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_caststd::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只做标记不立即擦除等派发结束再统一清理。这样能省掉每次派发的全量快照代价是连接表会短暂膨胀。我在一个每秒十万级事件的模块里用过这个改法快照开销降了大约三成但代码复杂度上去了是否值得看你的信号频率。从那以后我每次给这类事件机制做改造都强制先跑一遍“回调里增删连接”的用例再跑基准两个都过了才敢合。希望帮到你。本文还有配套的精品资源点击获取