ØMQ是一个消息系统
ZeroMQ是一个消息系统,也被称为“消息中间件”。它被广泛的用于经济、游戏、嵌入式等领域。
什么是消息系统
打个比方,消息系统就像我们使用的IM软件一样。首先,一方决定将消息发往何处(一对一或一对多)。然后将信息打包,点击发送按钮。之后,IM系统会帮你料理剩余的事务。
但是,它们也有很大的不同点。IM系统对于消息系统似乎太低效了一点。另外,消息系统是没有用户界面(GUI)的。在错误发生时,消息的另一端也不会有人来智能的介入处理。
所以,我们可以这样下定义。消息系统是具有高效性和容错性的消息传递解决方案。
ZeroMQ的起源和发展
ZeroMQ最先的设想是实现一个炒鸡快的用于证券交易的消息系统,所以在设计初期的关注点就是在极致的优化上。
第一年的工作重点,在于发明性能测试的方法,和设计高效架构。
之后,大约在第二年,工作重点转移到实现一个通用的消息系统,以应用于分布式系统,使其可以利用不同的编程语言,使用不同方式,来传递各种模式的信息。
启示1:独立应用 vs. 程序库
ZeroMQ是一个程序库,不是一个消息服务器。这样设计的主要原因是:性能。
使用一个中间消息服务器(Broker),每一条消息都会被在网络上传递两次(Sender->Broker->Receiver)。这样的设计会影响时延和吞吐量。更严重的是,如果所有消息都通过中间服务器,那么这个点就会成为整个系统的瓶颈。
使用中间消息服务器的另一个弊端,是不利于大规模部署。对于证券交易来说(ZeroMQ最初的应用场景),跨组织的消息传输是不可避免的。这样一来,中央集权式的消息传输就不在有效了。
所以,我们缩小消息系统的粒度,使之成为一个程序库。使其更轻量,更易于部署。同样,更轻量的消息系统可以实现更复杂的拓扑结构。
获得的启示:对于一个新项目来说,如果可能,尽量把它实现为一个程序库。将一个新库联编到原有程序中,只需要少量的人力,也提供了足够的灵活性。
启示2:全局状态
对于程序库来说,全局状态往往不能正确的工作。
由于一个程序库可能被一个应用程序加载多次,这就不能保证只有独一无二的全局状态。
ZeroMQ的解决方法是由库的调用者显式的维护一个“环境”,如图所示。libA
和libB
都有其独有的“环境”信息。
获得的启示:不要在程序库中使用全局状态。如果你这么做了,当库恰好需要在同一个进程中实例化两次时,它很可能会崩溃。
启示3:性能
ZeroMQ在最初设计时,性能调优就是首要的目标。做为一个消息系统,其性能指标主要有两个:吞吐量和延时。
但是我们怎么度量它们呢?
如上图所示,A向B发送信息。B在6秒内接收到了5条信息,因此吞吐量为0.83消息/秒,平均时延为1.2秒。
如果我们换一种计量方式,A向B发送消息,对于单条信息来说,其平均时延为3秒。A花费2秒,发送了所有的消息;B花费了4秒,接收到了所有的消息。所以A的吞吐量为2.5消息/秒,B的吞吐量为1.25消息/秒。这个数据与我们上面得出的数据相差甚远。
由此,我们可以看出,使用不同的计量标准,对于我们估计系统的性能,会带来很大的不同。
时延只有在两个节点通信时,才可能发生。所以,并不能有“某节点的时延”这种度量标准的出现。同样,我们可以对多条消息的时延进行平均,但是并不能有“一个消息流的平均时延”。
吞吐量,与时延不同,只能在单个节点上进行度量。所以,并不能有“节点间的吞吐量”这种度量方法。
获得的启示:深入了解你所要解决的问题。否则,你会在解决问题时会引入(错误的)假定和玄学,写出有缺陷的复杂代码。
启示4:关键路径
代码中经常被调用的代码,被称为关键路径。
对于ZeroMQ来说,建立链接与内存申请都不是影响性能的最主要因素。因为ZeroMQ使用长链接进行通信,所以建立链接的开销均摊到每一条信息微乎其微。同样,ZeroMQ使用高效的内存维护机制,尽可能少的向系统直接申请内存空间。
获得的启示:只在系统的关键路径上做优化。否则只是浪费时间。
启示5:内存申请
对于一个高效的系统来说,高效的处理内存的方法往往是在内存申请与内存拷贝之间寻求一个平衡。
对于小型数据来说,直接拷贝数据,即“深拷贝”,的开销更小。而对于大型数据来说,所谓“浅拷贝”的开销更小。
ZeroMQ使用透明的方式来处理两种不同的场景。并且,对于规模较大的数据,使用引用计数的策略,最大限度的复用与节省内存使用。
获得的启示:当我们优化性能时,不要假定只有一个全局最优解。在不同的场景下,最优解的定义可能大不相同。
启示6:批处理
对于一个高性能消息系统来说,系统调用的绝对数量就是系统的瓶颈。这其实是一个很普遍的问题,消息在调用栈之间传递,会带来不可忽略的性能损失。所以,在构建一个高性能系统时,应该尽量避免消息在栈间的传递。
如下图所示,对于四条消息,我们需要遍历整个网络栈四次。
然而,如果我们将这些消息打包成一条消息。我们只需要遍历网络栈一次。
在这里,我们的策略与TCP/IP协议中的Nagle算法类似。Nagle算法是为了充分利用带宽,而ZeroMQ的Batching策略是为了均摊网络栈的时间开销。
具体的实现如下:
- 当消息的频率没有超过网络栈的带宽时,ZeroMQ会把所有batching关掉,以CPU利用率来换取低时延。
- 当消息的频率超过网线栈的带宽时,ZeroMQ会启用batching,以时延为代价来优化吞吐量。
- 当消息队列中的消息过多时,ZeroMQ会采用更激进的batching策略。因为消息的堆积造成的时延增长已经不可避免,索性将更多的信息打包,这样可以更快的清空消息的积压。
另外,batching策略只应该被应用于顶层。在顶层采用batching之后,低层的batching就没有意义了。
获得的启示: * 在一个异步系统中,想要获得最佳的响应时间,应该把底层的batching策略转移到顶层。 * batching只应该在新数据到来的速度超过系统带宽时才采用。
启示7:整体架构
用户利用“socket”与ZeroMQ进行交互,一个socket可以同多个peer进行交互。
socket存在于用户线程中。而一些工作线程会处理异步的交互过程,如:从网络读取消息,将消息放入队列,接受新的连接请求等。
session负责与ZeroMQ中的socket进行交互,engine负责网络交互。session只有一种,而engine根据使用的协议不同,可以有很多种。
session与socket之间使用pipe进行通信,pipe被实现为线程安全的双端队列,用来在线程间传递信息。
获得的启示:学习了整体的设计思路(笑
启示8:并发模型
高效的ZeroMQ必然要利用多核的计算资源,而传统的多线程模型会引入锁、信号量等线程同步机制,会影响系统的整体性能。而使用独立的线程又会引入上下文切换的开销。
ZeroMQ使用的并发模型是Actor,其目标是完全避免锁的使用,让所有的组件全速运行。
每一个CPU核心只有一个worker线程,所有内部对象都是线程专有的,这样就完全避免了锁的使用。
这样一来,许多对象都要分享有限个数的worker。所以,系统应当是全异步的。因为每一个worker的阻塞,都会阻塞其它使用worker的对象。
获得的启示:Actor模型是极致解决性能与扩展性问题的方法。但是,如果你不是在使用ZeroMQ或Erlang,你需要手写许多相关的Test Case来测试系统的正确性和稳定性。另外,如果你无法应对模型中的复杂模块(如ZeroMQ的shutdown),请不要轻易尝试使用Actor模型。
启示9:无锁算法
无锁算法使用简单的方法进行线程间的通信,而不依赖内核级的同步原语。无锁算法的关键是CPU中的原子操作,例如compare-and-swap(CAS)操作。但是,要记住,无锁操作并不是真正的“无锁”,而是把锁操作放到了较为高效的硬件层。
ZeroMQ使用一个无锁队列在用户线程与工作线程间进行消息传递。ZeroMQ中的无锁队列有如下特点:
第一,每一个队列为“一写一读”。当有一对多的读写需求存在时,ZeroMQ会创建多条队列。这样的设计使得我们不用关心多线程读写带来的同步问题,有利于我们对其性能的优化。
第二,即使无锁算法比传统的锁算法要快很多,但是其代价仍然是过高的(尤其是在CPU核心之间的通信)。所以,我们仍然依赖于“batching”算法,将昂贵的同步操作均摊到多条消息上。在从队列真正的读写操作之前,加入一次预处理(pre-write / pre-read),将消息打个包,发申通。
获得的启示:无锁算法是非常精巧的,如果可能的话,尽量使用成熟的设计。如果你需要极致的性能,不要仅仅依赖于无锁算法。尽管无锁算法非常快,你仍然可以通过batching策略优化它。(另外,加钱上船也可以。)
启示10:API设计
设计API,就像设计一款产品。
用户对于一个程序库的第一感觉来自于用户接口。ZeroMQ简化了自己的用户接口,将其由原来的“炒鸡复杂一不小心坑死你的企业级消息框架”变为了“想发消息调用下就OK”的极易上手的入门级产品。
另外一个重要的方面,ZeroMQ使用了广为使用的BSD Sockets API。这样做的优点是:
- 这是一个大家都熟知的API,学习曲线相当平缓
- 使ZeroMQ与现有的技术连接起来,有利于复用已用的框架与设计
- 最重要的是,使用一个成熟与久经考验的框架,可以避免踩前人踩过的坑、
获得的启示:除了代码复用之外,我们还可以以一种更一般的方法,复用成熟的技术。当你设计一个新产品时,借鉴一下类似的产品(腾讯!)。不要犯“在这里还没有被发明”(Not Invented Here)综合症。复用一切合适的想法、API、框架抽象。允许用户使用已有的知识,同时也可以让我们规避未知的风险。
启示11:消息模式
ZeroMQ的设计思路是专注于一个领域,把它做到最好。因为,大而全的产品只能给领域专家来使用,而小而精的产品的受众则是任何受过训练的程序员。
s = socket (REQ)
s.connect ("tcp://192.168.0.111:5555")
s.send ("Hello World!")
reply = s.recv ()
当我们要给用户提供更一般性的解决方案时,一个技术栈,栈的每一层可以有不同的实现,从而适应不同人群的需要。
这和Internet栈的设计思路非常相似:TCP可以解决基于链接的、可靠的数据流传输,UDP可以解决不可靠的数据包传输,SCTP可以解决多用户数据流传输问题等等。
这样一来,所有的解决方案都是正交的,我们可以利用其中好的设计,也可以没有额外代价的抛弃不好的设计。
获得的启示:当解决一个复杂且多面化的问题时,单个通用型的解决方案可能并不是最好的方式。相反,我们可以把问题的领域想象成一个抽象层,并基于这个层次提供多个实现,每种实现只致力于解决一种定义良好的情况。
当我们这么做时,确定问题的粒度非常重要。如果粒度过细,软件的一般性就会受到限制。如果粒度过粗,那么产品就会变得非常复杂,给用户带来模糊和混乱的感觉。
后记
- 原文链接:http://www.aosabook.org/en/zeromq.html
- 中文翻译:http://www.ituring.com.cn/article/4669
本文中借鉴了中文翻译的部分词句,对此表示感谢。
Comments
comments powered by Disqus