IO Multiplexing (二)

前面一篇文章中,简要地介绍了在几个io多路复用的api的使用以及它们各自的优缺点.然而,对于epoll还有不少东西.如epoll的两种触发模式--边沿触发(edge trigger)和水平触发(level trigger).此外,epoll往往和非阻塞io搭配在一起使用,那么为什么epoll要和非阻塞io搭配在一起呢,是否有更好的效率呢?

本文用到的代码地址:https://share.weiyun.com/0qPmbRhs

epoll_level.c和epoll_edge.c是用于演示epoll在缓冲区没有被完全读取的行为.epoll_server.c是一个简单用epoll来实现的服务器.可以使用telnet localhost 8080用于测试.

阻塞IO和非阻塞IO

在Linux系统中,实际上还有好几种IO模型,异步IO的,使用aio_系列的函数来操作数据.信号驱动的io,即使用SIGIO来操作.不过这都不是本文的主要目的,接下来介绍阻塞IO和非阻塞IO.

阻塞io就是调用某api会使得当前进程陷入阻塞状态.比如说accept()等待一个网络请求的建立,recv()接受来自客户端的数据等等.在没有连接到来或者对方建立连接后没有发送数据过来,都会使得它们阻塞.后面的代码无法继续运行.

非阻塞io情况下,无论是否有数据到来,都会马上返回-1,并且根据errno来判断. 在linux中,可以使用fcntl来对io类别修改,将阻塞改为非阻塞的io.

代码如下,对于fcntl来说,各个参数代表什么意思,也有不少.具体的就看man fcntl吧.下面只介绍在本文中所涉及的操作:

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL); //获得fd原来的选项,F_GETFL表示读取
    int new_option = old_option | O_NONBLOCK; //在上面追加一个non blocking
    fcntl(fd, F_SETFL, new_option); // 更新fd,F_SETFL表示写入
    return old_option; //返回老的选项,有时候需要重新将fd设置为老的选项
}

epoll的触发模式

epoll一共有两种触发模式:边沿触发(edge trigger)和水平触发(level trigger).先给出结论,这两种触发模式在行为上有什么不同:

  • 边沿触发: 只要描述符fd有事件发生就会返回,后续的处理是否将资源完全读取或者写入,都没关系.
  • 水平触发: 只要描述符fd对应的资源里面有可读或者可写,那么会让epoll_wait()立即返回.

引用一段来自wikipedia中关于触发模式的讲解:

For instance, if a pipe registered with epoll has received data, a call to epoll_wait will return, signaling the presence of data to be read. Suppose, the reader only consumed part of data from the buffer. In level-triggered mode, further calls to epoll_wait will return immediately, as long as the pipe's buffer contains data to be read. In edge-triggered mode, however, epoll_wait will return only once new data is written to the pipe.

水平触发(level trigger)

理论总是不好理解,上代码,代码比较长,完整代码点击这里,下面只放关键部分.

子进程往里面写入了10字节,父进程只读取了5字节. 在水平触发的模式下,因为父进程没有完全读取数据,那么下一次循环到epoll_wait()的时候会立即返回,参见代码epoll_level.c.

while (1) {
    memset(buf,0,BUF_SIZE);
    //同时监听10个事件,虽然只有一个子进程,发生的事件放到events中
    nfds = epoll_wait(epfd,events,10,-1);
    printf("events: %d\n",nfds);
    if (events[0].data.fd == pfd[0]) { //如果所发生的描述符是管道的读
        len = read(pfd[0],buf,BUF_SIZE / 2);
        write(STDOUT_FILENO,buf,len);
    }
}
//output:
events: 1
aaaa
events: 1
bbbb

从运行结果可以观察到,epoll_wait()触发两次.将这个例子稍微拓展一下,如果在网络程序中,比如说服务器,服务端的缓冲区如果小于客户端所发送过来的数据,在水平触发的模式下,会多次触发epoll_wait(),而系统调用的过程又是一个开销比加大的过程,因此一定程度上代表着低效.

为了避免这个情况,显而易见的是,我们可以使用循环持续的读取里面的内容.直到没有数据可读,大致的代码如下:

int ret = 0;
while((ret = recv(sockfd,buf,BUFFER_SIZE,0))) { //对方没有发送数据过来,那么也会阻塞
    printf("get %d bytes of content: %s\n", ret, buf);
}
printf("end\n");
close(sockfd);

确实可以,不过在同步io的情况下,如果对方不发送数据,recv()就会陷入阻塞.导致epoll_wait()无法响应其他情况.

更好的方法是使用非阻塞的IO,在没有数据可读的时候(可能是因为对方暂时不发送数据过来),非阻塞io会返回一个EAGAIN或者EWOULDBLOCK.大致代码如下:

while (1) { //循环的读取,那么缓冲区较小也不会在反复的使得epoll_wait()返回
    memset(buf, '\0', BUFFER_SIZE);
    int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
    if (ret < 0) {
        //如果下面的情况成立,说明已经对方发送过来的数据已经都读完了.可能暂时没有数据可读了
        if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
            printf("read later\n");
            break;
        }
        close(sockfd);
        break;
    } else if (ret == 0) { //返回0说明对方已经主动结束了链接
        close(sockfd);    //和对方断开连接
    } else {
        printf("get %d bytes of content: %s\n", ret, buf);
    }
}

PS: 我没有在man page中看到说非阻塞io和水平触发不能搭配.但是就实验结果而言,水平触发和非阻塞io搭配起来也有用.

边沿触发(edge trigger)

前面已经说了边沿触发下的行为.接下来再来详细的介绍边沿触发.如下代码中,在往epoll中注册事件的时候,我们通过EPOLLIN | EPOLLET来说明是边沿触发的.完整的代码参见epoll_edge.c.

epfd = epoll_create(10);
evt.data.fd = pfd[0];  //监听管道有数据可读
evt.events = EPOLLIN | EPOLLET;  //edge trigger

//往epoll中注册我们感兴趣的事件events,struct events描述了我们要监听
//哪些情况,是数据可读还是可写等.要监听的描述符是pfd[0]
epoll_ctl(epfd,EPOLL_CTL_ADD,pfd[0],&evt);
while (1) {
    memset(buf,0,BUF_SIZE);
    //同时监听10个事件,虽然只有一个子进程,发生的事件放到events中
    nfds = epoll_wait(epfd,events,10,-1);
    printf("events: %d\n",nfds);
    if (events[0].data.fd == pfd[0]) { //如果所发生的描述符是管道的读
        len = read(pfd[0],buf,BUF_SIZE / 2);
        write(STDOUT_FILENO,buf,len);
    }
}
//output:
events: 1
aaaa
----等待下一次事件的发生后才会读取----
events: 1
bbbb

从结果可以观察到,边沿触发的情况下,即使缓冲区中数据没有完全读取,也不会如同水平触发那样使得epoll_wait()马上返回,如果没有正确操作的话,还会导致缓冲区溢出. 但是为了将数据完全读取,就要用一种非阻塞和循环读的方式来将缓冲区中的数据读完.方法和前面提到的一样.代码如下,代码参见epoll_server.c:

while (1) {
    memset(buf, '\0', BUFFER_SIZE);
    int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
    if (ret < 0) {
        //如果遇到下面情况,说明数据已经被完全读取.所以结束循环
        if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
            printf("read later\n");
            break;
        }
        close(sockfd);
        break;
    } else if (ret == 0) {
        close(sockfd);
    } else {
        printf("get %d bytes of content: %s\n", ret, buf);
    }
}

边沿触发的饥饿

在man epoll中举例了一个边沿触发下会导致饥饿的例子. 如果创建了一个管道,一个进程往管道写入了2kb,另外一端只读取了1kb的数据,因为边沿触发模式下即使缓冲区没有完全读完,那么epoll_wait()也不会立即返回.如果写入管道的进程期望对方将数据读完了之后给自己返回一个应答.然而在上述情况中,读管道的进程在边沿触发模式下读了一半数据就继续回到了epoll_wait()的阻塞状态,写入管道的进程也一直收不到来自对方的响应. 于是发生了死锁.

所以呢,man page中建议在边沿触发模式下要用非阻塞的读,来避免这个情况.当然不能用阻塞读,因为对方不发送数据但是tcp链接没有断开的情况下,阻塞读会让程序一直阻塞.

EPOLLONESHOT

另外一个情况就是,一个文件描述符上的事件可能有多个.比如说一个线程读某个socket开始处理,然后处理的过程中该socket上又有新的数据来了,另外一个线程又被唤醒去处理这些新来的数据.所以就出现了两个线程操作同一个socket的情况.

为了解决个问题,在注册事件的时候,我们可以指定EPOLLONESHOT在该fd上最多只能触发一个事件.对于有该选型的事件发生后,epoll不再相应该fd上的事件.于是,我们在处理好该fd上的事件后,应该重新注册该事件到epoll.

大致的代码如下,我没有实践过:


void reset_oneshot(int epollfd,int fd) {
    struct epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
    epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&event);
}

if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
    reset_oneshot(eopllfd,sockfd);
    printf("read later\n");
    break;
}

总结

接下来总结下select,poll,epoll这三种的差异以及epoll两种触发模式.首先先说下三种io多路复用api的区别.

参考了linux高性能服务器编程这本书.

  • select: 通过位图来注册要关注的事件,分别三个事件的集合:读事件集合,写事件集合,错误.当select阻塞后,

    当集合中的事件发生的时候会结束阻塞.我们要使用循环来遍历整个循环,来判断哪些事件中有数据可读或者可写.效率低下,这个过程为O(n).此外select在返回的时候,会修改原来的原来的事件集合,所以每次都要重新设置.额外的操作.而且,select的位图有上线,它只能操作0-1024号的文件描述符,这在现代程序看来远远不够.

    在内核中,select也是使用轮询的方式来检测就绪事件,时间复杂度O(n).

  • poll: poll和select差不多.在事件发生后,也要遍历所有的描述符集合来判断是否有事件发生,事件复杂度O(n).但是它使用一个struct pollfd结构来存放要关注的描述符集合.突破了select位图的限制.

    在内核中,也是使用轮询的方式来检测,时间复杂度O(n).

  • epoll: 在编程上更加复杂,要使用epoll_create,epoll_ctl,epoll_wait来完成.但是epoll_wait返回的事件都是就绪事件,不需要遍历整个时间列表.快了很多.

    在内核中,采用回调的方式来检测就绪事件,时间复杂度O(1).

接下来总结下epoll两种工作模式的区别:

  • 水平触发: 事件发生后,只要程序没有将fd上的数据完全读完,就会导致下次epoll_wait()立刻返回.所以效率较低.较好的方法是使用循环读的形式,不过如果使用阻塞io.对方暂时不发送数据过来,那么就会导致recv()陷入阻塞,除非对方主动断开链接.因此在使用非阻塞io更加科学.
  • 边沿触发: 只要有事件发生就会返回.但是在事件处理过程中,是否将文件描述符fd上的数据完全读取,都不会影响后面的epoll_wait()的返回,直到事件的再一次到来.因此使用循环读,另外为了处理对方暂时没有将数据发送过来的,使用非阻塞io可以通过返回值EAGAIN来察觉到这一情况.

另外知乎上看到一条评论,提到epoll水平触发模式下 + 非阻塞io和 边沿触发 + 非阻塞io的性能差异不大.原文

Reference

man-recv

man-epoll

epoll-wikipedia

知乎上的文章

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇