C10K问题

网络服务在处理数以万计的客户端连接时,往往出现效率底下甚至完全瘫痪,这被成为C10K问题。
(C10K = connection 10 kilo 问题)。k 表示 kilo,即 1000 比如:kilometer(千米), kilogram(千克)。
 
非阻塞I/O,最关键的部分是readiness notification(when ready, then notify!)和找出哪一个socket上面发生了I/O事件。

一般我们首先会想到用select来实现。
int select(int n, fd_set *rd_fds; fd_set *wr_fds, fd_set *ex_fds, struct timeval * timeout);
 
其中用到了fd_set结构,而fd_set不能大于FD_SETSIZE,默认是1024,很容易导致数组越界。
针对fd_set的问题,*nix提供了poll函数作为select的一个替代品:

int poll(struct pollfd *ufds, unsigned int nfds, int timeout)
第一个参数ufds是用户提供的一个pollfd数组,大小由用户自行决定,因此避免了FD_SETSIZE带来的麻烦

然而select和poll在连接数增加时,性能急剧下降。这有两方面的原因:
 
《1》首先操作系统面对每次的select/poll操作,都需要重新建立一个当前线程的关心事件链表,并把线程挂在这个复杂的等待队列上,这是相当耗时的。
 
《2》其次,应用软件在select/poll返回后也需要对传入的句柄链表做一次循环扫描来dispatch,这也是很耗时的。这两件事都是和并发数相关,而I/O事件的密度也和并发数相关导致CPU占用率和并发数近似成O(n2)的关系

基于以上原因,*nix的hacker们开发了epoll, kqueue, /dev/poll这3套利器。epoll是Linux的方案,kqueue是freebsd的方案,/dev/poll是solaris的方案。

简单的说,这些api做了两件事:
 
《1》避免了每次调用select/poll时kernel分析参素建立事件等结构的开销,kernel维护一个长期的时间关注列表,应用程序通过句柄修改这个链表和捕获I/P事件

《2》避免了select/poll返回后,应用程序扫描整个句柄表的开销,kernel直接返回具体的链表给应用程序。
 
在接触具体api之前,先了解一下边缘触发(edge trigger)条件触发(level trigger)的概念。边缘触发是指每当状态变化时发生一个io事件,假定经过长时间的沉默后,现在来了100个字节,这是无论边缘触发和条件触发都会产生一个read ready notification通知应用程序可读。应用程序在读完来的50个字节,然后重新调用api等待io事件。这时条件触发的api会因为还有50个字节可读从而立即返回用户一个read ready notification。而边缘触发的api会因为可读这个状态没有发生变化而陷入长期等待

因此在使用边缘触发的api时,要注意每次都要读到socket返回EWOULDBLOCK为止,否则这个socket就算废了。而使用条件触发的api时,如果应用程序不需要写就不要关注socket可写的事件,否则会无限次的立即返回一个write ready nitification. 大家常用的select就是属于条件触发这一类,以前本人翻过长期关注socket写事件从而CPU 100%的毛病。
 
epoll相关调用:
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 完全是select/poll的升级版,支持的事件完全一致。并且epoll同时支持边缘触发和条件触发,一般来讲边缘触发的性能要好一些
 
简单的例子:

[cpp]  view plain  copy
  1. strut epoll_event ev, *events;  
  2.   
  3. int kdpfd = epoll_create(100); //创建kernel中的关注事件表,返回一个kernel事件表的句柄  
  4.   
  5.   
  6.   
  7. ev.events = EPOLLIN | EPOLLET; //边缘触发  
  8.   
  9. ev.data.fd = listener;  
  10.   
  11. epoll_ctl(kdpfd, EPOLL_CTL_ADD, listener, &ev); //将事件ev加入到kernel关注的事件表中  
  12.   
  13.   
  14.   
  15. for(;;){  
  16.   
  17.    nfds = epoll_wait(kdpfd, events, maxevents, -1); //等待被通知   
  18.   
  19.    for(n = 0; n < nfds; n++){  
  20.   
  21.   
  22.   
  23.       if(events[n].data.fd == listener){  
  24.   
  25.          client = accept(listener, (struct sockaddr*)&local, &addrlen);   
  26.   
  27.          if(client < 0){  
  28.   
  29.             peror("accept");  
  30.   
  31.             continue;  
  32.   
  33.          }  
  34.   
  35.          setnonblocking(client);  
  36.   
  37.          ev.events = EPOLLIN | EPOLLET;  
  38.   
  39.          ev.data.fd = client;  
  40.   
  41.          if(epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0){  
  42.   
  43.             fprintf(stderr, "epoll set insertion error: fd = %d, client);  
  44.   
  45.             return -1;  
  46.      }  
  47.      }else  
  48.          do_use_fd(events[n].data.fd);  
  49.    }  
  50. }  
策略 
主要有两方面的策略:
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 
在有AIO支持的OS上,能提供相当高的性能。不过AIO编程模型和经典模型差别相当大,基本上很难写出一个框架同时支持AI/O和经典模型,降低了程序的可移植性。在Windows上,这基本上是唯一的可选方案。 
本文主要讨论模型2的细节,也就是在模型2下应用软件如何处理Socket I/O。
select 与 poll
最原始的同步阻塞 I/O 模型的典型流程如下:
从应用程序的角度来说,read 调用会延续很长时间,应用程序需要相当多线程来解决并发访问问题。
同步非阻塞I/O对此有所改进:

经典的单线程服务器程序结构往往如下:
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)
其中关键的部分是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);
针对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%的毛病。

简单介绍一下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的接口现在看上去比较笨拙可笑。 
异步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 模型的典型流程: 
对于文件操作而言,AIO有一个附带的好处:应用程序将多个细碎的磁盘请求并发的提交给操作系统后,操作系统有机会对这些请求进行合并和重新排序,这对同步调用而言是不可能的——除非创建和请求数目同样多的线程。 
Linux Kernel 2.6提供了对AIO的有限支持——仅支持文件系统。libc也许能通过线程来模拟socket的AIO,不过这对性能没意义。总的来说Linux的aio还不成熟。
Windows对AIO的支持很好,有IOCP队列和IPCP回调两种方式,甚至提供了用户级异步调用APC功能。Windows下AIO是唯一可用的高性能方案,详情请参考MSDN。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值