转自
- http://blog.csdn.net/lcx46/article/details/42006845
-
针对IO,总是涉及到阻塞、非阻塞、异步、同步以及select/poll和epoll的一些描述,那么这些东西到底是什么,有什么差异?
一般来讲一个IO分为两个阶段:
- 等待数据到达
- 把数据从内核空间拷贝到用户空间
现在假设一个进程/线程A,试图进行一次IO操作。
- A发出IO请求,两种情况:
1)立即返回
2)由于数据未准备好,需要等待,让出CPU给别的线程,自己sleep
第一种情况就是非阻塞,A为了知道数据是否准备好,需要不停的询问,而在轮询的空歇期,理论上是可以干点别的活,例如喝喝茶、泡个妞。
第二种情况就是阻塞,A除了等待就不能做任何事情。 - 数据终于准备好了,A现在要把数据取回去,有几种做法: 1)A自己把数据从内核空间拷贝到用户空间。
2)A创建一个新线程(或者直接使用内核线程),这个新线程把数据从内核空间拷贝到用户空间。
第一种情况,所有的事情都是同一个线程做,叫做同步,有同步阻塞(BIO)、同步非阻塞(NIO)
第二种情况,叫做异步,只有异步非阻塞(AIO)
同步阻塞:
同一个线程在IO时一直阻塞,直到读取数据成功,把数据从核心空间拷贝到用户空间
同步非阻塞:
同一个线程发起IO后,立即获得返回,后面定期轮询数据读取情况,发现数据读取成功,把数据从核心空间拷贝到用户空间
异步非阻塞:
一个线程发起IO后,立即返回,由另外的线程发现数据读取成功,把数据从核心空间拷贝到用户空间。
下面说一下多路复用:select/poll、epoll
- select是几乎所有unix、linux都支持的一种多路IO方式,通过select函数发出IO请求后,线程阻塞,一直到数据准备完毕,然后才能把数据从核心空间拷贝到用户空间,所以select是同步阻塞方式。
int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- 参数n表示监控的所有fd中最大值+1
-
readfds、writefds和exceptfds分别表示可读、可写、异常的文件句柄,这个文件句柄中每一个bit表示一个文件fd,所以能够表示的最大文件数和fd_set的长度有关,
- 假设fd_set的长度为1字节(即8bit),则可以表示8个可读文件、8个可写文件、8个异常文件句柄。下面以读文件为例:
-
使用select的时候,先初始化FD_ZERO(fd_set *set),把8bit全部置为0,readfds=00000000
使用FD_SET(int fd, fd_set *set)来把文件fd设置到fd_set中,例如3个文件fd=2,fd=3,fd=5,则readfds=00010110
然后使用select(6, readfds, 0, 0, 0)阻塞等待,若此时fd=2文件可读,则此时readfds=00000010(fd=5和fd=3对应的bit被清0)
使用FD_ISSET(int fd, fd_set *set)函数来判断fd对应的bit是否为1,如果为1则可读。
- poll对select的使用方法进行了一些改进,突破了最大文件数的限制,同时使用更加方便一些。
int poll(struct pollfd *ufds, unsigned int nfds, int timeout);
struct pollfd {
int fd; /* 对应的文件描述符 */
short events; /* 要监听的事件,例如POLLIN|POLLPRI */
short revents; /* 返回的事件,用于在poll返回时携带该fd上发生的事情,在poll调用时,该字段会自动被清空 */
};
- 通过poll函数发出IO请求后,线程阻塞,直到数据准备完毕,poll函数在pollfd中通过revents字段返回事件,然后线程把数据从核心空间拷贝到用户空间,
-
所以poll同样是同步阻塞方式,性能同select相比没有改进。
- epoll是linux为了解决select/poll的性能问题而新搞出来的机制,基本的思路是:由专门的内核线程来不停地扫描fd列表,有结果后,把结果放到fd相关的链表中,
用户线程只需要定期从该fd对应的链表中读取事件就可以了。同时,为了节省把数据从核心空间拷贝到用户空间的消耗,采用了mmap的方式,允许程序在用户空间直接访问数据所在的内核空间,不需要把数据copy一份。
- epoll一共有3个函数:
-
1.创建epoll文件描述符
int epoll_create(int size);
2.把需要监听的文件fd和事件加入到epoll文件描述符,也可以对已有的fd进行修改和删除
文件fd保存在一个红黑树中,该fd的事件保存在一个链表中(每个fd一个事件链表),事件由内核线程负责填充,用户线程读取
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
3.用户线程定期轮询epoll文件描述符上的事件,事件发生后,读取事件对应的epoll_data,该结构中包含了文件fd和数据地址,由于采用了mmap,程序可以直接读取数据。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
有人把epoll这种方式叫做同步非阻塞(NIO),因为用户线程需要不停地轮询,自己读取数据,看上去好像只有一个线程在做事情
也有人把这种方式叫做异步非阻塞(AIO),因为毕竟是内核线程负责扫描fd列表,并填充事件链表的
个人认为真正理想的异步非阻塞,应该是内核线程填充事件链表后,主动通知用户线程,或者调用应用程序事先注册的回调函数来处理数据,如果还需要用户线程不停的轮询来获取事件信息,就不是太完美了,所以也有不少人认为epoll是伪AIO,还是有道理的。
另外一个epoll的变化,是支持了边沿触发,以前select/poll中,每次遍历fd列表,发现fd可写、可读或异常后,就把bit置1(select)或返回对应事件(poll),
而在epoll中,同样支持这种方式,每次fd可写、可读或异常后,就写入事件到事件链表中,还支持只在事件发生变化时才写入事件链表,例如如果事件一直是可读,则只在第一次写入链表
业界把这两种方式分别叫做电平触发和边沿触发,像电信号(方波)一样,从高电平到低电平或低电平到高电平的“拐角”处的触发,叫做边沿触发,其他上下两个平面上的连续触发叫电平触发
epoll支持电平触发(Level Triggered)和边沿触发(Edge Triggered),默认为电平触发
- select是几乎所有unix、linux都支持的一种多路IO方式,通过select函数发出IO请求后,线程阻塞,一直到数据准备完毕,然后才能把数据从核心空间拷贝到用户空间,所以select是同步阻塞方式。
- 著作权归作者所有。
商业转载请联系作者获得授权,非商业转载请注明出处。
作者:蓝形参
链接:http://www.zhihu.com/question/20122137/answer/14049112
来源:知乎
首先我们来定义流的概念,一个流可以是文件,socket,pipe等等可以进行I/O操作的内核对象。
不管是文件,还是套接字,还是管道,我们都可以把他们看作流。
之后我们来讨论I/O的操作,通过read,我们可以从流中读入数据;通过write,我们可以往流写入数据。现在假定一个情形,我们需要从流中读数据, 但是流中还没有数据,(典型的例子为,客户端要从socket读如数据,但是服务器还没有把数据传回来),这时候该怎么办?
- 阻塞。阻塞是个什么概念呢?比如某个时候你在等快递,但是你不知道快递什么时候过来,而且你没有别的事可以干(或者说接下来的事要等快递来了才能做);那么你可以去睡觉了,因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。
- 非阻塞忙轮询。接着上面等快递的例子,如果用忙轮询的方法,那么你需要知道快递员的手机号,然后每分钟给他挂个电话:“你到了没?”
大部分程序也不会用第二种做法,因为第一种方法经济而简单,经济是指消耗很少的CPU时间,如果线程睡眠了,就掉出了系统的调度队列,暂时不会去瓜分CPU宝贵的时间片了。
为了了解阻塞是如何进行的,我们来讨论缓冲区,以及内核缓冲区,最终把I/O事件解释清楚。缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用(你知道它很慢的),当你操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。
假设有一个管道,进程A为管道的写入方,B为管道的读出方。
- 假设一开始内核缓冲区是空的,B作为读出方,被阻塞着。然后首先A往管道写入,这时候内核缓冲区由空的状态变到非空状态,内核就会产生一个事件告诉B该醒来了,这个事件姑且称之为“缓冲区非空”。
- 但是“缓冲区非空”事件通知B后,B却还没有读出数据;且内核许诺了不能把写入管道中的数据丢掉这个时候,A写入的数据会滞留在内核缓冲区中,如果内核也缓冲区满了,B仍未开始读数据,最终内核缓冲区会被填满,这个时候会产生一个I/O事件,告诉进程A,你该等等(阻塞)了,我们把这个事件定义为“缓冲区满”。
- 假设后来B终于开始读数据了,于是内核的缓冲区空了出来,这时候内核会告诉A,内核缓冲区有空位了,你可以从长眠中醒来了,继续写数据了,我们把这个事件叫做“缓冲区非满”
- 也许事件Y1已经通知了A,但是A也没有数据写入了,而B继续读出数据,知道内核缓冲区空了。这个时候内核就告诉B,你需要阻塞了!,我们把这个时间定为“缓冲区空”。
然后我们来说说阻塞I/O的缺点。但是阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),很不幸这两种方法效率都不高。
于是再来考虑非阻塞忙轮询的I/O方式,我们发现我们可以同时处理多个流了(把一个流从阻塞模式切换到非阻塞模式再此不予讨论):
while true {
for i in stream[]; {
if i has data
read until unavailable
}
}
我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了,但这样的做法显然不好,因为如果所有的流都没有数据,那么只会白白浪费CPU。这里要补充一点,阻塞模式下,内核对于I/O事件的处理是阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(后文介绍的select以及epoll)处理甚至直接忽略。
为了避免CPU空转,可以引进了一个代理(一开始有一位叫做select的代理,后来又有一位叫做poll的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候, 会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流(于是我们可以把“忙”字去掉了)。代码长这样:
while true {
select(streams[])
for i in streams[] {
if i has data
read until unavailable
}
}
于是,如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能 无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。
但是使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,每一次无差别轮询时间就越长。再次
说了这么多,终于能好好解释epoll了
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll之会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。(复杂度降低到了O(k),k为产生I/O事件的流的个数,也有认为O(1)的[更新 1])
在讨论epoll的实现细节之前,先把epoll的相关操作列出[更新 2]:
- epoll_create 创建一个epoll对象,一般epollfd = epoll_create()
- epoll_ctl (epoll_add/epoll_del的合体),往epoll对象中增加/删除某一个流的某一个事件
比如
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//有缓冲区内有数据时epoll_wait返回
epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//缓冲区可写入时epoll_wait返回 - epoll_wait(epollfd,...)等待直到注册的事件发生
一个epoll模式的代码大概的样子是:
while true {
active_stream[] = epoll_wait(epollfd)
for i in active_stream[] {
read or write till unavailable
}
}
限于篇幅,我只说这么多,以揭示原理性的东西,至于epoll的使用细节,请参考man和google,实现细节,请参阅linux kernel source。
======================================
[更新1]: 原文为O(1),但实际上O(k)更为准确
[更新2]: 原文所列第二点说法让人产生EPOLLIN/EPOLLOUT等同于“缓冲区非空”和“缓冲区非满”的事件,但并非如此,详细可以Google关于epoll的边缘触发和水平触发。