简介:面向Windows平台初学者的ZeroMQ快速入门资料,围绕VS2008环境下的订阅与发布模式展开,提供可编译运行的server端与client端示例工程及配套使用说明,帮助读者理解消息队列的编译配置、头文件与库文件引用、发布订阅通信流程等基础环节。压缩包共31个文件,包含h头文件、cpp源码、dll动态库、lib导入库、vcproj工程文件及doc/txt说明文档,整体约4.25MB,结构上覆盖ZeroMQ库与测试项目两部分,便于按工程直接打开对照学习。已有490人学习下载,适合需要从零搭建VS2008+ZeroMQ开发环境、验证发布订阅通信效果并积累工程配置经验的学习者参考。
1. 从一次收不到消息说起:Windows 下 ZeroMQ 发布订阅的落地场景
接手一个老工控采集项目时,最麻烦的不是算法,而是界面进程和采集进程之间那点消息通知:共享内存加轮询,睡 200ms 查一次,延迟和 CPU 都尴尬。系统锁死在 Windows + VS2008,换编译器不现实,于是选了 ZeroMQ 做订阅发布。反直觉的是,装库本身十分钟,真正卡人的是 VC9 运行库、库架构和订阅前缀。
这套教程和实例工程适合仍在维护 VS2008 C++ 项目、需要轻量消息总线的人,也适合想弄懂 ZeroMQ PUB/SUB 在 Windows 下怎么编译链接、收不到消息时从哪里排查的人。它不教你从零造消息队列,而是把发布端、订阅端、过滤、退出和踩坑点拆开,让你能在一个老工程里先跑通一条可观测的链路。
2. 工程骨架与环境对齐:VS2008 里把 ZeroMQ 发布订阅跑起来
2.1 选库与工具集:VC9 下静态库/动态库的取舍
VS2008 对应的是 VC9 工具集,C++ 标准停留在 C++98 附近,没有 C++11 的线程和智能指针可用。ZeroMQ 本身提供的是 C API,头文件zmq.h不依赖 C++11,这点对老工程友好;真正麻烦的是二进制兼容。拿到一个libzmq.lib或libzmq.dll时,不能只看文件名,要先确认它是用哪个编译器、哪个运行库、哪个平台编出来的。MSVC 不同版本的 CRT 和 ABI 不保证兼容,把高版本编译器生成的库硬塞进 VS2008 工程,常见结果是链接报 LNK2019,或者编译过了、运行到zmq_ctx_new时堆损坏。
我一般优先选仍带 VC9 工程文件、或者能用 VC9 工具集重新编译的 ZeroMQ 3.x 老分支。不是新版本不好,而是老项目里先求可控:源码能编、库能换、崩溃能定位。开发期用动态库方便替换,发布时再看现场机器是否缺运行库;如果现场部署环境干净、不想带 dll,就用静态库,但主程序和库的运行时库必须一致,Debug 配 Debug,Release 配 Release,/MTd对/MTd,/MT对/MT。动态库和静态库的差别可以先用一张表定下来:
| 方式 | 组成 | 适合场景 | 主要注意点 |
|---|---|---|---|
| 动态链接 | libzmq.lib+libzmq.dll | 开发调试、库需要独立替换 | dll 放 exe 同目录或输出目录,Debug/Release 不能混 |
| 静态链接 | libzmq.lib | 现场机器不想额外部署 dll | 定义ZMQ_STATIC,运行库与库一致,体积变大 |
平台也要一次定死。VS2008 默认新建 Win32 控制台工程,如果库是 x64 的,链接器会直接告诉你机器类型冲突。老工控现场多数还是 32 位系统,我一般先按 Win32 配通,再考虑 x64。不要在同一个解决方案里一半 Win32 一半 x64,后面调试时会把时间浪费在“为什么昨天能跑今天不能跑”上。
2.2 一个发布端一个订阅端:最小可运行工程怎么搭
最小验证不需要复杂框架,两个 Win32 控制台工程就够:一个pubdemo负责绑定端口并周期发送,一个subdemo负责连接并打印收到的消息。目录可以按下面这种方式放,第三方库单独放一层,后面换库不影响业务代码:
zeromq_demo/ pubdemo/ pubdemo.vcproj main.cpp subdemo/ subdemo.vcproj main.cpp third_party/ zeromq/ include/ zmq.h lib/ libzmq.lib bin/ libzmq.dll在 VS2008 里新建 Win32 Console Application,选空项目,不要预编译头,源文件用.cpp。字符集建议统一成多字节,控制台里用argv取参数省事;如果工程必须 Unicode,就把端点、主题这些字符串统一用std::string或CStringA转换,别在char*和wchar_t*之间来回转。两个工程都要加头文件目录、库目录和附加依赖项,配置方法下一节统一说。
发布端和订阅端的角色先按常规方向来:PUB 端bind,SUB 端connect。ZeroMQ 允许反过来连,但新手阶段不要给自己增加变量。先保证pubdemo.exe tcp://*:5556能启动,subdemo.exe tcp://127.0.0.1:5556能连上,再谈主题过滤和性能。启动顺序也有讲究:先起订阅端,再起发布端,否则发布端在订阅者连上来之前发出去的消息会直接丢掉,这不是 bug,是 PUB/SUB 的设计使然。
2.3 编译链接参数:头文件、lib、运行库和线程模型
VS2008 的项目属性是分配置的,Debug 和 Release 要分别设置,不能只改一个。下面这些位置是我每次接手老工程都会逐个核对的地方:
| 属性位置 | 建议值 | 错配后的典型现象 |
|---|---|---|
| C/C++ -> General -> Additional Include Directories | ..\third_party\zeromq\include | 找不到zmq.h |
| Linker -> General -> Additional Library Directories | ..\third_party\zeromq\lib | 找不到libzmq.lib |
| Linker -> Input -> Additional Dependencies | libzmq.lib ws2_32.lib | LNK2019 或网络初始化相关错误 |
| C/C++ -> Preprocessor -> Preprocessor Definitions | 静态链接时加ZMQ_STATIC | 静态库被当成 dllimport,链接或运行异常 |
| C/C++ -> Code Generation -> Runtime Library | Debug/MTd,Release/MT,与库一致 | 堆损坏、退出时崩溃 |
| Linker -> System -> SubSystem | Console | 程序没有控制台窗口,调试输出看不到 |
静态链接时通常还要把ws2_32.lib一起加到附加依赖项,具体以链接器报错为准。动态链接时不需要定义ZMQ_STATIC,但要把对应版本的libzmq.dll放到 exe 同目录。VS2008 调试时默认工作目录不一定是输出目录,我一般把调试工作目录设成$(OutDir),或者把 dll 直接复制到输出目录,省得每次 F5 都提示找不到 dll。
提示:不要为了省事把
libzmq.dll丢进系统目录。现场机器上可能还有别的程序加载不同版本的 dll,后面排查“为什么单独运行正常、集成后崩溃”会非常难受。
线程模型也提前定:ZeroMQ 的context可以跨线程共享,socket不是线程安全的。示例阶段可以单线程发送、单线程接收;后面接业务时,一个工作线程一个 socket,别在主线程里 close 一个正在别的线程 recv 的 socket。这个约束不写在编译器里,但违反后崩得很有随机性。
2.4 先跑通再谈性能:用两个控制台程序验证链路
配置完成后先做最小验证,不要一上来就接业务逻辑。开两个命令行窗口,先运行订阅端,再运行发布端,观察订阅端是否按秒打印消息。发布端默认绑定tcp://*:5556,订阅端连接tcp://127.0.0.1:5556,主题先用sensor.这种短前缀。端口被占用时zmq_bind会返回失败,可以用netstat -ano | findstr :5556看谁占着;如果订阅端连不上,先确认发布端确实在监听,再确认防火墙没有拦截本机回环以外的地址。
验证阶段要记录三件事:发布端是否每秒都在发、订阅端是否每帧都打印、退出时进程是否能在短时间内结束。第一项看发送循环有没有被阻塞,第二项看订阅过滤和连接方向,第三项看LINGER和close的顺序。很多人第一次跑通后直接拷进业务工程,结果业务工程是 MFC、多线程、Unicode,链接一塌糊涂;先把两个控制台程序跑稳,再往老工程里搬,能省掉一半排查时间。
3. 订阅发布链路拆解:从 zmq_ctx 到 topic 过滤的完整代码
3.1 上下文与套接字生命周期:先建 ctx 再建 socket
ZeroMQ 的入口是zmq_ctx_new,它创建一个进程级上下文,后面所有 socket 都挂在这个上下文上。上下文可以共享,但 socket 必须归单个线程使用。创建顺序是ctx -> socket -> setsockopt -> bind/connect,退出顺序反过来:先closesocket,再ctx_term。如果 socket 没关就ctx_term,进程可能卡在退出阶段,尤其在还有未发送消息的时候。
发布端最小代码如下,先看清生命周期,再看发送逻辑:
#include <zmq.h> #include <stdio.h> #include <string.h> #include <windows.h> int main(int argc, char* argv[]) { const char* endpoint = (argc > 1) ? argv[1] : "tcp://*:5556"; // 1. 创建上下文,进程内共享 void* ctx = zmq_ctx_new(); if (!ctx) { printf("zmq_ctx_new failed: %d\n", zmq_errno()); return 1; } // 2. 创建 PUB socket void* pub = zmq_socket(ctx, ZMQ_PUB); if (!pub) { printf("zmq_socket failed: %d\n", zmq_errno()); zmq_ctx_term(ctx); return 1; } // 3. 退出时不等待未发送消息,避免进程卡死 int linger = 0; zmq_setsockopt(pub, ZMQ_LINGER, &linger, sizeof(linger)); // 4. 绑定端点,PUB 端通常负责 bind if (zmq_bind(pub, endpoint) != 0) { printf("zmq_bind %s failed: %d\n", endpoint, zmq_errno()); zmq_close(pub); zmq_ctx_term(ctx); return 1; } printf("pub bind %s\n", endpoint); // 后面进入发送循环 zmq_close(pub); zmq_ctx_term(ctx); return 0; }这段代码只负责把上下文和 socket 建起来。endpoint是命令行第一个参数,默认tcp://*:5556,*表示绑定本机所有地址;如果只想本机测试,也可以写tcp://127.0.0.1:5556。ZMQ_LINGER设为 0,表示close时立即丢弃未发送完的消息,不让进程等;设为 -1 会无限等待,设为正数则等对应毫秒数。发布端退出时消息丢失是 PUB/SUB 的常态,后面避坑章节会展开。
3.2 PUB 端循环发送:主题帧与消息体的分帧
发布端发送时,我习惯把第一帧当主题,第二帧当业务数据。主题帧不是 ZeroMQ 强制的,但订阅过滤、日志和后续扩展都靠它。发送循环如下:
int seq = 0; while (1) { char topic[32]; char body[128]; // 主题帧用固定前缀,订阅端按前缀过滤 sprintf_s(topic, sizeof(topic), "sensor."); // 业务数据里带序号和时间戳,便于观察丢包 sprintf_s(body, sizeof(body), "seq=%d,ts=%lu", seq++, GetTickCount()); // 第一帧带 ZMQ_SNDMORE,表示后面还有一帧 int rc = zmq_send(pub, topic, (int)strlen(topic), ZMQ_SNDMORE); if (rc < 0) { printf("send topic failed: %d\n", zmq_errno()); break; } // 最后一帧不带 ZMQ_SNDMORE rc = zmq_send(pub, body, (int)strlen(body), 0); if (rc < 0) { printf("send body failed: %d\n", zmq_errno()); break; } Sleep(1000); }zmq_send的第三个参数是字节长度,不是字符个数,这里发送的是 ASCII 文本,所以strlen够用;如果发送二进制结构体,就要用sizeof并注意结构体对齐。ZMQ_SNDMORE表示当前帧后面还有帧,最后一帧要去掉这个标志。第一帧发送成功、第二帧失败时,这条多帧消息就是不完整的,最稳妥的做法是不要再往这个 socket 上继续发新消息,记录错误后重建 socket。
发布端不会检查有没有订阅者,也不会为某个订阅者保留历史消息。Sleep(1000)是为了让输出可读,真实业务里可能是采集回调触发发送。如果发送频率很高,要关注ZMQ_SNDHWM,PUB 端在达到高水位后会丢弃消息而不是阻塞;这是“慢订阅者”场景下最容易被误判成网络问题的点。
3.3 SUB 端订阅过滤:前缀匹配、空订阅和慢连接
订阅端的核心是ZMQ_SUBSCRIBE,它设置的是字节前缀,不是正则表达式。发送端主题为sensor.,订阅端可以订阅sensor.,也可以订阅更短的sensor;如果订阅sensor1,就匹配不到sensor.。先订阅再连接,空字符串表示订阅所有消息。接收时用zmq_msg_recv和zmq_msg_more处理多帧:
static void recv_one(void* sub) { zmq_msg_t msg; zmq_msg_init(&msg); // 接收第一帧,通常是主题帧 int rc = zmq_msg_recv(&msg, sub, 0); if (rc < 0) { printf("recv failed: %d\n", zmq_errno()); zmq_msg_close(&msg); return; } int more = zmq_msg_more(&msg); printf("frame len=%d more=%d data=%.*s\n", (int)zmq_msg_size(&msg), more, (int)zmq_msg_size(&msg), (char*)zmq_msg_data(&msg)); zmq_msg_close(&msg); // 继续接收同一消息的后续帧 while (more) { zmq_msg_init(&msg); rc = zmq_msg_recv(&msg, sub, 0); if (rc < 0) { printf("recv body failed: %d\n", zmq_errno()); zmq_msg_close(&msg); break; } more = zmq_msg_more(&msg); printf("frame len=%d more=%d data=%.*s\n", (int)zmq_msg_size(&msg), more, (int)zmq_msg_size(&msg), (char*)zmq_msg_data(&msg)); zmq_msg_close(&msg); } }这个函数把收到的每一帧都打印出来,more为 1 表示还有后续帧。zmq_msg_size返回size_t,打印时转成int;zmq_msg_data返回帧数据指针,%.*s用长度限制输出,避免把二进制数据当字符串读飞。订阅端初始化如下:
const char* endpoint = (argc > 1) ? argv[1] : "tcp://127.0.0.1:5556"; const char* filter = (argc > 2) ? argv[2] : "sensor."; void* ctx = zmq_ctx_new(); void* sub = zmq_socket(ctx, ZMQ_SUB); int linger = 0; zmq_setsockopt(sub, ZMQ_LINGER, &linger, sizeof(linger)); // 先设置订阅前缀,再连接 zmq_setsockopt(sub, ZMQ_SUBSCRIBE, filter, (int)strlen(filter)); zmq_connect(sub, endpoint);filter是第二个命令行参数,默认sensor.;想验证链路是否通,可以先传空字符串订阅全部。zmq_connect对 TCP 是异步的,返回成功只代表本地 socket 接受了连接请求,不代表发布端已经连上。先启动发布端、后启动订阅端时,订阅端连接完成之前发布的消息收不到,这就是常说的慢连接。测试时先起订阅端,再起发布端,或者让发布端周期发送,别用“发一条就等”的方式判断链路。
3.4 优雅退出:LINGER、close 和 ctx_term 的顺序
退出阶段最容易被忽略。发布端设置linger=0后,zmq_close会立即丢弃未发送消息,然后zmq_ctx_term回收上下文。订阅端如果阻塞在zmq_msg_recv,需要先用zmq_poll加超时,或者用另一个线程发停止标志,不能直接在主线程close一个正在 recv 的 socket。一个安全的退出顺序是:
int linger = 0; zmq_setsockopt(pub, ZMQ_LINGER, &linger, sizeof(linger)); // 先关闭 socket,再终止上下文 zmq_close(pub); zmq_ctx_term(ctx);linger=0适合发布端和测试程序,不适合“消息绝不能丢”的业务。PUB/SUB 本身没有确认机制,真要保证关键指令到达,得在应用层加序号、确认和重发,或者换用 PUSH/PULL、REQ/REP 这类有反压和应答的模式。退出时如果ctx_term卡住,先检查是不是还有 socket 没关,再检查是不是linger=-1导致在等一个永远连不上的订阅者。
4. 工程化改造:把两段示例拆成可配置的发布订阅小程序
4.1 端点与主题配置:命令行参数和 ini 文件
示例里把端点和主题写死在代码里,搬到现场就要重编译,不现实。我一般做成“命令行优先、ini 兜底”:命令行传了就用命令行,没传就读同目录的demo.ini。VS2008 自带GetPrivateProfileStringA,读简单的 ini 够用,不必引入额外库。配置可以长这样:
[pub] endpoint=tcp://*:5556 interval_ms=1000 [sub] endpoint=tcp://127.0.0.1:5556 filter=sensor. rcv_timeout_ms=3000读取函数可以封装成下面这样,返回值用std::string,调用处不用关心缓冲区:
#include <windows.h> #include <string> static std::string ReadIni(const char* file, const char* section, const char* key, const char* def) { char buf[512] = {0}; // 第三个参数是默认值,键不存在时返回 def GetPrivateProfileStringA(section, key, def, buf, sizeof(buf), file); return std::string(buf); }file建议传绝对路径,至少是 exe 同目录的完整路径,否则调试时工作目录一变就读不到。section和key对应 ini 里的节和键,def是缺省值。GetPrivateProfileStringA的 A 版本按多字节读取,和前面建议的字符集保持一致;路径里尽量不要放中文,老工程在不同代码页下容易出现找不到文件却看不出原因的情况。配置化之后,改端口、改主题、改发送间隔都不用重新编译,现场排查会轻松很多。
4.2 多帧消息的拼装:别把 ZeroMQ 当 TCP 流
ZeroMQ 会保留消息边界,不存在
本文还有配套的精品资源,点击获取