高性能服务器设计2

当服务器程序需要每秒处理大量离散消息或者请求时,该注意哪些问题。网服务器更符合这种情况,但并非所有的网络程序都是
严格意义上的服务器。使用“高性能请求处理程序”是一个很糟糕的标题,为简洁起见,下面将简称为“服务器”。

本文不会涉及到多任务应用程序,在单个程序里同时处理多个任务现在已经很常见。比如你的浏览器可能就在做一些并行处理,
但是这类并行程序设计没有多大挑战性。真正的挑战出现在服务器的架构设计对性能产生制约时,如何通过改善架构来提升系统 性能。
对于在拥有上G内存和G赫兹CPU上运行的浏览器来说,通过DSL进行多个并发下载任务不会有如此的挑战性。这里,应用的焦点不在于
通过吸管小口吮吸,而是如何通过水龙头大口畅饮,这里麻烦是如何解决在硬件性能的制约.(作者的意思应该是怎么通过网络硬件的改善
来增大流量)

一些人会可能会对我的某些观点和建议发出置疑,或者自认为有更好的方法, 这是无法避免的。在本文中我不想扮演上帝的角色;这里所
谈论我自己的一些经验,这些经验对我来说, 不仅在提高服务器性能上有效,而且在降低调试困难度和增加系统的可扩展性上也有作用。
但是对某些人的系统可能会有所不同。 如果有其它更适合于你的方法,那实在是很不错. 但是值得注意的是,对本文中所提出的每一条建议
的其它一些可替代方案,我经过实验得出的结论都是悲观的。
你自己的小聪明在这些实验中或许有更好的表现,但是如果因此怂恿我在这里建议读者这么做,可能会引起无辜读者的反感。你并不想
惹怒读者,对吧?

本文的其余部分将主要说明影响服务器性能的四大杀手:
   (1)数据 拷贝(Data Copies)
   (2)环境切换(Context Switches)
   (3)内存分配(Memory allocation)
   (4)锁竞争(Lock contention)

在文章结尾部分还会提出其它一些比较重要的因素,但是上面的四点是主要因素。如果服务器在处理大部分请求时能够做到没有数据拷贝,
没有环境切换,没有内存分配,没有锁竞争,那么我敢保证你的服务器的性能一定很出色。

(1)数据拷贝(Data Copies)
本节会有点短, 因为大多数人在数据拷贝上吸取过教训. 几乎每个人都知道产生数据拷贝是不对的, 这点是显而易见的, 在你的职业生涯
中, 你很早就会见识过它.  现今, 几乎每个大学课程和几乎所有how-to文档中都提到了它。甚至在某些商业宣传册中, "零拷贝" 都是
个流行用语。

尽管数据拷贝的坏处显而易见,但是还是会有人忽视它。因为产生数据拷贝的代码 常常不是很明显,
你知道你所调用的库或驱动的代码会进行数据拷贝吗? 答案往往超出想象。
"程序I/O"在计算机上到底指什么? 好好思考一下。
哈希函数 是另外一个可能产生数据拷贝的地方,在这里能看到访问内存时的拷贝和更多计算上的消耗。
Once it's pointed out that hashing is effectively "copying plus",似乎能够被避免,但据我所知,
有一些非常聪明的人说过要做到这一点是相当困难的。如果想真正去除数据拷贝,不管是因为影响了服务器性能,还是想在黑客大会上展示
"零复制”技术,你必须自己跟踪可能发生数据拷贝的所有地方,而不是轻信宣传。


有一种可以避免数据拷贝的方法是使用buffer的描述符(或者buffer chains的描述符)来取代直接使用buffer指针,每个buffer描述符
应该由以下元素组成:

    *一个指向buffer指针和整个buffer的长度
    *一个指向buffer中真实数据的指针和真实数据的长度,或者长度的偏移
    *以双向链表的形式提供指向其它buffer的指针
    *一个引用计数

现在,代码可以简单的在相应的描述符上增加引用计数来代替内存中数据的拷贝。这种做法在某些条件下表现的相当好,包括在典型的
网络协议栈的操作上,但有些情况下这做法也令人很头大。 一般来说,在buffer chains的开头和结尾增加buffer很容易,对整个buffer
增加引用计数, 以及对buffer chains的即刻释放也很容易。在chains的中间增加buffer,一块一块的释放buffer,或者对部分buffer增加
引用技术则比较困难。而分割,组合chains会让人立马崩溃。

我不建议在任何情况下都使用这种技术,因为当你想在链上搜索你想要的一个块时,就不得不遍历一遍描述符链,这甚至比数据拷贝更糟糕。
最适用这种技术地方是在程序中大的数据块上,这些大数据块应该按照上面所说的那样独立的分配描述符,以避免发生拷贝,也能避免
影响服务器其它部分的工作.(大数据块拷贝很消耗CPU,会影响其它并发线程的运行)

关于数据拷贝最后要指出的是:在避免数据拷贝时不要走极端。我看到过太多的代码为了避免数据拷贝,最后结果反而比拷贝数据更糟糕,
比如产生环境切换或者一个大的I/O请求被分解了。数据拷贝是昂贵的,但是在避免它时,是收益递减的(意思是做过头了,效果 反而不好)。
为了除去最后少量的数据拷贝而改变代码,继而让代码复杂度翻番,不如把时间花在其它方面。

(2)环境切换(Context Switches)

相对于数据拷贝影响的明显,非常多的人会忽视了环境切换对性能的影响。在我的经验里,比起数据拷贝,环境切换是让高负载应用彻底
完蛋的真正杀手。系统更多的时间都花费在线程切换上,而不是花在真正做工作的线程上。 令人惊奇的是,(和数据拷贝相比)在同一个
水平上,产生环境切换总是更常见。

引起环境切换的第一个原因往往是活跃线程数比CPU个数多。 随着活跃线程数相对于CPU个数的增加,环境切换的次数也在增加,如果你够
幸运,这种增长是线性的,但更常见是指数增长。这个简单的事实解释了为什么每个连接一个线程的多线程设计的可伸缩性更差。对于一个
可伸缩性的系统来说,限制活跃线程数少于或等于CPU个数是更有实际意义的方案。曾经这种方案的一个变种是只使用一个活跃线程,虽然这
种方案避免了环境争用,同时也避免了锁,但它不能有效利用多CPU在增加总吞吐量上的价值,因此除非程序will be non-CPU-bound,
(usually network-I/O-bound),应该继续使用更实际的方案。

一个有适量线程的程序首先要考虑的事情是规划出如何创建一个线程去管理多连接。这通常意味着前置一 个select/poll, 异步I/O,信号
或者完成端口,而后台使用一个事件驱动的程序框架。关于哪种前置API是最好的有很多争论。 Dan Kegel的C10K在这个领域是一篇不错的
论文。个人认为,select/poll和信号通常是一种正确但是丑陋的方案,因此我更倾向于使用AIO或者完成端口,但是实际上它并不会更好。
也许除了select(),它们都还不错。所以不要花太多精力去探索前置系统最外层内部到底发生了什么。

对于最简单的多线程事件驱动服务器的概念模型, 其内部有一个请求缓存队列,客户端请求被一个或者多个监听线程获取后放到队列里,
然后一个或者多个工作线程从队列里面取出请求并处理。从概念上来说,这是一个很好的模型,有很多用这种方式来实现他们的代码。
这会产生什么问题吗?

引起环境切换的第二个原因是把对请求的处理从一个线程转移到另一个线程,有些人甚至把对请求的回应又切换回最初的线程去做,这真
是雪上加霜,因为每一个请求至少引起了2次环境切换。把一个请求从监听线程转换到成工作线程,又转换回监听线程的过程中,使用一种
“平滑”的方法来避免环境切换是非常重要的。此时,是否把连接请求分配到多个线程,或者让所有线程依次作为监听线程来服务每个连接
请求,反而不重要了。

即使在将来,也不可能有办法知道在服务器中同一时刻会有多少激活线程.毕竟,每时每刻都可能有请求从任意连接发送过来,一些进行特殊
任务的“后台”线程也会在任意时刻被唤醒。那么如果你不知道当前有多少线程是激活的,又怎么能够限制激活线程的数量呢?

根据我的经验,最简单同时也是最有效的方法之一是:用一个老式的带计数的信号量,每一个线程执行的时候就先持有信号量。如果信号量
已经到了最大值,那些处于监听模式的线程被唤醒的时候可能会有一次额外的环境切换,(监听线程被唤醒是因为有连接请求到来, 此时监听
线程持有信号量时发现信号量已满,所以即刻休眠), 接着它就会被阻塞在这个信号量上,一旦所有监听模式的线程都这样阻塞住了,那么
它们就不会再竞争资源 了,直到其中一个线程释放信号量,这样环境切换对系统的影响就可以忽略不计。更主要的是,这种方法使大
部分时间处于休眠状态的线程避免在激活线程数中占用一个位置,这种方式比其它的替代方案更优雅。

一旦处理请求的过程被分成两个阶段(监听和工作),那么更进一步,这些处理过程在将来被分成更多的阶段(更多的线程)就是很自然的事了。
最简单的情况是一个完整的请求先完成第一步,然后是第二步(比如回应)。然而实际会更复杂:一个阶段可能产生出两个不同执行路径,
也可能只是简单的生成一个应答(例如返回一个缓存的值)。由此每个阶段都需要知道下一步该如何做,根据阶段分发函数的返回值有三种可能
的做法:
* 请求需要被传递到另外一个阶段(返回一个描述符或者指针)。
* 请求已经完成。(返回ok)
* 请求被阻塞(返回"请求阻塞")。这和前面的情况一样,阻塞到直到别的线程释放资源。

应该注意到在这种模式下,对阶段的排队是在一个线程内完成的,而不是经由两个线程中完成。这样避免不断把请求放在下一阶段的队列里,
紧接着又从该队列取出这个请求来执行。这种经由很多活动队列和锁的阶段很没必要。

这种把一个复杂的任务分解成多个较小的互相协作的部分的方式,看起来很熟悉,这是因为这种做法确实很老了。
我的方法,源于CAR在1978年发明的"通信序列化进程"(Communicating Sequential Processes CSP),它的基础可以上溯到1963时的P
er Brinch Hansen and Matthew Conway--在我出生之前!然而,当Hoare创造出CSP这个术语的时候,“进程”是从抽象的数学角度而言的,
而且,这个CSP术语中的进程和操作系统中同名的那个进程并没有关系。依我看来,这种在操作系统提供的单个线程之内,实现类似多线程
一样协同并发工作的CSP的方法,在可扩展性方面让很多人头疼。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值