作者:原文W | 来源:互联网 | 2024-10-29 18:24
本文深入探讨了IO复用技术的原理与实现,重点分析了其在解决C10K问题中的关键作用。IO复用技术允许单个进程同时管理多个IO对象,如文件、套接字和管道等,通过系统调用如`select`、`poll`和`epoll`,高效地处理大量并发连接。文章详细介绍了这些技术的工作机制,并结合实际案例,展示了它们在高并发场景下的应用效果。
流的概念:一个流可以是文件,socket,pipe等等可以进行I/O操作的内核对象。不管是文件,还是套接字,还是管道,都可以把他们看作流。
I/O的操作:通过read,可以从流中读入数据;通过write,可以往流写入数据。现在假定一个情形,需要从流中读数据,但是流中还没有数据,(典型的例子为,客户端要从socket读如数据,但是服务器还没有把数据传回来),这就涉及到阻塞与等待。
阻塞:阻塞是个什么概念呢?比如某个时候你在等快递,但是你不知道快递什么时候过来,而且你没有别的事可以干(或者说接下来的事要等快递来了才能做);那么你可以去睡觉了,因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。
非阻塞轮询(忙等):接着上面等快递的例子,如果用忙轮询的方法,那么你需要知道快递员的手机号,然后每分钟给他打个电话:“你到了没?”
缓冲区:引入缓冲区是为了减少频繁I/O操作而引起频繁的系统调用,当操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。
阻塞I/O的缺点:阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),但这两种方法效率都不高。
说明:阻塞模式下,内核对于I/O事件的处理是阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(select以及epoll)处理甚至直接忽略。
换个角度来说:进程无法直接操作I/O设备,其必须通过系统调用请求kernel来协助完成I/O动作 ;内核会为每个I/O设备维护一个buffer, 对于输入而言,等待(wait)数据输入至buffer需要时间,而从buffer复制(copy)数据至进程也需要时间 ;
根据等待模式不同,I/O动作可分为五种模式
blocking I/O: blocked all the way
nonblocking I/O: if no data in buffer, immediate returns EWOULDBLOCK
I/O multiplexing (select and poll): blocked separately in wait and copy
signal driven I/O (SIGIO): nonblocked in wait but blocked in copy (signaled when I/O can be initiated)
asynchronous I/O (the POSIX aio_functions): nonblocked all the way (signaled when I/O is complete)
同步阻塞IO在等待数据就绪上花去太多时间,而传统的同步非阻塞IO虽然不会阻塞进程,但是结合轮询来判断数据是否就绪仍然会耗费大量的CPU时间。IO多路复用提供了对大量文件描述符进行就绪检查的高性能方案。既解决了BlockingI/O数据处理不及时,又解决了Non-Blocking I/O采用轮旬的CPU浪费问题,同时它与异步I/O不同的是它得到了各大平台的广泛支持。
select,poll,epoll都是IO多路复用的机制,也可以理解为事件驱动的IO框架。所谓I/O多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
select
select诞生于4.2BSD,在几乎所有平台上都支持,其良好的跨平台支持是它的主要的也是为数不多的优点之一。
select的缺点
(1)单个进程能够监视的文件描述符的数量存在最大限制,默认是1024
(2)调用select需要复制大量的句柄数据结构,即把fd集合从用户态拷贝到内核态,产生巨大的开销
(3)select返回的是含有整个句柄的列表,应用程序需要在内核遍历传递进来的所有fd,才能发现发生了事件的对应句柄
(4)select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。
select()函数实现IO多路复用的步骤
(1)清空描述符集合
(2)建立需要监视的描述符与描述符集合的关系
(3)调用select函数
(4)检查监视的描述符判断是否已经准备好
(5)对已经准备好的描述符进程IO操作
poll
poll 诞生于UNIX System V Release 3,那时AT&T已经停止了UNIX的源代码授权,所以显然也不会直接使用BSD的select,所以AT&T自己实现了一个和select没有多大差别的poll,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构;除了没有监视文件数量的限制,select后面3条缺点同样适用于poll。
/dev/poll
Sun在Solaris中提出了新的实现方案,它使用了虚拟的/dev/poll设备,开发者可以将要监视的文件描述符加入这个设备,然后通过ioctl()来等待事件通知。
kqueue
FreeBSD实现了kqueue,可以支持水平触发和边缘触发,性能和下面要提到的epoll非常接近。kqueue实际上是一个功能相当丰富的kernel事件队列,它不仅仅是select/poll的升级,而且可以处理signal、目录结构变化、进程等多种事件。 Kqueue是边缘触发的 /dev/poll是Solaris的产物,是这一系列高性能API中最早出现的。Kernel提供一个特 殊的设备文件/dev/poll。应用程序打开这个文件得到操纵fd_set的句柄,通过写入pollfd来修改它,一个特殊ioctl调用用来替换select。
epoll
epoll可以说是select和poll的增强版,诞生于Linux 2.6内核,被公认为是Linux2.6下性能最好的多路IO复用方法。
select和poll都只提供了一个函数——select或者poll函数;而epoll提供了三个函数
- epoll_create 创建 kernel 中的关注事件表,相当于创建 fd_set
- epoll_ctl 修改这个表,相当于 FD_SET 等操作
- epoll_wait等待 I/O事件发生,相当于 select/poll 函数
epoll支持水平触发和边缘触发,理论上来说边缘触发性能更高,但是使用更加复杂,因为任何意外的丢失事件都会造成请求处理错误。Nginx就使用了epoll的边缘触发模型。
两种就绪通知机制:
LT(level triggered) 水平触发:如果文件描述符已经就绪可以非阻塞的执行IO操作了,此时会触发通知,允许在任意时刻重复检测IO的状态,select和poll属于水平触发。
ET(edge-triggered) 边缘触发:如果文件描述符自上次状态改变后有新的IO活动到来,此时会触发通知。在收到一个IO事件通知后要尽可能多的执行IO操作,因为如果在一次通知中没有执行完IO,那么就需要等到下一次新的IO活动到来才能获取到就绪的描述符,signal driven IO属于边缘触发.
举例说明:这两个词来源于计算机硬件设计,它们的区别是只要句柄满足某种状态,水平触发就会发出通知;而只有当句柄状态改变时,边缘触发才会发出通知。例如一个socket经过长时间等待后接收到一段100k的数据,两种触发方式都会向程序发出就绪通知。假设程序从这个socket中读取了50k数据,并再次调用监听函数,水平触发依然会发出就绪通知,而边缘触发会因为socket“有数据可读”这个状态没有发生变化而不发出通知且陷入长时间的等待。
从电子的角度来解释:
水平触发:也就是只有高电平(1)或低电平(0)时才触发通知,只要在这两种状态就能得到通知,上面提到的只要有数据可读(描述符就绪)那么水平触发就立即返回.
边缘触发:只有电平发生变化(高电平到低电平,或者低电平到高电平)的时候才触发通知,上面提到即使有数据可读,但是没有新的IO活动到来, 边缘触发也不会立即返回.
The C10K problem
http://www.kegel.com/c10k.html
最初的服务器都是基于进程/线程模型的,新到来一个TCP连接,就需要分配1个进程(或者线程)。而进程又是操作系统最昂贵的资源,一台机器无法创建很多进程。服务器在处理数以万计的并发客户端连接时,往往出现效率低下甚至完全瘫痪,如何让服务器能够支持concurrent 10000 connection,就是C10K问题的本质。
解决C10K问题主要从以下两角度出发:
1.应用软件以何种方式和操作系统合作,获取I/O事件并调度多个socket上的I/O操作?
主要有阻塞I/O、非阻塞I/O、异步I/O这3种方案
2. 应用软件以何种方式处理任务和线程/进程的关系?
主要有每任务1进程、每任务1线程、单线程、多任务共享线程池以及一些更复杂的变种方案。
常用的I/O策略:
1、Serve many clients with each thread and use nonblocking I/O and level triggered readiness notification
用一个线程响应多个的客户端的请求,采用非阻塞I/O以及水平触发方式的就绪通知,这是经典模型,datapipe等程序都是如此实现的。它将所有的网络文件句柄的工作模式都设置成NON-BLOCKING,通过调用select()方法或者poll()方法来告诉应用层哪些个网络句柄有正在等待着并需要被处理的数据。通过这种机制,内核能够告诉应用层一个文件描述符是否准备好了以及应用是否已经利用该文件描述符作了相应的操作。优点在于实现较简单,方便移植,也能提供足够的性能;缺点在于无法充分利用多CPU的机器,尤其是程序本身没有复杂的业务逻辑时。
注意:使用就绪通知一定要将文件描述符的模式设置成NOBLOCK,因为NOBLOCK模式的读取或者写入在文件描述符没有就绪的时候会直接返回,而不会引起阻塞。如果这里发生了阻塞,那将是非常致命的,因为只有一个线程。
2、Serve many clients with each thread and use nonblocking I/O and edge triggered readiness notification
用一个线程响应多个的客户端的请求,采用非阻塞I/O以及边沿触发的就绪通知机制,这种方式在Linux中主要通过epoll实现。
注意:如果某一次就绪通知的数据没有被正确得完整得处理就急急忙忙得开始等待下一次通知,那么下一次的就绪通知就会覆盖掉前面的数据,那么与之对应的客户端就永远崩溃了(意外事件)或者沉默(没有读取完上一次事件产生的数据)对于边沿触发方式的就绪通知,应用层必须在每次就绪通知后读取数据,一直读到EWOULDBLOCK为止。
3、Serve many clients with each thread and use asynchronous I/O
用一个线程响应多个的客户端的请求,采用异步IO,在Linux上并不支持原生态thread,需要借助glic中的线程库完成线程的创建、撤销等各种功能,相比之下windows就提供了很好的支持。异步IO的内核通知是完成通知,这就意味着一旦获得内核通知,那么IO操作就已经完成了,用户无需再调用任何操作来获取数据或者发送数据。实际上AI/O是由内核线程或者底层线程异步默默地完成了IO操作,而前两种方式是由用户线程自己来读取数据的。相比之下,内核线程自然要高效很多。因此从IO模型的效率上来讲,windows能提供相当高的性能。不过AI/O编程模型和经典模型差别相当大,基本上很难写出一个框架同时支持AI/O和经典模型,降低了程序的可移植性。
4、Serve one client with each process and use blocking I/O
用一个进程响应一个的客户端的请求,采用阻塞式I/O,这是小程序和java常用的策略,对于交互式的长连接应用也是常见的选择(比如BBS),Apache、ftpd等都是这种工作模式。这种策略下read和write调用都是阻塞的,一个进程响应一个请求很能难足高性能程序的需求,同时每个进程都需要占据一个完整的栈帧,对内存的考验比较大。当然,由于硬件的资源会越来越便宜,线程的内存开销可能不太会成为瓶颈,但进程量大带来的进程切换开销会消耗大量系统资源;好处是实现极其简单,容易嵌入复杂的交互逻辑。
参考文档:http://blog.chedushi.com/archives/6529