高性能服务器设计2
当服务器程序需要每秒处理大量离散消息或者请求时,该注意哪些问题。网服务器更符合这种情况,但并非所有的网络程序都是
严格意义上的服务器。使用“高性能请求处理程序”是一个很糟糕的标题,为简洁起见,下面将简称为“服务器”。
本文不会涉及到多任务应用程序,在单个程序里同时处理多个任务现在已经很常见。比如你的浏览器可能就在做一些并行处理,
但是这类并行程序设计没有多大挑战性。真正的挑战出现在服务器的架构设计对性能产生制约时,如何通过改善架构来提升个流行用语。
尽管数据拷贝的坏处显而易见,但是还是会有人忽视它。因为产生数据拷贝的代码常常不是很明显,
你知道你所调用的库或驱动的代码会进行数据拷贝吗? 答案往往超出想象。
"程序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的方法,在可扩展性方面让很多人头疼。