Jamzy Wang

life is a struggle,be willing to do,be happy to bear~~~

Linux I/O复用小结

2013-07-29 14:35

原创声明:本作品采用知识共享署名-非商业性使用 3.0 版本许可协议进行许可,欢迎转载,演绎,但是必须保留本文的署名(包含链接),且不得用于商业目的。

I/O模型

  • 同步和异步

同步和异步的概念描述的是用户线程与内核的交互方式:同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式:阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。(阻塞使一个线程进入阻塞或等待的状态,释放它所占有的cpu时间。使用类似wait、await、sleep、read、write等操作使当前调用的线程进入阻塞。)

IO分为同步、异步,阻塞、非阻塞,两两组合成4种模型。

此处输入图片的描述

  • 同步阻塞IO

同步阻塞IO模型是最简单的IO模型,用户线程在内核进行IO操作时被阻塞。

此处输入图片的描述

如图所示,用户线程通过系统调用read发起IO读操作,由用户空间转到内核空间。内核等到数据包到达后,然后将接收的数据拷贝到用户空间,完成read操作。用户线程使用同步阻塞IO模型的伪代码描述为:

1
2
3
4
{
    read(socket, buffer);
    process(buffer);
}

用户需要等待read将socket中的数据读取到buffer后,才继续处理接收的数据。整个IO请求的过程中,用户线程是被阻塞的,这导致用户在发起IO请求时,不能做任何事情,对CPU的资源利用率不够。

  • 同步非阻塞IO

同步非阻塞IO是在同步阻塞IO的基础上,将socket设置为NONBLOCK。这样做用户线程可以在发起IO请求后可以立即返回。

此处输入图片的描述

如图所示,由于socket是非阻塞的方式,因此用户线程发起IO请求时立即返回。但并未读取到任何数据,用户线程需要不断地发起IO请求,直到数据到达后,才真正读取到数据,继续执行。用户线程使用同步非阻塞IO模型的伪代码描述为:

1
2
3
4
5
{
    while(read(socket, buffer) != SUCCESS)
     ;
    process(buffer);
}

即用户需要不断地调用read,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。整个IO请求的过程中,虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。

不管是同步阻塞IO还是同步非阻塞IO,一个线程只能处理一个IO,如果要处理成千上万个socket,那么必须要采用一个进程处理一个IO或者一个线程处理一个IO的方式。那么有没有方法用一个线程处理多个IO呢?有,就是IO复用。

  • IO多路复用

IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。

此处输入图片的描述

如图所示,用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。

从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

IO多路复用除了select还有poll、epoll等方法,下文将具体介绍这三个多路复用技术。

IO多路复用

在介绍I/O多路复用之前先来说明一下IO中的 。(在本文中IO的读写指的是socket的读写)。IO读写分为两个步骤:

1) 等待数据准备好(Waiting for the data to be ready)

2) 将数据从内核拷贝到用户进程或者相反(Copying the data from the kernel to the process)

IO中的读

此处输入图片的描述

读本质来说其实不能是读,read也好,recv也好只负责把数据从底层缓冲copy到我们指定的位置。

对于读来说(read, 或者 recv),在阻塞条件下如果没有发现数据在网络缓冲中会一直等待,当发现有数据的时候会把数据读到用户指定的缓冲区,但是如果这个时候读到的数据量比较少,比参数中指定的长度要小,read并不会一直等待下去,而是立刻返回。read的原则是数据在不超过指定的长度的时候有多少读多少,没有数据就会一直等待。所以一般情况下我们读取数据都需要采用循环读的方式读取数据,一次read完毕不能保证读到我们需要长度的数据,read完一次需要判断读到的数据长度再决定是否还需要再次读取。在非阻塞的情况下,read的行为是如果发现没有数据就直接返回,如果发现有数据那么也是采用有多少读多少的进行处理.对于读而言,阻塞和非阻塞的区别在于没有数据到达的时候是否立刻返回。

IO中的写

此处输入图片的描述

写的本质也不是进行发送操作,而是把用户态的数据copy到系统底层去,然后再由系统进行发送操作,返回成功只表示数据已经copy到底层缓冲,而不表示数据以及发出,更不能表示对端已经接收到数据。

对于write(或者send)而言,在阻塞的情况是会一直等待直到write完全部的数据再返回。这点行为上与读操作有所不同,究其原因主要是读数据的时候我们并不知道对端到底有没有数据,数据是在什么时候结束发送的,如果一直等待就可能会造成死循环,所以并没有去进行这方面的处理。而对于write, 由于需要写的长度是已知的,所以可以一直再写,直到写完。不过问题是write是可能被打断造成write一次只write一部分数据, 所以write的过程还是需要考虑循环write, 只不过多数情况下一次write调用就可能成功。 非阻塞写的情况下,是采用可以写多少就写多少的策略。与读不一样的地方在于,有多少读多少是由网络发送的那一端是否有数据传输到为标准,但是对于可以写多少是由本地的网络堵塞情况为标准的,在网络阻塞严重的时候,网络层没有足够的内存来进行写操作,这时候就会出现写不成功的情况,阻塞情况下会尽可能(有可能被中断)等待到数据全部发送完毕, 对于非阻塞的情况就是一次写多少算多少,没有中断的情况下也还是会出现write到一部分的情况。

select

select诞生于4.2BSD,在几乎所有平台上都支持,其良好的跨平台支持是它的主要的也是为数不多的优点之一。

select函数原型如下:

1
2
3
4
5
6
7
8
#include <sys/time.h>
#include <sys/types.h>
#include <sys/select.h>
int select(int maxfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
FD_SET(int fd, fd_set *fdset);  //建立文件句柄fd与fdset的
FD_CLR(int fd, fd_set *fdset);  //清除文件句柄fd与fdset
FD_ISSET(int fd, fd_set *fdset);    //检查fdset关联文件句柄fd是否可读写
FD_ZERO(fd_set *fdset); //清空fdset与所有文件句柄的关联

参数说明:

1
2
3
4
timeout:告诉内核等待任一描述符准备好可以花费的时间。
readfdswritefdsexceptfds指定了让内核测试读,写和异常条件所需的描述字。
maxfds:说明了被测描述符的个数,它的值是要被测试的最大的描述符加1
返回值:所有描述符集的已准备好的总位数。

select用readfds、writefds、exceptfds来记录每个文件描述符的状态。

此处输入图片的描述

如果需要监控某个文件描述符的读事件,那么只需要把readfds中的对应为置为1即可。监控写事件和异常事件也类似。比如现在需要监控fd1和fd3的读事件,fd1和fd2的写事件,那么只需要把readfds、writefds置为下图所示的状态:

此处输入图片的描述

在select中还需要传递一次select操作需要等待的时间。这个等待时间有3种情况:第一种情况是timeout是NULL,这样将一直等待,直到某个描述符准备好;第二种情况是timeout的值是0,那么将不等待,立即返回;第三种情况是timeout中的秒或微秒被赋值,那么将等待指定的时间。此外,如果进程收到一个信号,select也会被中断返回。

select编程的流程图如下:

此处输入图片的描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int socket_fd, result;
fd_set readset;
...
/* Socket has been created and connected to the other party */
...

/* Call select() */
do {
   FD_ZERO(&readset);
   FD_SET(socket_fd, &readset);
   result = select(socket_fd + 1, &readset, NULL, NULL, NULL);
} while (result == -1 && errno == EINTR);

if (result > 0) {
   if (FD_ISSET(socket_fd, &readset)) {
      /* The socket_fd has data available to be read */
      result = recv(socket_fd, some_buffer, some_length, 0);
      if (result == 0) {
         /* This means the other side closed the socket */
         close(socket_fd);
      }
      else {
         /* I leave this part to your own implementation */
      }
   }
}
else if (result < 0) {
   /* An error ocurred, just print it to stdout */
   printf("Error on select(): %s\", strerror(errno));
}

调用select()后的执行流程如下:

1.当前进程阻塞,把需要监视的所有文件描述符(其实就是select函数的参数)从用户进程拷贝到内核;

2.内核扫描每一个文件描述符的状态(数据可读、可写、或者异常);

3.当有文件描述符就绪或者超时,则select函数返回,并把所有文件描述符从内核拷贝到用户进程;

4.用户进程遍历返回的文件描述符(fdset数组)找到就绪的文件描述符(某些状态位被是否修改)并进行相应的处理。

内核轮询I/O设备,如果对应的描述符有事件,修改readset对应的状态位,如果没有事件,将select调用进程放到I/O的唤醒队列。如果轮询完毕之后,都没有事件发生,阻塞select调用进程,否则返回。

从内核返回后内核只告诉用户进程确实有事件发生,但具体是哪些FD有事件发生并没有告诉用户进程,用户进程轮询描述符集合,使用FD_ISSET检查是否有事件发生,如果有则进行对应的处理。

select的缺点

1)单个进程能够监视的文件描述符的数量存在限制

内核中参数__FD_SETSIZE定义了每个FD_SET的句柄个数

1
2
include/linux/posix_types.h:
#define __FD_SETSIZE         1024

这个参数可以修改,但由于select采用轮询的方式扫描文件描述符,量越多,性能越差;拿select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE为1024的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接。

2)I/O效率随着文件描述符数量的增加而线性下降

select每次调用都会线性扫描全部的FD集合,这样效率就会呈现线性下降。由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描。

3)内核/用户空间内存拷贝效率低下

select调用有两次fd_set的复制过程,一次从用户空间到内核空间,一次从内核空间到用户空间,高并发的情况下,如果连接很多的情况下,则是一个巨大的资源浪费。

4)select的触发方式是水平触发

select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。相对应方式的是边缘触发。      此处输入图片的描述

select优点

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。

poll

poll函数原型:

1
2
3
4
5
6
7
8
#include <sys/poll.h>
int poll(struct pollfd *ufds, unsigned int nfds, int timeout);
struct pollfd {
    int fd;         // the socket descriptor
    short events;   // bitmap of events we're interested in
    short revents;  // when poll() returns, bitmap of events that occurred
};
struct pollfd ufds[100];

和select相比,poll采用了不同的数据结构来保存需要监控的文件描述符。每个文件描述符用一个struct表示,所有struct组成一个数组作为一个参数传递给poll函数,每个struct由3个域组成:文件描述符、注册需要监听的事件(bitmap)、poll返回时修改的bitmap。struct的结构如下:

此处输入图片的描述

poll返回时的结构如下:

此处输入图片的描述

poll编程的一个示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
int s1, s2;
int rv;
char buf1[256], buf2[256];
struct pollfd ufds[2];

s1 = socket(PF_INET, SOCK_STREAM, 0);
s2 = socket(PF_INET, SOCK_STREAM, 0);

// pretend we've connected both to a server at this point
//connect(s1, ...)...
//connect(s2, ...)...

// set up the array of file descriptors.
//
// in this example, we want to know when there's normal or out-of-band
// data ready to be recv()'d...

ufds[0].fd = s1;
ufds[0].events = POLLIN | POLLPRI; // check for normal or out-of-band

ufds[1] = s2;
ufds[1].events = POLLIN; // check for just normal data

// wait for events on the sockets, 3.5 second timeout
rv = poll(ufds, 2, 3500);

if (rv == -1) {
    perror("poll"); // error occurred in poll()
} else if (rv == 0) {
    printf("Timeout occurred!  No data after 3.5 seconds.\n");
} else {
    // check for events on s1:
    if (ufds[0].revents & POLLIN) {
        recv(s1, buf1, sizeof buf1, 0); // receive normal data
    }
    if (ufds[0].revents & POLLPRI) {
        recv(s1, buf1, sizeof buf1, MSG_OOB); // out-of-band data
    }

    // check for events on s2:
    if (ufds[1].revents & POLLIN) {
        recv(s1, buf2, sizeof buf2, 0);
    }
}

poll的优点

单个进程能够监视的文件描述符的数量没有最大限制(但是数量过大后性能也是会下降)

poll的缺点

1)基本上效率和select是相同的,select缺点的2和3它都没有改掉

2)I/O效率随着文件描述符数量的增加而线性下降

3)内核/用户空间内存拷贝效率低下

4)poll的触发方式是水平触发

epoll

epoll是在2.6内核中提出的。和select类似,它也是一种I/O复用技术,是之前的select和poll的增强版本。epoll是Linux内核为处理大批句柄而作改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著的减少程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

为了凸显epoll的特点,先来讲一下select的缺点:

1)最大并发数限制,受限于最大文件描述符数量的限制。

2)效率问题。这是select最大的问题,也是由select的机制决定的。select的实现机制是,轮询所有集合中的套接字,如果有套接字就绪就返回。这样的话select的效率和套接字的数量是成反比的,所以随着套接字的增加select的效率也会不断下降。

3)内核和用户空间之间的内存拷贝问题。select需要通过内核和用户空间之间的内存拷贝来实现fd消息的通知。

下面就来讲一下epoll的优点:

1)最大并发数无限制

epoll中的并发数不受打开文件描述符数量限制,只受系统打开文件数量上限的限制,而这个上限通常是很大的(至少几万)。具体数目可以 cat /proc/sys/fs/file-max 察看。

2)效率不随套接字数量变化

不同于select,epoll采用的是中断方式,某个socket就绪后会直接调用回调函数,这就避免了对全体套接字的扫描。这点从epoll的使用方式就能看出来。epoll只需要执行一次epoll_ctl函数来注册要监听的文件描述符,以后便可以直接调用epoll_wait函数来等待描述符可用。而select每次调用select函数都要将要监听的文件描述符作为参数传递进去,再由select传递给内核。

3)内核和用户空间之间不需要内存拷贝

epoll采用的是mmap(内存映射)技术,因此不需要内存拷贝,提高了效率。

4)epoll提供水平触发和边沿触发两种方式

epoll除了提供select\poll那种IO事件的电平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提供应用程序的效率。

epoll实现原理

那么epoll的高效是如何实现的呢?

高并发的核心解决方案是1个线程处理所有连接的“等待消息准备好”,这一点上epoll和select是无争议的。但select预估错误了一件事,当数十万并发连接存在时,可能每一毫秒只有数百个活跃的连接,同时其余数十万连接在这一毫秒是非活跃的。select的使用方法是这样的:

1
返回的活跃连接 == select(全部待监控的连接)

那么什么时候会调用select方法呢?在用户程序认为需要找出有报文到达的活跃连接时,就应该调用。所以,调用select在高并发时是会被频繁调用的。这样,这个频繁调用的方法就很有必要看看它是否有效率,因为,它的轻微效率损失都会被“频繁”二字所放大。它有效率损失吗?显而易见,全部待监控连接是数以十万计的,返回的只是数百个活跃连接,这本身就是无效率的表现。被放大后就会发现,处理并发上万个连接时,select就完全力不从心了。

再来说说epoll是如何解决的。它很聪明的用了3个方法来实现select方法要做的事:

1
2
3
1) 新建的epoll描述符==epoll_create()
2) epoll_ctrl(epoll描述符,添加或者删除所有待监控的连接)
3) 返回的活跃连接 ==epoll_wait epoll描述符 

这么做的好处主要是:分清了频繁调用和不频繁调用的操作。例如,epoll_ctrl是不太频繁调用的,而epoll_wait是非常频繁调用的。这时,epoll_wait却几乎没有入参,这比select的效率高出一大截,而且,它也不会随着并发连接的增加使得入参越发多起来,导致内核执行效率下降。

epoll是怎么实现的呢?其实很简单,从这3个方法就可以看出,它比select聪明的避免了每次频繁调用“哪些连接已经处在消息准备好阶段”的 epoll_wait时,是不需要把所有待监控连接传入的。这意味着,它在内核态维护了一个数据结构保存着所有待监控的连接。这个数据结构就是一棵红黑树,它的结点的增加、减少是通过epoll_ctrl来完成的。如下图所示:

此处输入图片的描述(图源)

图中左下方的红黑树由所有待监控的连接构成。左上方的链表,同是目前所有活跃的连接。于是,epoll_wait执行时只是检查左上方的链表,并返回左上方链表中的连接给用户

epoll函数原型如下:

1
2
3
4
#include <sys/epoll.h> 
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操作过程用到的三个接口。

  • epoll_create
1
int epoll_create(int size);

epoll_create执行成功则返回非负epoll描述符(一个整数)否则返回-1,并设置errno的值。size用来告诉内核这个监听的数目一共有多大,不同于select()中的第一个参数给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看 /proc/进程id号/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

  • epoll_ctl
1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *events);

epoll_ctl执行成功返回0,否则会返回-1,并设置errno的值。epoll_ctl是epoll的事件注册函数。它不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里注册要监听的事件类型。该函数的参数说明如下(fd是file descriptor的缩写,表示文件描述符):

1
2
3
4
5
6
7
1)第一个参数是epoll_create函数的返回值。
2)第二个参数表示动作:
  EPOLL_CTL_ADD:注册新的fdepfd中;
  EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
  EPOLL_CTL_DEL:从epfd中删除一个fd
3)第三个参数表示需要监听的fd
4)第四个参数告诉内核需要监听什么事。

struct epoll_event的结构如下:

1
2
3
4
struct epoll_event {
    uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
};

events可以是以下几个宏的集合:

1
2
3
4
5
6
7
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
  • epoll_wait
1
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); // 

epoll_wait若成功则返回在timeout时间内就绪的文件描述符数,否则返回-1,并设置errno的值。该函数等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合;maxevents告诉内核返回的events的最大大小,这个maxevents的值不能大于创建epoll_create()时的size,也必须大于0;参数timeout是超时时间(毫秒,0会立即返回,-1将永久阻塞)。该函数返回需要处理的数目,如返回0表示已超时。

epoll调用的完整执行流程如下图所示:

此处输入图片的描述(图源)

  • epoll编程流程

此处输入图片的描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
 for( ; ; )
    {
        nfds = epoll_wait(epfd,events,20,500);
        for(i=0;i<nfds;++i)
        {
            if(events[i].data.fd==listenfd) //有新的连接
            {
                connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
                ev.data.fd=connfd;
                ev.events=EPOLLIN|EPOLLET;
                epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
            }

            else if( events[i].events&EPOLLIN ) //接收到数据,读socket
            {
                n = read(sockfd, line, MAXLINE)) < 0    //读
                ev.data.ptr = md;     //md为自定义类型,添加数据
                ev.events=EPOLLOUT|EPOLLET;
                epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓
            }
            else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
            {
                struct myepoll_data* md = (myepoll_data*)events[i].data.ptr;    //取数据
                sockfd = md->fd;
                send( sockfd, md->ptr, strlen((char*)md->ptr), 0 );        //发送数据
                ev.data.fd=sockfd;
                ev.events=EPOLLIN|EPOLLET;
                epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
            }
            else
            {
                //其他的处理
            }
        }
    }
  • epoll工作模式

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

1) LT模式:只要某个监听中的文件描述符处于readable/writable状态,无论什么时候进行epoll_wait都会返回该描述符;

2) ET模式:只有某个监听中的文件描述符从unreadable变为readable或从unwritable变为writable时,epoll_wait才会返回该描述符。

LT(level triggered)是epoll的缺省方式,同时支持block和no-block socket,在这种做法中,内核告诉我们一个文件描述符是否被就绪了,如果就绪了,你就可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错的可能性较小。传统的select\poll都是这种模型的代表。

ET(edge-triggered),边沿触发,高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪状态时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如:你在发送、接受或者接受请求,或者发送接受的数据少于一定量时导致了一个EWOULDBLOCK错误)。但是请注意,如果一直不对这个fs做IO操作(从而导致它再次变成未就绪状态),内核不会发送更多的通知。Nginx就使用了epoll的边缘触发模型

下面通过一个实例说明LT和ET工作方式IO读写的区别。假设我们往epoll里边注册了一个套接字,并采用ET模式,然后调用epoll-wait监听这个套接字是否可读。之后这个套接字收到了2KB的数据,这时候epoll-wait会返回通知用户态程序读取这个套接字。之后用户态程序只读取了1KB的数据,这个套接字中还有1KB的数据没有读,便再次调用了epoll-wait函数。然后epoll-wait函数就阻塞并等待下一个事件了,也就是说前边少读的那1KB数据将永远不会被读取。而如果我们使用的是LT模式,那么第二次调用epoll-wait函数的时候函数仍然会返回通知程序读取套接字剩下的1KB数据,这样那1KB的数据就能得到读取了。

因此在epoll的ET模式下,正确的读写方式为当套接字可读的时候,多次读取套接字直到套接字没有数据可读;当一个tcp连接到达后,多次调用accept接受到达连接直到没有未接受的连接。

1
2
:只要可读,就一直读,直到返回0,或者 errno = EAGAIN
:只要可写,就一直写,直到数据发送完,或者 errno = EAGAIN

正确的读

1
2
3
4
5
6
7
 n = 0;
 while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {
     n += nread;
 }
 if (nread == -1 && errno != EAGAIN) {
     perror("read error");
 }

正确的写

1
2
3
4
5
6
7
8
9
10
11
12
int nwrite, data_size = strlen(buf);
n = data_size;
while (n > 0) {
    nwrite = write(fd, buf + data_size - n, n);
    if (nwrite < n) {
        if (nwrite == -1 && errno != EAGAIN) {
        perror("write error");
    }
    break;
}
n -= nwrite;
}
  • epoll FAQ

1) 单个epoll并不能解决所有问题,特别是当每个操作都比较费时的时候,因为epoll是串行处理的。所以还是必要建立线程池来发挥更大的效能。

2) 如果fd被注册到两个epoll中时,如果有事件发生则两个epoll都会触发事件。

3) 如果注册到epoll中的fd被关闭,则其会自动被清除出epoll监听列表。

4) 如果多个事件同时触发epoll,则多个事件会被联合在一起返回。

5) epoll_wait会一直监听epollhup事件发生,所以其不需要添加到events中。

6) 为了避免大数据量io时,et模式下只处理一个fd,其他fd被饿死的情况发生。linux建议可以在fd联系到的结构中增加ready位,然后epoll_wait触发事件之后仅将其置位为ready模式,然后在下边轮询ready fd列表。   ### select VS poll VS epoll

此处输入图片的描述

kqueue

FreeBSD实现了kqueue,可以支持水平触发和边缘触发,性能和下面要提到的epoll非常接近。

Ref

Comments