关于这部分,我只把其当作我阅读其他部分代码时的前置技能来学习——专注于接口和使用方法,尽量避免看得过深过细,所以这部分只提供几份很好的参考资料,相信这两篇资料能解释包括底层原理在内的众多优化技巧与基本使用方法:
zeromq源码分析笔记之无锁队列ypipe_t(3)- 曾志优
Internal Architecture of libzmq
zmq源码阅读笔记之基础数据结构 - susser43
位置:own_t.hpp, own_t.cpp
简述:是形成own层级结构的所有对象的基类,该类负责这种对象的初始化和销毁。
初始化:提供了两种初始化方法,分别用于IO进程内外的对象的初始化。作为object_t的子类,同样具有收发command的能力。
概念-对象树:
提供明确的初始化和销毁机制(主要是销毁机制)。基本原理是:对象树上的每一个对象在销毁自己之前,必须要收到它的所有子对象的销毁确认信息(即所有子对象都已销毁)。
主要变量列表:
void inc_seqnum():
当层级结构中的另一个对象给此对象发出command的时候,会调用此对象的该函数,以保证该对象在销毁时完成了所有收到的command。机制:用时钟组件记录下当前收到的command数量,在销毁对象前检验是否处理完了所有的command,只有处理完所有command之后才能被销毁,以防止底层的command被丢弃。(set_seqnum & prosessed_seqnum用于记录收到和已处理的command数)
当一个对象进入销毁流程,有以下几种情况:
• 父对象发出销毁命令,子对象销毁后向父对象发出销毁确认(term_ack);
• 子对象试图销毁自己(例如tcp连接断开时,tcp会话的关闭),子对象向父对象发出销毁请求,父对象再向子对象发出销毁命令;
• 子对象和父对象同时决定销毁此子对象时,父对象会直接忽略子对象的销毁请求,向子对象发出销毁命令。
当一个对象在进行最终的销毁之前,需要确认以下几点:
• 此对象是否在销毁流程中;
• 是否已处理了所有已收到的command;
• 子对象是否已经全部被销毁。
只有满足以上三点,就会被立刻删除(delete)。
Internal Architecture of libzmq - zeromq
在ZMQ中,信息只具有以上两种形式——command和message。而ZMQ作为消息中间件,最主要的功能就是传递信息(message);而ZMQ作为一个消息中间件,也需要多个线程中多个组件的相互配合,而组件与组件之间利用命令(command)进行沟通。换言之,如果将ZMQ比作一家快递公司,message便是客户(应用程序线程)寄出的快递包裹,而command则是公司里员工与员工之间的沟通:“装箱完成,发车吧”。
因此,可以说是command流完成了几乎整个ZMQ的内部信息沟通机制,组件通过发送、接收和处理command控制着自己与其他组件的连接、断开和“生老病死”。所以我在第一部分介绍的内容就是command flow。
位置:command.hpp
概述:一个command由三部分组成:分别是发往的目的地destination,命令的类型type,命令的参数args。所谓的命令就是一个对象交代另一个对象去做某件事情,说白了就是告诉另一个对象应该调用哪个方法,命令的发出者是一个对象,而接收者是一个线程,线程接收到命令后,根据目的地派发给相应的对象做处理。
种类:command只有18种可能的情况;看到这,可能绝大多数命令的意义看起来都是不知所云,不过没关系,这些意义仅在之后的源码分析中作为对照表使用~现在只需了解command的种类只有18种就可以。
1. stop:发给io thread以终止该线程;
2. plug:发送给io object以使其在所在的io thread注册;
3. own:发送给socket以让它知道新创建的对象,并建立own关系;
4. attach:将引擎engine连接至会话session,如果engine为null的话,告知session失败;
5. bind:从会话session发送到套接字socket以在他们之间建立管道pipe,调用者事先调用了inc_seqnum发送命令(增加接收方中的计数 sent_seqnum);
6. activate_read:由pipe wirter告知pipe reader管道中有message;
7. activate_write:由pipe reader告知pipe writer目前已读取的message数;
8. hiccup:创建新inpipe之后由pipe reader发送给writer,它的类型应该是pipe_t::upipe_t,然而这个类型的定义是private的,所以我们使用void*定义;
9. pipe_term:由pipe reader发送给pipe writer以使其term其的末端;
10. pipe_term_ack:pipewriter确认pipe_term命令;
11. pipe_hwm:由一个pipe发送到另一部分以修改hwm(高水位线);
12. term_req:由一个i/o object发送给套接口以请求关闭该i/o object;
13. term:由套接口发送给i/o object以启动;
14. term_ack:由i/o object发送给套接口以确认其已经关闭;
15. term_endpoint:由session_base(I/O thread)发送到套接字(app thread)以请求断开端点。
16. reap:将已closed的socket的所有权转移到reaper thread;
17. reaped:已closed的socket通知reaper thread它已经被解除分配;
18. done:当所有的socket都成功被解除分配之后,由收割者线程发送给term thread。
位置:mutex.hpp
概述:为防止多个线程同时访问同一块数据造成的数据错乱,系统库引入了互斥变量的概念,让每个线程都按顺序地访问变量。
变量:
RTL_CRITICAL_SECTION _cs:该锁所对应的临界区。
接口:
• inline void lock():将此组件对应的临界区设为受限状态,在该种状态下,此临界区只能由此组件对应的线程访问。
• inline bool try_lock():返回一个bool值表示是否访问成功。
• inline void unlock():放弃当前线程对锁定部分的所有权。一旦锁定部分的所有权被放弃,那么请求访问临界区的下一个线程,将可以对锁定部分进行操作。
• inline RTL_CRITICAL_SECTION* get_cs():返回变量_cs,即对应的临界区。
注意 ❗:
假设线程A与线程B是同时访问临界区CriticalSection的两个线程,这个过程实际上是通过限制有且只有一个函数进入临界区CriticalSection来实现代码同步的。简单地说,对于同一个CriticalSection,当一个线程执行了lock()而没有执行unlock()的时候,其它任何一个线程都无法完全执行lock()而不得不处于挂起状态。再次强调一次,没有任何资源被“锁定”,CriticalSection这个临界区不是针对资源的,而是针对不同线程间的代码段的!我们能够用它来进行所谓资源的“锁定”,其实是因为我们在任何访问共享资源的地方都加入了lock()和unlock()语句,使得同一时间只能够有一个线程的代码访问到该临界区而已(其它想访问该资源的代码不得不等待)。
参考资料:
什么是临界区资源?对临界区管理的基本原则是什么? - 百度知道
临界区与锁 - joannae
线程同步之临界区 - One heart
位置:signaler.hpp, signaler.cpp
概述:用于在两个线程之间发送信号,但是一定要注意:信号机中的任何时刻内都至多只能有一个信号,换言之,在接收到信号之前就给另一端发送信号是错误的。
主要变量列表:
• fd_t _w, r:分别是用于发送信号的write标识符和用于读取信号的read标识符,初始化出问题时标识符值为-1。
主要接口列表:
• fd_t get_fd():获得read标识符。
• void send():向write处发送信号。
• int wait(int timeout):等待信号,返回值:错误返回-1,正确返回0。
• void recv ():接收信号,如果出错程序退出。
• int recv_failable ():接收信号,如果发生错误返回-1,运行正常返回0。与recv()的区别就是recv()不会返回值,错误发生程序直接退出;此函数会返回错误值,而不会导致程序退出。
备注:信号机在代码中也包含了在多种不同平台上的不同实现,但不同于poll模式,信号机的不同实现之间只有兼容性的区别,没有性能上的明显区别。
位置:mailbox.hpp, mailbox.cpp, i_mallbox.hpp(找不到)
简述:是每一个真正的thread中处理命令流的组件。
主要变量列表:
• cpipe_t _cpipe:cpipe即command pipe,是用于存储真正的命令的管道,实现了单一读/写线程的无锁访问。(其中粒度的含义为,每一次内存操作会在底层的pipe中分配出16个command_t的size的内存空间)
• signaler_t _signaler(需要前置技能):从writer管道到reader管道用于通知命令到达的信号机。
• mutex_t _sync(需要前置技能):只有一个线程从信箱接收命令,但是有任意数量的线程向信箱发送命令,由于ypipe_t需要在它的两个端点进行同步访问,所以我们需要同步发送端。
• bool active:command pipe是否是可用的。
主要接口:
• fd_t get_fd():(备注typedef SOCKET fd_t)得到信箱所对应的读文件标识符。
• bool valid():检测mailbox的有效性,本质上就是检测信号机的有效性。
• void send(const command_t &cmd):发送命令。在实际使用的过程中其实是首先找到目的mailbox对象,然后调用此对象的send函数,把消息放入到这个对象的queue中。并不是从一个mailbox能够发送消息到另外一个mailbox。send的含义更像是把消息放入到函数所属对象的queue中。
void zmq::mailbox_t::send (const command_t &cmd_)
{
_sync.lock (); //加锁
_cpipe.write (cmd_, false); //写消息到管道。
const bool ok = _cpipe.flush (); //使消息对读线程可见
_sync.unlock (); //解锁
if (!ok) //如果发送成功,那么发送信号通知命令处理线程
_signaler.send ();
}
• int recv(command_t *cmd_, int timeout_):接收命令,把command读到参数cmd_里面,第二个参数是延迟时长,表示信号机等待信号的时长上限。
int zmq::mailbox_t::recv (command_t *cmd_, int timeout_)
{
//先尝试直接读取命令,如果读到了命令则直接返回。
//如果读取失败说明当前mailbox中没有未处理的命令,那么把状态设置为不活跃。
if (_active) {
if (_cpipe.read (cmd_))
return 0;
// If there are no more commands available, switch into passive state.
_active = false;
}
//等待信号,如果有信号到达说明有命令到达了mailbox。(函数会阻塞在此)
int rc = _signaler.wait (timeout_);
if (rc == -1) {
errno_assert (errno == EAGAIN || errno == EINTR);
return -1;
}
//收到信号说明有命令要处理。此时把mailbox状态设置为活跃,
//然后读取命令到指定buffer中。
rc = _signaler.recv_failable ();
if (rc == -1) {
errno_assert (errno == EAGAIN);
return -1;
}
// Switch into active state.
_active = true;
// Get a command.
const bool ok = _cpipe.read (cmd_);
zmq_assert (ok);
return 0;
}
注意 ❗:
在析构函数中,同步机执行了一个“加锁”后立即“解锁”的流程,因为其他线程的此时仍有可能正在使用此组件的send()方法,这样可以在消失之前在mutex中等待一段时间。
其实:
其实一条命令的发送的流程可以被表示为:将command压入接收方的pipe(此时接收方不知情) —> 用信号机告知接收方“你有新的command待处理” —> 接收方调用recv取出队列中刚刚被压入的command。
补充 ⚠:
先来想一个问题,既然signaler可作为信号通知,为何还要active这个属性?
active和signaler是这样合作的:写命令线程每写一条命令,先去检查读命令线程是否阻塞,如果阻塞,会调用读命令线程mailbox_t中的signaler,发送一个激活读线程mailbox_t的信号,读线程收到这个信号后在recv函数中把activ设置为true,这时,读线程循环调用recv的时候,发现active为true,就会一直读命令,直到没命令可读时,才会把active设置为false,等待下一次信号到来。
现在可以回答上面那个问题了,active是否多余?
先试想一下如果不使用active,每写一条命令都必须发送一个信号到读线程,在大并发的情况下,这也是一笔消耗。而使用active,只需要在读线程睡眠的时候(没有命令可读时,io_thread_t这类线程会睡眠,socket_base_t实例线程特殊,不会睡眠)发送信号唤醒读线程就可以,可以节省大量的资源。
位置:io_thread_t.hpp, io_thread_t.cpp
概述:io线程的通用部分,包含一个信箱和一个轮询器(轮询机制在其他文档中)。在io线程的构造函数中,mailbox_t的句柄被加入poller中,让poller监听mailbox的读事件,所以如果有信号发到io线程,poller会被唤醒,并调用io线程的in_event函数。命令被发送到与线程一对一绑定的mailbox中,但是命令的目的地是某个对象,一个线程可以对应多个对象;所以io线程在收到命令之后,会在in_event函数中调用cmd的destination处理命令的函数去处理命令。
主要接口:
object_t是最基础的基类之一,使继承自object_t的类具有发送命令的功能、处理命令的接口。因此object_t的子类既是命令的发送者,又是命令的处理者。
顾名思义,这个组件其实是轮询器,而且是一个事件触发型轮询器。也就是说,当一个事件(一个文件描述符)被注册到poller中后,该事件状态的变化就会使poller触发对应的in_event()方法或out_event()方法。
说到object_t及其子类都具有发送和处理命令的功能(没有收命令的功能),所以有必要弄清楚一件事,object_t、poller、线程、mailbox_t、command是什么关系?
• 在ZMQ中,每个线程都会拥有一个信箱,命令收发功能底层都是由信箱实现的
• ZMQ提供了object_t类,用于使用线程信箱发送命令的功能(object_t类还有其他的功能),object_t还有处理命令的功能。
• io线程内还有一个poller用于监听激活mailbox_t的信号,线程收到激活信号后,会去mailbox_t中读命令,然后把命令交由object_t处理
简单来说就是,object_t发命令,poller监听命令到来信号告知线程收命令,交给object_t处理。无论是object_t、还是线程本身、还是poller其实都操作mailbox_t,object_t、poller、mailbox_t都绑定在同一个线程上。
简单来说,就是对象A将命令发出到目标对象B所在线程绑定的mailbox,然后poller监听收到命令的信号,以通知线程处理命令,线程将命令交给对象B。
发出命令:
如果一个类想要使用线程收发命令的功能,那么这个类就必须继承自object_t。源码中可以看到,object_t定义了一个uint32_t变量tid,tid(thread id)表示该object_t对象所在的线程,即应该使用哪个线程的mailbox。关于ctx_t(context),在zmq中被称为上下文,上下文简单来说就是zmq的存活环境,里面存储着的是zmq的全局状态。zmq线程中的mailbox_t指针表(slots)会被zmq维护在ctx_t对象中,表示tid与对应线程绑定的mailbox的对应关系。在zmq中,在context中使用一个容器slots(插槽表)存储线程的mailbox;在新建线程的时候,给线程分配一个线程标识符tid和mailbox,把mailbox放入slots容器的编号为tid的位置,直观来说就是slots[tid]=mailbox。这样,线程A给线程B发命令就只要往slots[B.tid](B所在的线程绑定的邮箱)写入命令就可以了。
//发送一条command时需要经过的路径
void zmq::object_t::send_command (command_t &cmd_)
{
_ctx->send_command (cmd_.destination->get_tid (), cmd_);
}
void zmq::ctx_t::send_command (uint32_t tid_, const command_t &command_)
{
_slots[tid_]->send (command_);
}
void zmq::mailbox_t::send (const command_t &cmd_)
{
_sync.lock (); //加锁
_cpipe.write (cmd_, false); //写消息到管道。
const bool ok = _cpipe.flush (); //使消息对读线程可见
_sync.unlock (); //解锁
if (!ok) //如果发送成功,那么发送信号通知命令处理线程
_signaler.send ();
}
接收命令:
io_thread接收命令:每一个io线程中都含有一个poller;在构造函数中,mailbox的句柄被加入poller,则poller可监听mailbox的读事件。所以当有命令进入mailbox的时候,poller会被唤醒,并调用io_thread的in_event()函数。在in_event()函数中,线程调用了mailbox接收信息的recv,然后直接调用destination(目的对象)处理命令的函数去处理命令。
socket_base_t接收命令:每一个socket_base其实都可以被视为一个线程,但是并没有使用poller,而是在使用到socket下面几个方法的时候去检查是否有未处理的命令:
int zmq::socket_base_t::getsockopt(int option_, void *optval_, size_t *optvallen_)
int zmq::socket_base_t::bind(const char *addr_)
int zmq::socket_base_t::connect(const char *addr_)
int zmq::socket_base_t::term_endpoint(const char *addr_)
int zmq::socket_base_t::send(msg_t *msg_, int flags_)
int zmq::socket_base_t::recv(msg_t *msg_, int flags_)
void zmq::socket_base_t::in_event() //这个函数只有在销毁socket的时候会用到
socket_base_t使用process_commands方法来检查是否有未处理的命令:
int zmq::socket_base_t::process_commands (int timeout_, bool throttle_)
{
if (timeout_ == 0) {
// If we are asked not to wait, check whether we haven't processed
// commands recently, so that we can throttle the new commands.
// Get the CPU's tick counter. If 0, the counter is not available.
const uint64_t tsc = zmq::clock_t::rdtsc ();
// Optimised version of command processing - it doesn't have to check
// for incoming commands each time. It does so only if certain time
// elapsed since last command processing. Command delay varies
// depending on CPU speed: It's ~1ms on 3GHz CPU, ~2ms on 1.5GHz CPU
// etc. The optimisation makes sense only on platforms where getting
// a timestamp is a very cheap operation (tens of nanoseconds).
if (tsc && throttle_) {
// Check whether TSC haven't jumped backwards (in case of migration
// between CPU cores) and whether certain time have elapsed since
// last command processing. If it didn't do nothing.
if (tsc >= _last_tsc && tsc - _last_tsc <= max_command_delay)
return 0;
_last_tsc = tsc;
}
}
// Check whether there are any commands pending for this thread.
command_t cmd;
int rc = _mailbox->recv (&cmd, timeout_);
// Process all available commands.
while (rc == 0) {
cmd.destination->process_command (cmd);
rc = _mailbox->recv (&cmd, 0);
}
if (errno == EINTR)
return -1;
zmq_assert (errno == EAGAIN);
if (_ctx_terminated) {
errno = ETERM;
return -1;
}
return 0;
}
可见,最终都是使用mailbox_t的接收命令的功能。
这里有一个值得思考的问题,为什么socket_base_t实例这个线程不使用poller呢?每次使用上面那些方法的时候去检查不是很麻烦吗?
socket_base_t实例之所以被认为是一个特殊的线程,是因为其和io_thread_t一样,都具有收发命令的功能,(关于这点可以看一下io_thread_t的源码,可以发现其主要功能就是收发命令),但是socket_base_t实例是由用户线程创建的,也就是依附于用户线程,而zmq中所有通信都是异步了,所以用户线程是不能被阻塞的,一旦使用poller,线程将被阻塞,也就违背了设计初衷。
zeromq源码分析笔记之线程间收发命令(2) - 曾志优
ZeroMQ架构分析 form Martin Sústrik
ZMQ源码分析(三)–对象管理和消息机制 - 源码之间了无秘密
zmq源代码分析 - mailbox_t - gx_1983
资料:ZeroMQ源码分析之Message - 夕阳飞鸟
位置:msg_t.hpp, msg_t.cpp
概述:zmq中用于储存信息的类,对于不同大小的消息采用不同的处理,从内存分配的角度优化zmq的效率。
结构体定义中的unused数组的作用:zmq将每一种类型的消息人为地设置为等大小的,而且使在unused数组后面定义的type和flags变量在每一种struct中的位置是一样的。这样就能做到,无论是什么类型的消息(vsm或者lmsg),只要调用u.base.type就能获取到这个消息的类型了。
在api层(zmq.h中),zmq将一个消息定义为一个长度为64的unsigned char数组(资料中的版本为32字节),大小为64字节,这个大小与定义当中每个结构体的大小恰好相等,因此合理。
资料:ZMQ源码分析(五) --TCP通讯 - tbyzs
关系:每个session属于一个io线程(一个io线程可以有多个session);每个session属于一个socket(一个socket也可以同时拥有多个session);每个session与一个engine连接。(session与engine的关系的一对一的,一个session相当于socket对应的一个endpoint)
简述:每一条tcp连接都需要一对应的session_base(inproc连接不需要,socket_base互相直接连接,通过管道进行消息交换)。session_base是stream_engine和socket_base之间的纽带,他和socket_base之间有一个pipe_t进行连接,当socket_base需要发出一条数据的时候就把msg写入out管道,之后session_base通过stream_engine发送出去;当stream_engine读取到msg时session_base会把数据写入到session_base的in管道。session_base_t有一个变量active,它用来标记是否在process_plug中进行connecting操作,start_connecting操作中主要是建立一个tcp_connecter_t 并挂载tcp_connecter_t 作为自己的子对象。之前说过,session_base 和socket_base_t之间有一条传送msg的管道,这个管道是在process_attach的时候建立的,但是如果socket_base进行connect操作,并且制定了option的immediate为非1,则在socket_base_t的connect中直接建立管道。
session_base在attach_pipe 操作中会将自己设置为管道的数据事件监听对象,这样当管道读写状态发生变化时,session_base_t可以通知对应的engine。stream_engine和session_base_t进行msg传递主要通过两个方法,分别是从管道中读数据给engine发送以及收到msg写入管道中。
主要变量:
处理命令:
主要接口:
于我而言仍然是黑盒。
在应用程序中,将会用到各种具有不同功能特性的socket用于进程内、进程间的通信,在后文会介绍两种比较典型且普通的socket种类——router和dealer。
关系:该类在被触发in_event时,将新建一个stream引擎和一个session,并将engine连接到创建的session上。
变量:
处理命令:
接口:
tcp_connecter和tcp_listerner相反,他是由session_base_t创建的并负责管理和销毁的,tcp_connecter_t也不会一直存在,当连接成功之后就会被销毁;
当tcp_connecter连接失败时会设定一个定时器以便重新连接,这就使zmq支持在lbind之前进行connect依旧是可以成功的。
当socket_base需要发出一条数据的时候就把message写入out管道,之后session_base通过stream_engine发送出去;当stream_engine读取到message时session_base会把数据写入到session_base的in管道。