-
Notifications
You must be signed in to change notification settings - Fork 0
Home
Welcome to the StudyZeroMQ wiki!
原文链接 http://www.aosabook.org/en/zeromq.html
ZeroMQ是一个消息通信系统,如果你愿意的话也可以称其为“面向消息的中间件”。它能被应用在多种多样的环境中,例如金融服务、游戏开发、嵌入式系统、学术研究和航空航天领域。
消息传递系统大体上来说是应用的即时通信,一个应用程序决定发送一个事件给另一个(或多个)应用程序,它将需要发送的数据组合起来,点击“发送”按钮就行了——消息通信系统会负责剩下的工作。
不同于即时通信,消息传递系统没有图形用户界面且假设出现错误时终端没有人为干预。因此,消息传递系统必须既要有容错性,也要比一般的即时通信更快速。
ZeroMQ最初的设想是作为股票交易中的一个极快速的消息通信系统,因此重点放在了高度优化上。项目开始的头一年都花在制定性能基准测试的方法和尝试设计出一个尽可能高效的架构上了。
之后,大约是在项目进行的第二年里,开发的重点转变成为构建分布式应用程序提供一个通用系统,支持任意模式的消息通信、多种传输机制、对多种编程语言的绑定等等。
开发的第三年里重点主要是提高系统的可用性,将学习曲线平坦化。我们已经采用了BSD套接字API,尝试整理单个消息通信模式的语义等等。 希望本章能向读者介绍上述三个目标是如何转化为ZeroMQ的内部架构的,也希望给同样面对这些问题的人提供一些启示。
启动ZeroMQ项目的第三年里,其代码库已经膨胀的过于庞大。有一项提议要标准化ZeroMQ中所使用的协议,以及实验性地实现一个类ØMQ的消息通信系统以加入到Linux内核中等等。不过,本书并未涵盖这些主题,更多细节可以参考:http://www.250bpm.com/concepts, http://groups.google.com/group/sp-discuss-grop, 和http://www.250bpm.com/hits.
ZeroMQ是一个库,不是消息通信服务器。我们花了好几年时间在AMQP协议上,这是一种在金融行业中尝试标准化商业消息通信的协议。我们为其编写了一个参考性的实现并部署到几个主要基于消息通信技术的大型项目中使用——然后我们意识到,智能消息服务器(代理/broker)和哑客户端之间的这种客户机/服务器经典模型是有问题的。
当时我们的首要关注点是性能,如果中间有个服务器的话,每条消息都不得不穿越网络两次(从发送者到服务器,再从服务器到接收者),产生延迟并降低吞吐量。此外,如果所有的消息都要通过服务器传递的话,某一时刻它就必然会成为瓶颈。
一个次要关注点与大规模部署有关:当通信需要跨越组织的界限时,中央集权式管理所有消息流的设想就不再有效了。没有一家公司愿意把对服务器的控制权放在别的公司里,这里有商业机密和法律责任的问题。实际结果就是每家公司都有一个消息通信服务器,可通过手动桥接连接到其他公司的消息通信系统中,整个通信系统四分五裂,为每个公司维护大量的桥接并没有使情况变得更好。要解决这个问题,我们需要一个分布式的架构,每部分都可以由一个不同的商业实体来管辖。鉴于基于服务器架构的管理单元就是服务器,我们可以通过为每部分单独设置一个服务器,这样我们就可以让服务器和该部分共享同一个进程,进一步优化设计,最终得到了一个消息通信库。
当我们开始设想一种不需要中间服务器的消息通信机制时,ZeroMQ项目就开始了。这需要彻底颠覆消息通信的概念并将位于网络中央的集中信息存储模型替换为基于端到端机制的“智能终端哑网络”架构。正是因为这样的技术决策,ZeroMQ从一开始就是一个库,而非应用程序。同时,我们也证明了这种架构更加高效(低延迟,高吞吐量)也更加灵活(很容易在此之上构建任意复杂的拓扑结构,而不必拘泥于经典的中心辐射模型)。
选择以库的形式发布还带来了一个意想不到的结果,就是提高了产品的可用性,用户反复地表示他们很高兴不再需要安装和管理一个独立的消息通信服务器了。事实证明,去掉中间服务器是更优秀的方案,降低了运营的成本(不需要为消息通信服务器安排管理员)也加快了市场响应的时间(没有必要对客户、管理层或运营团队谈判沟通是否要运行服务器)。
我们从中学到的是,开始新项目时应尽可能的选择库的形式。我们可以很容易的调用库创建一个应用,却几乎不可能从已有的可执行程序中创建一个库。对用户来说,库可以提供更高的灵活性,也无需花费很多精力管理。
全局变量不适合在库中使用。一个进程可能会多次加载同一个库,而它们会共用一组全局变量。在图24.1中,ZeroMQ库被两个不同的、彼此独立的库所调用,而应用本身调用了这两个库。

当这种情况出现时,两个ZeroMQ的实例会访问到相同的变量,导致竞争条件,奇怪的错误和未定义的行为。
为了防止该问题,ZeroMQ中没有使用任何全局变量,而是由库的使用者来显式地创建全局状态。包含全局状态的对象称为context。从用户的角度来看,context或多或少类似一个工人线程(worker thread)池,而从ØMQ的角度来看,它仅仅是一个存储我们需要的全局状态的对象。在上图中,A库和B库都有自己的context。它们之间无法互相干扰。
看到这里应该已经非常明显了:绝不要在库中使用全局状态。如果你这么做了,当库恰好需要在同一个进程中实例化两次时,它很可能会崩溃。
ZeroMQ早期的主要目标是优化性能。消息通信系统的性能可以用两个指标来界定:吞吐量——在一段给定的时间内可以传递多少条消息;时延——一条消息从一端传到另一端需要花费多长时间。
我们应该重点关注哪个指标?这两者之间的关系是什么?这还不明摆着吗?跑测试,用测试的总时间除以消息的数量,你得到的就是时延。用消息的数量除以总时间,你得到的就是吞吐量。换句话说,时延是吞吐量的倒数。很简单,不是吗?
我们并没有直接开始编码,而是花了几周的时间详细调查性能指标,然后,我们发现吞吐量和时延之间的关系绝非如此简单,通常是相当违反直觉的。假设A发送消息给B(见图24.2),测试的总时间是6秒,总共有5条消息传递,因此吞吐量是0.83条消息/每秒(5/6),而时延是1.2秒(6/5),对吧?

请再看看这副图,每条消息从A到B花费不同的时间:2秒、2.5秒、3秒、3.5秒、4秒。平均是3秒,这和我们之前计算出的1.2秒相比差太远了。这个例子很直观的表明人们很容易对性能指标产生误解。
现在来看看吞吐量。测试的总时间是6秒。但是,在A点总共花费了2秒才把所有的消息都发送完毕。从A的角度来看,吞吐量是2.5条消息/秒(5/2)。在B点共花费了4秒才将所有的消息都接收完毕。因此,从B的角度来看,吞吐量是1.25条消息/秒(5/4)。这两个数据都同之前计算得出的1.2条消息/秒不吻合。
长话短说吧,时延和吞吐量显然是两个不同的指标,重要的是理解这两者之间的区别以及它们的相互关系。时延只能在系统的两个不同端点之间才能测量,A点本身并没有什么时延。每条消息都有它们自己的时延,你可以通过多条消息来计算平均时延,但对于一个消息流来说并没有什么时延。另一方面,吞吐量只能在系统的某个端点处才能测量。发送端有吞吐量,接收端有吞吐量,这两者之间的任意中间结点也有吞吐量,但整个系统没有什么总吞吐量的概念。另外,吞吐量只对一组消息有意义,单条消息是没有什么吞吐量可言的。至于吞吐量和时延的关系,我们已经证明了它们之间确实有联系。但公式中涉及到积分,我们就不在这里讨论了,想了解更多可以去读关于队列理论的著作。
对消息通信系统进行的基准测试中还有许多缺陷,但我们不会进一步探讨了。这里应该再次强调我们学到的东西:确保理解你正在解决的问题,即使是理解一个“让它更快”这样简单的问题也需要耗费大量工作。更何况如果你不理解问题,你很可能会隐式的将假设和流行的错误观点置入代码中,让解决方案要么有缺陷或至少非常复杂,要么没有达到它应达到的实用程度。
在性能优化的过程中,我们发现有三个因素会对性能产生重要影响: •内存分配的次数 •系统调用的次数 •并发模型
然而,并非每次内存分配或系统调用都对性能产生同样的影响。对于消息通信系统的性能,我们关注在给定的时间内能在两点间传送的消息数量,同时可能关注消息从一点传送到另一点需要多久。考虑到ZeroMQ被设计为针对长期连接的场景,建立一个连接或处理一个连接错误花费的时间基本上可以忽略,这些事件极少发生,因此它们对总体性能的影响可以忽略不计。
代码库中某个一遍又一遍被频繁使用的部分被称为关键路径,优化应该集中到这些关键路径上来。
让我们看一个例子:ZeroMQ在内存分配方面并没有优化太多,比如操作字符串时通常在每个变换的中间阶段都分配一个新字符串。然而,如果我们严格审查关键路径——实际完成消息通信的部分——我们会发现这部分几乎没有使用任何内存分配。如果是短消息,那么每256个消息才会有一次内存分配(这些消息都被保存到一个单独的大内存块中)。此外,如果消息流是稳定的,在不出现流峰值的情况下,关键路径部分的内存分配次数会降为零(已分配的内存块不会返回给系统,而是不断的进行重用)。
我们从中学到的是:只优化能对结果能产生影响的部分,优化非关键路径上的代码只是在做无用功。
当所有基础组件都已经初始化完成,两点之间的一条连接也已经建立完成时,要发送一条消息只有一样东西需要分配内存:消息本身。因此,要优化关键路径,我们就必须考虑消息是如何分配和在栈上来回传递的。
在高性能网络编程领域的常识中,最佳性能是通过仔细的平衡消息分配和消息拷贝的开销实现的(比如,http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf 参见针对“小型”、“中型”、“大型”消息的不同处理)。对小型的消息,拷贝操作比内存分配要经济的多。只要有需要,完全不分配新的内存块而直接把消息拷贝到预分配好的内存块上是有道理的。对于大型的消息,拷贝操作又比内存分配的开销要昂贵的多。为消息体分配一次内存然后传递指向分配块的指针,而非拷贝整个数据。这种方式被称为“零拷贝”。
ZeroMQ以透明的方式处理这两种情况,一条ZeroMQ消息由一个不透明的句柄来表示。对非常短小的消息,其内容被直接编码到句柄中。因此,对句柄的拷贝实际上就是对消息数据的拷贝。当遇到较大的消息时,它被分配到一个单独的缓冲区内,而句柄只包含一个指向缓冲区的指针。对句柄的拷贝并不会造成对消息数据的拷贝,当消息有数兆字节长时,这么处理是很有道理的(图24.3)。需要提醒的是,后一种情况里缓冲区是按引用计数的,因此可以做到被多个句柄引用而不必拷贝数据。

我们从中学到的是:当考虑性能问题时,不要假设存在一个单一的最佳解决方案。很可能这个问题有多个子问题(例如,小型消息和大型消息),而每一个子问题都有各自的最佳算法。
前面已经提到过,消息通信系统中过多的系统调用会导致性能瓶颈。实际上,这个问题要更普遍化的多,有无法忽视的性能损失与遍历栈有关。因此,明智的做法是,当创建高性能应用时应该尽可能的去避免遍历栈。
参考图24.4,为了发送4条消息,你不得不遍历整个网络协议栈4次(也就ZeroMQ、glibc、用户/内核空间边界、TCP实现、IP实现、以太网链路层、网卡本身,然后反过来再来一次)。

然而,如果你决定将这些消息合为一个单独的批次,就只需要遍历一次栈了(见图24.5)。这种处理方式对消息吞吐量的影响是巨大的,可达两个数量级,尤其是如果消息都比较短小,数百个这样的短消息才能包装成一个批次。

另一方面,批量处理会对时延带来负面影响。比如,我们来分析一下TCP实现中著名的Nagle算法。它为待发出的消息延迟一定的时间,然后将所有的数据合并成一个单独的数据包。显然,数据包中第一条消息的端到端时延要比最后一条消息严重的多。因此,如果应用程序需要持续的低时延的话,常见做法是将Nagle算法关闭。更常见的是取消整个栈层次上的批量处理(比如,网卡的中断汇聚功能)。
//未翻译
但同样,不做批量处理就意味着需要大量穿越整个调用栈,这会导致消息吞吐量降低。似乎我们被困在吞吐量和时延的两难境地中了。
ØMQ尝试采用以下策略来提供一致性的低时延和高吞吐量。当消息流比较稀疏,不超过网络协议栈的带宽时,ØMQ关闭所有的批量处理以改善时延。这里的权衡是CPU的使用率会变得略高——我们仍然需要经常穿越整个调用栈。但是在大多数情况下,这并不是个问题。
当消息的速率超过网络协议栈的带宽时,消息就必须进行排队处理了——保存在内存中直到协议栈准备好接收它们。排队处理就意味着时延的上升。如果消息在队列中要花费1秒时间,端到端的时延就至少会达到1秒。更糟糕的是,随着队列长度的增长,时延会显著提升。如果队列的长度没有限制的话,时延就会超过任何限定值。
据观察,即使调整网络协议栈以追求最低的时延(关闭Nagle算法,关闭网卡中断汇聚功能,等等),由于受前文所述的队列的影响,时延仍然会比较高。
在这种情况下,积极的采取批量化处理是有意义的。反正时延已经比较高了,也没什么好顾虑的了。另一方面,积极的采用批量处理能够提高吞吐量,而且可以清空队列中等待的消息——这反过来又意味着时延将逐步降低,因为正是排队才造成了时延的上升。一旦队列中没有未发送的消息了,就可以关闭批量处理,进一步的改善时延。
我们观察到批量处理只应该在最高层进行,这是需要额外注意的一点。如果消息在最高层汇聚为批次,在低层次上就没什么可做批量处理的了,而且所有低层次的批量处理算法除了会增加总体时延外什么都没做。 我们从中学到了:在一个异步系统中,要获得最佳的吞吐量和响应时间,需要在调用栈的底层关闭批量处理算法,而在高层开启。仅在新数据到达的速率快于它们被处理的速率时才做批量处理。
But again, no batching means extensive traversing of the stack and results in low message throughput. We seem to be caught in a throughput versus latency dilemma. ØMQ tries to deliver consistently low latencies combined with high throughput using the following strategy: when message flow is sparse and doesn't exceed the network stack's bandwidth, ØMQ turns all the batching off to improve latency. The trade-off here is somewhat higher CPU usage—we still have to traverse the stack frequently. However, that isn't considered to be a problem in most cases. When the message rate exceeds the bandwidth of the network stack, the messages have to be queued—stored in memory till the stack is ready to accept them. Queueing means the latency is going to grow. If the message spends one second in the queue, end-to-end latency will be at least one second. What's even worse, as the size of the queue grows, latencies will increase gradually. If the size of the queue is not bound, the latency can exceed any limit. It has been observed that even though the network stack is tuned for lowest possible latency (Nagle's algorithm switched off, NIC interrupt coalescing turned off, etc.) latencies can still be dismal because of the queueing effect, as described above. In such situations it makes sense to start batching aggressively. There's nothing to lose as the latencies are already high anyway. On the other hand, aggressive batching improves throughput and can empty the queue of pending messages—which in turn means the latency will gradually drop as the queueing delay decreases. Once there are no outstanding messages in the queue, the batching can be turned off to improve the latency even further. One additional observation is that the batching should only be done on the topmost level. If the messages are batched there, the lower layers have nothing to batch anyway, and so all the batching algorithms underneath do nothing except introduce additional latency. Lesson learned: To get optimal throughput combined with optimal response time in an asynchronous system, turn off all the batching algorithms on the low layers of the stack and batch on the topmost level. Batch only when new data are arriving faster than they can be processed.
到目前为止,我们都专注于那些使ZeroMQ变得快速的通用原则。从现在起,我们可以看一看实际的系统架构了(图24.6)。
Figure 24.6: ZeroMQ architecture
用户使用所谓的“套接字”与ZeroMQ交互,它们同TCP套接字很相似,主要的区别是这里的套接字能处理同多个对端的通信,有点像非绑定的UDP套接字。
套接字对象存在于用户线程中(见下一节的线程模型讨论)。除此之外,ZeroMQ运行多个工人线程以处理通信中的异步环节:从网络中读取数据、将消息排队、接受新连接等等。
工人线程中有多个对象,每个对象只能由唯一的父母对象拥有(所有权由图中一个简单的实线标记),父母对象的线程可以与子女对象不同。大多数对象直接由套接字拥有,但在几种情况下对象会被一个由套接字拥有的对象拥有,对每个套接字我们都有一个对应的对象树。我们在关闭连接时会用到对象树,在一个对象关闭它所有的子对象前其不能被关闭。这样我们可以确保关闭操作可以按预期的行为那样正常工作。比如,在队列中等待发送的消息要先发送到网络中,之后才能终止发送过程。
大致来说,异步对象有两种类型,有的对象不会涉及消息传递,而有些需要。前者主要负责管理连接,比如,一个TCP监听对象在监听接入的TCP连接,并为每个新连接创建一个引擎/会话对象。类似的,一个TCP连接对象试图连接到TCP对端,成功则创建一个引擎/会话对象来管理这个连接,失败则连接对象会尝试重新建立连接。
后者自己负责数据传输,这些对象由两部分组成:会话对象负责与ZeroMQ的套接字交互,而引擎对象负责同网络进行通信。会话对象只有一种类型,而每种ZeroMQ所支持的协议都有对应类型的引擎对象。因此,我们有TCP引擎,IPC(进程间通信)引擎,PGM引擎(一种可靠的多播协议,参见RFC 3208),等等。引擎的集合非常广泛——未来我们可能会选择实现比如WebSocket引擎或者SCTP引擎。
会话对象与套接字对象交换消息,允许双向传递消息,在每个方向上由一个管对象来处理。基本上来说,管对象就是一个优化过的用来在线程之间快速传递消息的无锁队列。最后我们来看看上下文对象(在前一节中提到过,但没有在图中表示出来),该对象保存全局状态,所有的套接字和异步对象都可以访问它。
ZeroMQ需要充分利用多核的优势,换句话说,就是增加CPU核心数能够线性的提升吞吐量。
我们之前关于消息通信系统的经验表明,采用经典的多线程方式(临界区、信号量等等)并不能较大的提升性能。事实上,即使在多核环境下,一个多线程版的消息通信系统可能会比一个单线程的版本还要慢。太多时间都花在等待其他线程上了,同时,引入的大量上下文切换拖慢了整个系统。
针对这些问题,我们决定采用一种不同的模型,希望能完全避免锁机制并让每个线程都能全速运行。线程间通信通过在线程间传递异步消息(事件)实现。内行人都应该知道,这就是经典的actor模式。
我们的想法是在每一个CPU核心上运行一个工人线程——让两个线程共享一个核心只会导致大量的上下文切换而没有特别的优势。每一个ZeroMQ的内部对象,比如TCP引擎,将会紧密地关联到一个特定的工人线程上。反过来,这意味着我们不再需要临界区、互斥锁、信号量这些东西了。此外,这些ZeroMQ对象不会在CPU核之间迁移,从而能避免由于缓存被污染导致的性能下降(图24.7)。
图24.7 多个工人线程
这个设计让很多传统多线程的问题都消失了。然而我们还需要在许多对象间共享工人线程,这又意味着必须有某种多任务间的合作机制,即我们需要一个调度器,对象必须是事件驱动的而非在整个事件循环中来控制。我们必须考虑任意序列的事件,甚至非常罕见的情况,必须确保不会有哪个对象持有CPU的时间过长等等。
简单来说,整个系统必须是全异步的。任何对象都无法承受阻塞式的操作,因为这不仅会阻塞其自身,而且会阻塞所有共享同一个工人线程的其他对象。所有对象都必须显式或隐式的成为一种状态机。随着成百上千的状态机并行运转着,你必须处理这些状态机之间的所有可能发生的交互,而其中最重要的就是关闭进程。
事实证明,要以一种清晰的方式关闭全异步的系统是一个相当复杂的任务。试图关闭上千个有的正在工作中、有的处于空闲状态、有的正在初始化中、有的已经自行关闭了的运转着的部分,极易出现各种竞态条件、资源泄露之类的情况。ZeroMQ中最复杂的部分就是这个关闭子系统了,快速检查一下bug跟踪系统的记录,就能发现30-50%的bug都同关闭有某种联系。
我们从中学到的是:当要追求极端的性能和可扩展性时,考虑采用actor模型,在这种情况下这几乎是你唯一的选择。不过,如果不使用像Erlang或者ZeroMQ这种专门的系统,你将不得不手工编写并调试大量的基础组件。此外,从一开始就要好好思考关于系统关闭的步骤。这将是代码中最为复杂的部分,而如果你没有清晰的思路该如何实现它,你可能应该重新考虑在一开始就使用actor模型。
最近比较流行使用无锁算法。它们是用于线程间通信的一种简单机制,不依赖于内核提供的互斥锁和信号量等同步原语。相反,它们用CPU原子操作来实现同步,比如原子化的比较并交换指令(CAS)。应该理解它们并不是字面意义上的无锁,锁机制是在硬件层面实现的。
ZeroMQ在管对象中采用无锁队列在用户线程和ZeroMQ的工人线程之间传递消息。关于ZeroMQ是如何使用无锁队列的,这里有两个有趣的地方。
首先,每个队列只有一个写线程和一个读线程。如果有一对多的通信需求,那么就创建多个队列(图24.8)。鉴于采用这种方式时队列不需要考虑对写线程和读线程的同步(只有一个写线程,也只有一个读线程),其能以非常高效的方式来实现。
Figure 24.8: Queues
Second, we realised that while lock-free algorithms were more efficient than classic mutex-based algorithms, atomic CPU operations are still rather expensive (especially when there's contention between CPU cores) and doing an atomic operation for each message written and/or each message read was slower than we were willing to accept. The way to speed it up—once again—was batching. Imagine you had 10 messages to be written to the queue. It can happen, for example, when you received a network packet containing 10 small messages. Receiving a packet is an atomic event; you cannot get half of it. This atomic event results in the need to write 10 messages to the lock-free queue. There's not much point in doing an atomic operation for each message. Instead, you can accumulate the messages in a "pre-write" portion of the queue that's accessed solely by the writer thread, and then flush it using a single atomic operation.
其次,尽管我们意识到无锁算法要比传统的基于互斥锁的算法更加高效,CPU的原子操作开销仍然非常高昂(尤其是当CPU核心之间有竞争时),对每条消息的读或者写都采用原子操作的话,效率将低于我们所能接受的水平。
提高速度的方法——再次采用批量处理。假设你有10条消息要写入到队列。比如,可能会出现当你收到一个网络数据包时里面包含有10条小型的消息的情况。由于接收数据包是一个原子事件,你不能只接收一半,因此这个原子事件导致需要写10条消息到无锁队列中。那么对每条消息都采用一次原子操作就显得没什么道理了。相反,你可以让写线程拥有一块自己独占的“预写”区域,让它先把消息都写到这里,然后再用一次单独的原子操作,整体刷入队列。
The same applies to reading from the queue. Imagine the 10 messages above were already flushed to the queue. The reader thread can extract each message from the queue using an atomic operation. However, it's overkill; instead, it can move all the pending messages to a "pre-read" portion of the queue using a single atomic operation. Afterwards, it can retrieve the messages from the "pre-read" buffer one by one. "Pre-read" is owned and accessed solely by the reader thread and thus no synchronisation whatsoever is needed in that phase. The arrow on the left of Figure 24.9 shows how the pre-write buffer can be flushed to the queue simply by modifying a single pointer. The arrow on the right shows how the whole content of the queue can be shifted to the pre-read by doing nothing but modifying another pointer. Figure 24.9: Lock-free queue Lesson learned: Lock-free algorithms are hard to invent, troublesome to implement and almost impossible to debug. If at all possible, use an existing proven algorithm rather than inventing your own. When extreme performance is required, don't rely solely on lock-free algorithms. While they are fast, the performance can be significantly improved by doing smart batching on top of them.
图24.8 队列
同样的方法也适用于从队列中读取消息。假设上面提到的10条消息已经刷新到队列中了。读线程可以对每条消息采用一个原子操作来读取,但是,这种做法过于重量级了。相反,读线程可以将所有待读取的消息用一个单独的原子操作移动到队列的“预读取”部分。之后就可以从“预读”缓存中一条一条的读取消息了。“预读取”部分只能由读线程单独访问,因此这里没有什么所谓的同步需求。
图24.9中左边的箭头展示了如何通过简单地修改一个指针来将预写入缓存刷新到队列中的。右边的箭头展示了队列的整个内容是如何通过修改另一个指针来移动到预读缓存中的。
图24.9 无锁队列
我们从中学到的是:发明新的无锁算法是很困难的,而且实现起来很麻烦,几乎不可能对其调试。如果可能的话,可以使用现有的成熟算法而不是自己来发明轮子。当需要追求极度的性能时,不要只依靠无锁算法。虽然它们的速度很快,但可以在其之上通过智能化的批量处理来显著提高性能。
用户接口是任何软件产品中最为重要的部分。这是你的程序唯一暴露给外部世界的部分,如果搞砸了全世界都会恨你的。对于面向最终用户的产品来说,用户接口就是图形用户界面或者命令行界面,而对于库来说,那就是API了。
在ZeroMQ的早期版本中,其API是基于AMQP的交易和队列模型的(参见AMQP规范)。从历史的角度来看,2007年的白皮书尝试要将AMQP同一个代理模式的消息通信系统相整合,这很有趣。我于2009年底重新使用BSD套接字API从零开始重写了整个项目。那就是转折点,从那一刻起ZeroMQ的用户数量开始猛增。之前的ZeroMQ是由消息通信领域的专家们所使用的产品,而现在成为任何人都能方便使用的普通工具。在1年左右的时间里,ZeroMQ的用户社群扩大了10倍之多,我们还实现了对20多种不同编程语言的绑定等等。
用户接口定义了人们对产品的感观。基本没有改变功能——仅仅通过修改了API——ZeroMQ就从一个“企业级消息通信”产品转变为一个“网络化”的产品。换句话说,人们对ZeroMQ的感观从一个“大金融机构所使用的复杂基础组件”转变为“嘿,这工具可以帮助我从程序A发送10字节长的消息到程序B”。
我们从中学到的是:正确理解你的项目,根据你对项目的愿景来合理地设计用户接口。用户接口同项目的愿景不相符合的话,可以100%保证该项目注定会失败。
将ZeroMQ的用户接口替换为BSD套接字API,这其中有个很重要的因素,那就是BSD套接字API并不是一个新的发明,而是早就为人们所熟悉了。事实上,BSD套接字API是当今仍在使用中的最为古老的API之一了。那得回溯到1983年以及4.2版BSD Unix的时代。它已经被广泛且稳定的使用了几十年了。
上面的事实带来了很多优势。首先,人人都知道BSD套接字API,因此学习的难度曲线非常平坦。就算你从未听说过ZeroMQ,你也可以在几分钟内创建出一个应用程序,这都得感谢你可以重用过去在BSD套接字上积累的经验。
其次,使用这样一种被广泛支持的API使得ZeroMQ可以同已有的技术进行融合。比如,将ZeroMQ对象暴露为“套接字”或者“文件描述符”,这可以让我们在同样的事件循环中处理TCP、UDP、管道、文件以及ZeroMQ事件。另一个例子是:要将类似ZeroMQ的功能加入到Linux内核中,这个实验性的项目就变得非常容易实现了。通过共享相同的概念框架,ZeroMQ可以复用很多已有的基础组件。
第三,也许也是最重要的一点,那就是BSD套接字API已经存活了将近30年的时间了,尽管中间人们曾多次尝试替换它。这意味着设计中有某种固有的正确性。BSD套接字API的设计者——无论是故意的还是偶然的——都做出了正确的设计决策。通过借用这套API,我们可以自动分享到这些设计决策,而不必知道这些决策究竟是什么,或者它们到底解决了什么问题。
我们从中学到的是:虽然代码复用的思想从远古时代就有了,随后模式复用的概念也加入了进来,重要的是要以一种更一般化的方式来思考复用。当做产品设计时,参考一下其他相似的产品。调查一下哪些方面是失败的,哪些方面是成功的,从成功的项目中学习。不要觉得没有创新就接受不了。复用好的点子、API、概念框架,任何你觉得合适的东西都可以复用。这么做的好处是你可以让用户重用他们之前的知识,同时你也可以避免当前你并不了解的技术方面的陷阱。
在任何消息通信系统中,所面临的最重要的设计问题是如何提供一种方式可以让用户指定哪条消息可以路由到哪个目的地。这里主要有两种方法,而且我相信这两种方法是相当通用的,基本可适用于软件领域中遇到的任何问题。
第一种方式是吸收Unix哲学中的“只做一件事,并把它做好”的原则。这意味着问题域应该人为地限制在一个较小且易理解的范围内。然后,程序应该以正确和详尽的方式来解决这个受限制的问题。在消息通信领域中,一个采用这种方式的例子是MQTT。这是一种将消息分发给一组消费者的协议。它很容易使用,而且在消息分发方面做得很出色,但除此之外它不能用于任何其他用途(比如说RPC)。
另一种方式是致力于一般性,并提供一种功能强大且高度可配置的系统。AMQP就是这样一个例子。它的队列和互换的模式提供给用户可编程的能力,几乎可以定义出他们可想到的任意一种路由算法。当然了,有得必有失,取舍的结果就是增加了许多选项需要我们去处理。
ZeroMQ选择了前一种方式,因为这种方式下的产品几乎所有的人都可以使用,而通用的方式下的产品需要消息通信方面的专家才能用上。为了阐明这个观点,让我们看看模式是如何对API的复杂度产生影响的。如下代码是在通用系统(AMQP)之上的RPC客户端实现: connect ("192.168.0.111") exchange.declare (exchange="requests", type="direct", passive=false, durable=true, no-wait=true, arguments={}) exchange.declare (exchange="replies", type="direct",passive=false, durable=true, no-wait=true, arguments={}) reply-queue=queue.declare(queue="", passive=false, durable=false, exclusive=true, auto-delete=true, no-wait=false, arguments={}) queue.bind (queue=reply-queue, exchange="replies", routing-key=reply-queue) queue.consume (queue=reply-queue, consumer-tag="", no-local=false, no-ack=false, exclusive=true, no-wait=true, arguments={}) request = new-message ("Hello World!") request.reply-to = reply-queue request.correlation-id = generate-unique-id () basic.publish (exchange="requests", routing-key="my-service", mandatory=true, immediate=false) reply = get-message () 而另一方面,ZeroMQ将消息划分为所谓的“消息模式”。几个模式方面的例子有“发布者/订阅者”,“请求/回复”或者“并行管线”。每一种消息通信的模式之间都是完全正交的,可被看做是一个单独的工具。
接下来采用ZeroMQ的请求/回复模式对上面的应用进行重构,注意ZeroMQ将繁杂的选择缩减为一个单一的步骤,这只要通过选择正确的消息模式“REQ”就可以了。 s = socket (REQ) s.connect ("tcp://192.168.0.111:5555") s.send ("Hello World!") reply = s.recv () 到这里为止,我们已经可以认为具体化的解决方案比通用型解决方案要更好。我们希望自己的解决方案能尽可能的具体化。但是,同时我们又希望提供给用户的功能面尽可能的广。我们该如何解决这个明显的矛盾?
答案分两步: 1.定义一个堆栈层,用以处理某个特定的问题领域。(比如,传输、路由、演示等) 2.为该层提供多种实现方式。对于每种实现的使用,都应该是非互相干扰的。
让我们看看网络协议栈中有关传输层的例子。传输层意味着需要在网络层(IP)之上提供例如数据流传输、流控、可靠性等服务。它是通过定义多种互不干扰的解决方案来实现的:TCP作为面向连接的可靠数据流传输机制、UDP作为面向非连接的非可靠式数据包传输机制、SCTP作为多个流的传输、DCCP作为非可靠性连接等等。
注意,这里每种实现都是完全正交的:UDP端不能同TCP端通信,SCTP端也不能同DCCP端通信。这意味着新的实现可以在任意时刻加到这个栈上,而不会对栈中已有的部分产生影响。相反如果实现是失败的,则可以被完全丢弃而不会影响传输层的整体能力。
同样的道理也适用于ZeroMQ中定义的消息模式。消息模式在传输层(TCP及其它成员)之上组成了新的一层(所谓的“可扩展性层”)。每个消息模式都是这一层的具体实现。它们都是严格正交的——“发布者/订阅者”端无法同“请求/回复”端通信,等等之类。消息模式之间的严格分离反过来又意味着新的模式可以按照需求增加进来,开发新模式的实验如果失败了,也不会对已有的模式产生影响。
我们从中学到的是:当解决一个复杂且多面化的问题时,单个通用型的解决方案可能并不是最好的方式。相反,我们可以把问题的领域想象成一个抽象层,并基于这个层次提供多个实现,每种实现只致力于解决一种定义良好的情况。当我们这么做时,要仔细划定用例情况。要确认什么在范围内,什么不在范围内。如果对使用范围限制的太过于严格,软件的应用性就会受到限制。如果对问题定义的太广,那么产品就会变得非常复杂,给用户带来模糊和混乱的感觉。
由于我们的世界变的充斥着大量通过互联网相连的小型计算机——移动电话、RFID阅读器、平板电脑以及便携式计算机、GPS设备等等——分布式计算已经不再局限于学术领域而是成为了每位开发者需要去解决的日常问题了。不幸的是,大多数解决方案都是领域相关的独门秘技。本文以系统化的方式总结了我们在构建大规模分布式系统中的经验,主要侧重于从软件架构的观点来阐明我们需要面对的挑战,希望开源社区中的架构师和程序员会发现本文很有帮助。
This work is made available under the Creative Commons Attribution 3.0 Unported license. Please see the full description of the license for details.
24.10. API The user interface is the most important part of any product. It's the only part of your program visible to the outside world and if you get it wrong the world will hate you. In end-user products it's either the GUI or the command line interface. In libraries it's the API. In early versions of ZeroMQ the API was based on AMQP's model of exchanges and queues. (See the AMQP specification.) From a historical perspective it's interesting to have a look at the white paper from 2007 that tries to reconcile AMQP with a brokerless model of messaging. I spent the end of 2009 rewriting it almost from scratch to use the BSD Socket API instead. That was the turning point; ZeroMQ adoption soared from that point on. While before it was a niche product used by a bunch of messaging experts, afterwards it became a handy commonplace tool for anybody. In a year or so the size of the community increased tenfold, some 20 bindings to different languages were implemented, etc. The user interface defines the perception of a product. With basically no change to the functionality—just by changing the API—ZeroMQ changed from an "enterprise messaging" product to a "networking" product. In other words, the perception changed from "a complex piece of infrastructure for big banks" to "hey, this helps me to send my 10-byte-long message from application A to application B". Lesson learned: Understand what you want your project to be and design the user interface accordingly. Having a user interface that doesn't align with the vision of the project is a 100% guaranteed way to fail. One of the important aspects of the move to the BSD Sockets API was that it wasn't a revolutionary freshly invented API, but an existing and well-known one. Actually, the BSD Sockets API is one of the oldest APIs still in active use today; it dates back to 1983 and 4.2BSD Unix. It's been widely used and stable for literally decades. The above fact brings a lot of advantages. Firstly, it's an API that everybody knows, so the learning curve is ludicrously flat. Even if you've never heard of ZeroMQ, you can build your first application in couple of minutes thanks to the fact that you are able to reuse your BSD Sockets knowledge. Secondly, using a widely implemented API enables integration of ZeroMQ with existing technologies. For example, exposing ZeroMQ objects as "sockets" or "file descriptors" allows for processing TCP, UDP, pipe, file and ZeroMQ events in the same event loop. Another example: the experimental project to bring ZeroMQ-like functionality to the Linux kernel turned out to be pretty simple to implement. By sharing the same conceptual framework it can re-use a lot of infrastructure already in place. Thirdly and probably most importantly, the fact that the BSD Sockets API survived almost three decades despite numerous attempts to replace it means that there is something inherently right in the design. BSD Sockets API designers have—whether deliberately or by chance—made the right design decisions. By adopting the API we can automatically share those design decisions without even knowing what they were and what problem they were solving. Lesson learned: While code reuse has been promoted from time immemorial and pattern reuse joined in later on, it's important to think of reuse in an even more generic way. When designing a product, have a look at similar products. Check which have failed and which have succeeded; learn from the successful projects. Don't succumb to Not Invented Here syndrome. Reuse the ideas, the APIs, the conceptual frameworks, whatever you find appropriate. By doing so you are allowing users to reuse their existing knowledge. At the same time you may be avoiding technical pitfalls you are not even aware of at the moment. 24.11. Messaging Patterns In any messaging system, the most important design problem is that of how to provide a way for the user to specify which messages are routed to which destinations. There are two main approaches, and I believe this dichotomy is quite generic and applicable to basically any problem encountered in the domain of software. One approach is to adopt the Unix philosophy of "do one thing and do it well". What this means is that the problem domain should be artificially restricted to a small and well-understood area. The program should then solve this restricted problem in a correct and exhaustive way. An example of such approach in the messaging area is MQTT. It's a protocol for distributing messages to a set of consumers. It can't be used for anything else (say for RPC) but it is easy to use and does message distribution well. The other approach is to focus on generality and provide a powerful and highly configurable system. AMQP is an example of such a system. Its model of queues and exchanges provides the user with the means to programmatically define almost any routing algorithm they can think of. The trade-off, of course, is a lot of options to take care of. ZeroMQ opts for the former model because it allows the resulting product to be used by basically anyone, while the generic model requires messaging experts to use it. To demonstrate the point, let's have a look how the model affects the complexity of the API. What follows is implementation of RPC client on top of a generic system (AMQP): connect ("192.168.0.111") exchange.declare (exchange="requests", type="direct", passive=false, durable=true, no-wait=true, arguments={}) exchange.declare (exchange="replies", type="direct", passive=false, durable=true, no-wait=true, arguments={}) reply-queue = queue.declare (queue="", passive=false, durable=false, exclusive=true, auto-delete=true, no-wait=false, arguments={}) queue.bind (queue=reply-queue, exchange="replies", routing-key=reply-queue) queue.consume (queue=reply-queue, consumer-tag="", no-local=false, no-ack=false, exclusive=true, no-wait=true, arguments={}) request = new-message ("Hello World!") request.reply-to = reply-queue request.correlation-id = generate-unique-id () basic.publish (exchange="requests", routing-key="my-service", mandatory=true, immediate=false) reply = get-message () On the other hand, ZeroMQ splits the messaging landscape into so-called "messaging patterns". Examples of the patterns are "publish/subscribe", "request/reply" or "parallelised pipeline". Each messaging pattern is completely orthogonal to other patterns and can be thought of as a separate tool. What follows is the re-implementation of the above application using ZeroMQ's request/reply pattern. Note how all the option tweaking is reduced to the single step of choosing the right messaging pattern ("REQ"): s = socket (REQ) s.connect ("tcp://192.168.0.111:5555") s.send ("Hello World!") reply = s.recv () Up to this point we've argued that specific solutions are better than generic solutions. We want our solution to be as specific as possible. However, at the same time we want to provide our customers with as wide a range of functionality as possible. How can we solve this apparent contradiction? The answer consists of two steps: 1.Define a layer of the stack to deal with a particular problem area (e.g. transport, routing, presentation, etc.). 2.Provide multiple implementations of the layer. There should be a separate non-intersecting implementation for each use case. Let's have a look at the example of the transport layer in the Internet stack. It's meant to provide services such as transferring data streams, applying flow control, providing reliability, etc., on the top of the network layer (IP). It does so by defining multiple non-intersecting solutions: TCP for connection-oriented reliable stream transfer, UDP for connectionless unreliable packet transfer, SCTP for transfer of multiple streams, DCCP for unreliable connections and so on. Note that each implementation is completely orthogonal: a UDP endpoint cannot speak to a TCP endpoint. Neither can a SCTP endpoint speak to a DCCP endpoint. It means that new implementations can be added to the stack at any moment without affecting the existing portions of the stack. Conversely, failed implementations can be forgotten and discarded without compromising the viability of the transport layer as a whole. The same principle applies to messaging patterns as defined by ZeroMQ. Messaging patterns form a layer (the so-called "scalability layer") on top of the transport layer (TCP and friends). Individual messaging patterns are implementations of this layer. They are strictly orthogonal—the publish/subscribe endpoint can't speak to the request/reply endpoint, etc. Strict separation between the patterns in turn means that new patterns can be added as needed and that failed experiments with new patterns won't hurt the existing patterns. Lesson learned: When solving a complex and multi-faceted problem it may turn out that a monolithic general-purpose solution may not be the best way to go. Instead, we can think of the problem area as an abstract layer and provide multiple implementations of this layer, each focused on a specific well-defined use case. When doing so, delineate the use case carefully. Be sure about what is in the scope and what is not. By restricting the use case too aggressively the application of your software may be limited. If you define the problem too broadly, however, the product may become too complex, blurry and confusing for the users.