简介
网络服务在处理数以万计的客户端连接时,往往出现效率低下甚至完全瘫痪,这被称为 C10K问题。随着互联网的迅速发展,越来越多的网络服务开始面临C10K问题.这个问题在2001年被c10k提出
经典的多线程模式和select模式都不再适用。 应当抛弃它们,采用epoll/kqueue/dev_poll来捕获I/O事件C10K问题, c10k翻译
c10k问题
C10K问题的最大特点是:设计不够良好的程序,其性能和连接数及机器性能的关系往往 是非线性的。举个例子:如果没有考虑过C10K问题,一个经典的基于select的程序能在 旧服务器上很好处理1000并发的吞吐量,它在2倍性能新服务器上往往处理不了并发 2000的吞吐量
常用框架模式
Serve many clients with each thread, and use nonblocking I/O and level-triggered readiness notification
把网络句柄设置为非阻塞模型,然后使用select()或poll()来告知哪个句柄已有数据在等待 处理。 此模型是最传统的,在此模型下,由内核告知你某个文件描述符是否准备好,是否已经完成你的任务
Serve many clients with each thread, and use nonblocking I/O and readiness change notification
Readiness change notification(或边缘触发就绪通知)的意思就是当你给内核一个文件描述 符,一段时间后, 如果该文件描述符从没有就绪到已经准备就绪,那么内核就会发出通知,告知 该文件描述符已经就绪, 并且不会再对该描述符发出类似的就绪通知直到你在描述符上进行一些 操作使得该描述符不再就绪 (如直到在send,recv或者accept等调用上遇到EWOULDBLOCK错误,或 者发送/接收了少于需要的字节数)。
当使用Readiness change notification时,必须准备好处理乱真事件,因为最常见的实现是只 要接收到任何数据包都发出就绪信号, 而不管文件描述符是否准备就绪
Serve many clients with each server thread, and use asynchronous I/O
IO使用的是边缘触发的完成时通知,例如,当一个操作完成时信号就被加入队列
Serve one client with each server thread
让read()和write()阻塞. 这样不好的地方在于需要为每个客户端使用一个完整的栈,从而比较浪费内存。 许多操作系统仍在处理数百个线程时存在一定的问题
Build the server code into the kernel
Novell和Microsoft都宣称已经在不同时期完成了该工作,至少NFS的实现完成了该工作。 khttpd在Linux下为静态web页面完成了该工作, Ingo Molnar完成了”TUX” (Threaded linUX webserver) ,这是一个Linux下的快速的可扩展的内核空间的HTTP服务器
在Linux内核的邮件列表上讨论了该方法的好处和缺点,多数人认为不应该把web服务器放进内核中, 相 反内核加入最小的钩子hooks来提高web服务器的性能,这样对其它形式的服务器就有益
影响服务器性能方面
高性能服务器架构(High-Performance Server Architecture)里面论述了影响服务器架构性能的若干方面
数据拷贝(Data Copies)
有一种可以避免数据拷贝的方法是使用buffer的描述符(或者buffer chains的描述符)来取代直接使用buffer指针, 每个buffer描述符应该由以下元素组成: - 一个指向buffer的指针和整个buffer的长度 - 一个指向buffer中真实数据的指针和真实数据的长度,或者长度的偏移 - 以双向链表的形式提供指向其它buffer的指针 - 一个引用计数
我不建议在任何情况下都使用这种技术,因为当你想在链上搜索你想要的一个块时,就不得不遍历一遍描述符链,这甚至比数据拷贝更糟糕。 最适用这种技术地方是在程序中大的数据块上,这些大数据块应该按照上面所说的那样独立的分配描述符,以避免发生拷贝, 也能避免影响服务器其它部分的工作
关于数据拷贝最后要指出的是:在避免数据拷贝时不要走极端
上下文切换(Context Switches)
在我的经验里,比起数据拷贝,上下文切换是让高负载应用彻底完蛋的真正杀手。系统更多的时间都花费在线程切换上, 而不是花在真正做有用工作的线程上
引起环境切换的第一个原因往往是活跃线程数比CPU个数多。随着活跃线程数相对于CPU个数的增加,上下文切换的次数也在增加, 如果你够幸运,这种增长是线性的,但更常见是指数增长
一个有适量线程的程序首先要考虑的事情是规划出如何创建一个线程去管理多连接c10k
引起环境切换的第二个原因是把对请求的处理从一个线程转移到另一个线程
限制激活线程的数量:
根据cpu个数限制线程个数?
最简单同时也是最有效的方法之一是:用一个老式的带计数的信号量,每一个线程执行的时候就先持有信号量。如果信号量已经到了最大值,那 些处于监听模式的线程被唤醒的时候可能会有一次额外的环境切换,(监听线程被唤醒是因为有连接请求到来, 此时监听线程持有信号量时发现 信号量已满,所以即刻休眠), 接着它就会被阻塞在这个信号量上,一旦所有监听模式的线程都这样阻塞住了,那么它们就不会再竞争资源了, 直到其中一个线程释放信号量,这样环境切换对系统的影响就可以忽略不计
一旦处理请求的过程被分成两个阶段(监听和工作),那么更进一步,这些处理过程在将来被分成更多的阶段(更多的线程)
应该注意到在这种模式下,对阶段的排队是在一个线程内完成的,而不是经由两个线程中完成。这样避免不断把请求放在下一阶段的队列里,紧接着又从该队列取出这个请求来执行。这种经由很多活动队列和锁的阶段很没必要
内存分配(Memory Allocator)
- 使用预分配
- 使用一个内存释放分配的lookaside list(监视列表或者后备列表)
- 第三条建议与我们还没有讨论的锁有关系
锁竞争(Lock Contention)
高效率的锁是非常难规划的, 以至于我把它称作卡律布狄斯和斯库拉(参见附录)。一方面, 锁的简单化(粗粒度锁)会导致并行处理的串行化, 因而降低了并发的效率和系统可伸缩性; 另一方面, 锁的复杂化(细粒度锁)在空间占用上和操作时的时间消耗上都可能产生对性能的侵蚀。 偏向于粗粒度锁会有死锁发生,而偏向于细粒度锁则会产生竞争
首要的事情是为你程序中的锁形成一张图表,有两个轴
- 纵轴表示代码。如果你正在应用剔出了分支的阶段架构
- 水平轴表示数据集。在请求的每个阶段都应该有属于该阶段需要的数据集
最重要的规则:两个请求不应该产生竞争,除非它们在同一个阶段需要同样的数据集
你定义出了上面那个网格图,在你的系统中的每种类型的锁就都可以被标识出来了。 你的下一个目标是确保这些标识出来的锁尽可能在两个轴之间均匀的分布, 这部分工作是和具体应用相关的
- 如果你能对请求按顺序编号,或者能对请求进行哈希,或者能把请求和事物ID关联起来,那么根据这些编号或者ID就能对数据更好的进行分隔。
- 有时,基于数据集的资源最大化利用,把请求动态的分配给数据,相对于依据请求的固有属性来分配会更有优势。就好像现代CPU的多个整数运算单元知道把请求分离一样
- 确定每个阶段指定的数据集是不一样的是非常有用的,以便保证一个阶段争夺的数据在另外阶段不会争夺
其他方面
- 你的存储子系统在大数据读写和小数据读写,随即读写和顺序读写方面是如何进行?在预读和延迟写入方面做得怎样?
- 你使用的网络协议效率如何?是否可以通过修改参数改善性能?是否有类似于TCP_CORK, MSG_PUSH,Nagle-toggling算法的手段来避免小消息产生?
- 你的系统是否支持Scatter-Gather I/O(例如readv/writev)? 使用这些能够改善性能,也能避免使用缓冲链(见第一节数据拷贝的相关叙述)带来的麻烦
- 你的系统的页大小是多少?高速缓存大小是多少?向这些大小边界进行对起是否有用?系统调用和上下文切换花的代价是多少?
- 你是否知道锁原语的饥饿现象?你的事件机制有没有”惊群”问题?你的唤醒/睡眠机制是否有这样糟糕的行为: 当X唤醒了Y, 环境立刻切换到了Y,但是X还有没完成的工作?
c500k c1000k
从 C10K 到 C500K讨论了 Urban Airship c500k的实现,基于Java + Pure NIO.参考文档A Million-user Comet Application with Mochiweb, Part 1 另外涉及内核调优的方法Linux Kernel Tuning for C500k
Select和Poll在连接数增加时,性能急剧下降的原因
- 首先操作系统面对每次的select/poll操作,都需要重新建立一个当前线程的关心事件列表,并把线程挂在这个复杂的等待队列上,这是相当耗时的
- 其次,应用软件在select/poll返回后也需要对传入的句柄列表做一次扫描来dispatch,这也是很耗时的。这两件事都是和并发数相关,而I/O事件的密度也和并发数相关,导致CPU占用率和并发数近似成O(n2)
Linux 2.6.35 Kernel Google捐赠了的两项新特性可以在系统的多个CPU之间分配网络处理负载,改进网络处理的性能
C1000K高性能服务器构建技术讨论了淘宝构建高性能服务器的经验