epoll的LT与ET模式以及阻塞和非阻塞

文章详细阐述了socket和EPOLL中的阻塞与非阻塞概念,以及不同IO模型(水平触发和边缘触发)的工作原理。通过代码示例展示了各种组合下的行为,强调了在高并发场景下选择合适IO模型的重要性。最后总结了在不同场景下如何选择阻塞和非阻塞,以及水平触发和边缘触发模式。
摘要由CSDN通过智能技术生成

1、基本概念

Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你

Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你

阻塞IO:当你去读一个阻塞的文件描述符时,如果在该文件描述符上没有数据可读,那么它会一直阻塞(通俗一点就是一直卡在调用函数那里),直到有数据可读。当你去写一个阻塞的文件描述符时,如果在该文件描述符上没有空间(通常是缓冲区)可写,那么它会一直阻塞,直到有空间可写。以上的读和写我们统一指在某个文件描述符进行的操作,不单单指真正的读数据,写数据,还包括接收连接accept(),发起连接connect()等操作

非阻塞IO:当你去读写一个非阻塞的文件描述符时,不管可不可以读写,它都会立即返回,返回成功说明读写操作完成了,返回失败会设置相应errno状态码,根据这个errno可以进一步执行其他处理。它不会像阻塞IO那样,卡在那里不动

2、关于在socket和EPOLL中的阻塞与非阻塞

关于socket中的阻塞与非阻塞,首先明白的是,阻塞与非阻塞是文件(文件描述符)的性质,而不是函数的性质

socket通信中用到的三个文件描述符

客户端:

  • connfd:客户端创建socket时候得到的文件描述符。connect使用这个描述符主动发起链接。

服务端:

  • listenfd:创建socket得到的文件描述符,同时bind和listen使用的也是这个文件描述符
  • clientfd:调用accept得到的文件描述符,也就是用于通信的文件描述符

注意:
accept函数并不参与三次握手过程,accept函数将建立好的连接从全连接队列中移除,并返回clientfd,随后客户端和服务店通过connfd和clientfd进行通信。

socket通信中相关的文件描述符是否设置为阻塞模式对下列api造成的影响

1、当connfd被设置为阻塞模式的时候(默认),connect函数会一直阻塞到连接成功或超时出错,超时值需要修改内核参数
(注意:connfd的阻塞与否影响的不仅是connect,还有客户端的read以及write(send、recv等)函数族)

2、当connfd被设置成非阻塞模式,无论连接是否成功,connect都会立刻返回

调用connect建立连接成功,返回0,失败则返回-1并设置对应的errno
例如:

  1. errno为EINPROGRESS:这表示连接仍在进行中,需要进一步等待。非阻塞式connect才会出现
  2. EACCES:拒绝连接,通常是由于权限问题
  3. EADDRINUSE:地址已经在使用中,无法建立连接
  4. ECONNREFUSED:远程主机拒绝连接
  5. ETIMEDOUT:连接超时,远程主机没有在指定的时间内响应
  6. EHOSTUNREACH:无法到达远程主机

3、当listenfd设置成阻塞模式的时候(默认,无需设置),如果连接全连接队列中有需要处理的连接,accet函数会立即返回,否则会一直阻塞下去,直到新的连接到来

(也就是说,listenfd的阻塞与否影响的是accept,而不会影响bind和listen)
4、当listenfd设置成非阻塞的时候,无论连接全连接队列是否有连接,accpet都会立即返回,不会阻塞。如果有连接,则accept返回对应的socket。如果没有连接,accept返回值小于0,并设置对应的errno为EAGAIN或EWOULDBLOCK

5、当connfd或clientfd设置为阻塞模式的时候(默认),send会尝试发数据,如果对端因为TCP窗口太小导致本段无法发送出去,send函数会一直阻塞到对端TCP窗口变大足以发送数据或者超时;recv则相反,如果此时没有数据可获取,recv函数会一直阻塞直到收取到数据或者超时,有的话,读到数据后返回。send和recv函数的超时时间可以分别使用SO_SNDTIMEO和SO_RCTIMEO两个套接字选项来设置

6、当connfd和clientfd设置成非阻塞模式的时候,send和recv函数都会立即返回,send函数即使因为对端TCP窗口太小发送不出去也会立即返回,recv函数如果无数据可收也会立即返回,此时这两个函数的返回值都是-1,错误码都是EAGIN。这种情况下,send和recv函数的返回值有三种情况,分别是大于0,等于0,小于0。总结 如下:

返回值返回值含义
大于0成功发送(send)或收取(recv) n 个字节
0对端关闭连接
-1返回值为-1并且errno为EAGAIN或EWOULDBLOCK:表示写操作暂时不可用,即套接字当前不可写,需要稍后重试。errno为其他值:表示写操作发生错误,具体的错误原因可以通过查看errno的值来确定。

3、几种IO模型的触发方式

考虑服务端
这里我们要探讨epoll()的水平触发(LT)和边缘触发(ET),以及阻塞IO和非阻塞IO对它们的影响
对于监听的socket文件描述符我们用listenfd代替,对于accept()返回的文件描述符(即要读写的文件描述符)用connfd代替

验证内容如下:

  1. 水平触发的非阻塞listenfd
  2. 边缘触发的非阻塞listenfd
  3. 水平触发的阻塞connfd
  4. 水平触发的非阻塞connfd
  5. 边缘触发的阻塞connfd
  6. 边缘触发的非阻塞connfd

以上没有验证阻塞的listenfd,因为最开始将listenfd添加到epoll中,调用epoll_wait()返回后必定是已就绪的连接,设不设置阻塞accept()都会立即返回

4、代码验证

#include <sys/epoll.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

// events数组的大小
const int max_epoll_events = 10;
// buffer缓冲区的大小
const int buffer_size = 5;
// listen的第二个参数,全连接队列的大小
const int listen_size = 10;
// LT模式
const int epoll_lt = 0;
// ET模式
const int epoll_et = 1;
// 文件描述符为阻塞
const int block = 0;
// 文件描述符为非阻塞
const int noblock = 1;

// 设置文件描述符为阻塞
void SetNoblock(int fd)
{
    int old_flag = fcntl(fd, F_GETFL);
    fcntl(fd, F_SETFL, old_flag | O_NONBLOCK);
}

// 注册文件描述符到epoll中,并设置其事件为EPOLLIN(可读事件)
void AddfdToEollp(int epollfd, int fd, int epoll_type, int block_type)
{
    struct epoll_event ev;
    ev.data.fd = fd;
    ev.events = EPOLLIN;

    // 如果是ET模式,则设置EPOLL_ET
    if (epoll_type == epoll_et)
    {
        ev.events |= EPOLLET;
    }

    // 是否设置阻塞
    if (block_type == block)
    {
        SetNoblock(fd);
    }

    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);
}

// LT处理流程
void EpollLT(int fd)
{
    char buffer[buffer_size];
    int size = 0;
    bzero(buffer, buffer_size);

    printf("read begin\n");
    if ((size = read(fd, buffer, buffer_size)) > 0)
    {
        printf("收到消息:%s\n", buffer);
    }
    else if (size == 0)
    {
        printf("客户端关闭连接\n");
        close(fd);
    }
}

// 带循环的ET流程
void EpollETLoop(int fd)
{
    char buffer[buffer_size];
    int size = 0;
    bzero(buffer, buffer_size);
    printf("带循环的ET开始读取数据\n");
    while (true)
    {
        bzero(buffer, buffer_size);
        size = read(fd, buffer, buffer_size);
        if (size > 0)
        {
            printf("收到消息:%s", buffer);
        }
        else if (size == 0)
        {
            printf("客户端关闭连接\n");
            close(fd);
            break;
        }
        else
        {
            if (errno == EAGAIN || errno == EWOULDBLOCK)
            {
                printf("循环读取数据结束\n");
                break;
            }
        }
    }
    printf("带循环的ET处理结束\n");
}

// 不带循环的ET流程
void EpollETNoLoop(int fd)
{
    char buffer[buffer_size];
    int size = 0;
    bzero(buffer, buffer_size);
    printf("不带循环的ET开始读取数据\n");
    size = read(fd, buffer, buffer_size);
    if (size > 0)
    {
        printf("收到消息:%s", buffer);
    }
    else if (size == 0)
    {
        printf("客户端关闭连接\n");
        close(fd);
    }
    printf("不带循环的ET处理结束\n");
}



void EpollProcess(int epollfd, struct epoll_event* events, int number, int listenfd, int epoll_type, int block_type)
{
    for(int i = 0; i < number; ++i)
    {
        int fd = events[i].data.fd;
        //监听套接字有事件发生,一般都是新连接到来
        if(fd == listenfd)
        {
            printf("=============================新一轮accept()=============================\n");
            printf("accept()开始\n");

            //休眠3秒,模拟服务器很繁忙,不能立刻处理accept连接
            printf("服务器繁忙...\n");
            sleep(3);
            printf("服务器繁忙结束...\n");
            
            struct sockaddr_in client_addr;
            socklen_t client_addr_len = sizeof(client_addr);
            int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_addr_len);
            //int connfd = accept4(listenfd, (struct sockaddr *)&clientaddr, &len, SOCK_NONBLOCK);
            AddfdToEollp(epollfd, connfd, epoll_type, block_type);
            printf("client ip is %s, client port is %d, accept fd is %d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), connfd);
            printf("accept 结束, fd is %d\n", connfd);
        }
        else if(events[i].events & EPOLLIN)
        {
            if(epoll_type == epoll_lt)
            {
                printf("水平触发开始...\n");
                EpollLT(fd);
            }
            else if(epoll_type == epoll_et)
            {
                printf("边缘触发开始...\n");
                //不带循环的水平触发
                EpollETLoop(fd);
                //带循环的水平触发
                EpollETNoLoop(fd);
            }
        }
    }
}


// 创建监听socket
int CreateListenSocket(const char *ip, const int port)
{
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    //int listensock = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    if (listenfd < 0)
    {
        fprintf(stderr, "socket error, error code is %d\n", errno);
    }

    struct sockaddr_in servaddr;
    bzero(&servaddr, sizeof(servaddr));
    // memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = inet_addr(ip);
    servaddr.sin_port = htons(port);

    // 设置地址复用
    int on = 1;
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) == -1)
    {
        fprintf(stderr, "SO_REUSEADDR error, error code is %d\n", errno);
        exit(1);
    }

    // 绑定
    if (bind(listenfd, (struct sockaddr *)&(servaddr), sizeof(servaddr)) == -1)
    {
        fprintf(stderr, "bind error, error code is %d\n", errno);
        exit(2);
    }

    if (listen(listenfd, 5) == -1)
    {
        fprintf(stderr, "listen error, error code is %d\n", errno);
        exit(3);
    }
    return listenfd;
}

int main(int argc, char *argv[])
{
    if (argc < 3)
    {
        fprintf(stderr, "usage:%s ip_address port_number\n", argv[0]);
        exit(4);
    }
    int listenfd, epollfd, number;
    listenfd = CreateListenSocket(argv[1], atoi(argv[2]));

    if ((epollfd = epoll_create1(0)) == -1)
    {
        fprintf(stderr, "epoll_create1 error, error code is%d", errno);
    }

    struct epoll_event events[max_epoll_events];

    //listenfd:非阻塞的LT
    //AddfdToEollp(epollfd,listenfd, epoll_lt, noblock);
    //listenfd:非阻塞的ET
    AddfdToEollp(epollfd,listenfd, epoll_et, noblock);

    while(true)
    {
        number = epoll_wait(epollfd, events, max_epoll_events, -1);//时间参数为0表示立即返回,为-1表示无限等待,大于0表示阻塞多少毫秒
        if(number > 0)
        {
            //connfd:阻塞的LT模式
            //EpollProcess(epollfd, events, number, listenfd, epoll_lt, block);
            
            //connfd:非阻塞的LT模式
            EpollProcess(epollfd, events, number, listenfd, epoll_lt, noblock);

            //connfd:阻塞的ET模式
            //EpollProcess(epollfd, events, number, listenfd, epoll_et, block);

            //connfd:非阻塞的ET模式
            //EpollProcess(epollfd, events, number, listenfd, epoll_et, noblock);
        }
    }
    return 0;
}

水平触发的非阻塞listenfd
放开AddfdToEollp(epollfd,listenfd, epoll_lt, noblock); 和 EpollProcess(epollfd, events, number, listenfd, epoll_lt, block); 然后编译运行

在这里插入图片描述

代码里面休眠了3秒,模拟繁忙服务器不能很快处理accept()请求。这里,我们开另一个终端快速用5个连接连到服务器:

在这里插入图片描述

我们再看看服务器的反映,可以看到5个终端连接都处理完成了,返回的新connfd依次为5,6,7,8,9:

在这里插入图片描述

测试完毕,批量kill掉那5个客户端:

for i in {1..5};do kill %$i;done

边缘触发的非阻塞listenfd
放开打开AddfdToEollp(epollfd,listenfd, epoll_et, noblock); 和 EpollProcess(epollfd, events, number, listenfd, epoll_lt, block);
然后编译运行,采用同样的方式,快速创建5个客户端连接。再看服务器的反映,5个客户端只处理了3个。说明高并发时,会出现客户端连接不上的问题:

在这里插入图片描述

后面4个测试等待listenfd都采用水平触发,后面就不重复写了

水平触发的阻塞connfd
放开EpollProcess(epollfd, events, number, listenfd, epoll_lt, noblock);
编译运行,用一个客户端连接,并发送1-9这几个数字:

在这里插入图片描述
再看服务器的反映,可以看到水平触发触发了2次。因为我们代码里面设置的缓冲区是5字节,处理代码一次接收不完,水平触发一直触发,直到数据全部读取完毕:
在这里插入图片描述

水平触发的非阻塞connfd
放开EpollProcess(epollfd, events, number, listenfd, epoll_lt, noblock);
编译运行,用一个客户端连接,并发送一段数据:

在这里插入图片描述
再看服务器的反映,可以看到水平触发触发了2次。跟水平触发的阻塞connfd一模一样
在这里插入图片描述

边缘触发的阻塞connfd
放开EpollProcess(epollfd, events, number, listenfd, epoll_et, block); 和 EpollETLoop(fd);
先测试不带循环的ET模式(即不循环读取数据,跟水平触发读取一样),编译运行后,开启一个客户端连接,并发送1-9这几个数字,再看看服务器的反映,可以看到边缘触发只触发了一次,只读取了5个字节:
在这里插入图片描述

我们继续在刚才的客户端发送一个字符a,告诉epoll_wait(),有新的可读事件发生:

在这里插入图片描述

这个时候,如果继续在刚刚的客户端再发送一个a,客户端这个时候就会读取上次没读完的a加上次的回车符,2个字节,还剩3个字节的缓冲区就可以读取本次的a加本次的回车符共4个字节:

在这里插入图片描述

我们可以看到,阻塞的边缘触发,如果不一次性读取一个事件上的数据,会干扰下一个事件!!!

接下来,我们就一次性读取数据,即带循环的ET模式。注意:我们这里测试的还是边缘触发的阻塞connfd,只是换个读取数据的方式。

放开EpollETLoop(fd);
编译运行,依然用一个客户端连接,发送1-9。看看服务器,可以看到数据全部读取完毕:

在这里插入图片描述

细心的朋友肯定发现了问题,程序没有输出"带循环的ET处理结束",是因为程序一直卡在了read()函数上,因为是阻塞IO,如果没数据可读,它会一直等在那里,直到有数据可读。如果这个时候,用另一个客户端去连接,服务器不能受理这个新的客户端!!!

边缘触发的非阻塞connfd
不带循环的ET测试同上面一样,数据不会读取完。这里我们就只需要测试带循环的ET处理,即正规的边缘触发用法。
放开EpollProcess(epollfd, events, number, listenfd, epoll_et, noblock);
编译运行,用一个客户端连接,并发送1-9。再观测服务器的反映,可以看到数据全部读取完毕,处理函数也退出了,因为非阻塞IO如果没有数据可读时,会立即返回,并设置error,这里我们根据EAGAIN和EWOULDBLOCK来判断数据全部读取完毕了,可以退出循环了:

在这里插入图片描述

这个时候,我们用另一个客户端去连接,服务器依然可以正常接收请求:

在这里插入图片描述

5、总结

  1. 对于监听的 listenfd,一般设置成非阻塞。最好使用水平触发模式,边缘触发模式会导致高并发情况下,有的客户端会连接不上。如果非要使用边缘触发,可以用 while 来循环 accept()。

  2. 对于读写的 connfd,水平触发模式下,阻塞和非阻塞效果都一样,只要是调用了read读取数据,那么就一定能读取到数据。不过还是建议设置为非阻塞。

  3. 对于读写的 connfd,边缘触发模式下,必须使用非阻塞 IO,并要求一次性地完整读写全部数据。

如果需要及时处理所有就绪事件,尤其是在高并发的情况下,可以选择 ET 模式。
如果应用程序的处理逻辑较为复杂,可能会花费较长的时间处理每个事件,或者存在一些短暂的阻塞情况,可以选择 LT 模式。

  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值