原文地址http://pl.atyp.us/content/tech/servers.html
写的非常不错的文章,在“the c10k problem” 那个文章也有提到,估计很多人都看过了。我觉得里面说的很多东西都是很多价值的,至少我之前不了解这方面的东西,这种好文章应该早点看到才好啊!!因为我那个项目结束了,周五有点空闲时间,本着能够加深理解以及仔细阅读的目的(我经常看东西都是刷刷扫一下,然后学一下基本概念),就尝试着翻译一下。结果发现翻译文章真是太费劲了,花了好多时间,集中精力忙乎了好几次才能弄完,下次再也不搞这种东西了,精力有限,水平有限啊,没看一下不懂得单词还要去查一下字典。在公司里面,因为水平太菜了。老被叫去参加英语培训,还要做翻译作业,我这也算是响应公司的号召吧。
这篇文章应该网上早有人翻译过了,不过我翻译的时候没有去核对他的。个人水平有限,可能会有些地方理解的不是很到位,有能力的尽量去看英文原文吧,我这只是为了好玩顺便消磨一下时间而已。另外,希望这种翻译不会引起什么版权问题什么的,看看作者文章的第一句话,应该会放过我吧^_^
写这篇文章的目的是为了分享我在多年积累起来的一些关于如何为服务器开发各种应用程序的见解,在这里“服务器”这个术语仅仅是一个近似的说法。 更准确的说,我要写的是关于各种为了每秒要处理大量消息/请求而设计的程序. 通常大多数网络服务器都符合这个定义,但有些程序并不是真正意义上的服务器。 虽然是这样,但为了简单起见, 同时因为"高性能请求处理程序" 是一个糟糕的标题,我们下面就只说“服务器” 了。
我要写的不是关于"优雅并行处理" 应用, 虽然在一个程序中实现多任务现在已经非常常见了。你用来阅读这篇文章的浏览器就是并行的例子 , 但是这样底层的并行概念没有什么吸引人的挑战。吸引人的挑战发生在那些影响整体性能的处理请求的基础设施上,所以改进这些设施就改进了性能。 通常不是指一个浏览器运行于千兆赫的处理器和一G内存的机器上通过DSL线同时进行6路下载的情况。 我们关注的不是那种通过吸管一小口的喝而是使用消防管畅饮的应用, 在那种快要达到硬件容量极限的时候,你如何去做是至关重要的。
肯定会有些人对我的解释和建议有不同的看法,或者他们有更好的做法。好了,我不是在这里传递上帝的旨意,这只是我自己找到的可行的办法,不仅仅是一些影响性能的方面也有关于影响代码可调试性和扩展性的方面。你有你自己的想法。如果你有更好的办法,那非常好,不过我想告诉你的是,我在这文章里面的所有建议都是通过比较其他糟糕办法的结果得出来的。你那些奇技淫巧也许在某些地方有着显著的作用,但如果你要我也把那些也说出来的话,那些读者一定无聊到死。你也不想伤害读者,不是吗?
文章的剩下部分主要就是说我把它叫做造成低劣性能的“四大骑士”。
最后有个总结,不过最大的性能杀手就是这几个了。如果你处理大多数请求时不复制数据、不用上下文切换、不用申请内存、不用为了锁而竞争,那么你就拥有一个性能不错的服务器了,即使你在其他小的方面做得差一些。
这可以是非常短的一节,有个简单的原因:大多数人都已经学过这一课了。所有人都知道数据复制是糟糕的,很明显,不是吗?但,实际上,它只是看起来明显因为你在计算机的生涯的很早时候,几十年前就听一些人提到过。我自己也是这样,有点跑题了。当前,所有的学校课程、非正式的how-to文档都会覆盖到这个了。甚至“零拷贝”都成为一个流行词了。
虽然重申复制的坏处显而易见只是马后炮,但还有一些微妙之处被人漏掉了。最重要的是数据复制往往被隐藏和掩盖起来了。你知道你调用的那些驱动或者库里面是如何做数据复制的吗?比如在PC机上的“Programmed I/O”指的是什么呢。另外一个数据复制被掩盖而不是隐藏的例子是散列函数(译注:哈希函数??都对吧),它不但有复制一样的内存开销而且还有一些额外的计算。一旦指出散列实际上是“复制外加”(译注:就是说做的比单纯的复制要还多的工作),好像很明显应该避免使用它,但我知道有相当的一部分人还是很难理解这一点。如果你想完全摆脱数据复制,不管是因为它真正影响了性能还是你想把“零拷贝操作”写到你的黑客会议的演讲稿里面去,你都要去统计所有的真正的做了数据复制的操作,而不是到处做广告。
真正可靠的避免复制数据的办法是间接使用,用传递内存描述符(或者是内存描述符的链)来取代单单的内存指针。典型的描述符有下面这些组成:
现在,不用复制一段数据以确保它是在内存里面的,代码可以简单的改为增加相应内存描述符上面的引用计数。这在一些情况下可以工作的非常的好,包括典型网络协议栈操作,不过这种办法也可能会引起令人头疼的大问题。通常的说,很容易的在链的开始和末尾增加一块内存,添加引用到整块内存和一次释放整条内存量也很容易。在中间添加或者一块一块的释放或者引用内存的一部分就变得难一些。尝试分割与合并内存块则会让你发疯。
我不推荐在任何情况在都使用这个办法。为什么呢?因为当你想遍历整个描述符链的来查看头部数据时,这将变的非常棘手。这比“数据复制”更糟。我发现最好的是找出程序中最大的对象,比如数据块,确保他们像上面描述的那样分别被申请,这样他们就不需要被复制,也不需要和其他东西做很多交互。
我关于数据复制要说的最后一点,不要过度的避免使用它。我见过太多的代码为了避免数据复制结果弄的更糟,比如强迫上下文切换、打散大I/O请求。数据复制是代价是昂贵的,当你要避免多余的操作时,这是你第一个要检查的地方,但也有一个收益递减点。仅仅为了消除那么几个少量的数据复制,就把整个代码搞的比以前复杂两倍,那也只会浪费时间,这个时间你都可以用来想起他更好的办法了。
(译注:上下文切换,好像听起来不那么直接,指的是从一个进程切换到另外一个进程,或者用户态切换到内核态等系统的进程调度切换吧。可以简单的说进程间切换可能还更容易理解一些。操作系统从一个进程切换到另外一个进程要做很多保存运行环境寄存器、进程结构操作,是开销比较大的。)
相对于每个人都清楚的知道数据复制是不好的办法,我通常很惊讶有那么多人完全忽略掉上下文切换对性能的影响。根据我的经验,引起高负载情况的“崩溃”更多的是上下文切换而不是数据复制,系统开始用更多的时间去从一个线程切换到另一个,比他自己在一个线程用于实际工作用的时间更多。令人惊奇的是,在一个层面上,什么原因导致过多的上下文切换是很明显的。第一个引起上下文切换的原因是拥有比你的实际处理器数量更多的活动进程。随着活动进程比处理器增加的越来越多,进程切换的次数也会越来越多,如果够幸运是线性的,但通常都是指数级的。这个简单的事实解释了为什么为每个连接都开一个进程的多线程设计的可伸缩性非常的差。一个可伸缩性系统上实际可行的方案就是限制活动线程的数量让它在(大多数情况下)都要小于或者等于处理器数量。一个过去流行的变种办法是只使用一个线程,这样的办法就可以避免上下文频繁切换和避免使用锁了,但这不能利用多cpu的性能和高吞吐量,所以不被重视,除非程序没有“cpu边界限制non-CPU-bound”(通常是“网络 I/O边界限制network-I/O-bound”) 。(译注:这最后的一句话看的不太懂,估计意思就是说除非应用程序可以自由的跨cpu利用到所有的cpu的性能吧,现在cpu和cpu之间是独立的,在一个cpu上运行一个程序和另外cpu完全没有关系)
一个"线程节俭"的程序第一件事要做的事就是找出如何在一个线程里面一次处理多个连接。通常是指前端接口使用select/poll、异步I/O、信号或者完成端口(completion ports),后面一个事件驱动的架构支持.许多“宗教战争”已经开始和继续,争论那种前端 API是最好的。 Dan Kegel 的C10K paper是这方面的不错资料. 我个人认为所有的select/poll和信号的特性都是一些丑陋的技巧,所有我更喜欢AIO或者完成端口一些,但这其实不那么重要。可能除了select()这些都能工作的很好,所以就不要花很多时间去分析你应用程序的前端外层发生了些什么。
一个最简单多线程的事件驱动的服务器的模型的中央有一个队列,有一个或者多个“监听者”线程读到请求并添加到队列,另有一个或者多个“工作者”线程移出并处理他们。从概念上说,这个是不错的模型,但人们太经常这样实现他们的代码了。为什么这是错的呢?因为第二个导致上下文切换的原因就是把东西从一个线程传到另外一个线程了。有一些人甚至犯更严重的错误,读取初始线程的请求的回应--这样就每个请求不是发生一个而是两个上下文切换了。非常重要的一点是使用“对称”的办法在已经线程中可以从监听者变成工作者在变回监听者而不用进行上下文切换。 至于是把所有连接分到几个线程还是用一个线程作为所有连接的监听者就不是那么重要了
通常,是不可能知道有多少个线程在接下来时间会被激活的,一刻也不行。毕竟,请求会在任何连接的任意时刻到来,或者“后台”线程也被维护任务控制着在某个时刻被唤醒。如果你不知道有多少线程是活动的,你如果做到限制多少被激活呢?以我的经验,最有效的办法也是最简单的。使用一个不时尚的计数用的信号量,每个线程都记录下是不是在“真正的工作”。如果到了线程数量到了限制值,那么每个监听模型线程就会产生一次额外的上下文切换因为他被唤醒又被信号量阻塞了,但一旦所有的监听模型线程被这样阻塞了,他们就不会继续竞争资源直到其中的一个线程“退出”,这样对系统的影响是微不足道的。更重要的是,这个办法处理了维护线程--大维护线程多数时间都在休眠所以不会被计入活动线程计数--比其他办法都要优雅。
一旦处理的请求被分为两部分(监听者和工作者),同时有多个线程在处理这两部,当然这个处理过程可以跟进一步的分为更多的几部分。最简单的形式,处理一个请求就是依次在一个流向的各个步骤进行处理,接着又反向处理(回复)。但是,事情可能变得更复杂,一个步骤也可能表现为一个“分叉”上的两个处理路径,分别连着不同的处理步骤,或者提可能生成一个回复(比如一个缓存值)而不用传个下一个步骤。所以每个步骤都需要能够为一个请求指定“下一步需要做什么”。一共有3种可能,表现为从某个步骤的分发函数上返回一个值:
注意,在这个模型中,排队请求的工作是在各个步骤里面做的,而不是在步骤之间做。这个避免常见愚蠢做法:不停往后续者队列添加请求,接着马上调用后续步骤,接着又从队列移出来。我把这称为大量的排队活动和锁,没必要。(译注:从前面看来,作者大概意思是说不要为每个步骤都准备一个请求队列吧,就是每个函数表示一个步骤的话,直接在意函数里面调用下个步骤的函数就可以了,这样就可以避免不必要的管理不同队列的同步工作。)
如果说这种把一个复杂的任务分为多个小的互相通信的部分的做法有点眼熟的话,这是因为它一种很古老的做法了。我的方法来自Communicating Sequential ProcessesC.A.R. Hoare 在 1978年提出的概念,基于 Per Brinch Hansen 和 Matthew Conway 1963年提出的思想 - 在我出生之前! 不过, 当 Hoare创造 CSP 这个术语是他指的是抽象数学意义的“处理”, 而且 CSP 处理不和操作系统的设施有任何关系。在我看来,通常的实现CSP办法就是通过类似线程的协作例程在单个系统线程里面实现,这会给用户所有并发头疼问题又没有什么扩展性。
一个关于按照合理流向“分步执行”(staged-execution)的现在历史是Matt Welsh的SEDA。 事实上,SEDA是这么一个“正确的服务器架构”的例子,值得在这里解释一下他的主要特征(特别是与我上面说不同点)。
申请和释放内存是一个很多应用都需要的常见操作。相应的,很多聪明的技巧已经发展起来让通用目的内存申请变的更高效。但是,没有什么聪明可以弥补这么一个事实,一般性用途的分配器在很多情况下都是相对低效的。所以我这里给出3个如何避免任何情况下都使用系统内置内存分配器的建议。
建议#1 是简单的预申请。我们所有人都知道给程序功能规定了人工限制的静态分配是不好的,但还有其他很多形式的预申请是非常有益的。通常原因来自这么一个事实,调用一次系统内存分配器比调用多次要好,即使进程里面有些内存暂时是“浪费”的。所以如果可以预计不会同时都多余N个项被用到,在程序启动时就预申请是一个不错的选择。即使不是这种情况,预先申请好处理一个请求需要的所有的东西也比每部分用到时再申请要好;除外在一个地方连续向系统分配器申请多项数据,也可以很大程度上的简化错误恢复代码。如果内存非常紧缺,预先申请也许不是一个好办法,但在所有的极端情况下,通常都是净赚不赔的。
建议#2 是为频繁申请和释放的对象准备一个后备链表。基本的思想是把最近释放的对象保存到一个链表里面而不是真正的释放,希望下次需要的时候只要从链表里面拿出来就可以了,不用从再次从系统分配权申请。额外的优点是,取出后交到后备列表的操作通常实现为跳过复杂对象的初始化和反初始化。
通常都不可能让后备链表无限的增长,也不会真正去卸载什么东西,即使程序是空闲的时候。所以,通常都需要一些定期“清理”任务去是否那些非活动对象,但如果你这个清理器引入不适当的锁的复杂度和竞争那也是不可取的。一个不错的折中办法是让系统的后备链表分为独立锁定的“旧的”与“新的”两个链表。申请的时候,优先从新链表里面取,然后才是旧的链表,最后迫不得已才会去请求系统分配器;对象都被释放到新的链表里面。清理器线程是这样操作的:
在这种系统里面对象只会在整一个清理周期里面都没有用到才会被真正的释放,但不会被保留超过两个周期。最重要的是,清理器做大多数工作的时候都不需要和其他常规进程来进行锁竞争。理论上,同样办法可以应用于更多级的链表,不过我发现现在已经够用了。
一个担忧是使用后备链表的话链表指针会增加对象大小。以我的经验,大多数我用了后备链表的对象本来就有一个链表指针在那里了,所以这个意见没什么意义。即使这个指针只会被后备链表用到,省下来的避免直接向系统分配器申请的次数(还有初始化对象)对比占多一些内存也是值得的。
建议#3 是关于上锁的,我们还没有讨论到这个,我在这里说一下。锁竞争通常是申请内存时最大的开销,即使使用了后备链表技术。一个解决办法是管理多分私有后备链表,这样就不会再竞争那个后备链表了。比如,你为每个线程建一个后备链表。可能每个处理器一个还更好,那就把缓存命中也考虑到了(译注:由于处理器的缓存技术,访问缓存里面的比直接访问内存的要快很多,处理器根据你访问的地址自己维护缓存的范围,如果你访问的数据都是同一个临近内存地址,反之你使用的不是同一个内存地址的话,处理器就要根据你的内存地址重新更新缓存,缓存失效的情况就更多的发生。各种处理器缓存大小和机制稍稍有点不同,不过高性能的实现在某些时候也考虑到对缓存影响的。感兴趣的可以自己看一下资料。),不过只有线程不会被抢占时才起作用。私有后备链表也可以结合共享链表一起使用,这样建立起来的系统就不会消耗太多的内存。
高效的上锁策略是如此臭名昭著的难以设计,所以我把它称作Odyssey(译注:奥德赛(荷马的叙事诗)。应该就是那个特洛伊木马故事里面提出使用木马后来历经万难才回到家的英雄)之外的Scylla 和 Charybdis女妖(译注:斯库拉(Scylla)是希腊神话中的女海妖,六头十二臂。与另一著名海妖卡律布狄斯(Charybdis)分别驻守在狭窄的墨西拿海峡(Strait of Messina)两侧。Scylla在荷马所著的《奥德赛》中曾经给返乡的希腊英雄奥德修斯(Odysseus)带来巨大的麻烦,甚至吃掉了奥德修斯的六名船员。资料来自http://baike.baidu.com/view/807066.htm ) 。Scylla 是那种太过简化的和粗粒度的上锁策略,这样活动不能完全并行执行就牺牲了性能和扩展性。Charybdis 是过度复杂和严谨的上锁策略,锁操作的时间和空间开销再一次损害了性能。偏向于Scylla容易表现为死锁(deadlock)和 Livelock(译注:类似死锁的情况。可能是用于检测死锁的避免情况。类似的比喻是,两个人通过一条狭窄的小路,虽然俩人都不停的改变左右方向以规避,但俩人选的都同一方向所以还是不停碰撞谁都过不去的情况。在wiki百科的deadlock词条里面有提到,)。偏向于Charybdis 容易表现为竞争状况(race conditions)。两者之间有个高效又合理的小范围。。。到底有没有?一般来说上锁策略和程序逻辑密切相关,如果不从根本上改变程序的工作方式很难设计出一个好的上锁策略来。这就是为什么大家都讨厌上锁,所以他们选者没有扩展性的单线程办法也是可以理解的了。
几乎所有的上锁策略都是这样开始的,先“给所有的东西上一个大锁”一边默默的希望性能不会很糟。如果这个希望不能实现,然后大多数情况是,大锁被分成几个小锁,然后祈祷又开始了,不停的重复这个过程,可能知道性能是合格的。通常每次迭代都增加复杂性和过度上锁20--50%,同时减少5-10%的锁竞争可能性。如果你运气好,最后还能得到一点点性能的提升,但实际下降的情况并不少见。设计者不停的挠他的头了(我说“他”是因为我自己是个男得,不要管它了)》“我都是按照书上的说的信条来设计这些锁的。”他想,“为什么性能变的更差了呢?”
在我看来,事情变得更糟,因为上述方法是一种误导。想象一下,如山的范围作为“解空间”,高点代表良好的解决方案和低点代表坏的解决方案。问题是,“一个大锁”的起点到顶峰几乎总是由各种山谷,马鞍,小峰和死角的方式隔开。这是一个经典的爬山问题,试图通过只采取小步移动的办法从这样一个起点到达更高的山峰,而从不下山,几乎不会起作用,我们需要的是一个完全不同的到达顶峰的方法。
首先,你需要做的就是形成一个你程序的锁的地图。这张地图有两个轴:
您现在有一个网格,每个单元格表示一个特定数据集处在特定的处理阶段。最重要的是以下规则:两个请求互相不存在竞争,除非他们属于同一个数据集和相同的处理阶段。如果你能够做到这点,你已经赢了一半的战争。
一旦你定义了这个表格,每种类型的锁定都可以被标注出来了,你下一步要做的就是尽可能的让这些描出来的点均匀的分布于两个轴上面。不幸的是,这部分是特定于具体应用的。你得想象为一个钻石切割师,根据你对程序是做什么的了解,找出那条处于阶段和数据集之间的天然“分割线”。有些时候很容易就可以开始了。有些时候很难找到,但回过头想起来就感觉更明显。把代码分为不同的处理阶段是困程序设计里很困难的事情,我也不能提供很多帮助了,不过这里有一些关于如果定义数据集的建议:
如果你已经把你的“锁空间”按照垂直和水平方向的划分了,并能够保证锁活动在每个单元格里面都均匀的分布,你就能肯定你的锁的已经是个不错的形态了。还有一步。你还记得我前面几节嘲弄过的“小步走”办法(译注:上面说的爬山,采用小的步子,不先下山不能爬到另一座山的顶峰)吗?它还有它的用处,因为你现在已经有一个好的起点了而不是之前那个糟糕的。有点像你已经处于山的某个高峰的斜坡上,但可能不是最高的那个。现在是时候收集这些竞争的统计信息,以便找到你要做些什么才能改进了,采用不同的办法划分不同的阶段和数据集直到你满意为止。如果你做完了这一切,你就可以站在山顶一览美丽景色了。
像我的承诺的那样,我已经覆盖了服务器设计的最大的四个性能问题。还有一些重要的问题所有的特定服务器都要关心的。大多数情况下,这个你的平台和环境有关:
我肯定还能用这样的方式想出很多问题来。你肯定也可以。在一些特殊情况下,可能也和这些问题没什么关系,但通常思考一下也是值得的。如果你不知道答案--很多你都不能在系统的文档里面找到--解决它。写一个测试程序或者小的性能基准程序以找到经验答案;写这样的一段代码也是有很多有用的技巧在里面的。如果你为多种平台写代码,很多问题都依赖于你在那个平台的库中找到切入点,这样你就可以知道哪些平台支持某些特性可以带来性能收益。
“刨根问底”的精神也应该继续在你的代码里面发扬。分析你代码里面的高效的操作是什么,测试它们在不同场合的时效。这不同于传统的性能分析,这是关于衡量设计元素的,不是具体的实现。低层次的优化通常是慢慢的研究整个设计的人的最后的手段。