https://github.com/anjuke/zguide-cn/blob/master/chapter2.md
ZMQ消息由zmq_msg_t结构表示(每种语言有特定的表示)。在C语言中使用ZMQ消息时需要注意以下几点:
- 你需要创建和传递zmq_msg_t对象,而不是一组数据块;
- 读取消息时,先用zmq_msg_init()初始化一个空消息,再将其传递给zmq_recv()函数;
- 写入消息时,先用zmq_msg_init_size()来创建消息(同时也已初始化了一块内存区域),然后用memcpy()函数将信息拷贝到该对象中,最后传给zmq_send()函数;
- 释放消息(并不是销毁)时,使用zmq_msg_close()函数,它会将对消息对象的引用删除,最终由ZMQ将消息销毁;
- 获取消息内容时需使用zmq_msg_data()函数;若想知道消息的长度,可以使用zmq_msg_size()函数;
- 至于zmq_msg_move()、zmq_msg_copy()、zmq_msg_init_data()函数,在充分理解手册中的说明之前,建议不好贸然使用。
// 从套接字中获取ZMQ字符串,并转换为C语言字符串 static char * s_recv (void *socket) { zmq_msg_t message; zmq_msg_init (&message); zmq_recv (socket, &message, 0); int size = zmq_msg_size (&message); char *string = malloc (size + 1); memcpy (string, zmq_msg_data (&message), size); zmq_msg_close (&message); string [size] = 0; return (string); } // 将C语言字符串转换为ZMQ字符串,并发送给套接字 static int s_send (void *socket, char *string) { int rc; zmq_msg_t message; zmq_msg_init_size (&message, strlen (string)); memcpy (zmq_msg_data (&message), string, strlen (string)); rc = zmq_send (socket, &message, 0); assert (!rc); zmq_msg_close (&message); return (rc); }
需要注意的是,当你将一个消息对象传递给zmq_send()函数后,该对象的长度就会被清零,因此你无法发送同一个消息对象两次,也无法获得已发送消息的内容。
如果你想发送同一个消息对象两次,就需要在发送第一次前新建一个对象,使用zmq_msg_copy()函数进行拷贝。这个函数不会拷贝消息内容,只是拷贝引用。然后你就可以再次发送这个消息了(或者任意多次,只要进行了足够的拷贝)。当消息最后一个引用被释放时,消息对象就会被销毁。
ZMQ支持多帧消息,即在一条消息中保存多个消息帧。这在实际应用中被广泛使用。
关于消息,还有一些需要注意的地方:
- ZMQ的消息是作为一个整体来收发的,你不会只收到消息的一部分;
- ZMQ不会立即发送消息,而是有一定的延迟;
- 你可以发送0字节长度的消息,作为一种信号;
- 消息必须能够在内存中保存,如果你想发送文件或超长的消息,就需要将他们切割成小块,在独立的消息中进行发送;
- 必须使用zmq_msg_close()函数来关闭消息,但在一些会在变量超出作用域时自动释放消息对象的语言中除外。
再重复一句,不要贸然使用zmq_msg_init_data()函数。它是用于零拷贝,而且可能会造成麻烦。
处理多个套接字 zmq_poll()
处理错误和ETERM信号
ZMQ的错误处理机制提倡的是快速崩溃。一个进程对于自身内部的错误来说要越脆弱越好,而对外部的攻击和错误要足够健壮。举个例子,活细胞会因检测到自身问题而瓦解,但对外界的攻击却能极力抵抗。
现实中的代码应该对每一次的ZMQ函数调用作错误处理。如果你不是使用C语言进行编程,可能那种语言的ZMQ类库已经做了错误处理。但在C语言中,你需要自己动手。以下是一些常规的错误处理手段,从POSIX规范开始:
- 创建对象的方法如果失败了会返回NULL;
- 其他方法执行成功时会返回0,失败时会返回其他值(一般是-1);
- 错误代码可以从变量errno中获得,或者调用zmq_errno()函数;
- 错误消息可以调用zmq_strerror()函数获得。
有两种情况不应该被认为是错误:
- 当线程使用NOBLOCK方式调用zmq_recv()时,若没有接收到消息,该方法会返回-1,并设置errno为EAGAIN;
- 当线程调用zmq_term()时,若其他线程正在进行阻塞式的处理,该函数会中止所有的处理,关闭套接字,并使得那些阻塞方法的返回值为-1,errno设置为ETERM。
遵循以上规则,你就可以在ZMQ程序中使用断言了
void *context = zmq_init (1);
assert (context);
void *socket = zmq_socket (context, ZMQ_REP);
assert (socket);
int rc;
rc = zmq_bind (socket, "tcp://*:5555");
assert (rc == 0);
ZMQ的原则是:如果需要解决一个新的问题,就该使用新的套接字。
下面是worker进程的代码,它会打开三个套接字:用于接收任务的PULL、用于发送结果的PUSH、以及用于接收自杀信号的SUB,使用zmq_poll()进行轮询:
处理中断信号
使用s_catch_signals()函数来捕捉像Ctrl-C(SIGINT)和SIGTERM这样的信号。收到任一信号后,该函数会将全局变量s_interrupted设置为1
多帧消息
发送多帧消息:
zmq_send (socket, &message, ZMQ_SNDMORE);
...
zmq_send (socket, &message, ZMQ_SNDMORE);
...
zmq_send (socket, &message, 0);
接收并处理这些消息,这段代码对单帧消息和多帧消息都适用:
while (1) {
zmq_msg_t message;
zmq_msg_init (&message);
zmq_recv (socket, &message, 0);
// 处理一帧消息
zmq_msg_close (&message);
int64_t more;
size_t more_size = sizeof (more);
zmq_getsockopt (socket, ZMQ_RCVMORE, &more, &more_size);
if (!more)
break; // 已到达最后一帧
}
关于多帧消息,你需要了解的还有:
- 在发送多帧消息时,只有当最后一帧提交发送了,整个消息才会被发送;
- 如果使用了zmq_poll()函数,当收到了消息的第一帧时,其它帧其实也已经收到了;
- 多帧消息是整体传输的,不会只收到一部分;
- 多帧消息的每一帧都是一个zmq_msg结构;
- 无论你是否检查套接字的ZMQ_RCVMORE选项,你都会收到所有的消息;
- 发送时,ZMQ会将开始的消息帧缓存在内存中,直到收到最后一帧才会发送;
- 我们无法在发送了一部分消息后取消发送,只能关闭该套接字。
DEALER和ROUTER套接字可以进行非阻塞的消息收发。DEALER过去被称为XREQ,ROUTER被称为XREP,但新的代码中应尽量使用DEALER/ROUTER这种名称。
内置装置
ZMQ提供了一些内置的装置,不过大多数人需要自己手动编写这些装置。内置装置有:
- QUEUE,可用作请求-应答代理;
- FORWARDER,可用作发布-订阅代理服务;
- STREAMER,可用作管道模式代理。
可以使用zmq_device()来启动一个装置,需要传递两个套接字给它:
zmq_device (ZMQ_QUEUE, frontend, backend);
QUEUE装置应使用ROUTER/DEALER套接字、FORWARDER应使用SUB/PUB、STREAMER应使用PULL/PUSH。
ZMQ多线程编程
使用ZMQ进行多线程编程时,不需要考虑互斥、锁、或其他并发程序中要考虑的因素,你唯一要关心的仅仅是线程之间的消息。
三十多年来,并发式应用程序开发所总结的经验是:不要共享状态。这就好比两个醉汉想要分享一杯啤酒,如果他们不是铁哥们儿,那他们很快就会打起来。当有更多的醉汉加入时,情况就会更糟。多线程编程有时就像醉汉抢夺啤酒那样糟糕。
如何用ZMQ进行多线程编程,以下是一些规则:
-
不要在不同的线程之间访问同一份数据,如果要用到传统编程中的互斥机制,那就有违ZMQ的思想了。唯一的例外是ZMQ上下文对象,它是线程安全的。
-
必须为进程创建ZMQ上下文,并将其传递给所有你需要使用inproc协议进行通信的线程;
-
你可以将线程作为单独的任务来对待,使用自己的上下文,但是这些线程之间就不能使用inproc协议进行通信了。这样做的好处是可以在日后方便地将程序拆分为不同的进程来运行。
-
不要在不同的线程之间传递套接字对象,这些对象不是线程安全的。从技术上来说,你是可以这样做的,但是会用到互斥和锁的机制,这会让你的应用程序变得缓慢和脆弱。唯一合理的情形是,在某些语言的ZMQ类库内部,需要使用垃圾回收机制,这时可能会进行套接字对象的传递。
当你需要在应用程序中使用两个装置时,可能会将套接字对象从一个线程传递给另一个线程,这样做一开始可能会成功,但最后一定会随机地发生错误。所以说,应在同一个线程中打开和关闭套接字。
消息的流向是这样的:REQ-ROUTER-queue-DEALER-REP。
- 服务端启动一组worker线程,每个worker创建一个REP套接字,并处理收到的请求。worker线程就像是一个单线程的服务,唯一的区别是使用了inproc而非tcp协议,以及绑定-连接的方向调换了。
- 服务端创建ROUTER套接字用以和client通信,因此提供了一个TCP协议的外部接口。
- 服务端创建DEALER套接字用以和worker通信,使用了内部接口(inproc)。
- 服务端启动了QUEUE内部装置,连接两个端点上的套接字。QUEUE装置会将收到的请求分发给连接上的worker,并将应答路由给请求方。
线程间的信号传输 PAIR
PUSH套接字发送消息时会进行负载均衡,如果你不小心开启了两个接收方,就会“丢失”一半的信号。而PAIR套接字建立的是一对一的连接,具有排他性。
节点协调
当你想要对节点进行协调时,PAIR套接字就不怎么合适了,这也是线程和节点之间的不同之处。一般来说,节点是来去自由的,而线程则较为稳定。第二个区别在于,线程的数量一般是固定的,而节点数量则会经常变化。
零拷贝
做零拷贝时,使用zmq_msg_init_data()函数创建一条消息,其内容指向某个已经分配好的内存区域,然后将该消息传递给zmq_send()函数。创建消息时,你还需要提供一个用于释放消息内容的函数,ZMQ会在消息发送完毕时调用。
在接收消息的时候是无法使用零拷贝的:ZMQ会将收到的消息放入一块内存区域供你读取,但不会将消息写入程序指定的内存区域。
ZMQ的套接字缓存对程序原来说是不可见的,正如TCP缓存一样。
瞬时套接字和持久套接字
为套接字设置标识,从而建立了一个持久的套接字:
zmq_setsockopt (socket, ZMQ_IDENTITY, "Lucy", 4);
关于套接字标识还有几点说明:
- 如果要为套接字设置标识,必须在连接或绑定至端点之前设置;
- 接收方会选择使用套接字标识,正如cookie在HTTP网页应用中的性质,是由服务器去选择要使用哪个cookie的;
- 套接字标识是二进制字符串;以字节0开头的套接字标识为ZMQ保留标识;
- 不用为多个套接字指定相同的标识,若套接字使用的标识已被占用,它将无法连接至其他套接字;
- 不要使用随机的套接字标识,这样会生成很多持久化套接字,最终让节点崩溃;
- 如果你想获取对方套接字的标识,只有ROUTER套接字会帮你自动完成这件事,使用其他套接字类型时,需要将标识作为消息的一帧发送过来;
- 说了以上这些,使用持久化套接字其实并不明智,因为它会让发送者越来越混乱,让架构变得脆弱。如果我们能重新设计ZMQ,很可能会去掉这种显式声明套接字标识的功能。
发布-订阅消息信封
多帧消息的典型用法——消息信封
// 发布两条消息,A类型和B类型
s_sendmore (publisher, "A");
s_send (publisher, "We don't want to see this");
s_sendmore (publisher, "B");
s_send (publisher, "We would like to see this");
(半)持久订阅者和阈值(HWM)
所有的套接字类型都可以使用标识(持久化)。如果你在使用PUB和SUB套接字,其中SUB套接字为自己声明了标识,那么,当SUB断开连接时,PUB会保留要发送给SUB的消息。
这种机制有好有坏。好的地方在于发布者会暂存这些消息,当订阅者重连后进行发送;不好的地方在于这样很容易让发布者因内存溢出而崩溃。
如果你在使用持久化的SUB套接字(即为SUB设置了套接字标识),那么你必须设法避免消息在发布者队列中堆砌并溢出,应该使用阈值(HWM)来保护发布者套接字。发布者的阈值会分别影响所有的订阅者。
uint64_t hwm = 2;
zmq_setsockopt (publisher, ZMQ_HWM, &hwm, sizeof (hwm));
关于阈值:
-
这个选项会同时影响套接字的发送和接收队列。当然,PUB、PUSH不会有接收队列,SUB、PULL、REQ、REP不会有发送队列。而像DEALER、ROUTER、PAIR套接字时,他们既有发送队列,又有接收队列。
-
当套接字达到阈值时,ZMQ会发生阻塞,或直接丢弃消息。
-
使用inproc协议时,发送者和接受者共享同一个队列缓存,所以说,真正的阈值是两个套接字阈值之和。如果一方套接字没有设置阈值,那么它就不会有缓存方面的限制。