Oh!Coder

Coding Life

为什么我们开发的Raptor比Unicorn快4倍,比Puma,Torquebox快2倍

| Comments

本文翻译自rubyraptor.org,仅供学习参考。

剖析Raptor part 1:性能优化技术

chart-blacktext

对于旁人来说会对此有一个大大的疑问,那就是为什么我们开发的Raptor运行如此之快。毕竟,自称比其他app server快四倍不是一个小的倍数。Unicorn,Puma 以及 Torquebox 已经非常快了,因此打败它们实属不易且颇费功夫。

其实这里面有多个原因使得Raptor运行很快,接下来我们将会发布一系列文章来高度详细的解释构造一个快速application server的技术细节。在以后的文章中我们将会阐述更多Raptor的高级特性,因为Raptor并不只擅长于性能。

进入正题之前,我们将会介绍Ruby app server的工作方式。这部分包括在优化技术的正文中。接下来我们会覆盖一些优化技术,包括HTTP parser,联合多线程(combining multithreading)和事件I/O(evented I/O),zero-copy架构以及内存分配技术(memory allocation techniques)。

为了让Raptor运行更快,我们做了很多底层的工作,为了更好的理解文章内容,需要你对socket API,网络,操作系统及其计算机硬件的工作原理有一个基本的了解,我们在文献中提供了一些资料可以帮助你更好的理解文中提到的一些运行机制。

本文内容:

Ruby app server是如何工作的

但是在深入详细讲解之前,这里先对Ruby app server的概念进行一些介绍。如果你对这些内容已经很熟悉了,可以跳过这部分。

Rack:HTTP协议的抽象

rack-logo

所有Ruby app server本质上都是HTTP server,因为HTTP是与所有Ruby app server进行对话的全局协议。也许你对HTTP server是什么有一个模糊的概念。但是HTTP server真正做了什么,以及它在全局应用中扮演了什么样的角色呢?

所有web application遵循一个基本的模型。首先,它们需要从一个I/O通道中接收HTTP请求。随后对这些请求做内部处理。最后,需要对这些向它们发送HTTP请求的client返回响应。这些client通常情况下是web浏览器,但是还可能是一些类似curl这样的工具,又或者是一些搜索引擎的网络爬虫。

但不管怎么说,Ruby web app通常情况下并不与HTTP的请求和响应直接发生关系,因为如果Ruby web app直接处理HTTP请求的话,那么每一个Ruby web app都应该实现一套自己的web server。所以为了自身的简化,它们只需要处理一个抽象过的HTTP请求和响应。而这个抽象层就称之为Rack。每一个Ruby app server都实现了Rack抽象,在Ruby web framework当中,像Rails,Sinatra等与app server之间的接口,都是通过Rack规范进行协定的。这样一来,你就可以无缝的在不同app server之间进行切换:很轻松的就可以从Puma或者Unicorn切换到Raptor。

rack
Rack是Ruby的HTTP抽象层。所有的Ruby app server都实现了Rack接口,所以所有的Ruby web framework能够与所有Ruby app server进行无缝工作。


所以app server的职责是接收和分析HTTP请求,然后在底层与web app通过Rack抽象协议进行对话,接着把Rack的响应(也就是application的返回值)转换回真正的HTTP响应。

连接处理和I/O模型

当编写网络软件(例如HTTP server)时,有很多I/O模型可以供程序员进行选择。I/O模型定义了如何处理I/O数据流的并发。每一个I/O模型都有其自身的优点和缺点。Rack协议本身并没有规定要用哪种I/O模型。这就给application server的具体实现留下了一些发挥的空间。

1. 多进程阻塞I/O(Muti-process bloking I/O)

multi-process-io
多进程阻塞I/O模型。每一个进程一次处理一个client。并发的实现是通过构造多个进程。


如果对方没有数据发送,那么read操作就会被阻塞,如果对方接收数据太慢,那么write操作也会被阻塞。由于这种阻塞行为,一个application一次只能处理一个client。那么我们如何一次处理多个client呢?那就构造多个进程!

在Ruby web app当中,这种I/O模型是一种传统模型,也是Unicorn以及Apache的MPM prefork当中使用的一种。

优点:

  • 工作原理非常简单。
  • 对多线程的bug免疫(因为根本就没有线程来处理I/O并发)。

缺点:

  • 进程本身很重,开销大,所以如果你需要许多I/O并发(例如处理WebSocket,或者你的app需要做很多HTTP的API调用),那么用这种模型并不是太合适。假设你想在单台服务器上对5000个websocket client进行服务。那么你将需要5000个进程(1个client一个进程)。一个正常大小的Rails app单个进程可以耗费250MB的内存,那你将需要1.2TB的RAM。耶稣哇!
  • 由于这些原因限制了I/O的并发,那么app server必须要通过使用“反向代理缓存(buffering reverse proxy)”进行保护,反向代理缓存可以处理更多数量的I/O并发。反向代理缓存的请求和响应是用以避免app server拒绝(against)slow client,这些个slow client会对application的其他方面进行阻塞,而反向代理缓存可以高效的将slow client进行纪录。关于这部分的详细内容可以参见“slow client问题”一节。

2. 多线程阻塞I/O(Muti-threaded blocking I/O)

多线程I/O模型。每一个进程有多个线程。每一个线程一次处理一个client。并发的实现通过构造小数量的进程,每一个进程包含有多个线程。


I/O调用依然是阻塞的,但是与其只构造进程来说,app server也可以构造线程。一个进程有多个线程,每一个线程一次处理一个client。因为线程是轻量级的,所以你可以用更少的内存处理相同数量的I/O并发。对于服务5000个websocket的client来讲,总共需要5000个线程。假设在你的8核server(每个CPU内核一个进程)上运行8个app进程,那么每个进程必须分配625个线程。Ruby以及OS可以轻松应对。单个进程会使用大约1GB的内存(假设单个线程有1MB的开销),或者更少。总共你只需要8GB的内存,远比使用多进程阻塞I/O模型的1.2TB要少的多。

这种I/O模型是Torquebox和Apache的MPM worker所使用的模型之一。也是Puma大面积使用的模型之一;Puma采用了一种带限制(limited)的混合策略,稍后我们会进行描述。

优点:

  • 对于类似embarrassingly parallel的工作负载,比如web请求,使用线程处理client的I/O还是非常的简单的。

缺点:

  • 你的application所有代码或程序库必须都得是线程安全的。
  • app server应该被反向代理缓存进行保护,原因和多进程I/O阻塞模型一样。相比较而言,多线程的app server不太容易受slow client影响,然而多线程的app server并没有完全解决slow client问题。

3.事件I/O(Evented I/O)

事件I/O模型。图片版权归Benjamin Erb所有


I/O调用绝对不会被阻塞。当对方尚未发送数据,或者对方接收数据过慢的时候,I/O调用只返回特定的错误信息。application有一个持续监听I/O事件并进行响应的事件循环。当没有事件发生的时候,事件循环便进入睡眠状态。

这个I/O模型到目前为止是最“怪异(weirdest)”的一种,并且是最难使用程序进行编写的一种。它所需要的方法与上面提到的两种方式完全不同。然而多进程阻塞I/O程序非常容易的就可以转变成多线程阻塞I/O程序,但使用事件I/O经常需要重新编写代码。使用事件I/O的application也必须要进行特定的设计。

这种I/O模型被用在NginxNode.js以及Thin上。部分被用在Puma上,为的是有限的(limited)保护拒绝slow client现象;后面我们会进行讨论。

优点:

  • 使用这种模型,可以处理看似无限量的I/O并发,仅使用单个进程和单个线程,使用很少的资源。虽然多线程已经可以承受更大量的I/O并发,但事件I/O则处在一个更高的层次。
  • 事件模型完全对slow client免疫,因此可以排除需要反向代理缓存的问题。

缺点:

  • 相比于阻塞I/O而言,让其工作起来更加困难。要时刻记住,编写application的代码和类库必须特定于事件模型之上进行编写。

slow client问题

什么是“slow client”以及为什么这会成为一个问题?把你的application想象成一个市政府,把app server想象成市政府里的办公桌。人们走近一张办公桌(发送一个请求),处理一些文件类的工作(在app内部进行处理),随后人们带着盖有印章的文件离开(收到一个响应)。slow client就像是人们走近一张办公桌,但是一直不离开,这个时候工作人员就无法帮助其他人。

slow client阻塞了application的处理,阻止进程处理更多的请求。


slow client是一个现实当中问题吗?简而言之:是的。slow client曾经是一些modem用户(译注:使用调制解调器的用户),但是如今slow client也可能是mobile client。mobile网络的高延迟非常的恶心。网络阻塞会让正常的client变成slow。一些client变成slow目的就是要攻击你的server:见Slowloris attack

这个问题通过使用反向代理缓存解决了,可以处理更大量的I/O并发。想象一下我们把百万的工作人员放到市政府外面。这些工作人员并没有受过处理文件的训练。于此相反,他们只是接收你的文件(缓存你的请求),把这些文件带到市政府内部,然后把带有印章的文件再返还给你(缓存你的响应)。这些外部的工作人员永远不会一直站在办公桌前面,所以他们永远不会引起slow client问题。因为这些外部工作人员只是受到过简单的训练,所以他们的雇佣费用很低(RAM占用低),我们可以雇佣很多。

这也就是为什么Eric Wong,Unicorn的作者,劝告大家把Unicorn放到反向代理缓存的后面的原因。这个反向代理通常来说就是Nginx,Nginx缓存了所有的东西,并且可以处理一个被虚拟化了的无限数量的client。像Puma和Torquebox这种多线程server不是很容易受到影响,这是因为它们在市政府内部有更多的办公桌,但是这些办公桌在数量上仍然是有限的,因为比起市政府外面雇佣费用很低的工作人员来说,内部的工作人员需要更多的训练(占用更多的RAM)。

反向代理缓存拥有无限制的I/O并发,这将确保application不会受到slow client的影响。


Raptor的I/O模型

Raptor使用了上面介绍的三种I/O模型。Raptor采取了混合策略为的是对抗slow client问题。默认情况下,Raptor使用多进程阻塞I/O模型,类似于Unicorn。然而,Raptor自带一个内置的反向代理缓存。这个内置的反向代理缓存是用C++写的,并且使用的模型是事件I/O。

为什么内置一个反向代理会比较cool?为什么不依赖于Nginx?

  • 用户可以降低工作量。你不必设置Nginx。
  • 如果你对Nginx不熟悉,那么使用Raptor意味着你将具有一个更加省心的工具。
  • 正确设置Nginx需要一定量的工作。想使用websocket或Rails streaming?请先确保为相关的URI关闭响应缓存,否则的话这些不会正常工作。Raptor的内置反向代理缓存默认情况下就为你完成了这些事情,不用任何额外的配置。

换句话说:Raptor让所有这些相关事情变得简单。

Puma也是使用类似的混合策略。它是多线程的,但是有一个内置的事件server,用来对请求和响应进行缓存。不管怎说,Puma的实现方案有一定的限制,只能够对拒绝slow sending client有保护作用,但是对拒绝slow receiving client却无法起到有效的保护。Raptor的实现则能够完全防止拒绝slow client,无论是sending还是receiving。

Raptor在以后的付费版本中允许使用多线程。Raptor的内核的大部分特性会开源,但是类似于多线程这种特定的特性会作为付费版本提供。之后我们会发布更多这方面的信息。当开启多线程的时候,app仍然会受到内置的反向代理缓存的保护,所以仍然不需要附庸到Nginx的后面。

编写一个快速的HTTP server

随着相关内容的介绍,现在是时候描述一下核心细节了:我们是如何让Raptor变的更快的。关于性能要多亏于我们的HTTP server的实现。这部分我们自己做了一些定制,把C++语言嵌入到了HTTP server中,针对性能做完全的优化。

内置的HTTP server大约要比Nginx快两倍。那是因为这部分完全是针对Raptor做的定制设计。话说回来,这部分的功能特性要比Nginx要少一些。比如说,我们的内置HTTP server根本就不会处理静态文件以及gzip压缩。削减的这些特性换来了高性能。话虽如此,不过你依然可以通过把Raptor放到Nginx后面享用Nginx的所有特性,甚至可以直接和Nginx进行整合。这些特性Raptor完全支持,在以后的文章中我们会做更多的详细的介绍。

内置的HTTP server同时也是我们内置的反向代理缓存,正如之前在“slow client问题”一节中所讨论的。

Node.js HTTP parser

我们内置的HTTP server利用了C语言写成的Node.js HTTP parser。Node.js的HTTP parser是基于Nginx的HTTP parser,但是这个模块已经被提取了出来并且已经具有通用性,所以这部分可以独立于Nginx的代码库以外单独使用。这个parser非常的nice,支持HTTP 0.9-1.1,keep-alive,以及更高级特性(例如websocket),支持请求和响应header以及body块。自身非常的健壮,能够正确的处理解析错误,站在安全的角度来看,容错非常的重要。性能也是非常的不错:其数据结构尽可能的在非常小的内存中做了优化,可以用在zero-copy模式中,与此同时,对所有常见的application依然保证了足够的通用性。

除此以外,我们也对其他可选的类库进行了考量。首先是Mongrel parser,源自于Zed Shaw所写的Mongrel。Zed使用Ragel自动生成一个C语言的parser用于HTTP的语法规则。这个parser已经被证明非常的成功并且也很健壮,同时已经被用在Thin,Unicorn和Puma中。不过即便如此,我们还是没有选择这个parser,因为它跟Ruby有太多夫妻相了。虽然它是用C代码写的,但是内部融入了太多Ruby,导致很难独立于Ruby。同时性能也不如Node.js的parser。

PicoHTTPParser使用在H2O中。作者宣称比Node.js的HTTP parser要快,但是尚未经受过考验。H2O在产品级别上还没有充分证明过自己,然而Nginx的HTTP parser已经经历了相当长的一段时间,多亏于Node.js的流行,Node.js的parser也经历过了一些实践检验。HTTP的解析是HTTP server当中最重要的部分,最好的情况下出现任何差错都会导致最后不正确的行为,最坏的情况会导致安全隐患。既然Raptor的定位是一款产品级别的server,那么我们自然会优先选择稍微有些慢但经受过更多检验的Node.js的HTTP parser,而不去选PicoHTTPParser。

我们曾经也考虑过手写我们自己的HTTP parser。不过,这会是一项宏伟的工程,而且还要负担后期的维护工作,而且还不像Node.js的parser那样经历过一些实践检验。所以短期来看,我们为什么不自己手写而使用Node.js的parser的原因与我们为什么不使用PicoHTTPParser的原因是类似的。

libev事件类库

正如上面提到的,我们内置的HTTP server是基于事件的。完成一个网络事件循环需要支持I/O,timer等等需要做大量的工作。实际上更为复杂的现实情况是,每一个操作系统都有其自身一套机制来完成可伸缩的I/O polling。Linux有epoll,BSD和OS X有kqueue,Solaris有事件端口;诸如此类。

幸运的是,已经有现成的类库对这些做了抽象的封装。我们使用的是非常棒的libev,此类库由Marc Lehmann创建。Libev非常的快,并提供了I/O监视器(watcher),timer监视器,异步信号安全通信管道(async-signal safe communication channel),支持多事件循环,等等。

不要因为名称上的相似而把libev与libevent搞混了。Libevent也是一个非常出色的类库,并且相比于libev有更全面的功能特性。比如说,它也提供了异步DNS查询,一个RPC framework,一个内置的HTTP server,等等。但是呢,我们用不到这些额外的特性,其实之前我们考虑过我们自己开发一个比libevent内置的HTTP server更快的server。但同时我们发现libev比libevent运行的更快,这得多亏于libev有更少的特性集。这就是为什么我们选择libev代替libevent的原因。

混合事件/多线程:每个线程一个事件循环

之前我们提到了Raptor的内置HTTP server是基于事件的,其实我们并没有讲出所有实情。它其实是一种多线程和事件的混合。我们的HTTP server根据CPU内核的数目构造出同样数目的线程,之后在每一个线程上运行一个事件循环。

我们这样做的原因是因为,通常来说事件server会绑定到单个CPU的内核上。我们还发现使用单个CPU内核,Raptor不会占用整个系统资源。因此我们使用了这种混合策略。

然而,我们最开始的混合方式暴露出一个问题。线程会变的“不均衡”,一个线程(单个CPU内核)服务大量client,同时其它线程只服务到少量client。负载不能够均匀分布到CPU的多个内核上。进一步研究发现,线程事件的自动触发是问题的罪魁祸首:在内核调度到其它线程之前,单个线程应该可以接收多个client。但通过时间进行调度就会产生这样的结果,第一个线程会接收大量的client。这个问题在传统的阻塞多进程/多线程server就不会发生,因为每一个进程/线程处理完当前单个client的请求之后,才会接收下一个client。

为了解决这个问题,我们写了一个内部负载平衡器(internal load balancer),它来负责接收新的client,然后依次均衡的分发到所有线程。

Zero-copy:降低CPU工作集,内存延迟拷贝

CPU变的越来越快,但是RAM的速度至少大幅度落后于十年以上。访问RAM会花费数千个CPU的时钟周期。鉴于此,CPU有了多层缓存,这些缓存非常的小,但是却非常的快。由于缓存的尺寸更小,高性能的application应该最小化它们的工作集(working set),只有这样才能够在足够短的时间内充分利用这些缓存。

HTTP server的主要工作是处理I/O,所以很自然的,这就意味着需要对I/O操作进行内存缓存的管理。然而,内存缓存比起CPU的缓存(cache)来说非常的大。4KB是一个典型的单个内存缓存的大小。相比较而言,大部分CPU所拥有的L1缓存的尺寸只有32KB或者更小(译注:内存缓存可以有多个4KB,但CPU的L1缓存整个大小只有32KB或更小)。

一个纯粹的写HTTP server通过在多个不同缓存之间对数据进行拷贝来管理I/O数据的生命周期。然而,这会对较小的CPU缓存造成一定的压力。所以,一个高效的HTTP server应该最小化数据拷贝的次数。

Raptor完全构建于“zero-copy架构”,这意味着在不必要的情况下,完全避免拷贝内存缓存。zero-copy架构包含两个核心的子系统:mbufs和scatter-gather I/O。

Mbufs:引用计数(reference-counted),堆分配(heap-allocated),重用内存缓存(reusable memory buffers)

网络软件通过read()系统调用接收数据。系统调用把数据放到一个特定的缓存中。一个重要的问题是:这个缓存来自哪儿?

这个缓存会在栈(stack)上分配。栈分配非常的快因为它只涉及相匹配的指针,但是这也意味着缓存的生存期只保留到方法定义的结束。同样这也意味着所有在缓存上的操作必须在方法返回之前完成。这对于阻塞I/O是可能的,但对于事件I/O却是不可能的。操作完全是异步并且缓存的生存周期也许要在方法返回之后还要继续。当使用栈分配时,必须要对缓存进行拷贝才能保证它的生存周期。

当client连接的时候,缓存还可以在堆(heap)上分配,当断开连接的时候进行释放。这种方式确实解决了缓存在方法退出之后的生存周期问题。然而,对空间分配这种操作非常的昂贵非常的影响性能。

我们最终在系统中进行堆分配的形式上发现了银弹,与之前说的进行释放不同,这次我们会对缓存进行重用,把不用的缓存只是放到了freelist上。下一次需要缓存的时候,我们会先去freelist上找可用缓存,若没有可用缓存,会重新进行分配。这种系统的实现称为mbuf。这个mbuf系统是基于Twitter的twemproxy,但是我们做了几处重要的修改:

  • 我们将其改成了引用计数(reference-counted),并且使用C++RAII方式自动管理引用。采用这种高级特性可以很容易使用缓存,不用再担心缓存的生命周期。
  • 我们增加了对切片(slice)的支持,类似于Node.js管理Buffer slices的方式。每一个切片都会增加引用计数。这种方式使得子缓存(sub-buffers)在不用拷贝数据的情况下进行协同工作。一个重要的使用场景是,即便调用一个单独的read()方法中包含有HTTP的body,我们也可以用切片只处理其中的header。
  • 我们将其改为可重入(reentrant)。Twemproxy的原生版本依赖的是全局变量,使用多线程时,整个系统将变的不可用。加锁会降低多核的性能。相应的,我们为每一个线程添加了属于它自己的mbuf相关内容,这样每个线程就可以拥有它自己的mbuf子系统。利用这种方法,线程之间就不存在锁的问题了。
  • 我们将其改为自省(introspectable)。Raptor的administration的接口允许对mbuf子系统的状态进行检测。关于administration特性我们会在以后的文章中讲述。

采用这种方式会产生一些负面影响。其一是内存缓存永远不会被释放,只是返还给操作系统进行自动管理,因为内存缓存的释放会给运行效率带来损伤。因此,当系统空转的时候,Raptor的内存用量也会和client峰值的时候成正比。幸运的是,我们提供了一个administration的命令,允许administrator可以强制释放内存缓存,随后还给操作系统。

另一个负面影响是,既然线程内部的mbuf是私有的,那么线程之间就不能够重用其它线程内部释放的这部分内存缓存。这会导致内存的使用量会比实际所需要的高一点点。

Scatter-gather I/O:避免缓存拼接

通常来说,当你有多个内存地址的字符串,你想通过socket把这些字符串做写入操作,你会有两种选择:

  1. 把所有字符串拼接成一个大的字符串,然后给内核发送一个大的字符串。这种操作需要占用更多的内存以及需要拷贝数据,但是只进行一次内核调用。除非你要一次性对大量数据进行拼接,否则的话内核的调用会显得非常的昂贵。
  2. 把每一个字符串独立发送给内核。虽然不需要太大的内存,但是却要进行多次昂贵的内核调用。
通常来说I/O操作需要进行多次系统调用或者使用一个临时缓存做拼接操作


类似的场景,如果你需要从一个socket中读取一些数据,但是你想要读取数据不同的部分并存到不同的内存缓存中,那么你要么调用read()方法读取数据到一个大的缓存,然后把每个部分拷贝到单独的缓存中,要么调用read()方法单独拷贝每一个独立的部分到其对应的缓存中。

使用scatter-gather I/O你可以传送一串内存缓存数组到内核中。使用这种方法,可以告诉内核写多个缓冲到一个socket中,就好像把它们格式化进单个连续的缓存中,但是只需要一个内核调用。类似的,也可以告诉内核把所读取不同的数据部分放到不同的缓存中。在Unix系统中,这些是通过readv()writev()系统调用来实现的。在Raptor中会广泛使用writev()

Scatter/gather I/O可以使用单个系统调用写入多个缓存


避免动态内存分配

动态内存分配中,C语言使用的malloc()方法以及C++使用的new操作符都是开销非常昂贵的。特别是对于一个HTTP server而言,动态内存管理可以很容易的吃掉大部分业务处理时间。这取决于内存分配器的具体实现,也可以是由于线程之间争夺资源锁引起的。这也是为什么我们会使用单线程对象池(per-thread object pooling)和区域内存管理(region-based memory management)。

为什么动态内存管理会非常的昂贵?分配和释放内存表面上看起来是很简单的操作,而现实是它们有着非常复杂的逻辑会导致变慢。原本分配和释放内存就是一件复杂的问题。因此,在高性能application中,应该尽可能的避免动态内存分配。在高性能游戏程序中避免动态内存分配是标准的做法

既然内存可以被一个线程分配,被另一个线程释放,那么动态内存分配器需要有全局被锁保护的数据结构。很自然,这也就意味着线程在同一时间分配和释放内存的时候,会去对锁进行争夺。一些动态内存管理器的实现,比如Google的tcmalloc,实现了单线程缓存,部分情况得到了改进,但是锁争夺问题最终还是没有得到解决。

这些问题的一个解决方案是在栈(stack)上分配对象。然而,就像缓存一样,这只对有限数量的案例有帮助,并不具有通用性。也许我们更希望一个对象的生存时间能够比方法的作用域长。

因此我们使用两种技术来避免动态内存分配:对象池(object pooling)和区域内存管理(region-based memory management)。

对象池(object pooling)

对象池是一种类似于我们之前用到的mbuf的技术。我们把不再使用的空对象放到freelist上。当我们再次需要一个新对象的时候,我们只要从freelist上获取就可以了(或者是当freelist上为空时重新分配一个),这种操作的复杂度为O(1),只需要改变几个指针。

跟mbuf不同,mbuf是分配到堆上,对象池使用优秀的boost::pool库通过隔离存储进行分配。使用boost::pool,我们可以分配大量的内存块,然后我们会把这些内存块分割成对象大小的尺寸,对象被创建到这些内存块中。这样做有两个好处:

  • 增加CPU缓存的局部性特征。比起使用隔离存储,在堆上分配mbuf,内存缓存会很大,对于利用CPU缓存的局部性,既没有什么帮助,也没有什么损伤。但是我们池子里的对象趋向于更小的尺寸,大小范围在100bytes到1.5KB之间。动态分配它们可以将它们分散在整个内存空间上,但是通过合并它们,又会将它们分布在临近的位置上。(译注:我个人认为这么做其实就是为了增加CPU缓存的命中率)
  • 降低malloc空间开销。每一个动态内存分配在分配的时候不仅仅是分配所指定的字节数,还有一些薄记信息(book keeping)数据结构。通过使用对象池,我们完全可以避免这些薄记信息数据结构。Mbuf的对象很大,所以薄记信息数据结构不会产生太大的开销,但对于小对象来说开销就会很大,这也是为什么要避开的原因。

我们把对象池技术应用于client对象,request对象,HTTP parser对象,body parsing对象以及application session对象。这些对象的创建非常的频繁,因此使用对象池技术非常的有好处。Client对象会在每次连接的时候进行创建,在每次一次请求过程中,请求本身,HTTP parser以及application session对象都会被创建。

在编写Raptor的过程中,我们还意识到这项技术不只是用来避免内存分配:还可以被用做最大限度的减少内存使用。例如,在每个请求都会需要HTTP parser对象,但并不是请求的全局时间段一直使用。所以相对于把HTTP parser对象作为一个静态对象嵌入到client对象当中,倒不如在对象池中动态分配,当不再使用的时候,在不影响性能的的情况下将其释放。

对象池的缺点和mbuf的缺点非常的类似。空闲内存永远都不会自动释放并还给操作系统。对象池里的对象属于单个线程,所以线程之间并不能共享这些对象,内存的占用上也会比实际使用略高。

Palloc:stack-like,区域内存管理

除了对象以外,Raptor HTTP server还需要在请求处理过程中分配各种大小的变量数据结构和字符串。比如,它需要分配可变大小的数组以便跟踪缓存。它需要分配小额缓存以便格式化字符串,比如生成时间戳字符串。它还需要分配哈希表和相关内容为了能够跟踪header区域和值。

对象池对固定大小的对象很有用,但是对动态大小的结构没有帮助。再次回来,栈分配由于生存周期的问题,使用受到一定限制。Mbuf仍然对这些数据结构没什么用,因为mbuf是固定大小的。如果mbuf太小,那么数据结构无法分配到里面。如果mbuf太大,那么也会浪费内存。这些限制导致mbuf只能当作缓存用在I/O操作上。由于大小是可变的,我们不可能预先在client和请求对象中分配一个静态的存储空间。

Raptor中的palloc子系统用了一种优雅的方法解决了这个问题。它是一种区域内存管理的实现方式。我们为请求相关的部分分配了一个16KB大小的内存块。这个内存块就像是一个栈,用来分配一些小的数据结构和字符串。分配非常的快,因为这里只需要匹配一个指针。但是跟系统栈不一样,palloc栈指针永远都不会减少:你只能分配,不能释放。在请求最后,栈指针被重设为0,使得整个内存块可再次重用。如果对于当前可用内存块需要更多内存空间,那么就会创建一个新的内存块。否则,palloc会保持完美的小系统, 大小可变的临时数据结构生存周期不会超过请求的时间长度。

palloc的另一个好处是,类似于系统栈,分配的数据结构之间非常的接近,可以极大的改善CPU缓存的局部性。

Raptor当中的palloc子系统是基于Nginx当中的palloc子系统,并且做了更多的改进。Nginx在每一次的请求都会创建和释放palloc内存块。然而,Raptor当中palloc内存块的重用则是跨越了多个请求,因此能够更多的降低内存分配。

这种技术的不好的一面是对于数据结构,一旦创建,直到请求结束一直不释放。这也是为什么对于中等大小的数据结构我们会使用对象池,当我们在除了I/O缓存以外的所有事情当中使用palloc时,我们很清楚的知道我们不会在一些技术点上进行使用(比如HTTP parser对象)。

文献

如果这篇文章的内容你很感兴趣,那么你或许对下面的文献也会感兴趣。

总结与下一篇

在这篇文章中,我们介绍了Ruby app server是如何工作的,我们也讲述了一些使得Raptor运行更快的技术。但是还有很多技术,例如需要优化哈希表碰撞冲突;使用微优化(micro-optimization)技术来降低内存使用以及提高CPU缓存局部性,像union和pointer tagging;语言层面的优化比如使用C++模版并且避免使用虚方法;链接字符串;内置响应缓存;等等。在以后这一系列的文章中我们会做详细的介绍。

但是尽管目前为止这里所有的焦点都在性能上,但Raptor的特点并不仅限于性能。Raptor提供了很多强大的特性,使得administration非常简单并且让你可以快速容易的分析产品的问题。在未来的文章中,我们将会详细介绍Raptor的特性。

我们希望你能够喜欢这篇文章,希望下次还能见到你!

如果你喜欢这篇文章,请分享到Twitter。:) 多谢。

Comments