【Linux网络编程】第二十二弹---深入理解 I/O 多路转接之 epoll:系统调用、工作原理、代码演示及应用场景

✨个人主页:熬夜学编程的小林

💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】【Linux网络编程】

目录

[1、I/O 多路转接之 epoll](#1、I/O 多路转接之 epoll)

[1.1、epoll 初识](#1.1、epoll 初识)

[1.2、epoll 的相关系统调用](#1.2、epoll 的相关系统调用)

1.2.1、epoll_create

1.2.2、epoll_ctl

1.2.3、epoll_wait

[1.3、epoll 工作原理](#1.3、epoll 工作原理)

1.3.1、理解数据到达主机

1.3.2、epoll原理

1.4、代码演示一(框架实现)

1.4.1、主函数

1.4.2、EpollServer类

1.4、代码演示二(通信实现)

1.4.1、Loop()

1.4.2、HandlerEvent()

[1.5、epoll 的优点](#1.5、epoll 的优点)

[1.6、epoll 工作方式](#1.6、epoll 工作方式)

[1.7、对比 LT 和 ET](#1.7、对比 LT 和 ET)

[1.8、理解 ET 模式和非阻塞文件描述符](#1.8、理解 ET 模式和非阻塞文件描述符)

[1.9、epoll 的使用场景](#1.9、epoll 的使用场景)


1、I/O 多路转接之 epoll

1.1、epoll 初识

  • 按照 man 手册的说法: 是为处理大批量句柄而作了改进的 poll.
  • 它是在 2.5.44 内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)
  • 它几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法.

作用:为了等待多个fd,等待fd上面的新事件就绪,通知程序员,事件已经就绪,可以进行IO拷贝了!

定位:只负责进行等,等就绪事件派发!

1.2、epoll 的相关系统调用

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

1.2.1、epoll_create

epoll_create()

复制代码
epoll_create - 创建一个 epoll 的句柄.

#include <sys/epoll.h>

int epoll_create(int size);

参数:

  • size在早期版本的 Linux 中指定了监听的文件描述符数量上限 ,自从 linux2.6.8 之后,这个参数被忽略,因为 epoll 自动调整以处理最大数量的文件描述符。因此,传递任何大于 0 的值都是可以的,通常使用 1 作为默认值。

返回值:

  • 成功时,epoll_create() 返回一个非负的文件描述符 ,该描述符用于后续的 epoll 操作,如 epoll_ctl()epoll_wait()
  • 失败时,返回 -1 并设置 errno 以指示错误类型。

注意:用完之后, 必须调用 close()关闭.

1.2.2、epoll_ctl

epoll_ctl()

复制代码
epoll_ctl - 允许你向一个 epoll 实例中添加、删除或修改监听的文件描述符及其相关的事件。

#include <sys/epoll.h>

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数:

  • **epfd:**epoll_create()epoll_create1() 返回的 epoll 文件描述符
  • **op:**要执行的操作,可以是以下三个值之一:
    • EPOLL_CTL_ADD:向 epoll 实例中添加一个新的文件描述符
    • EPOLL_CTL_DEL:从 epoll 实例中删除一个文件描述符
    • EPOLL_CTL_MOD修改一个已经存在于 epoll 实例中的文件描述符的监听事件
  • fd要添加、删除或修改的文件描述符
  • event:**指向一个 epoll_event 结构体的指针 ,该结构体指定了要监听的事件类型和数据** 。对于 EPOLL_CTL_DEL 操作,这个参数可以是 nullptr,因为删除操作不需要知道事件类型。

返回值:

  • 成功时,epoll_ctl() 返回 0
  • 失败时,返回 -1 并设置 errno 以指示错误类型。

struct epoll_event 结构如下:

复制代码
typedef union epoll_data 
{
    void    *ptr;
    int      fd;
    __uint32_t u32;
    __uint64_t u64;
    struct sockaddr sockaddr;    // 仅在特定情况下使用
} epoll_data_t;

struct epoll_event 
{
    __uint32_t events;      // 事件类型,可以是多个事件的按位或组合
    epoll_data_t data;      // 与事件相关的用户数据,可以是文件描述符、指针或 sockaddr 结构
};

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

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

1.2.3、epoll_wait

epoll_wait()

复制代码
epoll_wait - 阻塞调用线程,直到有至少一个文件描述符上的事件变得就绪,或者超时发生。

#include <sys/epoll.h>

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数:

  • **epfd:**epoll_create()epoll_create1() 返回的 epoll 文件描述符
  • **events:**指向一个 epoll_event 结构体数组的指针该数组用于存储返回的事件信息
  • maxevents:****events 数组的大小 ,即最多可以返回的事件数量
  • timeout:****等待事件的超时时间(毫秒)
    • 如果为**-1,则 epoll_wait() 将无限期地阻塞**,直到有事件发生。
    • 如果为 0,则 epoll_wait() 将立即返回,即使没有任何事件发生(这可以用于非阻塞模式)。

返回值:

  • 成功时,epoll_wait() 返回就绪事件的数量 ,这些事件被存储在 events 数组中。
  • 失败时,返回 -1 并设置 errno 以指示错误类型。

1.3、epoll 工作原理

1.3.1、理解数据到达主机

数据到达主机的原理涉及多个层级和协议的协同工作。通过逐层封装、转发和接收处理,数据能够准确地从源主机传输到目的主机。
硬件中断是由硬件设备发出的信号,用于通知计算机系统发生了某个事件,需要系统进行处理。这些硬件设备可以是磁盘、网卡、键盘、时钟等。

1.3.2、epoll原理

1.4、代码演示一(框架实现)

1.4.1、主函数

老规矩,根据主函数反向实现类和成员函数!

cpp 复制代码
// ./epoll_server 8888
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " locak-port" << std::endl;
        exit(0);
    }
    uint16_t port = std::stoi(argv[1]);
    EnableScreen(); // 开启日志
    std::unique_ptr<EpollServer> svr = std::make_unique<EpollServer>(port);
    svr->InitServer();
    svr->Loop();

    return 0;
}

1.4.2、EpollServer类

EpollServer类的成员变量 包括**端口号,listen套接字,epfd(epoll_create()函数的返回值),接收事件的数组,**成员函数与PollServer类基本一致

基本结构

cpp 复制代码
class EpollServer
{
    const static int size = 128;
    const static int num = 128;

public:
    EpollServer(uint16_t port);
    void InitServer();
    void Loop();
    ~EpollServer();

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;
    int _epfd;
    struct epoll_event revs[num];
};

构造析构函数

构造函数****初始化端口号,根据端口号创建监听套接字对象以及创建epoll句柄, 析构函数****关闭epfd(合法的前提下)和listenfd!

cpp 复制代码
EpollServer(uint16_t port) : _port(port), _listensock(std::make_unique<TcpSocket>())
{
    _listensock->BuildListenSocket(port);
    _epfd = ::epoll_create(size);
    if (_epfd < 0)
    {
        LOG(FATAL, "epoll_create error\n");
        exit(1);
    }
    LOG(INFO, "epoll create success,epfd: %d\n", _epfd); // 4
}

~EpollServer()
{
    if (_epfd >= 0)
        ::close(_epfd);
    _listensock->Close();
}

InitServer()

InitServer()函数使用系统调用(epoll_ctl)listensock添加到epoll!

cpp 复制代码
void InitServer()
{
    // 新链接到来,我们认为是读事件就绪
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = _listensock->Sockfd(); //???
    // 必须先把listensock添加到epoll
    int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Sockfd(), &ev);
    if (n < 0)
    {
        LOG(FATAL, "epoll_ctl error\n");
        exit(2);
    }
    LOG(INFO, "epoll_ctl success,add new sockfd: %d\n", _listensock->Sockfd());
}

Loop()

Loop()函数调用epoll_wait系统调用进行等待,根据返回值执行对应的操作:

1、返回值为0 :打印超时日志,并退出循环

2、返回值为-1 :打印出错日志,并退出循环

3、返回值大于0 :打印事件发生日志,暂时先退出循环

cpp 复制代码
void Loop()
{
    int timeout = 1000;
    while (true)
    {
        // _listensock->Accepter();
        int n = ::epoll_wait(_epfd, revs, num, timeout);
        switch (n)
        {
        case 0:
            LOG(INFO, "epoll time out...\n");
            break;
        case -1:
            LOG(ERROR, "epoll error\n");
            break;
        default:
            LOG(INFO,"haved event happend!,n : %d\n",n);
            break;
        }
    }
}

运行结果

1.4、代码演示二(通信实现)

代码演示二只需要修改EpollServer类中主函数调用的成员函数即可

1.4.1、Loop()

Loop()函数调用epoll_wait系统调用进行等待,根据返回值执行对应的操作:

1、返回值为0 :打印超时日志,并退出循环

2、返回值为-1 :打印出错日志,并退出循环

3、返回值大于0 :打印事件发生日志,并处理合法事件

cpp 复制代码
void Loop()
{
    int timeout = 1000;
    while (true)
    {
        // 事件通知,事件派发
        // _listensock->Accepter();
        int n = ::epoll_wait(_epfd, revs, num, timeout);
        switch (n)
        {
        case 0:
            LOG(INFO, "epoll time out...\n");
            break;
        case -1:
            LOG(ERROR, "epoll error\n");
            break;
        default:
            LOG(INFO, "haved event happend!,n : %d\n", n);
            HandlerEvent(n);
            break;
        }
    }
}

1.4.2、HandlerEvent()

HandlerEvent()函数处理就绪事件主要分为以下两步:

  • 1、从事件数组中读取合法fd和events
  • 2、判断读事件是否就绪
    • 2.1、listensock就绪
    • 2.2、normal sockfd就绪
cpp 复制代码
void HandlerEvent(int n)
{
    for (int i = 0; i < n; i++)
    {
        // 1.从事件数组中读取合法fd和events
        int fd = revs[i].data.fd;
        uint32_t revents = revs[i].events;

        LOG(INFO, "%d 上面有事件就绪了,具体事件是: %s\n", fd, EventsToString(revents).c_str());
        // 2.判断读事件是否就绪
        if (revents & EPOLLIN)
        {
            // listensock 读事件就绪,新链接到来了
            if (fd == _listensock->Sockfd())
                Accepter();
            else
                HandlerIO(fd);
        }
    }
}

Accepter()

Accepter()函数处理新链接主要分为以下两步:

  • 1、获取链接
  • 2、获取链接成功将新的 fd 和 读事件添加到epoll中(使用epoll_ctl系统调用)
cpp 复制代码
void Accepter()
{
    InetAddr addr;
    int sockfd = _listensock->Accepter(&addr); // 肯定不会出错
    if (sockfd < 0)
    {
        LOG(ERROR, "获取链接失败\n");
        return;
    }
    LOG(INFO, "得到一个新的链接: %d, 客户端信息: %s:%d\n", sockfd, addr.Ip().c_str(), addr.Port());
    // 得到了一个新的sockfd,我们能不呢个进行read,recv? 不能,不清楚有没有数据,没数据会阻塞
    // 等底层有数据(读事件就绪),read/recv才不会被阻塞
    // 底层有数据,谁最清楚?epoll
    // 将新的sockfd添加到epoll中!怎么做呢?
    struct epoll_event ev;
    ev.data.fd = sockfd;
    ev.events = EPOLLIN;
    ::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
    LOG(INFO, "epoll_ctl success,add new sockfd: %d\n", sockfd);
}

HandlerIO()

HandlerIO()函数处理普通fd情况直接读取文件描述符中的数据根据recv()函数的返回值做出不一样的决策,主要分为以下三种情况:

1、返回值大于0,读取文件描述符中的数据,并使用send()函数做出回应!

2、返回值等于0,读到文件结尾,打印客户端退出的日志,将epfd从epoll中移除并关闭fd!

3、返回值小于0,读取文件错误,打印接受失败的日志,然后同上!

cpp 复制代码
void HandlerIO(int fd)
{
    char buffer[4096];
    int n = ::recv(fd, buffer, sizeof(buffer) - 1, 0); // 会阻塞?不会
    if (n > 0)
    {
        buffer[n] = 0;
        std::cout << buffer;
        std::string response = "HTTP/1.0 200 OK\r\n";
        std::string content = "<html><body><h1>hello linux,hello world</h1></body></html>";
        response += "Content-Type: text/html\r\n";
        response += "Content-Length: " + std::to_string(content.size()) + "\r\n";
        response += "\r\n";
        response += content;

        ::send(fd, response.c_str(), response.size(), 0);
    }
    else if (n == 0)
    {
        LOG(INFO, "client quit,close fd: %d\n", fd);
        // 1.从epoll中移除,从epoll中移除fd,这个必须是健康&&合法的fd,否则会移除出错
        ::epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr); // 进一步验证红黑树以fd作为键值
        // 2.关闭fd
        ::close(fd);
    }
    else
    {
        LOG(ERROR, "recv error,close fd: %d\n", fd);
        // 1.从epoll中移除,从epoll中移除fd,这个必须是健康&&合法的fd,否则会移除出错
        ::epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
        // 2.关闭fd
        ::close(fd);
    }
}

运行结果

1.5、epoll 的优点

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

注意

网上有些博客说, epoll 中使用了内存映射机制

  • 内存映射机制 : 内核直接将就绪队列通过 mmap 的方式映射到用户态. 避免了拷贝内存这样的额外性能开销.

这种说法是不准确的 . 我们定义的 struct epoll_event 是我们在用户空间中分配好的内存 . 势必还是需要将内核的数据拷贝到这个用户空间的内存中的.

请uu们对比总结 select, poll, epoll 之间的优点和缺点(重要, 面试中常见).

1.6、epoll 工作方式

你妈喊你吃饭的例子

你正在吃鸡, 眼看进入了决赛圈, 你妈饭做好了, 喊你吃饭的时候有两种方式:

  1. 如果你妈喊你一次, 你没动, 那么你妈会继续喊你第二次, 第三次...(亲妈,水平触发)

  2. 如果你妈喊你一次, 你没动, 你妈就不管你了(后妈, 边缘触发)

epoll 有 2 种工作方式 -水平触发(LT)边缘触发(ET)

假如有这样一个例子:

  • 我们已经把一个 tcp socket 添加到 epoll 描述符
  • 这个时候 socket 的另一端被写入了 2KB 的数据
  • 调用 epoll_wait,并且它会返回. 说明它已经准备好读取操作
  • 然后调用 read, 只读取了 1KB 的数据
  • 继续调用 epoll_wait......

水平触发 Level Triggered 工作模式
epoll 默认状态下就是 LT 工作模式.

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

边缘触发 Edge Triggered 工作模式

如果我们在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志, epoll 进入 ET 工作模式.

  • 当 epoll 检测到 socket 上事件就绪时 , 必须立刻处理.
  • 如上面的例子, 虽然只读了 1K 的数据, 缓冲区还剩 1K 的数据, 在第二次调用epoll_wait 的时候, epoll_wait 不会再返回了.
  • 也就是说, ET 模式下, 文件描述符上的事件就绪后 , 只有一次处理机会.
  • ET 的性能比 LT 性能更高( epoll_wait 返回的次数少了很多). Nginx 默认采用ET 模式使用 epoll.
  • 只支持非阻塞的读写

select 和 poll 其实也是工作在 LT 模式下. epoll 既可以支持 LT, 也可以支持 ET.

1.7、对比 LT 和 ET

  • 1**、LT 是 epoll 的默认行为.**
  • 2、使用 ET 能够减少 epoll 触发的次数 . 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.
    • 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.
  • 3、另一方面, ET 的代码复杂程度更高了.
  • 4、ET的通知效率更高
  • 5、ET可能给对方一个更大的接受窗口,增加IO效率 -- 即ET的IO效率更高

1.8、理解 ET 模式和非阻塞文件描述符

使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工程实践" 上的要求,逻辑如下:

ET模式下,只通知一次,本轮数据没读完,epoll不在通知 -> ET模式一旦就绪,就必须把数据全部读完 -> 你怎么知道你把数据全部读完了? -> 循环读取,直到读取不到? -> 循环读取是不是可能出现阻塞问题? -> 因此需将fd设置为非阻塞!

有一个问题:LT也可以设置非阻塞,LT我也可以循环读取完毕啊,为什么要有ET呢?

最简单的理解,ET是被强制要求非阻塞的,但是LT可以是阻塞也可以是非阻塞!

假设这样的场景: 服务器接收到一个 10k 的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第二个 10k 请求.

如果服务端写的代码是阻塞式的 read, 并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来, 参考 man 手册的说明, 可能被信号打断), 剩下的 9k 数据就会待在缓冲区中.

此时由于 epoll 是 ET 模式, 并不会认为文件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中. 直到下一次客户端再给服务器写数据.epoll_wait 才能返回

但是问题来了.

  • 服务器只读到 1k 个数据, 要 10k 读完才会给客户端返回响应数据.
  • 客户端要读到服务器的响应, 才会发送下一个请求
  • 客户端发送了下一个请求, epoll_wait 才会返回, 才能去读缓冲区中剩余的数据.

所以, 为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区, 保证一定能把完整的请求都读出来.

如果是 LT 没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪.

1.9、epoll 的使用场景

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

  • 对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用 epoll.

例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll.

如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll 就并不合适. 具体要根据需求和场景特点来决定使用哪种 IO 模型.

相关推荐
雾岛听蓝25 分钟前
Qt开发核心笔记:从HelloWorld到对象树内存管理与坐标体系详解
开发语言·经验分享·笔记·qt
無限進步D4 小时前
Java 运行原理
java·开发语言·入门
是苏浙4 小时前
JDK17新增特性
java·开发语言
SPC的存折6 小时前
1、Redis数据库基础
linux·运维·服务器·数据库·redis·缓存
爱学习的小囧7 小时前
VMware ESXi 6.7U3v 新版特性、驱动集成教程和资源包、部署教程及高频问答详情
运维·服务器·虚拟化·esxi6.7·esxi蟹卡驱动
小疙瘩7 小时前
只是记录自己发布若依分离系统到linux过程中遇到的问题
linux·运维·服务器
dldw7778 小时前
IE无法正常登录windows2000server的FTP服务器
运维·服务器·网络
阿里加多8 小时前
第 4 章:Go 线程模型——GMP 深度解析
java·开发语言·后端·golang
likerhood8 小时前
java中`==`和`.equals()`区别
java·开发语言·python
运维有小邓@8 小时前
什么是重放攻击?如何避免成为受害者?
运维·网络·安全