卷2:第24章 ZeroMQ

2018-02-24 15:55 更新

原文链接:http://www.aosabook.org/en/zeromq.html

ØMQ是一个消息通信系统,如果你愿意的话也可以称其为“面向消息的中间件”。ØMQ的应用环境很广泛,包括金融服务、游戏开发、嵌入式系统、学术研究以及航空航天等领域。

消息通信系统完成的工作基本上可看作为负责应用程序之间的即时消息通信。一个应用程序决定发送一个事件给另一个应用程序(或者多个应用程序),它将需要发送的数据组合起来,点击“发送”按钮就行了——消息通信系统会搞定剩下的工作。

不同于即时消息通信的是,消息通信系统没有图形用户界面,并假设当出现错误时,对端并不会有人为干预的智能化处理。因此,消息通信系统必须既要有高度的容错性,也要比一般的即时消息通信更快速。

ØMQ最初的设想是作为股票交易中的一个极快速的消息通信系统,因此重点放在了高度优化上。项目开始的头一年都花在制定性能基准测试的方法上了,并尝试设计出一个尽可能高效的架构。

之后,大约是在项目进行的第二年里,开发的重点转变成为构建分布式应用程序而提供的一个通用系统,支持任意模式的消息通信、多种传输机制、对多种编程语言的绑定等等。

在开发的第三年里,重点主要集中于提高系统的可用性,将学习曲线平坦化。我们已经采用了BSD套接字API,尝试整理单个消息通信模式的语义等等。

本章试图向读者介绍,ØMQ为达到上述三个目标是如何设计其内部架构的,也希望给同样面对这些问题的人提供一些启示。

启动ØMQ项目的第三年里,其代码库已经膨胀的过于庞大。有一项提议要标准化ØMQ中所使用的协议,以及实验性地实现一个类ØMQ的消息通信系统以加入到Linux内核中等等。不过,本书并未涵盖这些主题,更多细节可以参考:http://www.250bpm.com/conceptshttp://groups.google.com/group/sp-discuss-group,和http://www.250bpm.com/hits

24.1 应用程序 vs 程序库

ØMQ是一个程序库,不是消息通信服务器。我们花了好几年时间在AMQP上,这是一种在金融行业中尝试标准化用于商业消息通信的协议。我们为其编写了一个参考性的实现,然后部署到几个主要基于消息通信技术的大型项目中使用——由此我们意识到,智能消息服务器(代理/broker)和哑客户端之间的这种经典的客户机/服务器模型是有问题的。

当时我们主要关心的是性能:如果中间有个服务器的话,每条消息都不得不穿越网络两次(从发送者到服务器,然后从服务器再到接收者),还附带有延迟和吞吐量方面的损耗。此外,如果所有的消息都要通过服务器传递的话,某一时刻它就必然会成为性能的瓶颈。

第二点需要关心的是关于大规模部署的问题:当消息通信需要跨越公司的界限时,这种中央集权式管理所有消息流的概念就不再有效了。没有一家公司愿意把对服务器的控制权放在别的公司里,这包含有商业机密以及法律责任相关的问题。实际结果就是每家公司都有一个消息通信服务器,可通过手动桥接的方式连接到其他公司的消息通信系统中。因此整个经济系统被极大的划分开来,但是为每个公司维护这样大量的桥接并没有使情况变得更好。要解决这个问题,我们需要一个分布式的架构。在这种架构中每一个组件都可以由一个不同的商业实体来管辖。鉴于基于服务器架构的管理单元就是服务器,我们可以通过为每个组件设置一个单独的服务器来解决这个问题。在这种情况下,我们可以通过使服务器和组件共享同一个进程来进一步地优化设计。我们最终得到的就是一个消息通信的程序库。

当我们开始设想一种不需要中间服务器的消息通信机制时,也就是ØMQ项目开始之时。这需要自下而上的将整个消息通信的概念颠倒过来,将位于网络中央的集中信息存储模型替换为基于端到端机制的“智能型终端,沉默化网络”的架构。正是由于这样的技术决策,ØMQ从一开始就作为一个库而存在,它不是应用程序。 同时,我们也已经证明了这种架构更加高效(低延迟,高吞吐量)也更加灵活(很容易在此之上构建任意复杂的拓扑结构,而不必拘泥于经典的中心辐射模型)。

然而选择以库的形式发布,这其中还有一个意想不到的结果,那就是这么做提高了产品的可用性。用户反复地表示由于他们不再需要安装和管理一个独立的消息通信服务器了,为此他们感到很庆幸。事实证明,去掉中间服务器是首选方案,因为这么做降低了运营的成本(不需要为消息通信服务器安排管理员),也加快了市场响应的时间(没有必要对客户、管理层或运营团队谈判沟通是否要运行服务器)。

我们从中学到的是,当开始一个新项目时,你应该尽可能的选择以库的形式来设计。我们可以很容易的通过从小型程序中调用库的实现而创建出一个应用,但是却几乎不可能从已有的可执行程序中创建一个库。库对用户来说可以提供更高的灵活性,同时也不需要花费他们很多精力来管理。

24.2 全局状态

全局变量不适于在库中使用。因为一个进程可能会加载同一个库几次,而它们会共用一组全局变量。在图24.1中,ØMQ库被两个不同的、彼此独立的库所调用,而应用本身调用了这两个库。

图24.2 从A到B发送消息

请再看看这副图。每条消息从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点本身并没有什么时延。每条消息都有它们自己的时延,你可以通过多条消息来计算平均时延,但是,对于一个消息流来说并没有什么时延。

换句话说,吞吐量只能在系统的某个端点处才能测量。发送端有吞吐量,接收端有吞吐量,这两者之间的任意中间结点也有吞吐量,但对整个系统来说就没有什么总吞吐量的概念了。另外,吞吐量只对一组消息有意义,单条消息是没有什么吞吐量可言的。

至于吞吐量和时延之间的关系,我们已经证明了原来它们之间确实有关系。但是,公式表达中涉及到积分,我们就不在这里讨论了。要得到更多的信息,可以去读一读有关队列的论文。

关于对消息通信系统进行的基准测试还有许多缺陷存在,但我们不会进一步探讨了。这里应该再次强调我们为此得到的教训:确保理解你正在解决的问题。即使是一个“让它更快”这样简单的问题也会耗费你大量的工作才能正确理解之。更何况如果你不理解问题,你很可能会隐式地将假设和某种流行的观点置入代码中,这使得解决方案要么是有缺陷的或者至少会变得非常复杂,又或者会使得该方案没有达到它应有的适用范围。

24.4 关键路径

我们在性能优化的过程中发现有3个因素会对性能产生严重的影响:

  • 内存分配的次数
  • 系统调用的次数
  • 并发模型

但是,并不是每个内存分配或者每个系统调用都会对性能产生同样的影响。对于消息通信系统的性能,我们所感兴趣的是在给定的时间内能在两点间传送的消息数量。另外,我们可能会感兴趣的是消息从一点传送到另一点需要多久。

考虑到ØMQ被设计为针对长期连接的场景,因此建立一个连接或者处理一个连接错误所花费的时间基本上可忽略。这些事件极少发生,因此它们对总体性能的影响可以忽略不计。

代码库中某个一遍又一遍被频繁使用的部分,我们称之为关键路径。优化应该集中到这些关键路径上来。 让我们看一个例子:ØMQ在内存分配方面并没有做高度优化。比如,当操作字符串时,常常是在每个转化的中间阶段分配一个新的字符串。但是,如果我们严格审查关键路径——实际完成消息通信的部分——我们会发现这部分几乎没有使用任何内存分配。如果是短消息,那么每256个消息才会有一次内存分配(这些消息都被保存到一个单独的大内存块中)。此外,如果消息流是稳定的,在不出现流峰值的情况下,关键路径部分的内存分配次数会降为零(已分配的内存块不会返回给系统,而是不断的进行重用)。

我们从中学到的是:只在对结果能产生影响的地方做优化。优化非关键路径上的代码只是在做无用功。

24.5 内存分配

假设所有的基础组件都已经初始化完成,两点之间的一条连接也已经建立完成,此时要发送一条消息时只有一样东西需要分配内存:消息体本身。因此,要优化关键路径,我们就必须考虑消息体是如何分配的以及是如何在栈上来回传递的。

在高性能网络编程领域中,最佳性能是通过仔细地平衡消息的分配以及消息拷贝所带来的开销而实现的,这是常识(比如,http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf 参见针对“小型”、“中型”、“大型”消息的不同处理)。对于小型的消息,拷贝操作比内存分配要经济的多。只要有需要,完全不分配新的内存块而直接把消息拷贝到预分配好的内存块上,这么做是有道理的。另一方面,对于大型的消息,拷贝操作比内存分配的开销又要昂贵的多。为消息体分配一次内存,然后传递指向分配块的指针,而不是拷贝整个数据。这种方式被称为“零拷贝”。

ØMQ以透明的方式同时处理这两种情况。一条ØMQ消息由一个不透明的句柄来表示。对于非常短小的消息,其内容被直接编码到句柄中。因此,对句柄的拷贝实际上就是对消息数据的拷贝。当遇到较大的消息时,它被分配到一个单独的缓冲区内,而句柄只包含一个指向缓冲区的指针。对句柄的拷贝并不会造成对消息数据的拷贝,当消息有数兆字节长时,这么处理是很有道理的(图24.3)。需要提醒的是,后一种情况里缓冲区是按引用计数的,因此可以做到被多个句柄引用而不必拷贝数据。

图24.4 发送4条消息

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

图24.6 ØMQ的架构框图

用户使用被称为“套接字”的对象同ØMQ进行交互。它们同TCP套接字很相似,主要的区别在于这里的套接字能够处理同多个对端的通信,有点像非绑定的UDP套接字。

套接字对象存在于用户线程中(见下一节的线程模型讨论)。除此之外,ØMQ运行多个工作者线程用以处理通信中的异步环节:从网络中读取数据、将消息排队、接受新的连接等等。

工作者线程中存在着多个对象。每一个对象只能由唯一的父对象所持有(所有权由图中一个简单的实线来标记)。与子对象相比,父对象可以存在于其他线程中。大多数对象直接由套接字sockets所持有。但是,这里有几种情况下会出现一个对象由另一个对象所持有,而这个对象又由socket所持有。我们得到的是一个对象树,每个socket都有一个这样的对象树。我们在关闭连接时会用到对象树,在一个对象关闭它所有的子对象前,任何对象都不能自行关闭。这样我们可以确保关闭操作可以按预期的行为那样正常工作。比如,在队列中等待发送的消息要先发送到网络中,之后才能终止发送过程。

大致来说,这里有两种类型的异步对象。有的对象不会涉及到消息传递,而有些需要。前者主要负责连接管理。比如,一个TCP监听对象在监听接入的TCP连接,并为每一个新的连接创建一个engine/session对象。类似的,一个TCP连接对象尝试连接到TCP对端,如果成功,它就创建一个engine/session对象来管理这个连接。如果失败了,连接对象会尝试重新建立连接。

而后者用来负责数据的传输。这些对象由两部分组成:session对象负责同ØMQ的socket交互,而engine对象负责同网络进行通信。session对象只有一种类型,而对于每一种ØMQ所支持的协议都会有不同类型的engine对象与之对应。因此,我们有TCP engine,IPC(进程间通信)engine,PGM engine(一种可靠的多播协议,参见RFC 3208),等等。engine的集合非常广泛——未来我们可能会选择实现比如WebSocket engine或者SCTP engine。

session对象同socket之间交换消息。可以由两个方向来传递消息,在每个方向上由一个pipe对象来处理。基本上来说,pipe就是一个优化过的用来在线程之间快速传递消息的无锁队列。

最后我们来看看context对象(在前一节中提到过,但没有在图中表示出来),该对象保存全局状态,所有的socket和异步对象都可以访问它。

24.8 并发模型

ØMQ需要充分利用多核的优势,换句话说就是随着CPU核心数的增长能够线性的扩展吞吐量。

以我们之前对消息通信系统的经验表明,采用经典的多线程方式(临界区、信号量等等)并不会使性能得到较大提升。事实上,就算是在多核环境下,一个多线程版的消息通信系统可能会比一个单线程的版本还要慢。有太多时间都花在等待其他线程上了,同时,引入了大量的上下文切换拖慢了整个系统。

针对这些问题,我们决定采用一种不同的模型。目标是完全避免锁机制,并让每个线程能够全速运行。线程间的通信是通过在线程间传递异步消息(事件)来实现的。内行人都应该知道,这就是经典的actor模式。

我们的想法是在每一个CPU核心上运行一个工作者线程——让两个线程共享同一个核心只会意味着大量的上下文切换而没有得到任何别的优势。每一个ØMQ的内部对象,比如说TCP engine,将会紧密地关联到一个特定的工作者线程上。反过来,这意味着我们不再需要临界区、互斥锁、信号量等等这些东西了。此外,这些ØMQ对象不会在CPU核之间迁移,从而可以避免由于缓存被污染而引起性能上的下降(图24.7)。

图24.8 队列

其次,尽管我们意识到无锁算法要比传统的基于互斥锁的算法更加高效,CPU的原子操作开销仍然非常高昂(尤其是当CPU核心之间有竞争时),对每条消息的读或者写都采用原子操作的话,效率将低于我们所能接受的水平。

提高速度的方法——再次采用批量处理。假设你有10条消息要写入到队列。比如,可能会出现当你收到一个网络数据包时里面包含有10条小型的消息的情况。由于接收数据包是一个原子事件,你不能只接收一半,因此这个原子事件导致需要写10条消息到无锁队列中。那么对每条消息都采用一次原子操作就显得没什么道理了。相反,你可以让写线程拥有一块自己独占的“预写”区域,让它先把消息都写到这里,然后再用一次单独的原子操作,整体刷入队列。

同样的方法也适用于从队列中读取消息。假设上面提到的10条消息已经刷新到队列中了。读线程可以对每条消息采用一个原子操作来读取,但是,这种做法过于重量级了。相反,读线程可以将所有待读取的消息用一个单独的原子操作移动到队列的“预读取”部分。之后就可以从“预读”缓存中一条一条的读取消息了。“预读取”部分只能由读线程单独访问,因此这里没有什么所谓的同步需求。

图24.9中左边的箭头展示了如何通过简单地修改一个指针来将预写入缓存刷新到队列中的。右边的箭头展示了队列的整个内容是如何通过修改另一个指针来移动到预读缓存中的。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号