多路转接epoll

I/O多路转接之poll

函数原型

c 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数说明

  • fds是⼀个poll函数监听的结构列表.每⼀个元素中,包含了三部分内容:⽂件描述符,监听的事件集合,返回的事件集合.
  • nfds表⽰fds数组的⻓度.
  • timeout表⽰poll函数的超时时间,单位是毫秒(ms).
    -1:永久阻塞
    0:立即返回(非阻塞)
    大于0:等待指定毫秒数

返回值:

  • 大于0:就绪的文件描述符数量(revents 非零的元素个数)

  • 0:超时

  • -1:出错(设置 errno)

pollfd 结构体

c 复制代码
struct pollfd {
    int fd;          // 要监控的文件描述符
    short events;    // 感兴趣的事件(位掩码)
    short revents;   // 实际发生的事件(由内核填充)
};

events和revents的取值:

socket就绪条件

同select

select 正是基于核条件来判断哪些 fd 就绪。例如:

  • 监听套接字:当三次握手完成、队列不为空 → 可读。

  • 普通已连接套接字:收到数据 → 可读;发送缓冲区有空 → 可写。

这些条件在 poll(POLLIN/POLLOUT)和 epoll(EPOLLIN/EPOLLOUT)中完全一致。

poll的优点

  1. 使用 pollfd 数组,而不是三个位图
  • select 用 fd_set 分别表示读、写、异常,每次调用需要重新填充;poll 用一个结构体数组,每个元素包含 fd、events(关心的事件)、revents(返回的事件)。

  • 事件分离:输入和输出分开,不需要像 select 那样每次调用前重新复制整个集合。

  1. 没有最大文件描述符数量的硬限制
  • select 受 FD_SETSIZE(通常 1024)限制,需要重新编译内核才能改变。

  • poll 的最大数量仅受系统资源(内存)限制,理论上可以支持更多连接。

poll的缺点

poll中监听的⽂件描述符数⽬增多时

  • 和select函数⼀样,poll返回后,需要轮询pollfd来获取就绪的描述符.
  • 每次调⽤poll都需要把⼤量的pollfd结构从⽤⼾态拷⻉到内核中.
  • 同时连接的⼤量客⼾端在⼀时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增⻓,其效率也会线性下降.

poll⽰例:使⽤poll监控标准输⼊

c 复制代码
#include <unistd.h>
#include <stdio.h>

int main()
{
    struct pollfd poll_fd;
    poll_fd.fd = 0;//监控标准输入
    poll_fd.events = POLLIN;//关心可读事件

    for(;;)
    {
        int ret = poll(&poll_fd, 1, 1000);
        if(ret < 0)
        {
            perror("poll");
            continue;
        }
        if(ret == 0)
        {
            printf("poll timeout\n");
            continue;
        }
        if(poll_fd.revents == POLLIN)
        {
            char buffer[1024] = 0;
            read(0, buffer, sizeof(buffer)-1);
            printf("stdin:%s", buffer);
        }
    }
    return 0;

}

I/O多路转接之epoll

epoll 就是为了处理大批量句柄而作的改进版 poll。

同 I/O 多路复用和信号驱动 I/O 一样,Linux 的 epoll(event poll)API 可以检查多个文件描述符上的 I/O 就绪状态。

epoll 是 Linux 下多路复用IO接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率

它解决了 poll 在高并发下的两个核心瓶颈:

  • 每次调用都全量拷贝 pollfd 数组(包括所有被监视的 fd)到内核。

  • 返回后需要线性扫描整个数组 才能找出哪些 fd 就绪。

epoll 通过三个关键设计实现改进:

  • 分离注册与等待:使用 epoll_ctl 一次注册,内核用红黑树持久化存储 fd 及事件,避免了每轮全量拷贝。

  • 就绪列表直接返回:epoll_wait 只拷贝已经就绪的事件到用户空间,数量通常远小于总 fd 数。

  • 边缘触发(ET):进一步减少事件通知次数,提升大并发下效率。

epoll的相关系统调⽤

epoll 有3个相关的系统调⽤.

epoll_create

c 复制代码
 int epoll_create(int size); 
  • 早期 Linux 内核:size 用来告诉内核"要监控的文件描述符数量",内核据此预分配内存。
  • 从 Linux 2.6.8 开始:内核动态管理内存,size 参数被忽略,但必须传入一个 >0 的值(通常写 1 或 1024)以兼容旧接口。

⽤完之后,必须调⽤close()关闭.

内核创建 eventpoll 对象

这是最核心的一步。eventpoll 是一个结构体,可以把它想象成一个 epoll 实例的总控制块

  • eventpoll 对象是 epoll 高效的关键。其中,rbr (红黑树) 负责 epoll_ctl 操作的快速增删改查,将时间复杂度稳定在 O(logN)-1;而 rdllist (就绪链表) 则负责 epoll_wait 的快速就绪事件获取,直接返回就绪连接,无需遍历整个树。

  • 内核创建匿名文件并关联

    epoll 机制在 Linux 内核中实现了一个名为 eventpollfs 的虚拟文件系统,专门用来管理 epoll 对象。

具体理解下面会说

epoll_ctl

c 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll的事件注册函数.

  • 它不同于select()是在监听事件时告诉内核要监听什么类型的事件,⽽是在这⾥先注册要监听的事件类型.
  • 第⼀个参数是epoll_create()的返回值(epoll的句柄).
  • 第⼆个参数表⽰动作,⽤三个宏来表⽰.
  • 第三个参数是需要监听的fd.
  • 第四个参数是告诉内核需要监听什么事.

第⼆个参数的取值:

  • EPOLL_CTL_ADD :注册新的fd到epfd中;
  • EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
  • EPOLL_CTL_DEL :从epfd中删除⼀个fd;

struct epoll_event结构如下:

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

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

epoll_wait

c 复制代码
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int 
timeout);

收集在epoll监控的事件中已经发送的事件.

  • 参数events是分配好的epoll_event结构体数组.
  • epoll将会把发⽣的事件赋值到events数组中(events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在⽤⼾态中分配内存).
  • maxevents告之内核这个events有多⼤,这个maxevents的值不能⼤于创建epoll_create()时的size.
  • 参数timeout是超时时间(毫秒,0会⽴即返回,-1是永久阻塞).
  • 如果函数调⽤成功,返回对应I/O上已准备好的⽂件描述符数⽬,如返回0表⽰已超时,返回⼩于0表⽰函数失败.

加入等待队列 → 检查信号 → 调度睡眠 → 唤醒后检查就绪链表 → 拷贝事件给用户。这正是 epoll 高效且省 CPU 的原因:没有事件时进程休眠,有事件时才被唤醒并直接处理就绪列表。

epoll原理

当某⼀进程调⽤epoll_create⽅法时,Linux内核会创建⼀个eventpoll结构体,这个结构体中有两个成员与epoll的使⽤⽅式密切相关.

c 复制代码
struct eventpoll{ 
 .... 
 /*红⿊树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/  
 struct rb_root rbr; 
 /*双链表中则存放着将要通过epoll_wait返回给⽤⼾的满⾜条件的事件*/  
 struct list_head rdlist; 
 .... 
}; 
  • 在epoll中,对于每⼀个事件,都会建⽴⼀个epitem结构体.
c 复制代码
struct epitem{ 
 struct rb_node rbn;//红⿊树节点  
 struct list_head rdllink;//双向链表节点  
 struct epoll_filefd ffd; //事件句柄信息  
 struct eventpoll *ep; //指向其所属的eventpoll对象  
 struct epoll_event event; //期待发⽣的事件类型  
 }

`

  • 每⼀个epoll对象都有⼀个独⽴的eventpoll结构体,⽤于存放通过epoll_ctl⽅法向epoll对象中添加进来的事件.

  • 这些事件都会挂载在红⿊树中,如此,重复添加的事件就可以通过红⿊树⽽⾼效的识别出来(红⿊树的插⼊时间效率是lgn,其中n为树的⾼度).

  • ⽽所有添加到epoll中的事件都会与设备(⽹卡)驱动程序建⽴回调关系,也就是说,当响应的事件发⽣时会调⽤这个回调⽅法.

  • 这个回调⽅法在内核中叫ep_poll_callback,它会将发⽣的事件添加到rdlist双链表中.

  • 当调⽤epoll_wait检查是否有事件发⽣时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可.

  • 如果rdlist不为空,则把发⽣的事件复制到⽤⼾态,同时将事件数量返回给⽤⼾.这个操作的时间复杂度是O(1).

总结⼀下,epoll的使⽤过程就是三部曲:

  • 调⽤epoll_create创建⼀个epoll句柄;
  • 调⽤epoll_ctl,将要监控的⽂件描述符进⾏注册;
  • 调⽤epoll_wait,等待⽂件描述符就绪;

epoll 工作模型总结

  1. epoll_create
  • 内核创建 eventpoll 对象(内含红黑树、就绪链表、等待队列等)。
  1. epoll_ctl (ADD/MOD)
  • 为每个被监控的文件描述符创建 epitem 结构体,挂载到红黑树上。

  • 同时为该 fd 注册回调函数(ep_poll_callback)。

3.事件发生

  • 内核协议栈触发回调,回调将对应的 epitem 通过 rdllink 加入就绪链表,并唤醒等待队列上的进程。
  1. epoll_wait
  • 检查就绪链表是否为空:

  • 非空 → 将就绪事件拷贝到用户空间,返回事件个数。

  • 为空 → 进程加入等待队列,睡眠;被回调唤醒后重新检查。

核心优势:红黑树管理所有监控 fd(快速增删改查),就绪链表只存放已就绪的 fd(epoll_wait 直接消费),避免了全量扫描。

epoll的优点(和 select 的缺点对应)

  • 接⼝使⽤⽅便:虽然拆分成了三个函数,但是反⽽使⽤起来更⽅便⾼效.不需要每次循环都设置关注的⽂件描述符,也做到了输⼊输出参数分离开
  • 数据拷⻉轻量:只在合适的时候调⽤EPOLL_CTL_ADD 将⽂件描述符结构拷⻉到内核中,这个操作并不频繁(⽽select/poll都是每次循环都要进⾏拷⻉)
  • 事件回调机制:避免使⽤遍历,⽽是使⽤回调函数的⽅式,将就绪的⽂件描述符结构加⼊到就绪队列中,epoll_wait返回直接访问就绪队列就知道哪些⽂件描述符就绪.这个操作时间复杂度O(1).即使⽂件描述符数⽬很多,效率也不会受到影响.
  • 没有数量限制:⽂件描述符数⽬⽆上限.

epoll⼯作⽅式

水平触发LT

epoll默认情况下就是LT

  • 当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分
  • 假设只读了1K数据, 缓冲区中还剩1K数据, 在第二次调epoll_wait 时, epoll_wait仍然会立刻返回并通知socket读事件就绪. 直到缓冲区上所有的数据都被处理完, epoll_wait 不会立刻返回
  • 支持阻塞读写和非阻塞读写

边缘触发ET

将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式

  • 当epoll检测到socket上事件就绪时, 必须立刻处理.如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候,epoll_wait 不会再返回了
  • ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会
  • 只支持非阻塞的读写

对⽐LT和ET

LT是epoll的默认⾏为.

使⽤ET能够减少epoll触发的次数.但是代价就是强逼着程序猿⼀次响应就绪过程中就把所有的数据都处理完.相当于⼀个⽂件描述符就绪之后,不会反复被提⽰就绪,看起来就⽐LT更⾼效⼀些.但是在LT情况下如果也能做到每次就绪的⽂件描述符都⽴刻处理,不让这个就绪被重复提⽰的话,其实性能也是⼀样的.

另⼀⽅⾯,ET的代码复杂程度更⾼了.

理解ET模式和⾮阻塞⽂件描述符

对于 ET 模式,正确的做法是:

epoll_wait 返回该 socket 可读。

循环调用 read 直到返回 EAGAIN(在非阻塞 fd 下)。

因为 ET 只通知一次,如果用户不读完数据,下次 epoll_wait 不会再通知,会导致数据滞留在缓冲区,永远无法被读取。

所以,代码通常写成:

c 复制代码
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK)
            break;   // 数据已读完
        else
            // 处理其他错误
            break;
    } else if (n == 0) {
        // 对端关闭
        break;
    } else {
        // 处理接收到的 n 字节数据
    }
}

循环结束的条件是 read 返回 -1 且 errno 为 EAGAIN,这表示接收缓冲区暂时没有数据了,可以安全退出,等待下次事件。

如果 fd 是阻塞模式,会发生什么?

假设 socket 是阻塞的(默认行为)。

第一次 read:数据充足,读取成功,返回正数(比如读取了 500 字节,但缓冲区还有 500 字节未读)。

第二次 read:继续读取,再次成功,又读了 500 字节,此时缓冲区数据已为空。

第三次 read:由于缓冲区已空,且 fd 是阻塞的,read 系统调用会阻塞(挂起),等待新数据到来。

此时问题出现:

线程/进程永远卡在第三次 read 调用上,不会返回到 epoll_wait。

epoll的使⽤场景

epoll的⾼性能,是有⼀定的特定场景的.如果场景选择的不适宜,epoll的性能可能适得其反.

  • 对于多连接,且多连接中只有⼀部分连接⽐较活跃时,⽐较适合使⽤epoll.
    例如,典型的⼀个需要处理上万个客⼾端的服务器,例如各种互联⽹APP的⼊⼝服务器,这样的服务器就很适合epoll.
  • 如果只是系统内部,服务器和服务器之间进⾏通信,只有少数的⼏个连接,这种情况下⽤epoll就并不合适.具体要根据需求和场景特点来决定使⽤哪种IO模型.

简单的演示 epoll 如何使用的例子

更可靠的版本,需要加入非阻塞、循环处理、异步发送等机制,下一篇中会体现

c 复制代码
#pragma once

#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "InetAddr.hpp"
#include "Socket.hpp"
#include "Logger.hpp"

const static int gsize = 64;

class EpollServer
{
public:
    EpollServer(uint16_t port)
        : _listensock(std::make_unique<TcpSocket>()), _epfd(-1)
    {
        _listensock->BuildListenSocketMethod(port);
        _epfd = epoll_create(128);
        if (_epfd < 0)
        {
            LOG(LogLevel::FATAL) << "epoll_create failed";
            return;
        }
        LOG(LogLevel::INFO) << "Listen sockfd is: " << _listensock->SockFd()
                            << " epoll fd is : " << _epfd;
        // 首先要做的是把唯一的一个listensock添加到epoll模型中
        // 用户告诉内核,你要帮我关心哪一个fd上面的哪些事件
        struct epoll_event ev;
        ev.events = EPOLLIN | EPOLLET; // EPOLLLT:没有该标志位,epoll默认是LT模式的!
        ev.data.fd = _listensock->SockFd(); // 方便后续处理!
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->SockFd(), &ev);
        (void)n;
    }
    void Recver(int sockfd)
    {
        char buffer[1024]; // bug!
        ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "Client Say@ " << buffer;
            std::string echo_string = "echo server# ";
            echo_string += buffer;

         
            send(sockfd, echo_string.c_str(), echo_string.size(), 0);
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit, me too, fd is : " << sockfd;
            // 坑: epoll_ctl EPOLL_CTL_DEL : 不能对非法fd进行操作!
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            (void)n;
            close(sockfd);
        }
        else
        {
            LOG(LogLevel::INFO) << "recv error, fd is : " << sockfd;
            // 坑: epoll_ctl EPOLL_CTL_DEL : 不能对非法fd进行操作!
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            (void)n;
            close(sockfd);
        }
    }
    void Accepter()
    {
        InetAddr clientaddr;
        int sockfd = _listensock->Accept(&clientaddr);
        if (sockfd > 0)
        {
            LOG(LogLevel::INFO) << "获取一个新连接, fd : " << sockfd
                                << " 客户端地址是: " << clientaddr.ToString();
            // sockfd, 可以对这个新的连接进行读取吗?不能!
            // 应该做什么?新的sockfd添加到epoll模型中,红黑树中
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = sockfd;
            int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
            (void)n;
            LOG(LogLevel::INFO) << "添加新连接到epoll中: " << sockfd;
        }
    }
    void Dispatcher(int num)
    {
        LOG(LogLevel::INFO) << "有事件就绪了...";
        for (int i = 0; i < num; i++)
        {
            int fd = _revs[i].data.fd;
            uint32_t events = _revs[i].events;

            if (events & EPOLLIN)
            {
                // 1. listensockfd
                if (fd == _listensock->SockFd())
                {
                    Accepter();
                }
                else
                {
                    // 2. normal
                    Recver(fd);
                }
            }
            if (events & EPOLLOUT)
            {
                // TODO
            }
        }
    }
    void Start()
    {
        int timeout = -1; // 同poll
        while (true)
        {
            // 1. 可以直接accept吗?不可以!应该做什么呢?
            // 内核告诉用户,哪些fd上面的 哪些事件就绪了
            // 2. epoll_wait就绪的fd,就绪的事件可是多种多样的!
            int n = epoll_wait(_epfd, _revs, gsize, timeout);
            switch (n)
            {
            case 0:
                LOG(LogLevel::DEBUG) << "time out...";
                break;
            case -1:
                LOG(LogLevel::FATAL) << "epoll error ...";
                break;
            default:
                Dispatcher(n);
                break;
            }
        }
    }
    ~EpollServer() {}

private:
    std::unique_ptr<Socket> _listensock;
    int _epfd;
    struct epoll_event _revs[gsize]; // revs: return events;
};
相关推荐
代码中介商13 小时前
Git 版本控制完全指南:从分支管理到远程协作
linux·git
s_w.h13 小时前
【 linux 】进程的调度算法
linux·运维·服务器
天若有情67313 小时前
Deepseek-V4-Flash-20260423 深度评测与实战指南
java·大数据·网络·ai
r-t-H13 小时前
KVM虚拟化与Docker基础实践-第三章
linux·运维·nginx·docker·容器
嘿嘿嘿x313 小时前
Linux-知识点1-$-POSIX等
linux·ubuntu
艾莉丝努力练剑13 小时前
【Linux网络】Linux 网络编程:传输层UDP
linux·运维·服务器·网络·计算机网络·udp
陈eaten13 小时前
centos 7等保整改学习
linux·运维·服务器·网络安全·centos·等保
牢七13 小时前
契约锁分析
linux·运维·服务器
承渊政道13 小时前
Linux系统学习【进程概念从入门到深入理解】
linux·服务器·笔记·学习·ubuntu·系统架构·bash