一、Zeromq简介
ZeroMQ是一种基于消息队列的多线程网络库,其对套接字类型、连接处理、帧、甚至路由的底层细节进行抽象,提供跨越多种传输协议的套接字。ZeroMQ是网络通信中新的一层,介于应用层和传输层之间(按照TCP/IP划分),其是一个可伸缩层,可并行运行,分散在分布式系统间。
zeroMQ在设计上主要采用了以下几个高性能的特征:
1、无锁的队列模型
对于跨线程间的交互(用户端和session)之间的数据交换通道pipe,采用无锁的队列算法CAS;在pipe的两端注册有异步事件,在读或者写消息到pipe的时,会自动触发读写事件。
2、批量处理的算法
对于传统的消息处理,每个消息在发送和接收的时候,都需要系统的调用,这样对于大量的消息,系统的开销比较大,zeroMQ对于批量的消息,进行了适应性的优化,可以批量的接收和发送消息。
3、多核下的线程绑定,无须CPU切换
区别于传统的多线程并发模式,信号量或者临界区, zeroMQ充分利用多核的优势,每个核绑定运行一个工作者线程,避免多线程之间的CPU切换开销。
ZMQ连接和传统的TCP连接是有区别的,主要有:
ZMQ提供了一组单播传输协议(inporc, ipc, tcp),和两个广播协议(epgm, pgm)。广播协议是比较高级的协议,我们会在以后讲述。如果你不能回答我扇出比例会影响一对多的单播传输时,就先不要去学习广播协议了吧。
一般而言我们会使用tcp作为传输协议,这种TCP连接是可以脱机运作的,它灵活、便携、且足够快速。为什么称之为脱机,是因为ZMQ中的TCP连接不需要该端点已经有某个服务进行了绑定,客户端和服务端可以随时进行连接和绑定,这对应用程序而言都是透明的。
进程间协议,即ipc,和tcp的行为差不多,但已从网络传输中抽象出来,不需要指定IP地址或者域名。这种协议很多时候会很方便,本指南中的很多示例都会使用这种协议。ZMQ中的ipc协议同样可以是脱机的,但有一个缺点——无法在Windows操作系统上运作,这一点也许会在未来的ZMQ版本中修复。我们一般会在端点名称的末尾附上.ipc的扩展名,在UNIX系统上,使用ipc协议还需要注意权限问题。你还需要保证所有的程序都能够找到这个ipc端点。
进程内协议,即inproc,可以在同一个进程的不同线程之间进行消息传输,它比ipc或tcp要快得多。这种协议有一个要求,必须先绑定到端点,才能建立连接,也许未来也会修复。通常的做法是先启动服务端线程,绑定至端点,后启动客户端线程,连接至端点。
TCP套接字和ZMQ套接字之间在传输数据方面的区别:
二、Zeromq模式详解
1.Request-Reply模式
问答模式,由请求端发起请求,然后等待回应端应答。一个请求必须对应一个回应,从请求端的角度来看是发-收配对,从回应端的角度是收-发对。请求端可以是1~N个。该模型主要用于远程调用及任务分配等。
使用REQ-REP套接字发送和接受消息是需要遵循一定规律的。客户端首先使用zmq_send()发送消息,再用zmq_recv()接收,如此循环。如果打乱了这个顺序(如连续发送两次)则会报错。类似地,服务端必须先进行接收,后进行发送。
也就是说Request-Reply模式是严格同步的,Request端必须先发送后接受,reply端必须先接受后发送。
深入到信封通信原理,Request在发送数据帧之前一并包含了一个空白的数据分割符数据帧,即在程序中虽然只是发送了一个数据帧作为参数,实际Request套接字又在数据帧的基础上添加了空字符数据帧。Request在接受的时候会去掉空白分隔符数据帧,直接将实际的数据返回到应用程序。reply套接字在接受的时候会读取消息帧并存储一直遇到空白分隔符,然后将剩余的消息返回到应用程序,在发送的时候会将存储的消息与待发送的数据一并发送出去。
Server:
public static void main(String[] args) throws Exception {
ZMQ.Context context = ZMQ.context(1);
// Socket to talk to clients
ZMQ.Socket responder = context.socket(ZMQ.REP);
responder.bind("tcp://*:5555");
while (!Thread.currentThread().isInterrupted()) {
// Wait for next request from the client
byte[] request = responder.recv(0);
System.out.println("Received Hello");
// Do some 'work'
Thread.sleep(1000);
// Send reply back to client
String reply = "World";
responder.send(reply.getBytes(), 0);
}
responder.close();
context.term();
}
Client:
public static void main(String[] args) {
ZMQ.Context context = ZMQ.context(1);
// Socket to talk to server
System.out.println("Connecting to hello world server…");
ZMQ.Socket requester = context.socket(ZMQ.REQ);
requester.connect("tcp://localhost:5555");
for (int requestNbr = 0; requestNbr != 100; requestNbr++) {
String request = "Hello";
System.out.println("Sending Hello " + requestNbr);
requester.send(request.getBytes(), 0);
byte[] reply = requester.recv(0);
System.out.println("Received " + new String(reply) + " " + requestNbr);
}
requester.close();
context.term();
}
eclipse下maven项目工程下载地址,可直接运行:点击打开链接
2.Pub-Sub模式
发布订阅模式 发布端单向分发数据,且不关心是否把全部信息发送给订阅端。如果发布端开始发布信息时,订阅端尚未连接上来,则这些信息会被直接丢弃。订阅端未连接导致信息丢失的问题,可以通过与请求回应模型组合来解决。订阅端只负责接收,而不能反馈,且在订阅端消费速度慢于发布端的情况下,会在订阅端堆积数据。该模型主要用于数据分发。天气预报、微博明星粉丝可以应用这种经典模型。
在使用SUB套接字时,必须使用subscribe()方法来设置订阅的内容。如果你不设置订阅内容,那将什么消息都收不到。订阅信息可以是任何字符串,可以设置多次。只要消息满足其中一条订阅信息,SUB套接字就会收到。
PUB-SUB套接字组合是异步的。客户端在一个循环体中使用zmq_recv()接收消息,如果向SUB套接字发送消息则会报错;类似地,服务端可以不断地使用zmq_send()发送消息,但不能在PUB套接字上使用zmq_recv()。
关于PUB-SUB套接字,还有一点需要注意:你无法得知SUB是何时开始接收消息的。就算你先打开了SUB套接字,后打开PUB发送消息,这时SUB还是会丢失一些消息的,因为建立连接是需要一些时间的。很少,但并不是零。
我们知道在建立TCP连接时需要进行三次握手,会耗费几毫秒的时间,而当节点数增加时这个数字也会上升。在这么短的时间里,ZMQ就可以发送很多很多消息了。举例来说,如果建立连接需要耗时5毫秒,而ZMQ只需要1毫秒就可以发送完这1000条消息。
所以需要发布者和订阅者同步,只有当订阅者准备好时发布者才会开始发送消息。有一种简单的方法来同步PUB和SUB,就是让PUB延迟一段时间再发送消息。现实编程中我不建议使用这种方式,因为它太脆弱了,而且不好控制。不过这里我们先暂且使用sleep的方式来解决。
另一种同步的方式则是认为发布者的消息流是无穷无尽的,因此丢失了前面一部分信息也没有关系。我们的气象信息客户端就是这么做的。
示例中的气象信息客户端会收集指定邮编的一千条信息,其间大约有1000万条信息被发布。你可以先打开客户端,再打开服务端,工作一段时间后重启服务端,这时客户端仍会正常工作。当客户端收集完所需信息后,会计算并输出平均温度。
关于发布-订阅模式的几点说明:
public static void main (String[] args) throws Exception {
// Prepare our context and publisher
ZMQ.Context context = ZMQ.context(1);
ZMQ.Socket publisher = context.socket(ZMQ.PUB);
publisher.bind("tcp://*:5556");
// Initialize random number generator
Random srandom = new Random(System.currentTimeMillis());
while (!Thread.currentThread ().isInterrupted ()) {
// Get values that will fool the boss
int zipcode, temperature, relhumidity;
zipcode = 10000 + srandom.nextInt(10000) ;
temperature = srandom.nextInt(215) - 80 + 1;
relhumidity = srandom.nextInt(50) + 10 + 1;
// Send message to all subscribers
String update = String.format("%05d %d %d", zipcode, temperature, relhumidity);
publisher.send(update, 0);
}
publisher.close ();
context.term ();
}
Client1:
public static void main (String[] args) throws IOException {
ZMQ.Context context = ZMQ.context(1);
// Socket to talk to server
System.out.println("Collecting updates from weather server");
ZMQ.Socket subscriber = context.socket(ZMQ.SUB);
subscriber.connect("tcp://localhost:5556");
// Subscribe to zipcode, default is NYC, 10001
String filter = (args.length > 0) ? args[0] : "10001 ";
subscriber.subscribe(filter.getBytes());
// Process 100 updates
int update_nbr;
long total_temp = 0;
for (update_nbr = 0; update_nbr < 100; update_nbr++) {
// Use trim to remove the tailing '0' character
String string = subscriber.recvStr(0).trim();
StringTokenizer sscanf = new StringTokenizer(string, " ");
int zipcode = Integer.valueOf(sscanf.nextToken());
int temperature = Integer.valueOf(sscanf.nextToken());
int relhumidity = Integer.valueOf(sscanf.nextToken());
total_temp += temperature;
}
System.out.println("Average temperature for zipcode '"
+ filter + "' was " + (int) (total_temp / update_nbr));
System.out.println("...");
System.in.read();
subscriber.close();
context.term();
}
Client2;
public static void main (String[] args) throws IOException {
ZMQ.Context context = ZMQ.context(1);
// Socket to talk to server
System.out.println("Collecting updates from weather server");
ZMQ.Socket subscriber = context.socket(ZMQ.SUB);
subscriber.connect("tcp://localhost:5556");
// Subscribe to zipcode, default is NYC, 10001
String filter = (args.length > 0) ? args[0] : "10002";
subscriber.subscribe(filter.getBytes());
// Process 100 updates
int update_nbr;
long total_temp = 0;
for (update_nbr = 0; update_nbr < 100; update_nbr++) {
// Use trim to remove the tailing '0' character
String string = subscriber.recvStr(0).trim();
StringTokenizer sscanf = new StringTokenizer(string, " ");
int zipcode = Integer.valueOf(sscanf.nextToken());
int temperature = Integer.valueOf(sscanf.nextToken());
int relhumidity = Integer.valueOf(sscanf.nextToken());
total_temp += temperature;
}
System.out.println("Average temperature for zipcode '"
+ filter + "' was " + (int) (total_temp / update_nbr));
System.out.println("...");
System.in.read();
subscriber.close();
context.term();
}
Eclipse下maven项目工程下载地址,可直接运行:点击打开链接
3.Push-Poll模式
Server端作为Push端,而Client端作为Pull端,如果有多个Client端同时连接到Server端,则Server端会在内部做一个负载均衡,采用平均分配的算法,将所有消息均衡发布到Client端上。如果有多个Server端同时连接到Client端,这里Push与Pull之间的对应关系是多个Push角色对应一个Pull角色,在ZeroMQ中,给这种结构取的名叫做公平队列,这里也就是说将Pull角色理解为一个队列,各个Push角色不断的向这个队列中发送数据。与发布订阅模型相比,推拉模型在没有消费者的情况下,发布的消息不会被消耗掉;在消费者能力不够的情况下,能够提供多消费者并行消费解决方案。该模型主要用于多任务并行处理。
下面一个示例程序中,我们将使用ZMQ进行超级计算,也就是并行处理模型:
并行处理模型的几个细节:
worker上游和任务分发器相连,下游和结果收集器相连,这就意味着你可以开启任意多个worker。但若worker是绑定至端点的,而非连接至端点,那我们就需要准备更多的端点,并配置任务分发器和结果收集器。所以说,任务分发器和结果收集器是这个网络结构中较为稳定的部分,因此应该由它们绑定至端点,而非worker,因为它们较为动态。
我们需要做一些同步的工作,等待worker全部启动之后再分发任务。这点在ZMQ中很重要,且不易解决。连接套接字的动作会耗费一定的时间,因此当第一个worker连接成功时,它会一下收到很多任务。所以说,如果我们不进行同步,那这些任务根本就不会被并行地执行。你可以自己试验一下。
任务分发器使用PUSH套接字向worker均匀地分发任务(假设所有的worker都已经连接上了),这种机制称为负载均衡,以后我们会见得更多。
结果收集器的PULL套接字会均匀地从worker处收集消息,这种机制称为公平队列。
Eclipse下maven项目工程下载地址,可直接运行:点击打开链接
三、Zeromq进阶
1.多套接字处理
如何读取多个套接字中的消息呢?最简单的方法是将套接字连接到多个端点上,让ZMQ使用公平队列的机制来接受消息。如果不同端点上的套接字类型是一致的,那可以使用这种方法。但是,如果一个套接字的类型是PULL,另一个是PUB怎么办?如果现在开始混用套接字类型,那将来就没有可靠性可言了。
正确的方法应该是使用zmq_poll()函数。zmq_poll()函数为应用程序提供了一种对一组socket进行多路I/O事件水平触发的机制。
Eclipse下maven项目工程下载地址,可直接运行:点击打开链接
2.代理服务
发布-订阅代理
我们经常会需要将发布-订阅模式扩充到不同类型的网络中。比如说,有一组订阅者是在外网上的,我们想用广播的方式发布消息给内网的订阅者,而用TCP协议发送给外网订阅者。
我们要做的就是写一个简单的代理服务装置,在发布者和外网订阅者之间搭起桥梁。这个装置有两个端点,一端连接内网上的发布者,另一端连接到外网上。它会从发布者处接收订阅的消息,并转发给外网上的订阅者们。
我们称这个装置为代理,因为它既是订阅者,又是发布者。这就意味着,添加该装置时不需要更改其他程序的代码,只需让外网订阅者知道新的网络地址即可。
代理服务:
public static void main (String[] args) {
//Prepare our context and sockets
Context context = ZMQ.context(1);
//This is where the weather server sits
Socket frontend =context.socket(ZMQ.SUB);
frontend.connect("tcp://192.168.55.210:5556");
//This is our public endpoint for subscribers
Socket backend= context.socket(ZMQ.PUB);
backend.bind("tcp://10.1.1.0:8100");
//Subscribe on everything
frontend.subscribe("".getBytes());
//Run the proxy until the user interrupts us
ZMQ.proxy (frontend, backend, null);
frontend.close();
backend.close();
context.term();
}
请求-应答代理
在请求应答模式实例中是一个客户端和一个服务端进行通信。但在真实环境中,我们会需要让多个客户端和多个服务端进行通信。关键问题在于,服务端应该是无状态的,所有的状态都应该包含在一次请求中,或者存放其它介质中,如数据库。
下面就让我们编写这样一个组件。这个代理会绑定到两个端点,前端端点供客户端连接,后端端点供服务端连接。它会使用zmq_poll()来轮询这两个套接字,接收消息并进行转发。装置中不会有队列的存在,因为ZMQ已经自动在套接字中完成了。
在使用REQ和REP套接字时,其请求-应答的会话是严格同步。客户端发送请求,服务端接收请求并发送应答,由客户端接收。如果客户端或服务端中的一个发生问题(如连续两次发送请求),程序就会报错。
但是,我们的代理装置必须要是非阻塞式的,虽然可以使用zmq_poll()同时处理两个套接字,但这里显然不能使用REP和REQ套接字。
幸运的是,我们有DEALER和ROUTER套接字可以胜任这项工作,进行非阻塞的消息收发。DEALER过去被称为XREQ,ROUTER被称为XREP,但新的代码中应尽量使用DEALER/ROUTER这种名称。
使用请求-应答代理可以让你的C/S网络结构更易于扩展:客户端不知道服务端的存在,服务端不知道客户端的存在。网络中唯一稳定的组件是中间的代理装置。所以客户端发送的request不会由特定的reply处理。
下方的简图描述了一个请求-应答模式,REQ和ROUTER通信,DEALER再和REP通信。ROUTER和DEALER之间我们则需要进行消息转发:
Eclipse下maven项目工程下载地址,可直接运行:点击打开链接
ZMQ提供了一些内置的装置,不过大多数人需要自己手动编写这些装置。内置装置有:
可以使用zmq_device()来启动一个装置,需要传递两个套接字给它:
zmq_device (ZMQ_QUEUE, frontend, backend);
java中的使用方法 ZMQ.proxy (frontend, backend, null);
启动了QUEUE队列就如同在网络中加入了一个请求-应答代理,只需为其创建已绑定或连接的套接字即可。
内置装置会恰当地处理错误,而我们手工实现的代理并没有加入错误处理机制。所以说,当你能够在程序中使用内置装置的时候就尽量用吧。
可能你会像某些ZMQ开发者一样提出这样一个问题:如果我将其他类型的套接字传入这些装置中会发生什么?答案是:别这么做。你可以随意传入不同类型的套接字,但是执行结果会非常奇怪。所以,QUEUE装置应使用ROUTER/DEALER套接字、FORWARDER应使用SUB/PUB、STREAMER应使用PULL/PUSH。
前面的实例中有关的parallel-pipeline并行处理、以及代理服务都可以使用内置服务完成,减少编码工作量。
比如请求-问答代理可以简化成以下写法:
public static void main (String[] args) {
// Prepare our context and sockets
Context context = ZMQ.context(1);
// Socket facing clients
Socket frontend = context.socket(ZMQ.ROUTER);
frontend.bind("tcp://*:5559");
// Socket facing services
Socket backend = context.socket(ZMQ.DEALER);
backend.bind("tcp://*:5560");
// Start the proxy
ZMQ.proxy (frontend, backend, null);
// We never get here but clean up anyhow
frontend.close();
backend.close();
context.term();
}
当你需要其他的套接字类型进行组合时,那就需要自己编写装置了。
4.多线程
使用ZMQ进行多线程编程时,不需要考虑互斥、锁、或其他并发程序中要考虑的因素,你唯一要关心的仅仅是线程之间的消息。
如何用ZMQ进行多线程编程,以下是一些规则:
不要在不同的线程之间访问同一份数据,如果要用到传统编程中的互斥机制,那就有违ZMQ的思想了。唯一的例外是ZMQ上下文对象,它是线程安全的。
必须为进程创建ZMQ上下文,并将其传递给所有你需要使用inproc协议进行通信的线程;
你可以将线程作为单独的任务来对待,使用自己的上下文,但是这些线程之间就不能使用inproc协议进行通信了。这样做的好处是可以在日后方便地将程序拆分为不同的进程来运行。
不要在不同的线程之间传递套接字对象,这些对象不是线程安全的。从技术上来说,你是可以这样做的,但是会用到互斥和锁的机制,这会让你的应用程序变得缓慢和脆弱。唯一合理的情形是,在某些语言的ZMQ类库内部,需要使用垃圾回收机制,这时可能会进行套接字对象的传递。
server:
public class Worker extends Thread {
private Context context;
private int workerNum;
Worker (Context context,int worker)
{
this.context = context;
this.workerNum=worker;
}
@Override
public void run() {
ZMQ.Socket socket = context.socket(ZMQ.REP);
socket.connect ("inproc://workers");
while (true) {
// Wait for next request from client (C string)
String request = socket.recvStr (0);
System.out.println ( Thread.currentThread().getName() + " Received request: [" + request + "]");
// Do some 'work'
try {
Thread.sleep (1000);
} catch (InterruptedException e) {
}
// Send reply back to client (C string)
socket.send("work"+ this.workerNum +"reply is: "+"world", 0);
}
}
}
public static void main (String[] args) {
Context context = ZMQ.context(1);
Socket clients = context.socket(ZMQ.ROUTER);
clients.bind ("tcp://*:5559");
Socket workers = context.socket(ZMQ.DEALER);
workers.bind ("inproc://workers");
for(int thread_nbr = 0; thread_nbr < 5; thread_nbr++) {
Thread worker = new Worker (context,thread_nbr);
worker.start();
}
// Connect work threads to client threads via a queue
ZMQ.proxy (clients, workers, null);
// We never get here but clean up anyhow
clients.close();
workers.close();
context.term();
}
eclipse下maven项目工程下载地址,可直接运行:点击打开链接
三、Zeromq高级
1.请求-应答信封原理
ZMQ消息通讯中信封机制是其中的核心,我们在使用req-rep时只是进行一次请求发送,然后进行一次请求处理,但是实际req端在进行请求发送时的数据格式是这样的:
可见,虽然我们只是发送了一帧的数据,实际上ZMQ发送了两帧的数据,第一帧是空数据,代表分隔符,第二帧才是我们要发送的数据,同时在rep端也是接收的两帧数据,只不过ZMQ自动解信封,只获取数据帧,返回到我们的程序,然后,通用也是发送两个数据帧:
在实际程序中我们也只是发送了数据帧而已,空帧是ZMQ自动加上的,req端接收后自动解信封,获取数据帧。
我们知道Router和Dealer是Req-Rep的高级模式,使得可以实现多对多的通讯模式,其中Router和Dealer的信封机制是含有三个帧,我们以多对多通讯模式为例:
其中router接收来自req的请求,其具体帧如下:
第一帧是地址,即标定请求的来源,这个地址并不是req发送来的,而是router自己添加的,另外这个地址可以再req端进行设置request.setIdentity(identity.getBytes());如果req端没有进行地址的设定,那么router端将自动进行设置uuid编码,代表其地址;第二帧同样是空分割符号,第三帧才是数据帧。
router接下来将这三帧数据继续写入到Dealer,dealer同样接收的也是三帧数据,
然后dealer将数据发送到rep,
rep端自动解析出数据帧,并进行反馈,其中地址就是保留的req地址,
反馈后,dealer端收到三帧:
其中第一帧也是地址,第二帧空白分隔符,第三帧是数据帧,
接下来dealer继续将三帧写入Router,
router然后在发送到req,
整个过程结束。
总结来说,req-rep每一次的请求应答都是只接收或者发送一帧数据,实际ZMQ是包装了两帧数据,而router-dealer每次的交互是三帧数据,第一帧是地址,第二帧是空白符号,第三帧是数据帧,其中地址帧可以在请求端设置,也可以不设置,但是第二帧的空白符号不能省略,这里dealer可以理解成req端,roouter可以理解成req端。
2.负载均衡
负载均衡就是对多请求的一个缓存问题,中间层就是负载均衡的代理,一端是请求,另一端是对请求的相应。其中请求响应端主动去中间层获取请求,然后处理并相应。ZMQ的负载均衡模式如下:
这里由两个Router来作为中间层,具体的数据流程如下:
(1)中间层启动,Worker连接Backend,向其发送Request请求(ready),这个时候中间层就能够知道哪一个worker现在是空闲的,将其保存起来(放到worker队列),可以处理请求
worker的执行流程就是send(发送ready)--->recv(获取请求),
(2)Client端向Fronted发送请求,中间层将请求缓存到一个任务队列
(3)中间层从任务队里里面取出一个任务,将其发送给worker队列中的一个worker,并将其从woker队列中移除
(4)worker处理完以后,发送执行结果,也就是send,中间层收到woker的数据 之后,将其发送给相应的client,然后在讲这个worker放到worker队列中,表示当前这个worker可用。
eclipse下maven项目工程负载均衡实例下载地址,可直接运行:点击打开链接