ZeroMQ的内部架构(一)

部分翻译自www.zeromq.org/whitepapers:architecture

 

概述

想要了解ZMQ内部结构的人越来越多,大量关于代码库的讨论经常提到的问题是缺乏一个可以让新人快速了解代码结构的架构文档。

本文的目的便是提供一种这样的文档。本文会逐步覆盖整个代码库,但是不会去关注太多的细节问题。因为随着时间的推移,细节部分可能与文档脱节。如果想要获得详细信息,你应该查看相关部分的源码。

首先需要提醒读者的是代码库是复杂的。从代码行数(也许是意大利面式的代码行数,我想ZMQ应该不是意大利面)的角度来看代码库并不复杂(目前有10000行)。想法,由于ZMQ要考虑大量不同的组合,因此是很复杂的。比如,它需要在超过10个操作系统的不同版本上运行;需要运行在许多不同的指令体系结构,从ARMItanium;可以由不同的编译器编译,从gccMSVCSunStudio;可以与20多种不同语言绑定进行交互;可以使用不同的底层传输协议,不同的进程间消息传递机制并支持可靠多播;支持不同的消息模式:远程过程调用,数据分发,并行流水线等;每个socket可以连接或被连接到对等socket,或者同时建立双向连接;两个节点之间的通信失败可能是由于底层连接的中断也可能是暂时性的网络问题等等。所有这些选项是相互正交的,需要考虑到上千种可能的组合。

以上意思是说代码即使看起来很简单,很容易上手,但实际上很复杂。到目前为止,代码大约花费了10人年的工作量,每一行的代码上的花费大约是两个小时,包括像“i + +;”这样的代码。因此,在对代码做任何更改之前要小心仔细的了解代码在做什么和为什么要这样做。

全局状态

在库中使用全局变量绝对是一个搬起石头砸自己的脚的最佳方式。一切都工作良好除非库被链接到可执行文件两次(见图片),而这时你就会遇到古怪的错误和崩溃。为了防止这类问题, libzmq 没有使用全局变量,而是由使用者负责显式的创建全局状态。包含全局状态的对象被称为上下文”(context)。从使用者的角度来看 context 或多或少像是供ZMQ socket使用的I/O线程池,在libzmq的观点来看context只是一个存储libzmq所需要的全局状态的对象。例如,进程内部使用的可用端点(endpoint)列表(已经关闭的但仍然逗留在内存中的ZMQ socket列表,之所以逗留在内存中是由于在上下文中有需要发出的消息)就存储在上下文中。

 

上下文由类 ctx_t 实现。

 


 

并发模型

 

表面来看ZMQ的并发模型的可能会让人感到费解。令人费解的原因是,我们使用自己独特的消息传递方式来实现并发性和可扩展性。这样设计的好处是,在多线程环境中,ZMQ的使用者不必使用互斥锁,条件变量或信号等用来同步并行处理的东西。ZMQ中的每个对象只会在自己所在的线程上执行,其他线程只能通过发送消息(以下称之为命令,以区别于使用者发送给ZMQ的消息)与对象进行交互而不能直接使用对象(这就是为什么是不需要互斥体的原因),同时对象间也可以相互发送命令进行交互(实际上线程在ZMQ中也是一种对象)。

 

对象由类object_t定义,类中定义了发送命令和处理命令的接口。如果想要在自定义对象之间传递命令,对象类只需要简单的派生自object_t,并定义处理命令的程序就可以了。

 

命令由类command_t 实现,command_t 中定义了所有可用的命令。比如有一个带有单参数'linger'“term”(销毁)命令,以参数linger=100发送term命令到对象p中,可以像下面这样调用

send_term(p,100);

另一方面,如果你想给对象定义一个处理“term”命令的程序,可以这样做:

 

void my_object_t::process_term(int linger) 
{ 
    // 处理动作在这里实现 
}

需要注意的是以上实现只有当应用类派生自object_t类才有效!

 

对于大多数命令,ZMQ可以保证命令在传递过程中目标对象不会消失,也就是说命令可以保证投递。(关于如何实现这一保证,可以查看本文中关于对象树模型的相关描述,对象数模型将异步对象绑定至树状的层次结构。)不过对于少数跨越对象树的命令需要额外的操作来实现保证投递:对于这类命令,发送者在向接收方发送命令之前需要调用接收方的 inc_seqnum() 方法来获得这一保证。inc_seqnum()增加接收方中的计数 sent_seqnum。当接收方处理命令时,会增加另一个计数processed_seqnum。在接收方将要销毁时,如果发现processed_seqnum小于sent_seqnum,就说明有正在传送而没有处理的命令,这时接收方就不会继续执行销毁动作。销毁操作的逻辑对 object_t own_t 来说是透明的,命令发送方和接收方只需要发送和接收命令,而无需关心命令的具体序列号seqnum

备注:事实上,有些数据被限制在临界区中。使用临界区遵循以下两个规则:

1          在任何时候,数据对每个线程都是可访问的,(例如,前面提到的同一进程内的端点列表)。

2         在消息传递的过程中不应使用临界区中的数据。

线程模型

 

从操作系统来看,ZMQ中只有两种线程,应用线程和I/O线程。应用线程在ZMQ外部创建,访问ZMQAPII / O线程在ZMQ内部创建,用于在后台发送和接收消息。thread_t是系统级线程的抽象,可以以OS无关的方式创建线程。

 

而从ZMQ的观点来看,线程只是一个拥有邮箱(mailbox_t)的对象。邮箱存储发送给居住在当前线程上所有对象的信件(命令command_t),所有这些对象公用线程上的邮箱。线程从邮箱中按序获取命令并交给其上的对象进行处理。

 

目前ZMQ内部使用两种不同类型的线程(拥有邮箱的对象):I/O线程(io_thread_t)socket(socket_base_t)

 

I / O线程很容易理解,每个 I/O 线程与一个系统级线程一一对应。I / O线程运行在自己的系统线程上,并且拥有独立的获取命令的邮箱。

 

socket在某种程度上显得复杂一些。每个ZMQ socket拥有自己的接收命令的邮箱,因此socket可被ZMQ视为分离线程。而实际上,一个应用程序线程可以创建多个套接字,也就是说多个ZMQ socket被映射到同一个系统线程。更加复杂的是,ZMQ socket可以在系统线程之间迁移。例如,Java语言绑定可以在单线程中使用ZMQ socket,而当线程结束时,ZMQ socket会传递给垃圾回收线程,并在垃圾回收线程上销毁。

 

I / O线程

I / O线程(io_thread_t)ZMQ异步处理网络IO的后台线程。它的实现非常简洁。io_thread_t实现继承object_t ,并实现 i_poll_events 接口,其内部包含一个邮箱(mailbox_t)和一个poller对象(poller_t)

 

继承object_t使得io_thread_t能够发送和接收command(如 stop 命令,当收到该命令时,I / O线程将被终止)。

 

i_poll_events 接口定义了文件描述符和计时器事件就绪时的回调处理函数(in_event/out_event/timer_event)。io_thread_t 实现此接口(in_event)来处理来自mailbox的事件。当mailbox_t事件触发时,io线程从mailbox中获取命令,并让命令的接收者进行处理。

 

mailbox_t 用来存储发送给任何居住在io_thread_t 上的object_t 的命令,每个io_thread_t 上有多个对象,这些对象公用同一个邮箱,邮箱的收件人就是对象。mailbox_t本质是一个具有就绪通知功能的存储命令的队列。就绪通知机制由signaler_t提供的文件描述符实现。队列是由ypipe_t实现的无锁无溢出队列。

 

poller_t 是从不同操作系统提供的事件通知机制中抽象出来的概念,用来通知描述符和计时器事件,poller_t 通过 typedef定义为操作系统首选的通知机制(select_t/poll_t/epoll_t )。所有运行在 io_thread_t上的对象都继承自辅助类 io_object_t,该类实现了向io_thread_t注册/删除文件描述符 (add_fd/rm_fd)和计时器(add_timer/cancel_timer)事件的功能,同时io_object_t 还继承了 i_poll_events 接口来实现事件回调功能。

 

 

 

对象树模型

ZMQ库的内部,对象在大多数情况下被组织成树状层次结构。树的根节点只能是供应用使用的ZMQ socket (socket_base_t)


 

树中的每个对象可以处在不同的线程上。想要将子节点束缚在根节点对应的线程上是不可行的,因为根节点(ZMQ socket)处于应用程序线程上,而其他节点则处在I/O 线程上:


 

对象树模型产生的主要目的是为了实现一致性的销毁机制(对象销毁)。经验做法是在对象销毁之前,向其所有子对象发送销毁请求命令,当收到所有孩子对销毁请求的确认时对象才会真正被销毁。

由于命令处理是按序的,销毁请求和销毁确认机制能够刷新在对象之间送中的命令。这就是为什么大多数命令(在对象树内传递的命令)不需要使用命令序列号(见上文)来保证当有正在传送的消息时对象不会销毁的原因。

当孩子对象决定销毁自身而父对象没有向它发出销毁请求时,销毁过程会变得更加复杂,例如TCP连接断开时回话对象会销毁。我们必须仔细处理由父对象向子对象发起销毁请求,子对象自身发起销毁请求,以及二者同时发生时的情况。最后发现只要子对象销毁自身时向其父对象发出销毁子对象的请求就可以同一处理以上三种情况。下面是以上三种情况的时序图。与销毁有关的有三种命令:父对象要求子对象销毁的term命令,子对象向父对象的销毁确认term_ack命令,子对象想销毁自身时向父对象发送的term_req 命令。

 

 

在上图的最后一种情况下term_req 命令被父对象简单地丢弃。因为父对象已经向子对象发出了销毁请求(term),所以重新发送没有任何意义。如果父对象发送两次term请求,第二次请求到达时孩子已被释放,将会造成segmentation fault错误或覆盖现有的内存。

对象树机制通过类 own_t 来实现,own_t 定义了对象树中的节点。own_t 继承自 object_t 使得对象树中的每一个节点可以发送和接收命令(在销毁阶段需要接收和发送命令)。需要注意的是,并不是每个对象都在对象树中。有些对象可以发送和接收命令,但不属于对象树(如管道端点)。

 

回收线程

 上一小节描述了与销毁相关的机制。而销毁任何一个指定的对象(包括socket)消耗的时间是不确定的。然而,我们希望close有类似于POSIX的行为:当关闭TCP套接字时,即使在后台还有没有完全发出的数据,调用也会立即返回。

所以,应用程序线程调用 close 时,ZMQ应该关闭对应的套接字,但是,我们不能依赖应用线程来完成socket子对象的销毁(销毁可能需要多次命令交互)。同时应用线程调用zmq_close以后不会在继续使用该socket甚至可能永远不会再调用 ZMQ库函数。因此,ZMQ socket应该从应用线程迁移到一个工作线程来处理销毁的逻辑。一个可能的解决方案是将socket迁移到某个后台的I/O线程上去,然而ZMQ可以初始化为具有零个I / O线程(适用于只在进程间通信的情况),因此,我们需要一个专门的回收线程来执行销毁任务。

回收线程由类reaper_t实现。socket通过(send_reap)向回收线程发送回收命令,回收线程收到命令后会将socket从应用线程迁移到回收线程上,这样socket就可以在回收线程上处理命令(term/term_ack),直到socket的所有子对象都成功销毁时,socket就会在回收线程上销毁。实际上回收线程只是待回收对象驻留的线程,对象的处理逻辑仍然由对象自身处理。

 

未完待续...

你可能感兴趣的:(源码,C++,zeromq,翻译)