【Linux】网络编程 —— 多路转接:epoll

🌈欢迎来到Linux专栏 ~~ 网络编程

网络编程

🌏Epoll相关函数

1️⃣epoll_create

创建⼀个epoll的句柄

cpp 复制代码
int epoll_create(int size);
  • size:在早期是内核要监控的文件描述符数量;自从linux2.6.8之后,size参数是被忽略的,传入一个大于 0 的值即可(早期可能是担心文件描述符数量过多,内存打爆了的问题)
  • 用完之后,必须调用close()关闭

返回的是一个文件描述符fd!

2️⃣ epoll_ctl

epoll的事件注册函数,用户设置到内核中,用户要求内核,你要帮我关心哪些fd、哪些event

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

不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这⾥先注册要监听的事件类型

  • epfd:是epoll_create()的返回值(epoll的句柄)
  • op:表示动作,用三个宏来表示
  • 第三个参数是需要监听的fd
  • event:是告诉内核需要监听什么事

第二个参数op的取值:

  • EPOLL_CTL_ADD :注册新的fd到epfd中
  • EPOLL_CTL_MOD :修改已经注册的fd的监听事件
  • EPOLL_CTL_DEL :从epfd中删除一个fd
cpp 复制代码
struct epoll_event {
    uint32_t      events;  //事件
    epoll_data_t  data;    /
};

union epoll_data {
    void     *ptr;
    int       fd;
    uint32_t  u32;
    uint64_t  u64;
};

epoll_data 这个数据,epoll是不用的;是做epoll和上层代码出现关联的:告诉上层底层什么东西准备就绪了

events可以是以下几个宏的集合:(本质上也只有一个比特位为1)

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

3️⃣epoll_wait

收集在epoll监控的事件中已经发生的事件,把就绪的fd和event告诉用户!

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

epoll_wait只检查事件是否为空,不为空该进程就应该被唤醒,从就绪队列里一个个的捞取任务 ,严格从0开始按顺序存放在events数组,返回x个就绪的,后续遍历工作没有一点浪费!

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

🌏epoll原理

epoll 内核中主要维护两类结构:

  1. 红黑树 (RB Tree):管理所有被监视的文件描述符
  2. 就绪链表 (Ready List):管理发生了就绪事件、等待用户获取的节点 ------ 激活节点

在epoll里要关心什么fd和事件直接设置在内核里 ,不用再传了,可以有效避免从用户到内核的数据频繁拷贝

  • 该颗红黑树的的键值是fd

当我们调用epoll_create时,在内核会创建出一个就绪队列! ------ 该队列永远只包含就绪的fd和event

检测fd上的event是否就绪的时间复杂度是O(1):只要判断该队列是否为空,是检测!! 不是获取 ,获取是O(n)

  • select的检查fd是否就绪是采用双循环!epoll的检测快很多!

一个红黑树节点可以既属于红黑树,也可以属于就绪队列中,后续我要关心in 和 out 两个事件

  • 就将event设置成in,revents设置成out。并不需要形成新节点,只要将红黑树节点用链式方法链入到就绪队列即可,设置revents为对应的就绪事件

收到数据本质上是:将sk_buff (内核描述网络数据包的结构体)拷贝到接收队列recv_queue里,那么就可以给内核去设计回调机制

  • 给传输层的数据结构里设置一个函数指针:默认情况下,函数指针为空,回调机制不起作用

  • 我们epoll就是把激活节点函数设置进此函数指针里,实现自动化的设计

  • 数据到达后,内核会通过 Socket 的 sk_data_ready 回调机制发出数据就绪通知 ------ 底层是把epitem(红黑树节点)添加到就绪队列里;如果我们没有设置sk_data_ready的回调,就只能在上层进行read读取等等

从此往后epoll进行就绪事件通知的时候,再也不轮询了 :从硬件中断到我们上层封装 ------ 回调 ------ 执行激活节点函数(完全自动化的设计):把节点放进就绪队列

epoll = 红黑树 + 就绪队列 + 回调函数机制

💥回调函数机制: 网卡在网络里收到了数据,通过硬件中断告诉OS有数据来了,OS把数据从外设搬到内存,形成sk_buff开始向上交付(交付到传输层),把sk_buff数据节点对象移入recv_queue里,OS会自动判断对应的结构体指针内的回调函数是否为空,不为空就执行sk_data_ready(此回调在epoll_ctl时就进行注册了) ------ 激活节点...

ps:sk_data_ready 通常在 Socket 初始化时设置 ,ep_poll_callback 才是在 epoll_ctl() 添加监听时注册的

是谁去设置回调的呢?

  • epoll_ctl() 会创建一个等待队列节点 ,把 ep_poll_callback 函数地址保存到这个节点中,再将该节点挂到 Socket 的等待队列上

  • 这样,当 Socket 收到数据并唤醒等待队列时,就会自动执行 ep_poll_callback()

用户就可以拿文件描述符fd去访问整个epoll了

因为 Linux 把 epoll 实例封装成了内核文件对象 struct file,并通过 private_data 关联真正的struct eventpoll,所以用户只需要保存一个整数 fd,就可以通过系统调用间接操作整个 epoll 实例

既然epoll 封装成一个内核文件对象,是不是就要将其管理起来?------ eventpoll

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

cpp 复制代码
struct eventpoll{
	....
	/*红⿊树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
	struct rb_root rbr;
	/*双链表中则存放着将要通过epoll_wait返回给⽤⼾的满⾜条件的事件*/
	struct list_head rdlist;
	//等待队列
	wait_queue_head_t wq;
	....
};

如果调用epoll_wait发现没有任何一个事件就绪,就会阻塞住 ,将调用 epoll_wait() 的那个进程 PCB从运行队列里拿下来放进eventpoll的等待队列里

注意:不是把整个 PCB 直接放进等待队列 。

严格来说,是创建一个等待队列项 (wait_queue_entry),它与当前任务的 task_struct 相关联,并把这个等待项挂入 eventpoll 的等待队列

  • 每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
  • 这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树二高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)
  • 而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系 ,也就是说,当响应的事件发生时会调用这个回调方法
  • 这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表(就绪队列)中
  • 在epoll中,对于每⼀个事件,都会建立⼀个epitem结构体(红黑树的节点)
cpp 复制代码
struct epitem
{
	struct rb_node rbn;//红⿊树节点
	struct list_head rdllink;//双向链表节点
	struct epoll_filefd ffd;//事件句柄信息
	struct eventpoll *ep;//指向其所属的eventpoll对象
	struct epoll_event event;//期待发⽣的事件类型
}

当调用epoll_wait检查是否有事件发生时,只需要检查 eventpoll对象中的rdlist双链表(就绪队列)中是否有epitem元素即可

如果 rdlist 不为空,则 epoll_wait() 从就绪链表中处理就绪事件,将最多 maxevents 个就绪事件拷贝到用户态,并返回实际拷贝的事件数量 k。处理并拷贝 k 个就绪事件的时间复杂度通常为 O(k)

🌏epoll实现demo

telnet进行测试的过程:

  • telnet 发起三次握手👋,内核完成建连并把连接放入 accept 队列,告诉监听方,新链接已经建立好了,你可以进行处理了
  • 后续调用Accepter获取到一个新的 fd(客户端的):此处仍不可以直接读数据,必须托管给epoll,因为还是不知道该链接的数据是否就绪了
cpp 复制代码
#pragma once

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

using namespace NS_LOG_MODULE;
using namespace NS_SOCKET;

static const uint16_t default_port = 8080;
static const uint16_t ign_size = 256;
static const uint16_t recsnum = 64;

class EpollServer
{
public:
    EpollServer(uint16_t port = default_port) : _port(port),
                                                _listensock(std::make_unique<TcpSocket>()),
                                                _epfd(-1),
                                                _quit(false)
    {
        // 1.创建套接字
        _listensock->BuildTcpSocketMethod(_port);
        LOG(LogLevel::INFO) << "created socket success, socket fd: " << _listensock->Sockfd(); // 3

        // 2.创建epoll对象
        _epfd = epoll_create(ign_size);
        if (_epfd < 0)
        {
            LOG(LogLevel::FATAL) << "epoll_create error, return : " << _epfd;
            return;
        }
        LOG(LogLevel::INFO) << "epoll_create success, epoll fd: " << _epfd; // 4

        // 3.将新链接_listensock加入到epoll对象中,让os监控帮你关心
        struct epoll_event ev;
        ev.events = EPOLLIN; // 关心读事件
        ev.data.fd = _listensock->Sockfd();
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Sockfd(), &ev);
        if (n == 0)
        {
            LOG(LogLevel::INFO) << "epoll_ctl add listen socket success, epoll fd: " << _epfd; // 5
        }
    }

    void Listener()
    {
        InetAddr clientaddr;
        int newsockfd = _listensock->Accepter(clientaddr);
        if (newsockfd < 0)
            return;
        LOG(LogLevel::INFO) << "accept new socket success, new socket fd: " << newsockfd;

        // 此处仍不可以直接读数据,必须托管给epoll,因为还是不知道该链接的数据是否就绪了
        struct epoll_event ev;
        ev.events = EPOLLIN; // 关心读事件
        ev.data.fd = newsockfd;
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, newsockfd, &ev);
        (void)n;
    }

    void IOServer(int sockfd)
    {
        char inbuffer[1024];
        ssize_t n = recv(sockfd, inbuffer, sizeof(inbuffer) - 1, 0); // 本次读取是不会被阻塞的!
        if (n > 0)
        {
            inbuffer[n] = 0;
            LOG(LogLevel::INFO) << "recv data from socket fd: " << sockfd << ", data: " << inbuffer;
            std::string echo_string = "echo: ";
            echo_string += inbuffer;

            send(sockfd, echo_string.c_str(), echo_string.size(), 0); // 因为是写事件,默认可以直接发
        }
        else if (n == 0)
        {
            // 链接关闭
            LOG(LogLevel::INFO) << "socket fd: " << sockfd << " closed by peer";
            // 1.从epoll中删除该fd
            // epoll_ctl要删除对特定fd事件的关心,前提是fd在系统里是合法的!
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            (void)n;
            // 2.关闭该fd
            close(sockfd);
        }
        else
        {
            // 读取出错
            LOG(LogLevel::ERROR) << "recv error on socket fd: " << sockfd;
            // 1.从epoll中删除该fd, 注意
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            (void)n;
            // 2.关闭该fd
            close(sockfd);
        }
    }

    void HandleEvents(struct epoll_event recv[], int n)
    {
        // 就绪多少个就处理多少个
        for (int i = 0; i < n; ++i)
        {
            uint32_t revents = recv[i].events; // 就绪的事件?
            int sockfd = recv[i].data.fd;      // 怎么知道哪个fd上的事件就绪了呢?
            if (revents & EPOLLIN)
            {
                // 读链接就绪 :是新链接 还是普通IO
                if (sockfd == _listensock->Sockfd())
                {
                    // 有新链接到来 -- 链接管理器
                    Listener();
                }
                else
                {
                    // 普通IO就绪 -- 读数据
                    IOServer(sockfd);
                }
            }
            else if (revents & EPOLLOUT)
            {
            }
            else if (revents & EPOLLERR)
            {
            }
        }
    }

    void run()
    {
        int timeout = 0;
        struct epoll_event recv[recsnum];

        while (!_quit)
        {
            int n = epoll_wait(_epfd, recv, recsnum, timeout);
            if (n > 0)
            {
                // 有事件就绪
                LOG(LogLevel::DEBUG) << "Events ready!";
                // 要处理的事件在recv数组中,n表示有多少个事件就绪
                HandleEvents(recv, n);
            }
            else if (n == 0)
            {
                // 超时
                LOG(LogLevel::DEBUG) << "epoll_wait timeout, no events";
            }
            else
            {
                // 错误
                LOG(LogLevel::ERROR) << "epoll_wait error, return: " << n;
                break;
            }
        }
    }

    ~EpollServer()
    {
        if (_epfd >= 0)
        {
            close(_epfd);
        }
    }

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;

    int _epfd;  // 指向epoll
    bool _quit; // 是否退出
};

🌏epoll工作方式

你妈喊你吃饭的例子

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

  1. 如果你妈喊你一次, 你没动, 那么你妈会继续喊你第二次, 第三次...(亲妈, 水平触发)
  2. 如果你妈喊你一次,你没动, 你妈就不管你了(后妈, 边缘触发)

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

  • 水平触发 (LT):只要接收缓冲区一直有数据,LT模式下,就会一直通知上层 数据就绪了 ------ epoll默认就是处于这个模式下的
  • 边缘触发 (ET):在接收缓冲区中,数据从无到有,从有到多,本质是变化的时候,才会通知用户来读取 ------ 有数据来了 ,我只通知你一次,拿不拿是你的事,我通知完转头就走了

通知和一直通知做好区分:

  • 通知 :把该节点从红黑树上放到就绪队列里的过程 ------ 激活节点
  • 一直通知 :我把就绪队列里的节点取走了,但是没有进行处理(听到老妈喊吃饭,知道要吃饭了,但是还一直在打游戏! ) ,OS会重新执行把节点从红黑树上放到就绪队列里 ------ 直到你把该数据给处理完毕了(一直叫你吃饭,直到你来)

🔥EL vs LT

EL 和 LT的概念实际上从硬件上参考得来的,这两个是硬件级别的概念!

LT是epoll的默认行为

使用ET能够减少epoll触发的次数 。但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完

ET模式效率更高理由如下:

  • 无效通知少,通知效率高!
  • ET模式下,可以在一定概率上提高网络吞吐量!
  • IO = 等待 + 拷贝,无效通知少已经减少了单位等待时间,并且单位时间内对方给我发送的数据还多了 ------ 双重影响下提高了效率

你怎么保证把数据全部读完了? ------ 不知道,只能一直读,读到没有数据为止 ~ 循环读 不就是要设置成非阻塞吗??

会出现缓冲区没有数据了,但是用户还一直在读,此时就会阻塞住了

  • 但是我整个多路转接是单进程的,一旦阻塞立马就挂起了,不就完蛋了吗?所以ET模式下,要将对应的文件描述符fd必须设置为非阻塞!

【面试题】🌈为什么ET模式下要设置成非阻塞?

相当于一个文件描述符就绪之后,不会反复被提示就绪,看起来就比LT更高效一些。但是在LT情况下如果也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的 ------ 也就引出了下面的问题!

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

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

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

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

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

但是问题来了:

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

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

【面试题】🌈ET模式这么好为什么还要有LT模式?

  • 能做到和必须怎么做是两码事,LT是能做到,ET是必须做到
  • LT 也可以设计成轮询 + 非阻塞 的情况,但是如果多数去实现不能够保证每个都是轮询 + 非阻塞;但是ET就能保证所有人都是轮询 + 非阻塞 。所以二者的区别在于:能做到和必须这样做的区别!
  • ET最大的优势在于倒逼程序员一次性地读取数据

📢写在最后

接下来登场的是 Reactor

相关推荐
睡一觉就好了。1 小时前
linux网络编程基础
网络
花间相见1 小时前
【计算基础|网络09】TCP(上):三次握手与四次挥手
网络·网络协议·tcp/ip
宋明河1 小时前
五种 IO 模型与 select、poll、epoll 对比
linux
玄芯散人1 小时前
【金丹·81】性能分析:你的程序慢在哪
linux·性能优化·perf
天意__1 小时前
Linux部署Qwen轻量模型
linux·服务器·qwen
wdfk_prog2 小时前
LWIP教程 08:从 `tcp_write()` 到 ACK 清队列——TCP 数据发送、窗口与回调
网络·网络协议·tcp/ip
木卫二号Coding2 小时前
第八十六篇-Strata 部署指南(Ubuntu 22.04 + Tesla V100【单卡】)
linux·运维·人工智能·ubuntu
11路没有终点2 小时前
sed 命令详解:文本编辑流处理实战
linux