zeromq对传统的⽹络接⼝进⾏再次封装,提供了⼀个跨语⾔的库,⽀持任意语⾔使⽤统⼀的接⼝。ZMQ不是单独的服务,⽽是⼀个嵌⼊式库,它封装了⽹络通信、消息队列、线程调度等功能,向上层提供简洁的API,应⽤程序通过加载库⽂件,调⽤API函数来实现⾼性能⽹络通信。简单来说,zeromq就是实现了消息队列的一个库。这里只简单介绍一下zeromq线程间的交互与四个模型的实现。
主线程与IO线程
ZMQ根据⽤户调⽤zmq_init函数时传⼊的参数,创建对应数量的I/O线程。每个I/O线程都有与之绑定的Poller,Poller采⽤经典的Reactor模式实现,处理具体的网络IO操作。主线程处理具体的业务,比如sub、pub、push、pull。
可以看出,主线程与IO线程之间的交互是通过管道实现的。注意这里的管道使用的是无锁队列。由于无锁队列不能阻塞,当管道中没有数据时,就需要通过命令去阻塞,当有数据到来时,通过mailbox去发命令通知主线程,然后调用recv()。这里特别提醒一下,不需要每次来数据都通知,只是在管道中数据从无到有时才通知,从有到无会阻塞。这有点像epoll的边沿触发。
消息协议设计
01 00 # 消息分隔符
01 05 48 65 6c 6c 6f # 第一个字节表示是否还有后续数据,即ZMQ_RCVMORE,第二个字节代表数据长度,这里是5字节,后面是数据,即“Hello”
00 07 20 64 61 72 72 65 6e # 第一个字节为0,则没有后续数据,即本帧为最后一帧。后面7字节为内容
常用模型
这里介绍zeromq用到的四种模型。
请求响应模型
ZMQ_REQ请求端
请求端主要有以下几个函数。
发送数据
int zmq::req_t::xsend (msg_t *msg_);
这是发送数据的接口,后面讲的其他模型也是xsend(),只不过是在不同的类中。
通过_receiving_reply控制send的发送,当_receiving_reply为true时等待对⽅应答。当消息分帧发送时,只有最后⼀帧才将_receiving_reply设置为true,可以对应协议来理解。
// If we've sent a request and we still haven't got the reply,
// we can't send another request unless the strict option is disabled.
if (_receiving_reply) {
if (_strict) {
errno = EFSM;
return -1;
}
_receiving_reply = false;
_message_begins = true;
}
接收数据
int zmq::req_t::xrecv (msg_t *msg_);
接收完同⼀个消息的所有帧才将_receiving_reply置为false,运⾏继续send。
// If the reply is fully received, flip the FSM into request-sending state.
if (!(msg_->flags () & msg_t::more)) {
_receiving_reply = false;
_message_begins = true;
}
ZMQ_REP响应端
响应端的函数类似。函数中的源码就不贴了,只给出一些说明。
发送数据
int zmq::rep_t::xsend (msg_t *msg_);
通过_sending_reply控制send的发送,当_sending_reply为false时需要对⽅发请求后才能send数据;当消息分帧时,只有最后⼀帧才将_sending_reply设置为false,说明send rep完成,等待新的req。
接收数据
int zmq::rep_t::xrecv (msg_t *msg_)
_sending_reply如果为true,说明还没有回复消息,此时不能再接收数据。接收完同⼀个消息的所有帧才将_sending_reply置为true,说明转到rep状态,只有send rep后才能再接收数据。
发布订阅模型
SUB订阅者要先到PUB发布端订阅数据。
模式的核⼼在于:
- subscriber向publisher注册filter (topic),注意不同的subscriber注册的topic可以相同
- publisher根据filter(topic)向对应的subscriber发布消息
- 每个subscriber订阅者都有自己的一套管道
zeromq使⽤了两组socket_base_t的派⽣类。
xsub_t和sub_t⽤于sub端。sub_t的xsetsockopt()⽤于设置filter选项,它会调⽤xsub_t的xsend()向pub端发送⼀个注册请求。当sub端收到发布的消息时,会暂存在xsub_t的fq_t成员中,以备以后⽤户调⽤xrecv()来获取。
int zmq::sub_t::xsetsockopt (int option_,
const void *optval_,
size_t optvallen_)
{
if (option_ != ZMQ_SUBSCRIBE && option_ != ZMQ_UNSUBSCRIBE) {
errno = EINVAL;
return -1;
}
// Create the subscription message.
msg_t msg;
int rc;
const unsigned char *data = static_cast<const unsigned char *> (optval_);
if (option_ == ZMQ_SUBSCRIBE) {
rc = msg.init_subscribe (optvallen_, data);
} else {
rc = msg.init_cancel (optvallen_, data);
}
errno_assert (rc == 0);
// Pass it further on in the stack.
rc = xsub_t::xsend (&msg);
return close_and_return (&msg, rc);
}
xpub_t和pub_t⽤于pub端。 mtrie_t成员和dist_t成员配合使⽤,⽤于过滤和发布消息。mtrie_t是⼀种字典树,⽀持prefix match模式。sub端注册filter时,对应的pipe_t同时保存在mtrie_t和dist_t中,在mtrie_t中根据消息头(可以理解为topic)找到期望的⽬标pipe_t时,会调⽤mark_as_matching()在dist_t中标记pipe_t,然后dist_t根据标记发送消息。
void generic_mtrie_t<T>::match (prefix_t data_, size_t size_,
void (*func_) (value_t *pipe_, Arg arg_), //这里的回调函数会传入mark_as_matching
Arg arg_)
{
for (generic_mtrie_t *current = this; current; data_++, size_--) {
// Signal the pipes attached to this node.
if (current->_pipes) {
for (typename pipes_t::iterator it = current->_pipes->begin (),end = current->_pipes->end ();it != end; ++it) {
func_ (*it, arg_); //匹配时会调用
}
}
// If we are at the end of the message, there's nothing more to match.
if (!size_)
break;
// If there are no subnodes in the trie, return.
if (current->_count == 0)
break;
if (current->_count == 1) {
// If there's one subnode (optimisation).
if (data_[0] != current->_min) {
break;
}
current = current->_next.node;
} else {
// If there are multiple subnodes.
if (data_[0] < current->_min || data_[0] >= current->_min + current->_count) {
break;
}
current = current->_next.table[data_[0] - current->_min];
}
}
}
推拉模式
每个收到的消息都有⼀个routing_id,它是⼀个32位⽆符号整数。要将消息发送到给定的CLIENT对等⽅,应⽤程序必须在消息上设置对等⽅的 routing_id。如果未指定routing_id,或者未引⽤已连接的客户端对等⽅,则发送调⽤将失败。如果客户端对等⽅的传出缓冲区已满,则发送调⽤将阻塞。在任何情况下,SERVER套接字都不会丢弃消息。
push端和pull端都可以作为服务端或客户端,但是一个端不能同时是客户端和服务端。
pull端读取各个push端的数据,使用的是公平队列。
int zmq::pull_t::xrecv (msg_t *msg_)
{
return _fq.recv (msg_);
}
注意pull_t中没有xsend(),同样push_t没有xrecv()。
push端给各个pull端发送数据,是使用的负载均衡。
int zmq::push_t::xsend (msg_t *msg_)
{
return _lb.send (msg_);
}
这里的公平队列和负载均衡,本质上都是轮询。
int zmq::lb_t::sendpipe (msg_t *msg_, pipe_t **pipe_){
// Drop the message if required. If we are at the end of the message
...
while (_active > 0) {
if (_pipes[_current]->write (msg_)) { // 发送数据到pipe
if (pipe_)
*pipe_ = _pipes[_current];
break;
}
// If send fails for multi-part msg rollback other
// parts sent earlier and return EAGAIN.
// Application should handle this as suitable
if (_more) {
_pipes[_current]->rollback ();
_dropping = (msg_->flags () & msg_t::more) != 0;
_more = false;
errno = EAGAIN;
return -2;
}
_active--;
if (_current < _active)
_pipes.swap (_current, _active);
else
_current = 0;
}
...
return 0;
}
这是负载均衡的代码,可以看出是轮询,公平队列代码就不贴了。
ZMQ_DEALER和ZMQ_ROUTER路由模型
本质上是多个req端对应多个rep端。这里也用到了刚才提到的公平队列和负载均衡。
这里需要了解的一个问题是,rep端回发时如何找到发送请求的req端?答案是routing_id。
发送时,req端会在router绑定一个routing_id,在dealer发送给rep端时,会附带这个routing_id。回发时,router会根据这个id找到对应的pipe,再往这个pipe中写数据,回发时,也会附带routing_id,但是router回发给req时不带routing_id(其实req给router发送数据时也没有routing_id,这个id的绑定和存储都是在router内部的)。
# REQ->ZMQ_ROUTER
01 00
00 05 48 65 6c 6c 6f # "Hello"
# ZMQ_ROUTER->REQ
01 00
00 05 57 6f 72 6c 64 # "World"
# ZMQ_DEALER->REP
01 05 00 6b 8b 45 68 # routing_id
01 00
00 05 48 65 6c 6c 6f # "Hello"
# REP->ZMQ_DEALER
01 05 00 6b 8b 45 68 # routing_id
01 00
00 05 57 6f 72 6c 64 # "World"
当客户端断开重连时,routing_id会改变,会如同连接一个新的客户端一样在已有值上+1。
此外,router路由类是rep的基类,带有响应的一些功能;dealer是req的基类,带有发送的一些功能。