引言
本文将与你分享我多年来在服务器开发方面的一些经验。对于这里所说的服务器,更精确的定义应该是每秒处理大量离散消息或者请求的服务程序,网络服务器更符合这种情况,但并非所有的网络程序都是严格意义上的服务器。使用“高性能请求处理程序”是一个很糟糕的标题,为了叙述起来简单,下面将简称为“服务器”。
本文不会涉及到多任务应用程序,在单个程序里同时处理多个任务现在已经很常见。比如你的浏览器可能就在做一些并行处理,但是这类并行程序设计没有多大挑战性。真正的挑战出现在服务器的架构设计对性能产生制约时,如何通过改善架构来提升系统性能。对于在拥有上G内存和G赫兹CPU上运行的浏览器来说,通过DSL进行多个并发下载任务不会有如此的挑战性。这里,应用的焦点不在于通过吸管小口吮吸,而是如何通过水龙头大口畅饮,这里麻烦是如何解决在硬件性能的制约.(作者的意思应该是怎么通过网络硬件的改善来增大流量)
一些人可能会对我的某些观点和建议发出置疑,或者自认为有更好的方法,
本文的其余部分将主要说明影响服务器性能的四大杀手:
1)
2)
3)
4)
在文章结尾部分还会提出其它一些比较重要的因素,但是上面的四点是主要因素。如果服务器在处理大部分请求时能够做到没有数据拷贝,没有环境切换,没有内存分配,没有锁竞争,那么我敢保证你的服务器的性能一定很出色。
数据拷贝(Data Copies)
本节会有点短,因为大多数人在数据拷贝上吸取过教训。几乎每个人都知道产生数据拷贝是不对的,这点是显而易见的,在你的职业生涯中,
尽管数据拷贝的坏处显而易见,但是还是会有人忽视它。因为产生数据拷贝的代码常常隐藏很深且带有伪装,你知道你所调用的库或驱动的代码会进行数据拷贝吗?答案往往超出想象。猜猜“程序I/O”在计算机上到底指什么?哈希函数是伪装的数据拷贝的例子,它有带拷贝的内存访问消耗和更多的计算。曾经指出哈希算法是一种有效的“拷贝+”似乎能够被避免,但据我所知,有一些非常聪明的人说过要做到这一点是相当困难的。如果想真正去除数据拷贝,不管是因为影响了服务器性能,还是想在黑客大会上展示"零复制”技术,你必须自己跟踪可能发生数据拷贝的所有地方,而不是轻信宣传。
有一种可以避免数据拷贝的方法是使用buffer的描述符(或者buffer chains的描述符)来取代直接使用buffer指针,每个buffer描述符应该由以下元素组成:
l
l
l
l
现在,代码可以简单的在相应的描述符上增加引用计数来代替内存中数据的拷贝。这种做法在某些条件下表现的相当好,包括在典型的网络协议栈的操作上,但有些情况下这做法也令人很头大。一般来说,在buffer chains的开头和结尾增加buffer很容易,对整个buffer增加引用计数,以及对buffer chains的即刻释放也很容易。在chains的中间增加buffer,一块一块的释放buffer,或者对部分buffer增加引用技术则比较困难。而分割,组合chains会让人立马崩溃。
我不建议在任何情况下都使用这种技术,因为当你想在链上搜索你想要的一个块时,就不得不遍历一遍描述符链,这甚至比数据拷贝更糟糕。最适用这种技术地方是在程序中大的数据块上,这些大数据块应该按照上面所说的那样独立的分配描述符,以避免发生拷贝,也能避免影响服务器其它部分的工作.(大数据块拷贝很消耗CPU,会影响其它并发线程的运行)。
关于数据拷贝最后要指出的是:在避免数据拷贝时不要走极端。我看到过太多的代码为了避免数据拷贝,最后结果反而比拷贝数据更糟糕,比如产生环境切换或者一个大的I/O请求被分解了。数据拷贝是昂贵的,但是在避免它时,是收益递减的(意思是做过头了,效果反而不好)。为了除去最后少量的数据拷贝而改变代码,继而让代码复杂度翻番,不如把时间花在其它方面。
上下文切换(Context Switches)
相对于数据拷贝影响的明显,非常多的人会忽视了上下文切换对性能的影响。在我的经验里,比起数据拷贝,上下文切换是让高负载应用彻底完蛋的真正杀手。系统更多的时间都花费在线程切换上,而不是花在真正做有用工作的线程上。令人惊奇的是,(和数据拷贝相比)在同一个水平上,导致上下文切换原因总是更常见。引起环境切换的第一个原因往往是活跃线程数比CPU个数多。随着活跃线程数相对于CPU个数的增加,上下文切换的次数也在增加,如果你够幸运,这种增长是线性的,但更常见是指数增长。这个简单的事实解释了为什么每个连接一个线程的多线程设计的可伸缩性更差。对于一个可伸缩性的系统来说,限制活跃线程数少于或等于CPU个数是更有实际意义的方案。曾经这种方案的一个变种是只使用一个活跃线程,虽然这种方案避免了环境争用,同时也避免了锁,但它不能有效利用多CPU在增加总吞吐量上的价值,因此除非程序无CPU限制(non-CPU-bound),(通常是网络I/O限制
一个有适量线程的程序首先要考虑的事情是规划出如何创建一个线程去管理多连接。这通常意味着前置一个select/poll,
对于最简单的多线程事件驱动服务器的概念模型,
即使在将来,也不可能有办法知道在服务器中同一时刻会有多少激活线程.毕竟,每时每刻都可能有请求从任意连接发送过来,一些进行特殊任务的“后台”线程也会在任意时刻被唤醒。那么如果你不知道当前有多少线程是激活的,又怎么能够限制激活线程的数量呢?根据我的经验,最简单同时也是最有效的方法之一是:用一个老式的带计数的信号量,每一个线程执行的时候就先持有信号量。如果信号量已经到了最大值,那些处于监听模式的线程被唤醒的时候可能会有一次额外的环境切换,(监听线程被唤醒是因为有连接请求到来,
一旦处理请求的过程被分成两个阶段(监听和工作),那么更进一步,这些处理过程在将来被分成更多的阶段(更多的线程)就是很自然的事了。最简单的情况是一个完整的请求先完成第一步,然后是第二步(比如回应)。然而实际会更复杂:一个阶段可能产生出两个不同执行路径,也可能只是简单的生成一个应答(例如返回一个缓存的值)。由此每个阶段都需要知道下一步该如何做,根据阶段分发函数的返回值有三种可能的做法:
l
l
l
应该注意到在这种模式下,对阶段的排队是在一个线程内完成的,而不是经由两个线程中完成。这样避免不断把请求放在下一阶段的队列里,紧接着又从该队列取出这个请求来执行。这种经由很多活动队列和锁的阶段很没必要。
这种把一个复杂的任务分解成多个较小的互相协作的部分的方式,看起来很熟悉,这是因为这种做法确实很老了。我的方法,源于CAR在1978年发明的"通信序列化进程"(Communicating Sequential Processes
一个实际的例子是,Matt Welsh的SEDA,这个例子表明分段执行的(stage-execution)思想朝着一个比较合理的方向发展。SEDA是一个很好的“server Aarchitecture done right”的例子,值得把它的特性评论一下:
1.
2.
3.
内存分配(Memory Allocator)
申请和释放内存是应用程序中最常见的操作,
建议一是使用预分配。我们都知道由于使用静态分配而对程序的功能加上人为限制是一种糟糕的设计。但是还是有许多其它很不错的预分配方案。通常认为,通过系统一次性分配内存要比分开几次分配要好,即使这样做在程序中浪费了某些内存。如果能够确定在程序中会有几项内存使用,在程序启动时预分配就是一个合理的选择。即使不能确定,在开始时为请求句柄预分配可能需要的所有内存也比在每次需要一点的时候才分配要好。通过系统一次性连续分配多项内存还能极大减少错误处理代码。在内存比较紧张时,预分配可能不是一个好的选择,但是除非面对最极端的系统环境,否则预分配都是一个稳赚不赔的选择。
建议二是使用一个内存释放分配的lookaside list(监视列表或者后备列表)。基本的概念是把最近释放的对象放到链表里而不是真的释放它,当不久再次需要该对象时,直接从链表上取下来用,不用通过系统来分配。使用lookaside list的一个额外好处是可以避免复杂对象的初始化和清理.
通常,让lookaside list不受限制的增长,即使在程序空闲时也不释放占用的对象是个糟糕的想法。在避免引入复杂的锁或竞争情况下,不定期的“清扫"非活跃对象是很有必要的。一个比较妥当的办法是,让lookaside list由两个可以独立锁定的链表组成:一个"新链"和一个"旧链".使用时优先从"新"链分配,然后最后才依靠"旧"链。对象总是被释放的"新"链上。清除线程则按如下规则运行:
1.
2.
3.
4.
5.
使用了这种方式的系统中,对象只有在真的没用时才会释放,释放至少延时一个清除间隔期(指清除线程的运行间隔),但同常不会超过两个间隔期。清除线程不会和普通线程发生锁竞争。理论上来说,同样的方法也可以应用到请求的多个阶段,但目前我还没有发现有这么用的。
使用lookaside lists有一个问题是,保持分配对象需要一个链表指针(链表结点),这可能会增加内存的使用。但是即使有这种情况,使用它带来的好处也能够远远弥补这些额外内存的花销。
第三条建议与我们还没有讨论的锁有关系。先抛开它不说。即使使用lookaside list,内存分配时的锁竞争也常常是最大的开销。解决方法是使用线程私有的lookasid list,
锁竞争(Lock Contention)
高效率的锁是非常难规划的,
由于锁倾向于对程序逻辑产生束缚,所以如果要在不影响程序正常工作的基础上规划出锁方案基本是不可能的。这也就是人们为什么憎恨锁,并且为自己设计的不可扩展的单线程方案找借口了。
几乎我们每个系统中锁的设计都始于一个"锁住一切的超级大锁",并寄希望于它不会影响性能,当希望落空时(几乎是必然),
在我的经验里,上面的方法从基础上来说就不正确。设想把解决方案当成一座山,优秀的方案表示山顶,糟糕的方案表示山谷。上面始于"超级锁"的解决方案就好像被形形色色的山谷,凹沟,小山头和死胡同挡在了山峰之外的登山者一样,是一个典型的糟糕爬山法;从这样一个地方开始登顶,还不如下山更容易一些。那么登顶正确的方法是什么?
首要的事情是为你程序中的锁形成一张图表,有两个轴:
l
l
现在,你有了一张网格图,图上每个单元格表示一个特定阶段需要的特定数据集。下面是应该遵守的最重要的规则:两个请求不应该产生竞争,除非它们在同一个阶段需要同样的数据集。如果你严格遵守这个规则,那么你已经成功了一半。
一旦你定义出了上面那个网格图,在你的系统中的每种类型的锁就都可以被标识出来了。你的下一个目标是确保这些标识出来的锁尽可能在两个轴之间均匀的分布,
l
l
l
如果你在纵向和横向上把“锁空间(这里实际指锁的分布)"
其他方面
我已经阐述完了影响性能的四个主要方面。然而还有一些比较重要的方面需要说一说,大所属都可归结于你的平台或系统环境:
l
l
l
l
l
我在这里考虑的了很多方面,相信你也考虑过。在特定情况下,应用这里提到的某些方面可能没有价值,但能考虑这些因素的影响还是有用的。如果在系统手册中,你没有找到这些方面的说明,那么就去努力找出答案。写一个测试程序来找出答案;不管怎样,写这样的测试代码都是很好的技巧锻炼。如果你写的代码在多个平台上都运行过,那么把这些相关的代码抽象为一个平台相关的库,将来在某个支持这里提到的某些功能的平台上,你就赢得了先机。
对你的代码,“知其所以然”,
(map注:下面这段文字原文没有,这是译者对于翻译的理)
[附录:奥德修斯(Odysseus,又译“奥德赛”),神话中伊塔刻岛国王,《伊利亚特》和《奥德赛》两大史诗中的主人公(公元前11世纪到公元前9世纪的希腊史称作“荷马时代”。包括《伊利亚特》和《奥德赛》两部分的《荷马史诗》,是古代世界一部著名的杰作)。奥德修斯曾参加过著名的特洛伊战争,在战争中他以英勇善战、足智多谋而著称,为赢得战争的胜利,他设计制造了著名的“特洛伊木马”(后来在西方成了“为毁灭敌人而送的礼物”的代名词)。特洛伊城毁灭后,他在回国途中又经历了许多风险,荷马的《奥德赛》就是奥德修斯历险的记述。“斯库拉和卡律布狄斯”的故事是其中最惊险、最恐怖的一幕。
相传,斯库拉和卡律布狄斯是古希腊神话中的女妖和魔怪,女妖斯库拉住在意大利和西西里岛之间海峡中的一个洞穴里,她的对面住着另一个妖怪卡律布狄斯。它们为害所有过往航海的人。据荷马说,女妖斯库拉长着12只不规则的脚,有6个蛇一样的脖子,每个脖子上各有一颗可怕的头,张着血盆大口,每张嘴有3
这个故事在语言学界和翻译界被广为流传。前苏联著名翻译家巴尔胡达罗夫就曾把
德国著名语言学家洪堡特也说过类似的话:“我确信任何翻译无疑地都是企图解决不可能解决的任务。因为任何一个翻译家都会碰到一个暗礁而遭到失败,
本文探讨了影响服务器性能的四大关键因素:数据拷贝、环境切换、内存分配及锁竞争,并提供了优化建议。
1841

被折叠的 条评论
为什么被折叠?



