简介:这份资源面向在 Windows 平台使用 VS2008 进行 C++ 开发的初学者,聚焦 ZeroMQ 的订阅与发布模式,帮助读者快速搭建可运行的消息通信环境。包内包含完整的 ZeroMQ 使用教程文档、服务端与客户端两个示例工程,以及编译所需的头文件、库文件和动态链接库,读者可直接参考工程结构理解发布订阅的调用流程。资源共 31 个文件,以 h 头文件、cpp 源文件、dll 动态库、lib 静态库和 txt 说明文档为主,另有 vcproj 工程文件与 sln 解决方案,压缩包约 4.25MB,体积轻便便于携带。目前已有 487 人学习下载,适合需要快速入门 ZeroMQ 消息通信、对照示例代码完成环境配置与调试的开发者参考。
1. 为什么今天还有人要在 VS2008 里跑 ZeroMQ 的发布订阅
如果你手上维护的是一套十多年前的工业控制上位机、老式检测设备配套软件,或者某个只在 Windows XP/7 上跑得动的采集程序,那你大概率躲不开 VS2008 这个工具链。它老,但它稳,很多现场设备厂商的 SDK 只给了 VC9 的库。这时候想加一层进程间或跨机器的消息通信,ZeroMQ 几乎是成本最低的选择:不用装服务端、不用配 broker、一个头文件加一个 lib 就能编。而发布订阅模式又是这类场景里最常用的——一个采集端往外广播数据,多个显示端、记录端、报警端各自订阅自己关心的内容。这篇就把我在 VS2008 下用 ZeroMQ 做 pub/sub 的完整路径讲清楚,包括怎么编、怎么连、参数怎么调、以及那些年我踩过的坑。
2. 在 VS2008 里把 ZeroMQ 编成能用的库
2.1 为什么不用官方预编译包,而是自己编
ZeroMQ 官方给的 Windows 二进制包,绝大多数是用更高版本的 Visual Studio 编的。VS2008 对应的是 VC9 工具集,运行时库版本对不上,直接链接会出现一堆LNK2001和LNK2019,报的符号名看着都对,就是找不到。更麻烦的是 CRT 混用——如果你的主程序用/MT静态链接,而 libzmq 用/MD动态链接,程序跑起来会在某个不确定的时刻崩掉,这种崩溃用调试器都很难定位,属于典型的玄学问题。
所以我的习惯是:拿到 ZeroMQ 源码,用 VS2008 自己编一遍。源码里带了builds/msvc目录,里面有现成的解决方案文件,但那个文件通常是给更高版本 VS 准备的,VS2008 打开会提示升级,升级后有些项目配置会丢。稳妥的做法是自己新建一个静态库工程,把src目录下的源文件加进去。
2.2 新建静态库工程的具体步骤
先建工程。VS2008 菜单 File → New → Project,选 Win32 Project,名字填libzmq,下一步选 Static Library,不要勾 Precompiled header。建好之后,把 ZeroMQ 源码src目录下所有.cpp和.h文件加进工程,但要注意排除几个平台相关的文件。
# 需要加入工程的源文件(在 src 目录下) # 核心文件 zmq.cpp ctx.cpp socket_base.cpp # 发布订阅相关 pub.cpp sub.cpp xpub.cpp xsub.cpp # 底层传输 tcp.cpp tcp_address.cpp ipc.cpp # 工具类 msg.cpp err.cpp clock.cpp上面只是示意,实际文件比这多。加文件的时候有个原则:src根目录下的.cpp全加,src/tcp、src/ipc这些子目录里的也加,但src/udp、src/tipc这类如果编译报错可以先不加,pub/sub 走 TCP 用不到。加完之后在项目属性里改几个配置。
# 项目属性 → C/C++ → Preprocessor → Preprocessor Definitions _CRT_SECURE_NO_WARNINGS ZMQ_HAVE_WINDOWS ZMQ_USE_SELECT # 项目属性 → C/C++ → Code Generation → Runtime Library Multi-threaded (/MT) # 和你的主程序保持一致 # 项目属性 → C/C++ → Language → Enable Run-Time Type Info No (/GR-) # ZeroMQ 不用 RTTI,关掉能减小体积ZMQ_USE_SELECT这个宏要重点说。Windows 下 ZeroMQ 默认用 IOCP 做事件驱动,但 IOCP 在 VS2008 的某些补丁版本上编译会报error C2065: 'WSAPOLLFD' : undeclared identifier,因为老版本 SDK 的头文件里没有这个类型。改成 select 模型就能绕过去,代价是单进程能处理的连接数受FD_SETSIZE限制,默认 64。如果你的订阅端不超过这个数,用 select 完全够。
编的时候如果报error C3861: 'snprintf': identifier not found,在文件顶部加一行#define snprintf _snprintf就行。这是 VC9 的老毛病,不是 ZeroMQ 的问题。
2.3 编出 lib 之后怎么接到你的工程里
静态库编出来是libzmq.lib,大概几 MB。接到主工程需要三步:头文件路径、库路径、附加依赖项。
# 主工程属性 → C/C++ → General → Additional Include Directories D:\libzmq\include # 主工程属性 → Linker → General → Additional Library Directories D:\libzmq\lib # 主工程属性 → Linker → Input → Additional Dependencies libzmq.lib ws2_32.libws2_32.lib必须加,ZeroMQ 的 TCP 传输底层调的是 Winsock。如果链接时报unresolved external symbol __imp__WSAStartup@8,就是漏了这个库。另外注意,如果你的主程序是 MFC 工程,MFC 默认用/MD,那 libzmq 也得用/MD编,两边运行时库必须一致,这是铁律。
3. 发布端和订阅端的最小可运行代码
3.1 发布端:绑定端口往外广播
发布端的逻辑很简单:创建 context、创建 PUB socket、绑定地址、循环发消息。但有几个细节决定了它能不能正常工作。
#include <zmq.h> #include <stdio.h> #include <string.h> #include <windows.h> int main() { void* ctx = zmq_ctx_new(); void* publisher = zmq_socket(ctx, ZMQ_PUB); // 绑定所有网卡的 5555 端口,订阅端可以跨机器连 int rc = zmq_bind(publisher, "tcp://*:5555"); if (rc != 0) { printf("bind failed: %s\n", zmq_strerror(zmq_errno())); return -1; } int count = 0; while (1) { char topic[32]; char payload[128]; // 模拟两路数据:温度用 topic "TEMP",湿度用 topic "HUMI" if (count % 2 == 0) { strcpy(topic, "TEMP"); sprintf(payload, "temp=%d", 20 + count % 10); } else { strcpy(topic, "HUMI"); sprintf(payload, "humi=%d", 40 + count % 20); } // 发布订阅是多帧消息:第一帧是主题,第二帧是内容 zmq_send(publisher, topic, strlen(topic), ZMQ_SNDMORE); zmq_send(publisher, payload, strlen(payload), 0); printf("sent [%s] %s\n", topic, payload); count++; Sleep(500); } zmq_close(publisher); zmq_ctx_destroy(ctx); return 0; }关键点在ZMQ_SNDMORE。发布订阅模式里,主题过滤是在订阅端做的,但过滤的依据是消息的第一帧。如果你把主题和内容拼成一个字符串发,订阅端就只能收到全部再自己判断,失去了 ZeroMQ 在传输层过滤的优势。用两帧发,订阅端设了ZMQ_SUBSCRIBE之后,不匹配的消息根本不会传到你的进程里,网络带宽和 CPU 都省了。
zmq_bind用tcp://*:5555表示监听所有网卡。如果只想本机通信,用tcp://127.0.0.1:5555,这样外部连不进来,安全性更好。绑定失败一定要打错误码,zmq_errno()返回的是 ZeroMQ 自己的错误号,zmq_strerror能翻译成人话。最常见的失败是端口被占,错误信息是Address already in use。
3.2 订阅端:连接并设置过滤规则
订阅端比发布端多一个步骤:设置订阅过滤器。这个步骤必须在zmq_connect之后、第一次zmq_recv之前完成,否则可能丢消息。
#include <zmq.h> #include <stdio.h> #include <string.h> int main() { void* ctx = zmq_ctx_new(); void* subscriber = zmq_socket(ctx, ZMQ_SUB); // 连接发布端,注意这里是 connect 不是 bind int rc = zmq_connect(subscriber, "tcp://127.0.0.1:5555"); if (rc != 0) { printf("connect failed: %s\n", zmq_strerror(zmq_errno())); return -1; } // 只订阅 TEMP 开头的消息,HUMI 的不会传过来 zmq_setsockopt(subscriber, ZMQ_SUBSCRIBE, "TEMP", 4); while (1) { zmq_msg_t topic_msg, payload_msg; zmq_msg_init(&topic_msg); zmq_msg_init(&payload_msg); // 收第一帧:主题 int size = zmq_msg_recv(&topic_msg, subscriber, 0); if (size == -1) { printf("recv topic failed: %s\n", zmq_strerror(zmq_errno())); zmq_msg_close(&topic_msg); zmq_msg_close(&payload_msg); break; } // 收第二帧:内容 size = zmq_msg_recv(&payload_msg, subscriber, 0); if (size == -1) { printf("recv payload failed\n"); zmq_msg_close(&topic_msg); zmq_msg_close(&payload_msg); break; } printf("recv [%.*s] %.*s\n", (int)zmq_msg_size(&topic_msg), (char*)zmq_msg_data(&topic_msg), (int)zmq_msg_size(&payload_msg), (char*)zmq_msg_data(&payload_msg)); zmq_msg_close(&topic_msg); zmq_msg_close(&payload_msg); } zmq_close(subscriber); zmq_ctx_destroy(ctx); return 0; }zmq_setsockopt的第三个参数是过滤前缀,第四个参数是前缀长度。传"TEMP"和4表示只收第一帧以TEMP开头的消息。如果想收全部,传空字符串和 0:zmq_setsockopt(subscriber, ZMQ_SUBSCRIBE, "", 0)。注意这里的前缀匹配是字节级的,"TEMP"能匹配"TEMPERATURE",也能匹配"TEMP_001",设计主题命名的时候要考虑到这一点。
用zmq_msg_t而不是zmq_recv的缓冲区版本,是因为多帧消息必须用消息对象来收,用缓冲区版本会把帧边界搞丢。每收完一条完整消息,两个zmq_msg_close都要调,不然内存会慢慢涨上去,跑几天就崩了。
3.3 编译运行时的两个必调参数
代码能编过不代表能跑对。有两个 socket 选项在调试阶段特别有用。
// 发布端:设置发送高水位,超过就丢消息而不是阻塞 int sndhwm = 1000; zmq_setsockopt(publisher, ZMQ_SNDHWM, &sndhwm, sizeof(sndhwm)); // 订阅端:设置接收超时,避免 recv 永久阻塞 int rcvtimeo = 3000; // 3 秒 zmq_setsockopt(subscriber, ZMQ_RCVTIMEO, &rcvtimeo, sizeof(rcvtimeo));ZMQ_SNDHWM默认是 1000。发布端如果发得太快,订阅端处理不过来,消息会堆在发布端的发送队列里。堆到 1000 条之后,ZeroMQ 默认的行为是直接丢弃新消息,而不是阻塞发布端。这个行为在实时采集场景里是对的——宁可丢数据也不能让采集端卡住。但如果你不能接受丢数据,就要把 HWM 调大,同时确保订阅端消费得够快。
ZMQ_RCVTIMEO设了之后,zmq_msg_recv在超时后会返回 -1,zmq_errno()返回EAGAIN。这样你的订阅端就能在收不到数据的时候做点别的事,比如检查退出标志、刷新界面。不设的话,recv 会一直阻塞,程序关都关不掉。
4. 发布订阅模式里那些让人翻车的坑
4.1 慢连接问题:订阅端启动晚就收不到消息
现象:发布端先启动,订阅端后启动,订阅端一条消息都收不到。等订阅端重启一次,又正常了。
原因:ZeroMQ 的发布订阅是“即发即弃”模型。发布端 bind 之后,如果没有订阅端连接,它发的消息直接丢掉,不会缓存。订阅端 connect 之后,TCP 连接建立、订阅信息传到发布端,这中间有个时间窗口,窗口内发布端发的消息订阅端收不到。这不是 bug,是设计如此。
解决:如果业务不能接受丢消息,就别用纯 pub/sub。常见做法是发布端同时开一个 REP socket,订阅端连上之后先发一个“我准备好了”的请求,发布端收到之后再开始发数据。或者用 XPUB/XSUB 代理模式,代理端能感知到订阅者的连接事件。最简单的办法是让订阅端先启动,或者接受启动阶段丢几条。
4.2 订阅过滤器不生效:主题帧和内容帧搞反了
现象:订阅端设了ZMQ_SUBSCRIBE过滤"TEMP",但"HUMI"的消息也收到了。
原因:发布端发消息的时候,把内容放在了第一帧,主题放在了第二帧。ZeroMQ 只对第一帧做前缀匹配,匹配的是内容,当然过滤不对。
解决:发布端严格保证第一帧是主题、第二帧是内容。如果消息结构复杂,第一帧放主题,后面可以跟任意多帧内容,订阅端按顺序收就行。调试的时候可以在订阅端把收到的第一帧打出来看,确认是不是主题。
4.3 程序退出时卡死:context 没有正确销毁
现象:点关闭按钮,程序界面没了,但进程还在任务管理器里,CPU 占用 0,就是不退出。
原因:zmq_ctx_destroy会阻塞,直到所有 socket 都关闭。如果某个 socket 还在zmq_recv里阻塞着,zmq_ctx_destroy就永远等下去。VS2008 的调试器里看,主线程卡在zmq_ctx_destroy里面。
解决:退出顺序必须是先关 socket,再销毁 context。如果 socket 在别的线程里阻塞着,要么给 socket 设ZMQ_RCVTIMEO让它定期返回,要么用zmq_ctx_shutdown先发一个关闭信号,等线程退出了再zmq_ctx_destroy。我一般会在订阅端用一个volatile bool做退出标志,recv 超时返回EAGAIN的时候检查这个标志,置位了就跳出循环关 socket。
4.4 跨机器连不上:防火墙和绑定地址
现象:本机 pub/sub 正常,换成两台机器就连不上。订阅端报Connection refused或者一直收不到数据。
原因:三个可能。一是发布端 bind 的是tcp://127.0.0.1:5555,只监听本机回环,外部连不进来。二是 Windows 防火墙拦了 5555 端口的入站连接。三是两台机器不在同一网段,路由不通。
解决:发布端 bind 改成tcp://*:5555。在发布端机器上开防火墙入站规则,放行 5555 端口的 TCP。先用telnet 发布端IP 5555测一下通不通,通了再跑程序。如果公司网络有隔离策略,可能还需要找网管开策略,这个就不是代码能解决的了。
4.5 中文主题乱码:编码问题
现象:主题里带中文,订阅端过滤不到,或者收到的主题显示成乱码。
原因:VS2008 默认源文件编码是 GBK,而 ZeroMQ 传输的是原始字节。发布端发出去的 GBK 字节,订阅端如果按 UTF-8 解释就是乱码。更麻烦的是zmq_setsockopt的前缀长度如果按字符数算而不是字节数算,过滤就会错位。
解决:主题一律用 ASCII 字符,别用中文。如果业务上非要中文,统一转成 UTF-8 再发,订阅端也按 UTF-8 设前缀,长度用转换后的字节数。转换函数用WideCharToMultiByte,代码页传CP_UTF8。这个坑我在一个多语言界面的项目里踩过,查了一下午才定位到编码上。
5. 让发布订阅在生产环境稳下来的几个技巧
5.1 用 XPUB 替代 PUB 来感知订阅者
标准 PUB socket 有个黑匣子特性:你没法知道有没有订阅者连上来、连了几个。XPUB 是 PUB 的扩展版,它会把订阅事件当成消息发给你。订阅端连上来的时候,XPUB 会收到一条内容为\x01加主题的消息;断开的时候收到\x00加主题。利用这个特性,可以在没有订阅者的时候暂停采集,省 CPU。
void* publisher = zmq_socket(ctx, ZMQ_XPUB); zmq_bind(publisher, "tcp://*:5555"); // 非阻塞收订阅事件 int rcvtimeo = 100; zmq_setsockopt(publisher, ZMQ_RCVTIMEO, &rcvtimeo, sizeof(rcvtimeo)); int subscriber_count = 0; while (running) { zmq_msg_t event; zmq_msg_init(&event); int rc = zmq_msg_recv(&event, publisher, 0); if (rc > 0) { unsigned char* data = (unsigned char*)zmq_msg_data(&event); if (data[0] == 1) subscriber_count++; else if (data[0] == 0) subscriber_count--; } zmq_msg_close(&event); if (subscriber_count > 0) { // 有订阅者,正常发数据 zmq_send(publisher, "TEMP", 4, ZMQ_SNDMORE); zmq_send(publisher, "25", 2, 0); } Sleep(500); }注意 XPUB 收订阅事件和发数据用的是同一个 socket,所以收事件要用非阻塞或者短超时,不然会卡住发数据的流程。subscriber_count只是个粗略计数,同一个订阅端设多个过滤器会加多次,实际用的时候按需处理。
5.2 用 zmq_poll 同时管多个 socket
生产环境里一个进程往往不止一个 socket,可能既订阅别人的数据,又发布自己的数据。这时候用zmq_poll做事件循环,比开多个线程简单。
zmq_pollitem_t items[2]; items[0].socket = subscriber; items[0].events = ZMQ_POLLIN; items[1].socket = publisher; items[1].events = ZMQ_POLLOUT; while (running) { int rc = zmq_poll(items, 2, 1000); // 超时 1 秒 if (rc < 0) break; if (items[0].revents & ZMQ_POLLIN) { // 有数据可收 zmq_msg_t msg; zmq_msg_init(&msg); zmq_msg_recv(&msg, subscriber, 0); // 处理数据... zmq_msg_close(&msg); } if (items[1].revents & ZMQ_POLLOUT) { // 可以发数据 zmq_send(publisher, "STATUS", 6, ZMQ_SNDMORE); zmq_send(publisher, "ok", 2, 0); } }zmq_poll的超时单位是毫秒,传 -1 表示永久阻塞,传 0 表示立即返回。用 1000 这种中间值,既能及时响应事件,又能定期醒过来检查退出标志。revents是返回的事件掩码,用按位与判断。这个模式在 VS2008 里跑很稳,比多线程加锁的方案好维护得多。
5.3 消息大小和频率的边界
ZeroMQ 单条消息理论上能到几 GB,但实际用的时候别超过几 MB。消息越大,内存拷贝开销越大,而且一旦丢包重传的代价也高。我一般把单条消息控制在 64KB 以内,超过就分片。分片的时候在主题帧后面加一个序号帧,订阅端按序号重组。
发送频率上,PUB socket 在千兆网里跑到每秒几万条小消息没问题,但前提是订阅端消费得过来。如果订阅端处理一条要 10ms,发布端发 1000 条/秒,HWM 很快就会满,然后开始丢消息。这种时候要么降低发布频率,要么在订阅端加一个队列缓冲,要么用多线程消费。没有银弹,得按业务算。
5.4 一个我常备的调试习惯
每次新写一个 pub/sub 程序,我都会先写一个最简单的“发一条收一条”的验证程序,确认链路通了再往上加业务逻辑。验证程序里发布端只发"hello",订阅端设空前缀收全部,收到就打印。这个程序跑通了,说明编译、链接、绑定、连接、过滤这几个环节都没问题。后面出问题,就可以排除这些基础环节,直接查业务代码。
另外我会在发布端和订阅端都加一个计数器,发布端每发 1000 条打一次日志,订阅端每收 1000 条打一次日志。两边一对,就知道有没有丢消息、丢了多少。这个习惯帮我省了很多猜的时间。希望帮到你。
本文还有配套的精品资源,点击获取