IO多路复用-epoll的使用详解【C语言】

1.概念

IO多路复用是一种通过一种机制使一个进程能同时监控多个文件描述符的状态变化的技术。它通过操作系统提供的机制,让单个线程能够有效管理多个IO操作,如读取、写入等,而不需要创建多个线程或进程来处理每个IO事件。这种技术在网络编程中特别有用,可以显著提高服务器的并发处理能力和响应速度。

在Linux系统中,常见的IO多路复用机制有selectpollepoll。其中,epoll由于其高效的特性,成为了主流的选择。epoll是select和poll的升级版,相较于这两个前辈,epoll改进了工作方式,因此它更加高效。

epoll相较于select和poll的改进和优势

  1. 数据结构与管理方式

    • select 和 poll:使用线性数组来管理待检测的文件描述符集合,每次调用时需要遍历整个集合,效率随集合大小线性下降。
    • epoll:通过红黑树(用于存储大量文件描述符)和就绪链表(用于存储准备好的事件),实现了高效的事件管理。红黑树的查找效率为O(log n),极大地提升了处理大量描述符时的效率。
  2. 事件通知机制

    • select 和 poll:使用轮询方式,应用程序需要在返回的就绪集合中逐个检查每个描述符的状态。
    • epoll:通过回调机制,在文件描述符状态改变时直接通知应用程序,避免了反复检查的开销,提升了系统的响应速度。
  3. 内存管理和数据传输

    • select 和 poll:每次调用都涉及到内核和用户空间之间的数据复制,增加了系统开销。
    • epoll:利用内存映射(mmap)技术,内核和用户空间共享一块内存区域,减少了数据传输的次数和开销,提升了效率。
  4. 可扩展性和限制

    • select 和 poll:对监视的文件描述符数量有限制,尤其在处理大量并发连接时表现较差。
    • epoll:几乎没有对监视文件描述符数量的限制,能够轻松处理大规模的并发连接,适应了现代高性能服务器应用的需求。
  5. 适用场景

    • select 和 poll:适合于监视数量较少的文件描述符,对于少量连接和简单的IO场景仍然有其用处。
    • epoll:特别适合于需要处理大量并发连接和高频IO操作的场景,如网络服务器,能够显著提升系统的性能和吞吐量。

2.相关操作函数

2.1 epoll_create

// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
功能:创建一个epoll实例,返回一个epoll文件描述符。
参数:
size:表示epoll实例中能够监听的文件描述符数量的建议值。
返回值:
成功:返回一个新的epoll文件描述符。
失败:返回-1,并设置errno表示错误的类型。

2.2 epoll_ctl

// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:用于控制epoll监听的文件描述符和事件。
参数:
epfd:epoll实例的文件描述符,由epoll_create返回。
op:操作类型,可以是以下几种:
    EPOLL_CTL_ADD:将文件描述符 fd 添加到epoll实例中进行监听。
    EPOLL_CTL_MOD:修改已经添加到epoll实例中的文件描述符 fd 的事件类型。
    EPOLL_CTL_DEL:从epoll实例中删除文件描述符 fd。
fd:要进行操作的文件描述符。
event:指向 struct epoll_event 结构体的指针,用来描述事件类型和相关数据。
返回值:
    成功:返回0。
    失败:返回-1,并设置errno表示错误的类型。

2.3 epoll_wait

// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能:等待epoll实例中的文件描述符上发生事件。
参数:
epfd:epoll实例的文件描述符,由epoll_create返回。
events:指向 struct epoll_event 数组的指针,用于存储发生事件的文件描述符和事件类型。
maxevents:events 数组的最大长度,即最多能够返回多少个事件。
timeout:等待事件发生的超时时间(以毫秒为单位),-1表示永久阻塞直到有事件发生。
返回值:
    成功:返回就绪的文件描述符数量,如果超时时间到达而没有事件发生则返回0。
    失败:返回-1,并设置errno表示错误的类型。

2.4 struct epoll_event 结构体

struct epoll_event {
    uint32_t events;    // 事件类型
    epoll_data_t data;  // 用户数据
};

typedef union epoll_data {
    void *ptr;
    int fd;        // 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;
成员变量:
events:表示文件描述符上的事件类型,可以是以下几种:
    EPOLLIN:表示对应的文件描述符可以读(包括对端连接的关闭)。
    EPOLLOUT:表示对应的文件描述符可以写。
    EPOLLERR:表示对应的文件描述符发生错误。
    EPOLLHUP:表示对应的文件描述符被挂断。
    EPOLLET:设置为边缘触发模式(Edge Triggered)。
    EPOLLONESHOT:设置为单次触发模式(One Shot)。
data:存储用户数据,可以是文件描述符或指针。

select/poll低效的原因之一是将“添加/维护待检测任务”和“阻塞进程/线程”两个步骤合二为一。每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket个数相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl()维护等待队列,再调用epoll_wait()阻塞进程(解耦)。通过下图的对比显而易见,epoll的效率得到了提升。

3.epoll的使用

#include <stdio.h>
#include <ctype.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

// server
int main(int argc, const char* argv[])
{
    // 创建监听的套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if(lfd == -1)
    {
        perror("socket error");
        exit(1);
    }

    // 绑定
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(9999);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 本地多有的IP
    
    // 设置端口复用
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 绑定端口
    int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    if(ret == -1)
    {
        perror("bind error");
        exit(1);
    }

    // 监听
    ret = listen(lfd, 64);
    if(ret == -1)
    {
        perror("listen error");
        exit(1);
    }

    // 创建一个epoll实例
    int epfd = epoll_create(100);
    if(epfd == -1)
    {
        perror("epoll_create");
        exit(1);
    }

    // 将监听套接字lfd添加到epoll实例中进行事件监听
    struct epoll_event ev;
    ev.events = EPOLLIN;    // 监听读事件
    ev.data.fd = lfd;       // 数据是监听套接字lfd
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
    if(ret == -1)
    {
        perror("epoll_ctl");
        exit(1);
    }

    // 用于存放触发的事件的数组
    struct epoll_event evs[1024];
    int size = sizeof(evs) / sizeof(struct epoll_event);
    
    // 进入事件处理循环
    while(1)
    {
        // 等待事件触发
        int num = epoll_wait(epfd, evs, size, -1);
        if(num == -1)
        {
            perror("epoll_wait");
            exit(1);
        }

        // 处理所有触发的事件
        for(int i = 0; i < num; ++i)
        {
            int curfd = evs[i].data.fd;  // 获取当前事件对应的文件描述符

            // 如果是监听套接字lfd有事件发生,表示有新连接
            if(curfd == lfd)
            {
                // 接受新连接
                int cfd = accept(lfd, NULL, NULL);
                if(cfd == -1)
                {
                    perror("accept error");
                    continue;
                }

                // 将新连接cfd添加到epoll实例中监听其读事件
                ev.events = EPOLLIN;
                ev.data.fd = cfd;
                ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
                if(ret == -1)
                {
                    perror("epoll_ctl-accept");
                    exit(1);
                }

                printf("新连接 %d 加入\n", cfd);
            }
            else
            {
                // 处理已连接套接字的数据收发
                char buf[1024];
                memset(buf, 0, sizeof(buf));
                int len = recv(curfd, buf, sizeof(buf), 0);
                if(len == -1)
                {
                    perror("recv error");
                    // 出错时关闭连接,并从epoll实例中删除
                    epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
                    close(curfd);
                }
                else if(len == 0)
                {
                    // 客户端断开连接
                    printf("客户端 %d 已断开连接\n", curfd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
                    close(curfd);
                }
                else
                {
                    // 收到数据,打印并回传
                    printf("客户端 %d 说: %s", curfd, buf);
                    send(curfd, buf, len, 0);
                }
            }
        }
    }

    close(lfd);
    return 0;
}

当在服务器端循环调用epoll_wait()的时候,就会得到一个就绪列表,并通过该函数的第二个参数传出:

struct epoll_event evs[1024];
int num = epoll_wait(epfd, evs, size, -1);

每当epoll_wait()函数返回一次,在evs中最多可以存储size个已就绪的文件描述符信息,但是在这个数组中实际存储的有效元素个数为num个,如果在这个epoll实例的红黑树中已就绪的文件描述符很多,并且evs数组无法将这些信息全部传出,那么这些信息会在下一次epoll_wait()函数返回的时候被传出。

通过evs数组被传递出的每一个有效元素里边都包含了已就绪的文件描述符的相关信息,这些信息并不是凭空得来的,这取决于我们在往epoll实例中添加节点的时候,往节点中初始化了哪些数据:

struct epoll_event ev;
// 节点初始化
ev.events = EPOLLIN;    
ev.data.fd = lfd;	// 使用了联合体中 fd 成员
// 添加待检测节点到epoll实例中
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);

在添加节点的时候,需要对这个struct epoll_event类型的节点进行初始化,当这个节点对应的文件描述符变为已就绪状态,这些被传入的初始化信息就会被原样传出,这个对应关系必须要搞清楚。

4.epoll的工作模式

4.1 水平模式

水平模式可以简称为LT模式,LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核通知使用者哪些文件描述符已经就绪,之后就可以对这些已就绪的文件描述符进行IO操作了。如果我们不作任何操作,内核还是会继续通知使用者。

水平模式的特点:

  • 读事件:如果文件描述符对应的读缓冲区还有数据,读事件就会被触发,epoll_wait()解除阻塞
  • 当读事件被触发,epoll_wait()解除阻塞,之后就可以接收数据了
  • 如果接收数据的buf很小,不能全部将缓冲区数据读出,那么读事件会继续被触发,直到数据被全部读出,如果接收数据的内存相对较大,读数据的效率也会相对较高(减少了读数据的次数)
  • 因为读数据是被动的,必须要通过读事件才能知道有数据到达了,因此对于读事件的检测是必须的
  • 写事件:如果文件描述符对应的写缓冲区可写,写事件就会被触发,epoll_wait()解除阻塞
  • 当写事件被触发,epoll_wait()解除阻塞,之后就可以将数据写入到写缓冲区了
  • 写事件的触发发生在写数据之前而不是之后,被写入到写缓冲区中的数据是由内核自动发送出去的
  • 如果写缓冲区没有被写满,写事件会一直被触发
  • 因为写数据是主动的,并且写缓冲区一般情况下都是可写的(缓冲区不满),因此对于写事件的检测不是必须的

(上述代码就是水平模式LT)

4.2 边沿模式

边沿模式可以简称为ET模式,ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当文件描述符从未就绪变为就绪时,内核会通过epoll通知使用者。然后它会假设使用者知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知(only once)。如果我们对这个文件描述符做IO操作,从而导致它再次变成未就绪,当这个未就绪的文件描述符再次变成就绪状态,内核会再次进行通知,并且还是只通知一次。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。

边沿模式的特点:

  • 读事件:当读缓冲区有新的数据进入,读事件被触发一次,没有新数据不会触发该事件
  • 如果有新数据进入到读缓冲区,读事件被触发,epoll_wait()解除阻塞
  • 读事件被触发,可以通过调用read()/recv()函数将缓冲区数据读出
  • 如果数据没有被全部读走,并且没有新数据进入,读事件不会再次触发,只通知一次
  • 如果数据被全部读走或者只读走一部分,此时有新数据进入,读事件被触发,并且只通知一次
  • 写事件:当写缓冲区状态可写,写事件只会触发一次
  • 如果写缓冲区被检测到可写,写事件被触发,epoll_wait()解除阻塞
  • 写事件被触发,就可以通过调用write()/send()函数,将数据写入到写缓冲区中
  • 写缓冲区从不满到被写满,期间写事件只会被触发一次
  • 写缓冲区从满到不满,状态变为可写,写事件只会被触发一次

综上所述:epoll的边沿模式下 epoll_wait()检测到文件描述符有新事件才会通知,如果不是新的事件就不通知,通知的次数比水平模式少,效率比水平模式要高。

4.2.1 ET模式的设置

边沿模式不是默认的epoll模式,需要额外进行设置。epoll设置边沿模式是非常简单的,epoll管理的红黑树示例中每个节点都是struct epoll_event类型,只需要将EPOLLET添加到结构体的events成员中即可:

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;	// 设置边沿模式

示例代码:

int num = epoll_wait(epfd, evs, size, -1);
for(int i=0; i<num; ++i)
{
    // 取出当前的文件描述符
    int curfd = evs[i].data.fd;
    // 判断这个文件描述符是不是用于监听的
    if(curfd == lfd)
    {
        // 建立新的连接
        int cfd = accept(curfd, NULL, NULL);
        // 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了
        // 读缓冲区是否有数据, 并且将文件描述符设置为边沿模式
        struct epoll_event ev;
        ev.events = EPOLLIN | EPOLLET;   
        ev.data.fd = cfd;
        ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
        if(ret == -1)
        {
            perror("epoll_ctl-accept");
            exit(0);
        }
    }
}

4.2.2 设置非阻塞

对于写事件的触发一般情况下是不需要进行检测的,因为写缓冲区大部分情况下都是有足够的空间可以进行数据的写入。对于读事件的触发就必须要检测了,因为服务器也不知道客户端什么时候发送数据,如果使用epoll的边沿模式进行读事件的检测,有新数据达到只会通知一次,那么必须要保证得到通知后将数据全部从读缓冲区中读出。那么,应该如何读这些数据呢?

方式1:准备一块特别大的内存,用于存储从读缓冲区中读出的数据,但是这种方式有很大的弊端:

  • 内存的大小没有办法界定,太大浪费内存,太小又不够用
  • 系统能够分配的最大堆内存也是有上限的,栈内存就更不必多言了

方式2:循环接收数据

int len = 0;
while((len = recv(curfd, buf, sizeof(buf), 0)) > 0)
{
    // 数据处理...
}

这样做也是有弊端的,因为套接字操作默认是阻塞的,当读缓冲区数据被读完之后,读操作就阻塞了也就是调用的read()/recv()函数被阻塞了,当前进程/线程被阻塞之后就无法处理其他操作了。

要解决阻塞问题,就需要将套接字默认的阻塞行为修改为非阻塞,需要使用fcntl()函数进行处理:

// 设置完成之后, 读写都变成了非阻塞模式
int flag = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;                                                        
fcntl(cfd, F_SETFL, flag);

通过上述分析就可以得出一个结论:epoll在边沿模式下,必须要将套接字设置为非阻塞模式,但是,这样就会引发另外的一个bug,在非阻塞模式下,循环地将读缓冲区数据读到本地内存中,当缓冲区数据被读完了,调用的read()/recv()函数还会继续从缓冲区中读数据,此时函数调用就失败了,返回-1,对应的全局变量 errno 值为 EAGAIN 或者 EWOULDBLOCK如果打印错误信息会得到如下的信息:Resource temporarily unavailable

// 非阻塞模式下recv() / read()函数返回值 len == -1
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == -1)
{
    if(errno == EAGAIN)
    {
        printf("数据读完了...\n");
    }
    else
    {
        perror("recv");
        exit(0);
    }
}
#include <stdio.h>
#include <ctype.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>

// server
int main(int argc, const char* argv[])
{
    // 创建监听的套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if(lfd == -1)
    {
        perror("socket error");
        exit(1);
    }

    // 绑定
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(9999);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 本地多有的IP
    // 127.0.0.1
    // inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr.s_addr);
    
    // 设置端口复用
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 绑定端口
    int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    if(ret == -1)
    {
        perror("bind error");
        exit(1);
    }

    // 监听
    ret = listen(lfd, 64);
    if(ret == -1)
    {
        perror("listen error");
        exit(1);
    }

    // 现在只有监听的文件描述符
    // 所有的文件描述符对应读写缓冲区状态都是委托内核进行检测的epoll
    // 创建一个epoll模型
    int epfd = epoll_create(100);
    if(epfd == -1)
    {
        perror("epoll_create");
        exit(0);
    }

    // 往epoll实例中添加需要检测的节点, 现在只有监听的文件描述符
    struct epoll_event ev;
    ev.events = EPOLLIN;    // 检测lfd读读缓冲区是否有数据
    ev.data.fd = lfd;
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
    if(ret == -1)
    {
        perror("epoll_ctl");
        exit(0);
    }


    struct epoll_event evs[1024];
    int size = sizeof(evs) / sizeof(struct epoll_event);
    // 持续检测
    while(1)
    {
        // 调用一次, 检测一次
        int num = epoll_wait(epfd, evs, size, -1);
        printf("==== num: %d\n", num);

        for(int i=0; i<num; ++i)
        {
            // 取出当前的文件描述符
            int curfd = evs[i].data.fd;
            // 判断这个文件描述符是不是用于监听的
            if(curfd == lfd)
            {
                // 建立新的连接
                int cfd = accept(curfd, NULL, NULL);
                // 将文件描述符设置为非阻塞
                // 得到文件描述符的属性
                int flag = fcntl(cfd, F_GETFL);
                flag |= O_NONBLOCK;
                fcntl(cfd, F_SETFL, flag);
                // 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了
                // 通信的文件描述符检测读缓冲区数据的时候设置为边沿模式
                ev.events = EPOLLIN | EPOLLET;    // 读缓冲区是否有数据
                ev.data.fd = cfd;
                ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
                if(ret == -1)
                {
                    perror("epoll_ctl-accept");
                    exit(0);
                }
            }
            else
            {
                // 处理通信的文件描述符
                // 接收数据
                char buf[5];
                memset(buf, 0, sizeof(buf));
                // 循环读数据
                while(1)
                {
                    int len = recv(curfd, buf, sizeof(buf), 0);
                    if(len == 0)
                    {
                        // 非阻塞模式下和阻塞模式是一样的 => 判断对方是否断开连接
                        printf("客户端断开了连接...\n");
                        // 将这个文件描述符从epoll模型中删除
                        epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
                        close(curfd);
                        break;
                    }
                    else if(len > 0)
                    {
                        // 通信
                        // 接收的数据打印到终端
                        write(STDOUT_FILENO, buf, len);
                        // 发送数据
                        send(curfd, buf, len, 0);
                    }
                    else
                    {
                        // len == -1
                        if(errno == EAGAIN)
                        {
                            printf("数据读完了...\n");
                            break;
                        }
                        else
                        {
                            perror("recv");
                            exit(0);
                        }
                    }
                }
            }
        }
    }

    return 0;
}

  • 22
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值