The C10K problem
编写连接数巨大的高负载服务器程序时,经典的多线程模式和select模式都不再适用。应当抛弃它们,采用epoll/kqueue/dev_poll来捕获I/O事件。最后简要介绍了AIO。
网络服务在处理数以万计的客户端连接时,往往出现效率低下甚至完全瘫痪,这被称为 C10K问题。随着互联网的迅速发展,越来越多的网络服务开始面临C10K问题,作为大型 网站的开发人员有必要对C10K问题有一定的了解。本文的主要参考文献是 http://www.kegel.com/c10k.html。 C10K问题的最大特点是:设计不够良好的程序,其性能和连接数及机器性能的关系往往 是非线性的。举个例子:如果没有考虑过C10K问题,一个经典的基于select的程序能在 旧服务器上很好处理1000并发的吞吐量,它在2倍性能新服务器上往往处理不了并发2000的吞吐量。 这是因为在策略不当时,大量操作的消耗和当前连接数n成线性相关。会导致单个任务的资源消耗和当前连接数的关系会是O(n)。而服务程序需要同时对数以万计的socket进 行I/O处理,积累下来的资源消耗会相当可观,这显然会导致系统吞吐量不能和机器性 能匹配。为解决这个问题,必须改变对连接提供服务的策略。
主要有两方面的策略:1.应用软件以何种方式和操作系统合作,获取I/O事件并调度多 个socket上的I/O操作;2. 应用软件以何种方式处理任务和线程/进程的关系。前者主 要有阻塞I/O、非阻塞I/O、异步I/O这3种方案,后者主要有每任务1进程、每任务1线 程、单线程、多任务共享线程池以及一些更复杂的变种方案。常用的经典策略如下:
1. Serve one client with each thread/process, and use blocking I/O 这是小程序和java常用的策略,对于交互式的长连接应用也是常见的选择(比如BBS)。 这种策略很能难足高性能程序的需求,好处是实现极其简单,容易嵌入复杂的交互逻 辑。Apache、ftpd等都是这种工作模式。
2. Serve many clients with single thread, and use nonblocking I/O and readiness notification 这是经典模型,datapipe等程序都是如此实现的。优点在于实现较简单,方便移植,也 能提供足够的性能;缺点在于无法充分利用多CPU的机器。尤其是程序本身没有复杂的 业务逻辑时。
3. Serve many clients with each thread, and use nonblocking I/O and readiness notification 对经典模型2的简单改进,缺点是容易在多线程并发上出bug,甚至某些OS不支持多线程 操作readiness notification。
4. Serve many clients with each thread, and use asynchronous I/O 在有AI/O支持的OS上,能提供相当高的性能。不过AI/O编程模型和经典模型差别相当 大,基本上很难写出一个框架同时支持AI/O和经典模型,降低了程序的可移植性。在 Windows上,这基本上是唯一的可选方案。
本文主要讨论模型2的细节,也就是在模型2下应用软件如何处理Socket I/O。
select 与 poll
最原始的同步阻塞 I/O 模型的典型流程如下:
同步阻塞 I/O 模型的典型流程 从应用程序的角度来说,read 调用会延续很长时间,应用程序需要相当多线程来解决 并发访问问题。同步非阻塞I/O对此有所改进: 经典的单线程服务器程序结构往往如下:
C代码
do {
Get Readiness Notification of all sockets
Dispatch ready handles to corresponding handlers
If (readable) {
read the socket
If (read done)
Handler process the request
}
if (writable)
write response
if (nothing to do)
close socket
} while(True)
异步阻塞 I/O 模型的典型流程
其中关键的部分是readiness notification,找出哪一个socket上面发生了I/O事件。 一般从教科书和例子程序中首先学到的是用select来实现。Select定义如下: int select(int n, fd_set *rd_fds, fd_set *wr_fds, fd_set *ex_fds, struct timeval *timeout); Select用到了fd_set结构,从man page里可以知道fd_set能容纳的句柄和FD_SETSIZE相 关。实际上fd_set在*nix下是一个bit标志数组,每个bit表示对应下标的fd是不是在 fd_set中。fd_set只能容纳编号小于 FD_SETSIZE的那些句柄。 FD_SETSIZE默认是1024,如果向fd_set里放入过大的句柄,数组越界以后程序就会垮 掉。系统默认限制了一个进程最大的句柄号不超过1024,但是可以通过ulimit -n命令 /setrlimit函数来扩大这一限制。如果不幸一个程序在FD_SETSIZE=1024的环境下编 译,运行时又遇到ulimit –n > 1024的,那就只有祈求上帝保佑不会垮掉了。 在ACE环境中,ACE_Select_Reactor针对这一点特别作了保护措施,但是还是有recv_n 这样的函数间接的使用了select,这需要大家注意。 针对fd_set的问题,*nix提供了poll函数作为select的一个替代品。Poll的接口如下: int poll(struct pollfd *ufds, unsigned int nfds, int timeout); 第1个参数ufds是用户提供的一个pollfd数组,数组大小由用户自行决定,因此避免了 FD_SETSIZE带来的麻烦。Ufds是fd_set的一个完全替代品,从select到poll的移植很方 便。到此为止,至少我们面对C10K,可以写出一个能work的程序了。
然而Select和Poll在连接数增加时,性能急剧下降。这有两方面的原因:首先操作系统 面对每次的select/poll操作,都需要重新建立一个当前线程的关心事件列表,并把线 程挂在这个复杂的等待队列上,这是相当耗时的。其次,应用软件在select/poll返回 后也需要对传入的句柄列表做一次扫描来dispatch,这也是很耗时的。这两件事都是和 并发数相关,而I/O事件的密度也和并发数相关,导致CPU占用率和并发数近似成O(n2)的关系。
epoll, kqueue, /dev/poll
因为以上的原因,*nix的hacker们开发了epoll, kqueue, /dev/poll这3套利器来帮助 大家,让我们跪拜三分钟来感谢这些大神。其中epoll是linux的方案,kqueue是 freebsd的方案,/dev/poll是最古老的Solaris的方案,使用难度依次递增。
简单的说,这些api做了两件事:1.避免了每次调用select/poll时kernel分析参数建立 事件等待结构的开销,kernel维护一个长期的事件关注列表,应用程序通过句柄修改这 个列表和捕获I/O事件。2.避免了select/poll返回后,应用程序扫描整个句柄表的开 销,Kernel直接返回具体的事件列表给应用程序。
在接触具体api之前,先了解一下边缘触发(edge trigger)和条件触发(level trigger) 的概念。边缘触发是指每当状态变化时发生一个io事件,条件触发是只要满足条件就发 生一个io事件。举个读socket的例子,假定经过长时间的沉默后,现在来了100个字 节,这时无论边缘触发和条件触发都会产生一个read ready notification通知应用程 序可读。应用程序读了50个字节,然后重新调用api等待io事件。这时条件触发的api会 因为还有50个字节可读从而立即返回用户一个read ready notification。而边缘触发 的api会因为可读这个状态没有发生变化而陷入长期等待。 因此在使用边缘触发的api时,要注意每次都要读到socket返回EWOULDBLOCK为止,否则 这个socket就算废了。而使用条件触发的api时,如果应用程序不需要写就不要关注 socket可写的事件,否则就会无限次的立即返回一个write ready notification。大家 常用的select就是属于条件触发这一类,以前本人就犯过长期关注socket写事件从而CPU 100%的毛病。
epoll的相关调用如下:
C代码
int epoll_create(int size)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
epoll_create创建kernel中的关注事件表,相当于创建fd_set。
epoll_ctl修改这个表,相当于FD_SET等操作
epoll_wait等待I/O事件发生,相当于select/poll函数
epoll完全是select/poll的升级版,支持的事件完全一致。并且epoll同时支持边缘触 发和条件触发,一般来讲边缘触发的性能要好一些。这里有个简单的例子:
C代码
struct epoll_event ev, *events;
int kdpfd = epoll_create(100);
ev.events = EPOLLIN | EPOLLET; // 注意这个EPOLLET,指定了边缘触发
ev.data.fd =listener;
epoll_ctl(kdpfd, EPOLL_CTL_ADD, listener, &ev);
for(;;) {
nfds = epoll_wait(kdpfd, events, maxevents, -1);
for(n = 0; n < nfds; ++n) {
if(events[n].data.fd == listener) {
client = accept(listener, (struct sockaddr *) &local,
&addrlen);
if(client < 0){
perror(“accept”);
continue;
}
setnonblocking(client);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = client;
if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0) {
fprintf(stderr, “epoll set insertion error: fd=%d0,
client);
return -1;
}
}
else
do_use_fd(events[n].data.fd);
}
}
简单介绍一下kqueue和/dev/poll
kqueue是freebsd的宠儿,kqueue实际上是一个功能相当丰富的kernel事件队列,它不 仅仅是select/poll的升级,而且可以处理signal、目录结构变化、进程等多种事件。 Kqueue是边缘触发的 /dev/poll是Solaris的产物,是这一系列高性能API中最早出现的。Kernel提供一个特 殊的设备文件/dev/poll。应用程序打开这个文件得到操纵fd_set的句柄,通过写入pollfd来修改它,一个特殊ioctl调用用来替换select。由于出现的年代比较早,所以 /dev/poll的接口现在看上去比较笨拙可笑。C++开发:ACE 5.5以上版本提供了ACE_Dev_Poll_Reactor封装了epoll和/dev/poll两种 api,需要分别在config.h中定义ACE_HAS_EPOLL和ACE_HAS_DEV_POLL来启用。 Java开发: JDK 1.6的Selector提供了对epoll的支持,JDK1.4提供了对/dev/poll的支 持。只要选择足够高的JDK版本就行了。
异步I/O以及Windows和经典模型不同,异步I/O提供了另一种思路。和传统的同步I/O不同,异步I/O允许进程发起很多 I/O 操作,而不用阻塞或等待任何操作完成。稍后或在接收到 I/O 操作完 成的通知时,进程就可以检索 I/O 操作的结果。
异步非阻塞 I/O 模型是一种处理与 I/O 重叠进行的模型。读请求会立即返回,说明 read 请求已经成功发起了。在后台完成读操作时,应用程序然后会执行其他处理操 作。当 read 的响应到达时,就会产生一个信号或执行一个基于线程的回调函数来完成 这次 I/O 处理过程。异步I/O 模型的典型流程:
异步非阻塞 I/O 模型的典型流程
对于文件操作而言,AIO有一个附带的好处:应用程序将多个细碎的磁盘请求并发的提 交给操作系统后,操作系统有机会对这些请求进行合并和重新排序,这对同步调用而言 是不可能的——除非创建和请求数目同样多的线程。
Linux Kernel 2.6提供了对AIO的有限支持——仅支持文件系统。libc也许能通过来线 程来模拟socket的AIO,不过这对性能没意义。总的来说Linux的aio还不成熟
Windows对AIO的支持很好,有IOCP队列和IPCP回调两种方式,甚至提供了用户级异步调 用APC功能。Windows下AIO是唯一可用的高性能方案,详情请参考MSDN。
附: http://www.kegel.com/c10k.html
下文作者:http://dbanotes.net/arch/c10k_c500k.html
从 C10K 到 C500K
还在谈 C10K 的问题?这个已经过时了,现在大家已经开始说 C500K 。
国外的 Urban Airship 公司的工程师在其官方网志上发文章介绍他们在产品环境中做到 50 万并发客户端,Java + Pure NIO 的实现,最近又有文章介绍针对 Linux Kernel 调优的经验:Linux Kernel Tuning for C500k 。并且指出了”单个 IP 最大并发数量上限为64K” 只是一个误解。
硬件环境?操作系统为 Ubuntu(Lucid),租用 Amazon 的 EC2 ,使用 EC2 Large instances,64 位操作系统,每个 7.5 GB 内存。
当然,Urban Airship 是做手机消息 Push 服务的(Android Push 架构),所以,如果你也要做到这样的并发,还要看你的应用场景是否合适。去年了解到曾在新浪、腾讯任职的杨建已经做到超过 20 万的 HTTP 并发(现在可能已经突破这个限制了),非常的惊人。我非常想知道现在各个公司在这方面的实践数据。
–EOF–
另外参考:A Million-user Comet Application with Mochiweb
更新:杨建同学发来消息,去年已经单击突破 46.5万 Connections, 两块网卡, 1.5G 输出。10万请求处理每秒,每个响应 2k 左右。据说当时遇到一个坎一直没能过 50 万,不过这个坎三个月前已经过了,现在过 60 应该没悬念,四核双 CPU 机器。据杨建说,”按现在 4 Core * 4CPU 的机器,我觉得可以冲刺 80~100万,前提需要4块网卡(千兆)”。可见,把事情做到极致是没有极限的。
这周看了Dan Kegel那篇"The C10K problem",以下和大家分享一下。
故名思义,这文章是分析如果编写一个服务器程序来支持上万的客户端连接的。
其关注的重点是io效率,目前大规模网络程序的关键瓶颈。
作者认为需要权衡利弊的要点有以下
1.单线/进程处理多个I/O的做法: 其可选的方案有
a. 不这样做。 也就是对单线程使用阻塞同步的IO(意为依靠多线/进程来处理多个IO)。
b. 使用非阻塞IO。 即以非阻塞方式启动IO,并等待IO就绪通知(select / poll等,通常对网络IO有效,但不能提高磁盘IO的效率)
c. 使用异步IO。 调用 aio_write启动IO,并等待IO的完成通知。
2.如果管理client连接
a. 一个进程处理一个client (经典unix方式)
b. 一个OS-level线程处理多个client, 对应每个client使用一个user-level线程(即线程库,虚拟机提供的线程,协同等方式)
c. 一个OS-level线程处理一个client (java的内置线程等)
d. 一个OS-level线程处理一个活跃的client
3. 是否使用标准OS服务,还是把依靠具体OS内核
以上的选择互相组合,产生一些常见解决方案.
1. 单线程处理多client,使用非阻塞IO 和水平触发的就绪通知
**水平触发(level-trigger)与边界触发(edge-trigger)对应,简要说水平触发意为根据状态
也判断是否触发某事件, 而边界触发根据变化来判断是否触发某事件。
也就是传统的io多路复用,这是目前大部分网络程序的解决方式,它可以方便的在一个线程里管理多个
io。不需要考虑多线/程等问题, 代码的逻辑不需要加入额外的复杂性。
但这里所指的非阻塞,是指在某个fd未就绪时的read或write操作, 程序不等待它的就绪而已。
这边存在上面说对于磁盘IO并没有提高效率的问题。
在大规模地对磁盘读写操作时, read 或write还是会导致整个程序长时间阻塞。
如果要避免这种情况,必须引入异步IO。 对于部分缺乏AIO的系统,就只能建立子线/进程来完成该操作。
另外一种对磁盘IO的优化方式是使用 内存映像IO。
这种方式的关键是判断一组非阻塞的socket何时处于就绪状态。
实现它们的方法有 select, poll, dev/poll 等。
2. 单线程处理多client,使用非阻塞IO 和就绪变化通知
意为使用边界触发的就绪通知。其实这种方案只是1的加速方式, 即只在fd状况发生变化时
才去检查fd是否变为就绪。这种实现必须去处理假事件,因为有些实现方式是fd接到任何包的时候都假定其变为就绪了。
实现它们的有 kqueue, epoll(指定ET模式)等。
一些通用的库如libevent ACE等,把这些底层不同实现封装起来,并提供不同IO策略的选择。 按照
libevent作者Niels的测试数据 kqueue在所有之中性能最高。
3. 多个线程处理多个client, 使用异步IO
这是对于网络IO和磁盘IO都很高效的方式。通常当异步IO发起后, 它使用边界触发的方式来提供IO的完成通知。
然后把完成的通知放到消息队列里,等待程序的后续相应。
但这需要os提供AIO的支持,并且需要以异步的方式来设计程序,有一定复杂性。
4. 多个线程处理client
这是最简单方式,就是让但进/线程 的read 和 write 直接阻塞。
这个方法的最大缺点是, 需要支付每个进/线程的栈空间,并引入额外的上下文切换的开销。
如果需要上万个client, 建立上万的进/线程目前是不太可能的。
5. 把服务器整合到os内核里
部分系统有这样的尝试,例如Liunx下的 khttpd 就是把web服务器整合到
系统内核里。 但这对我们的游戏参考价值不大。