当前位置: 首页 > article >正文

io多路复用:epoll水平触发(LT)和边沿触发(ET)的区别和优缺点

在进行ET模式的正式分析之前,我们来举个例子简单地了解下ET和LT:

假设我们通过fork函数创建了父子两个进程,并通过匿名管道来通信,在子进程中,我们一次向管道写入10个字符数据,为"aaaa\nbbbb\n";每隔5s写入10个字符数据;在父进程中,我们从管道中一次读取5个字符数据:

若我们采用的是LT模式,则在5s的周期内,会先读取5个字符数据,读完之后,因为文件描述符中仍然有数据,epoll_wait会立即返回,会继续读取接下来的5个数据,之后在5s周期以内的剩余时间内,管道中的数据都为空,如下图1。

若我们采用的是ET模式,则当父进程先读完管道中的5个字符后,由于子进程没有立即向管道中写入字符(间隔5s后才会第二次写入),所以此时父进程会先读到5个字符"aaaa\n",隔5s之后,再读到5个字符"bbbb\n",如下图2,可以看到ET模式下,随时间推移,管道中数据会越来越多。

图1

图2

epoll工作流程

想要了解ET模式和LT模式的区别,首先需要熟悉epoll的工作流程:

【注:】上图的poll不要理解成和select相似那个poll,这是通过epoll_ctl调用的,进行回调函数注册。

下面简要分析一下epoll的工作过程:

1)epoll_wait调用ep_poll,当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒。

2)****文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),导致相应fd上的回调函数ep_poll_callback()被调用。

ep_poll_callback将相应fd对应epitem加入rdlist,导致rdlist不空,进程被唤醒,epoll_wait得以继续执行。

3)ep_events_transfer函数将rdlist中的epitem拷贝到txlist中,并将rdlist清空。

4)ep_send_events函数(很关键),它扫描txlist中的每个epitem,调用其关联fd对应的poll方法(图中蓝线)。此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events和相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。

5)之后如果这个epitem对应的fd是LT模式监听且取得的events是用户所关心的,则将其重新加入回rdlist(图中蓝线),否则(ET模式)不在加入rdlist。

ET模式 vs LT模式

区别

通过上图epoll的工作流程描述,可以看出ET模式和LT模式的区别主要发生在第5步:是否将文件描述符fd放回rdlist中,而rdlist的是否为空决定了epoll_wait函数的阻塞和非阻塞。因此,总结下二者的区别:

ET

如果是ET, epitem是不会再进入到readly list;除非fd再次发生了状态改变, ep_poll_callback被调用。【因此,ET模式可减少epoll_wait函数的调用,从而减少系统开销,提高效率。】

LT

如果是非ET, 不管你还有没有有效的事件或者数据,都会被重新插入到ready list, 再下一次epoll_wait时, 会立即返回, 并通知给用户空间。当然如果这个被监听的fds确实没事件也没数据了, epoll_wait会返回一个0,空转一次。

加入rdlist途径分析

红线:fd状态改变才会触发,那什么情况会导致fd状态的改变呢?

对于读取操作:

1)当buffer由不可读状态变为可读的时候,即由空变为不空的时候。

2)当有新数据到达时,即buffer中的待读内容变多的时候。

对于写操作:

1)当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。

2)当有旧数据被发送走时,即buffer中待写的内容变少得时候。

蓝线:fd的events中有相应的时间(位置1)即会触发。那什么情况下会改变events的相应位呢?

对于读操作:

1)buffer中有数据可读的时候,即buffer不空的时候,fd的events的可读为就置1。

对于写操作:

2) buffer中有空间可写的时候,即buffer不满的时候fd的events的可写位就置1。

【说明:】

  • 红线是时间驱动被动触发(fd状态改变);

  • 蓝线是函数查询主动触发(LT和ET判断是否加入rdlist)。

需要C/C++ Linux服务器架构师学习资料加qun579733396获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

代码实践

基础代码

以下为一个epoll触发模式测试的基础代码,也不算太长,直接拿来就可以测试:

#include <sys/socket.h> //for socket
#include <arpa/inet.h>  //for htonl htons
#include <sys/epoll.h>  //for epoll_ctl
#include <unistd.h>     //for close
#include <fcntl.h>      //for fcntl
#include <errno.h>      //for errno
#include <iostream>     //for cout

class fd_object
{
public:
    fd_object(int fd) { listen_fd = fd; }
    ~fd_object() { close(listen_fd); }
private:
    int listen_fd;
};

/*
./epoll for lt mode
and
./epoll 1 for et mode
*/
int main(int argc, char* argv[])
{
    //create a socket fd
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1)
    {
        std::cout << "create listen socket fd error." << std::endl;
        return -1;
    }
    fd_object obj(listen_fd);

    //set socket to non-block
    int socket_flag = fcntl(listen_fd, F_GETFL, 0);
    socket_flag |= O_NONBLOCK;
    if (fcntl(listen_fd, F_SETFL, socket_flag) == -1)
    {
        std::cout << "set listen fd to nonblock error." << std::endl;
        return -1;
    }

    //init server bind info
    int port = 51741;
    struct sockaddr_in bind_addr;
    bind_addr.sin_family = AF_INET;
    bind_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind_addr.sin_port = htons(port);
    if (bind(listen_fd, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) == -1)
    {
        std::cout << "bind listen socket fd error." << std::endl;
        return -1;
    }

    //start listen
    if (listen(listen_fd, SOMAXCONN) == -1)
    {
        std::cout << "listen error." << std::endl;
        return -1;
    }
    else
        std::cout << "start server at port [" << port << "] with [" << (argc <= 1 ? "LT" : "ET") << "] mode." << std::endl;

    //create a epoll fd
    int epoll_fd = epoll_create(88);
    if (epoll_fd == -1)
    {
        std::cout << "create a epoll fd error." << std::endl;
        return -1;
    }

    epoll_event listen_fd_event;
    listen_fd_event.data.fd = listen_fd;
    listen_fd_event.events = EPOLLIN;
    if (argc > 1) listen_fd_event.events |= EPOLLET;

    //add epoll event for listen fd
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &listen_fd_event) == -1)
    {
        std::cout << "epoll ctl error." << std::endl;
        return -1;
    }

    while (true)
    {
        epoll_event epoll_events[1024];
        int n = epoll_wait(epoll_fd, epoll_events, 1024, 1000);

        if (n < 0)
            break;
        else if (n == 0) //timeout
            continue;

        for (int i = 0; i < n; ++i)
        {
            if (epoll_events[i].events & EPOLLIN)//trigger read event
            {
                if (epoll_events[i].data.fd == listen_fd)
                {
                    //accept a new connection
                    struct sockaddr_in client_addr;
                    socklen_t client_addr_len = sizeof(client_addr);
                    int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);
                    if (client_fd == -1)
                        continue;

                    socket_flag = fcntl(client_fd, F_GETFL, 0);
                    socket_flag |= O_NONBLOCK;
                    if (fcntl(client_fd, F_SETFL, socket_flag) == -1)
                    {
                        close(client_fd);
                        std::cout << "set client fd to non-block error." << std::endl;
                        continue;
                    }

                    epoll_event client_fd_event;
                    client_fd_event.data.fd = client_fd;
                    client_fd_event.events = EPOLLIN | EPOLLOUT;
                    if (argc > 1) client_fd_event.events |= EPOLLET;

                    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &client_fd_event) == -1)
                    {
                        std::cout << "add client fd to epoll fd error." << std::endl;
                        close(client_fd);
                        continue;
                    }

                    std::cout << "accept a new client fd [" << client_fd << "]." << std::endl;
                }
                else
                {
                    std::cout << "EPOLLIN event triggered for client fd [" << epoll_events[i].data.fd << "]." << std::endl;

                    char recvbuf[1024] = { 0 };
                    int m = recv(epoll_events[i].data.fd, recvbuf, 1, 0); // only read 1 bytes when read event triggered

                    if (m == 0 || (m < 0 && errno != EWOULDBLOCK && errno != EINTR))
                    {
                        if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, epoll_events[i].data.fd, NULL) != -1)
                            std::cout << "the client fd [" << epoll_events[i].data.fd << "] disconnected." <<  std::endl;
                        close(epoll_events[i].data.fd);
                    }

                    std::cout << "recv data from client fd [" << epoll_events[i].data.fd << "] and data is [" << recvbuf << "]." << std::endl;
                }
            }
            else if (epoll_events[i].events & EPOLLOUT)
            {
                if (epoll_events[i].data.fd == listen_fd) //trigger write event
                    continue;

                std::cout << "EPOLLOUT event triggered for client fd [" << epoll_events[i].data.fd << "]." << std::endl;
            }
        }
    }

    return 0;
}

简单说下这段代码的测试方法,可以使用g++ testepoll.cpp -o epoll进行编译,编译后通过./epoll运行为LT模式,通过./epoll et模式运行为ET模式,我们用编译好的epoll程序作为服务器,使用nc命令来模拟一个客户端。

测试分类

1.编译后直接./epoll,然后在另一个命令行窗口用 nc -v 127.0.0.1 51741 命令模拟一次连接,此时 ./epoll 会产生大量的 EPOLLOUT event triggered for client fd ...,那是因为在LT模式下,EPOLLOUT会被一直触发。

albert@home-pc:/mnt/d/data/cpp/testepoll$ ./epoll
start server at port [51741] with [LT] mode.
accept a new client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLOUT event triggered for client fd [5].
...

2.注释包含 EPOLLOUT event triggered for client fd 输出内容的第152行代码,编译后 ./epoll运行,然后在另一个命令行窗口用 nc -v 127.0.0.1 51741 模拟一次连接后,输入abcd回车,可以看到服务器./epoll输出内容,EPOLLIN被触发多次,每次读取一个字节。

albert@home-pc:/mnt/d/data/cpp/testepoll$ ./epoll
start server at port [51741] with [LT] mode.
accept a new client fd [5].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [a].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [b].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [c].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [d].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [
].

3.还原刚才注释的那行代码,编译后执行 ./epoll et 启动服务器,然后在另一个命令行窗口用 nc -v 127.0.0.1 51741 模拟一次连接后,然后在另一个命令行窗口用 nc -v 127.0.0.1 51741 模拟一次连接,服务器窗口显示触发了EPOLLOUT事件

albert@home-pc:/mnt/d/data/cpp/testepoll$ ./epoll et
start server at port [51741] with [ET] mode.
accept a new client fd [5].
EPOLLOUT event triggered for client fd [5].

在此基础上,从刚刚运行nc命令的窗口中输入回车、输入回车、输出回车,那么epoll服务器窗口看到的是触发了三次EPOLLIN事件,每次收到一个回车:

albert@home-pc:/mnt/d/data/cpp/testepoll$ ./epoll et
start server at port [51741] with [ET] mode.
accept a new client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [
].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [
].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [
].

但是如果在nc模拟的客户端里输出abcd回车,那么在epoll服务器窗口触发一次EPOLLIN事件接收到一个a之后便再也不会触发EPOLLIN了,即使你在nc客户端在此输入也没有用,那是因为在接受的缓冲区中一直还有数据,新数据来时没有出现缓冲区从空到有数据的情况,所以在ET模式下也注意这种情况。

albert@home-pc:/mnt/d/data/cpp/testepoll$ ./epoll et
start server at port [51741] with [ET] mode.
accept a new client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [
].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [
].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [
].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [a].

怎么解决ET触发了一次就不再触发了

改代码呗,ET模式在连接后触发一次EPOLLOUT,接收到数据时触发一次EPOLLIN,如果数据没收完,以后这两个事件就再也不会被触发了,要想改变这种情况可以再次注册一下这两个事件,时机可以选择接收到数据的时候,所以可以修改这部分代码:

else
{
    std::cout << "EPOLLIN event triggered for client fd [" << epoll_events[i].data.fd << "]." << std::endl;

    char recvbuf[1024] = { 0 };
    int m = recv(epoll_events[i].data.fd, recvbuf, 1, 0); // only read 1 bytes when read event triggered

    if (m == 0 || (m < 0 && errno != EWOULDBLOCK && errno != EINTR))
    {
        if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, epoll_events[i].data.fd, NULL) != -1)
            std::cout << "the client fd [" << epoll_events[i].data.fd << "] disconnected." <<  std::endl;
        close(epoll_events[i].data.fd);
    }

    std::cout << "recv data from client fd [" << epoll_events[i].data.fd << "] and data is [" << recvbuf << "]." << std::endl;
}

添加再次注册的逻辑:

else
{
    std::cout << "EPOLLIN event triggered for client fd [" << epoll_events[i].data.fd << "]." << std::endl;

    char recvbuf[1024] = { 0 };
    int m = recv(epoll_events[i].data.fd, recvbuf, 1, 0); // only read 1 bytes when read event triggered

    if (m == 0 || (m < 0 && errno != EWOULDBLOCK && errno != EINTR))
    {
        if (epoll_ctl(epoll_fd, EPOLL_CTL_DEL, epoll_events[i].data.fd, NULL) != -1)
            std::cout << "the client fd [" << epoll_events[i].data.fd << "] disconnected." <<  std::endl;
        close(epoll_events[i].data.fd);
    }

    epoll_event client_fd_event;
    client_fd_event.data.fd = epoll_events[i].data.fd;
    client_fd_event.events = EPOLLIN | EPOLLOUT;
    if (argc > 1) client_fd_event.events |= EPOLLET;

    epoll_ctl(epoll_fd, EPOLL_CTL_MOD, epoll_events[i].data.fd, &client_fd_event);

    std::cout << "recv data from client fd [" << epoll_events[i].data.fd << "] and data is [" << recvbuf << "]." << std::endl;
}

这次以./epoll et方式启动服务器,使用nc -v 127.0.0.1 51741模拟客户端,输入abc回车发现,epoll服务器输出显示触发的事件变了:

albert@home-pc:/mnt/d/data/cpp/testepoll$ ./epoll et
start server at port [51741] with [ET] mode.
accept a new client fd [5].
EPOLLOUT event triggered for client fd [5].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [a].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [b].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [c].
EPOLLIN event triggered for client fd [5].
recv data from client fd [5] and data is [
].
EPOLLOUT event triggered for client fd [5].

为什么使用边缘触发必须使用非阻塞

在设置边缘触发时,因为每次发消息只会触发一次(不管缓存区是否还留有数据),所以必须把数据一次性读取出来,否则会影响下一次消息

下面的代码实现的是监听文件描述符,每次固定读取5个字节

先看下面这段代码

int main(int argc, char *argv[])
{
    int epfd, nfds;
    int i;
    struct epoll_event event, events[10];
    char buf[5];
    int flag;
	
    epfd = epoll_create(10);
    event.data.fd = 0; /* 监听标准输入 */
    event.events = EPOLLIN | EPOLLET; /* 读监听、边缘触发 */
    //event.events = EPOLLIN; /* 读监听、边缘触发 */
    epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &event);
 
#if 0
	flag = fcntl(0, F_GETFL);
	fcntl(0, F_SETFL, flag | O_NONBLOCK);
#endif	
 
    while (1)
    {
        int i;
        int num = 0;
        char c;
		
        nfds = epoll_wait(epfd, events, 5, -1); /* 返回就绪的文件描述符 */
		
        for (i = 0; i < nfds; ++i) 
        {
            if (events[i].data.fd == STDIN_FILENO) 
            {
                for(i = 0; i < 5; i++)
                {
                    buf[i] = getc(stdin);
                    if(buf[i] == -1)
                        break;
                }
 
                printf("hello world\n");
            }
            
        }
    }
	
    return 0;
}

这段代码的目的是,使用边沿触发,每次触发读取5个字节,此时getc函数是阻塞读取,这就会引起一个问题,当缓存中的数据小于5时,就会在这里阻塞等待,导致无法处理其他IO,这是非常错误的行为,在并发的服务器中,会导致服务器阻塞,无法处理其他客户端

正确的处理方法是将读取的文件描述符设置为非阻塞,循环读取,如果没有数据了就放回错误,这样就不会让服务器阻塞

可以添加下面这两行代码,将标准输入设置为非阻塞IO

flag = fcntl(0, F_GETFL);
fcntl(0, F_SETFL, flag | O_NONBLOCK);

这就解释了为什么边沿触发必须使用非阻塞的问题。

水平触发和边沿触发的优缺点

水平触发LT:

优点:程序简单,会完整地读取所有数据。

缺点:重复地事件触发会影响高并发服务器地性能,因为epoll监控事件涉及到系统调用,需要用户态-内核态的转换。LT消耗了大量的系统资源,影响服务器性能;

边沿触发ET:

优点:每次epoll_wait只用触发一次,通过程序逻辑实现读取缓冲区的所有数据,工作效率高,大大提升了服务器性能;

缺点(没归纳出来,随便写一个):不能保证数据的完整。(这个可以通过上面提到的程序逻辑实现完整地读取数据)

边沿触发没什么缺点?那是不是用epoll就用ET边沿触发就好了?

我的理解是,YES。在日常用epoll实现并发处理,可以优先使用“边沿触发(EPOLLET)+非阻塞IO”模式。

在高并发服务器中边沿触发(ET)的效率更高

因为边沿触发只在数据到来的一刻才触发,很多时候服务器在接受大量数据时会先接受数据头部(水平触发在此触发第一次,边沿触发第一次)。

接着服务器通过解析头部决定要不要接这个数据。此时,如果不接受数据,水平触发需要手动清除(水平触发当有数据时,会一直触发,直到没有数据可读),而边沿触发可以将清除工作交给一个定时的清除程序去做(只触发一次,不需要的数据可以不读),自己立刻返回。

但是如果sockfd中发送的数据较小,我一次recv就能全部读完,这样LT也不会重复触发epoll事件,和边沿触发的性能差不多,那我们为什么不用更简单的水平触发呢,当然可以使用。那其实就可以理解水平触发和边沿触发是有一个分界点,就是看sockfd的数据是小数据还是大数据。recv的BUFFER_LENGTH如果一次能接收完recv buffer中的数据,就是小数据,一次接收不完就是大数据。小数据就用水平触发,大数据就用边沿触发

但但但但是LT还在一种场景有使用,就是Nginx服务器中listenfd是用的水平触发 。(网络服务器中一般涉及两类sockfd,一种是用于监听是否有连接请求的socket,一种是用于传输数据的socket(上面讲的sockfd都是用于传输数据的))

有一些解释是listenfd用水平触发,如果多个client同时连接进来,listenfd里面积攒多个连接的话,accept一次只处理一个连接,防止漏掉连接,选择水平触发。


http://www.kler.cn/a/314564.html

相关文章:

  • 少儿学习Scratch编程的好处和坏处
  • 深入理解接口测试:实用指南与最佳实践5.0(二)
  • 怎么监控员工电脑?分享5个监控员工电脑的绝佳方法(立竿见影!建议收藏!)
  • 985研一学习日记 - 2024.11.12
  • 【前端】深入浅出的React.js详解
  • 十九:Spring Boot 依赖(4)-- spring-boot-starter-security依赖详解
  • Linux 自旋锁
  • Spring Mybatis 动态语句 总结
  • 简单生活的快乐
  • (k8s)kubernetes集群基于Containerd部署
  • Flask-SQLAlchemy一对多 一对一 多对多关联
  • GDPU Andriod移动应用 Activity
  • 【数据结构与算法】LeetCode:哈希表
  • Alinx MPSoC驱动开发第17章I2C实验修改设备树后petalinux编译报错
  • 分布式Id生成策略-美团Leaf
  • 使用python对图像批量水平变换和垂直变换
  • 深度学习参数管理
  • MySQL-DDL/DML(数据定义/操作语言)
  • GIS开发之如何使用OpenLayers,Leaflet,Mapbox,Cesium,ArcGIS, Turf.js 与 D3.js
  • 【Webpack--00802】配置Babel语法兼容
  • 【图像检索】基于Gabor特征的图像检索,matlab实现
  • Python面试宝典第50题:分割等和子集
  • Vscode、插件历史版本下载
  • [数据结构与算法·C++] 笔记 1.4 算法复杂性分析
  • [附源码]SpringBoot+VUE+Java实现人脸识别系统
  • 实战指南:深度剖析Servlet+JSP+JDBC技术栈下的用户CRUD操作