Linux网络之多路转接——实用的epoll

目录

一、高级IO

[1.1 概念](#1.1 概念)

[1.2 五种IO模型](#1.2 五种IO模型)

[1.3 小结](#1.3 小结)

二、多路转接的实用派

[2.1 epoll 接口](#2.1 epoll 接口)

[2.1.1 epoll_create](#2.1.1 epoll_create)

[2.1.2 epoll_ctl](#2.1.2 epoll_ctl)

[2.1.3 epoll_wait](#2.1.3 epoll_wait)

[2.2 epoll 底层原理](#2.2 epoll 底层原理)

[2.2.1 epoll_ctl](#2.2.1 epoll_ctl)

[2.2.2 epoll_wait](#2.2.2 epoll_wait)

[2.2.3 epoll_create](#2.2.3 epoll_create)

[三、 epoll 类的编写](#三、 epoll 类的编写)

[3.1 类的框架](#3.1 类的框架)

[3.1.1 私有成员](#3.1.1 私有成员)

[3.1.2 构造函数](#3.1.2 构造函数)

[3.1.3 析构函数](#3.1.3 析构函数)

[3.2 类的执行 Loop](#3.2 类的执行 Loop)

[3.2.1 Loop 框架](#3.2.1 Loop 框架)

[3.2.2 handlerEvent](#3.2.2 handlerEvent)

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


一、高级IO

1.1 概念

了解了网络通信相关的知识后,我们也许能直到,通信的本质就是IO,通信的核心是在两个或多个设备之间传输数据,这与计算机系统中的输入输出操作类似。

当通信时使用接收端口例如 recv 时,系统等待网络数据包从远程服务器通过网络传输到本地机器,数据包从网卡的硬件缓冲区复制到系统内存中的应用程序缓冲区;当文件读取时,系统等待磁盘将所请求的数据读取到磁盘缓冲区中,数据从磁盘缓冲区复制到系统内存中的用户空间。

所以换种说法,IO = 等待 + 拷贝

那么如何提高IO的效率呢?
当缩小了等待的时间后,IO的效率就会提高。

1.2 五种IO模型

然后,从一个钓鱼的实例引入今天的主题:

将上面的例子抽象成通信IO:

水池:OS内部缓冲区

水桶:用户缓冲区

鱼:数据

鱼竿:文件描述符

上面的五个人物分别对应了五种IO模型:

其中,前四个人都属于同步IO,即只要参与了IO的过程,那就是同步IO。田七将钓鱼的工作交给了另一个人,并没有参与IO,所以是异步IO。

**阻塞 IO:**在内核将数据准备好之前,系统调用会一直等待。所有的套接字,默认都是阻塞方式。阻塞 IO 是最常见的 IO 模型。

**非阻塞 IO:**如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码。非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。

**信号驱动 IO:**内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO 操作。

**IO 多路转接:**虽然从流程图上看起来和阻塞 IO 类似。实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。

**异步 IO:**由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。

1.3 小结

任何 IO 过程中,都包含两个步骤。第一是等待,第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的办法就是让等待的时间尽量少。

二、多路转接的实用派

上面介绍的五人中,只有赵六真正实现了减少等待的时间,所以在IO中可以使用多路转接以达到高校IO,这里我们要介绍的就是多路转接中的实用派 ------ epoll 。它相比之前说的 select ,改进了不少,是目前很多厂商使用多路转接都会用的方法。被公认为 Linux2.6 下性能最好的多路IO就绪通知方法。

2.1 epoll 接口

这里先简单认识一下 epoll 的接口, 2.2 会深入将有关 epoll 的底层逻辑,可以直接跳转到 2.2 来了解 epoll 的底层,届时会有图解,配合图解来理解 epooll 接口。

2.1.1 epoll_create

cpp 复制代码
int epoll_create(int size);

创建一个 epoll 的句柄

• 自从 linux2.6.8 之后, size 参数是被忽略的.

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

2.1.2 epoll_ctl

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

epoll 的事件注册函数

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

• 第一个参数是 epoll_create()的返回值(epoll 的句柄)

• 第二个参数表示动作, 用三个宏来表示

• 第三个参数是需要监听的 fd

• 第四个参数是告诉内核需要监听什么事

struct epoll_event 结构如下,这里简单认识一下,后面会具体来讲:

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

• EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭);

• EPOLLOUT : 表示对应的文件描述符可以写;

• EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);

• EPOLLERR : 表示对应的文件描述符发生错误;

• EPOLLHUP : 表示对应的文件描述符被挂断;

• EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的(这两种触发后面会讲);

• EPOLLONESHOT: 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里。

2.1.3 epoll_wait

cpp 复制代码
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 表示函数失败.

2.2 epoll 底层原理

2.2.1 epoll_ctl

在创建、使用epoll时,OS会先为我们在内部创建一棵红黑树,这颗红黑树以文件描述符作为key值,每个节点包含的信息大致如下图所示,红黑树的含义是内核要关心哪些 fd 的哪些事件,也就是节点中的前两个信息:

short events 即:

与红黑树相关的系统调用为 epoll_create :

epfd: 暂时还不关心。
op: 当用户使用 ADD 时,epoll_ctl 使用用户传入的文件描述符与事件创建新节点,并将其插入到红黑树中;当用户使用DEL时, epoll_event 不需要传入参数,仅需要传入 fd ,系统就会将该节点从红黑树中删除。
***event:**表示用户需要系统帮助关心文件描述符的事件,如 EPOLLIN、EPOKKOUT 分别表示关心读事件与写事件。

2.2.2 epoll_wait

操作系统内部除了一棵红黑树,还会维护一个双向链表 形式的就绪队列

其中,当红黑树中有 fd 就绪时,就会将其添加到就绪队列中:

这时,就可以使用 epoll_wait ,它可以将就绪队列中的 fd 添加到用户传入的数组中:

此时,作为应用层,检测有没有事件就绪的时间复杂度为O(1),相比于 select 确实进步了很多。

同时,因为就绪队列中已就绪的 fd 严格按照数组下标放入数组,所以以后在遍历的时候,也只需要遍历 epoll_wait 的返回值个数,而不需要遍历整个传入的数组,这点相较于 select 也是进步

上面说了操作系统会自主把已就绪的 fd 添加到就绪队列中,这里其实设置了一个回调函数,当有 fd 就绪时,就会调用该回调函数,完成回调方法,所以 epoll 模型就是由这三部分组成(两种数据结构 + 一种函数调用):

2.2.3 epoll_create

Linux 一切皆文件,它将 epoll 模型也归结成了文件,在调用 epoll_create 时,操作系统会在文件描述符表中创建一个文件描述符,它指向底层的 epoll 模型!

这也就是为什么 epoll_create 返回一个文件描述符,而 epoll_ctl 与 epoll_wait 都需要传入一个文件描述符。它们都需要该文件描述符才能找到底层的 epoll 模型。此外, task_struct 虽然创建了 epoll 模型,而 epoll 管理的就是它表中的文件描述符。

三、 epoll 类的编写

3.1 类的框架

对于 epoll_server ,我们还是从 epoll_echo 入手,理解 epoll 模型的调用与使用。同时,在 epoll_server 中,有用到相关的头文件,下面不再重复,可以去 select_server 中找到,链接如下:Linux网络之多路转接------老派的select-CSDN博客

3.1.1 私有成员

epoll 与 select 相似,但它们都是对报文进行多路转接,所以与之前编写的 TCP 协议一样,都是需要端口号与 listen 套接字。除此之外,通过之前对 epoll_create 的讲解, OS 底层创建的 epoll 模型其实也是文件,所以需要设置一个文件描述符标识 epoll 模型,以便于后面使用 epoll 模型时,OS 可以根据该 fd 找到 epoll 模型。最后,在 epoll_wait 中也介绍到, OS 帮助我们将就绪队列中的文件描述符递交给应用层,这里就需要我们定义一个结构体数组,为 epoll_wait 提供输入参数。

cpp 复制代码
#include <sys/epoll.h>
#include <memory>
#include "Log.hpp"
#include "Socket.hpp"
using namespace socket_ns;

class EpollServer
{
    static const int gnum = 64;

public:
private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;
    int _epfd;

    struct epoll_event _revs[gnum];
};

3.1.2 构造函数

在构造函数中,首先 是对端口号的初始化;其次 ,需要对 listen 套接字进行创建与初始化;然后 ,需要创建 epoll 模型,并对 epfd 进行初始化;最后,将监听套接字添加到 epoll 实例,并设置关心读事件。

对于最后将 listensock 添加到 epoll 中的解释:监听套接字的主要作用是接受新的客户端连接。当一个新的客户端尝试连接服务器时,监听套接字会变为可读状态(即有新连接到达),可以确保在有新的连接到达时,epoll 会通知程序,触发相应的处理逻辑(如调用 accept 接受新连接)。

除此之外,因为创建 epoll 模型时可能会出错,所以这里将 Socket.hpp 中的错误原因新增了一条

cpp 复制代码
enum
{
    SOCKET_ERROR = 1,
    BIND_ERROR,
    LISTEN_ERROR,
    USAGE_ERROR,
    EPCREATE_ERROR
};
class EpollServer
{
    static const int gnum = 64;

public:
    EpollServer(uint16_t port) : _port(port), _listensock(std::make_unique<TcpSocket>()), _epfd(-1)
    {
        // 1. 创建listensock
        InetAddr addr("0", _port);
        _listensock->BuildListenSocket(addr);

        // 2. 创建epoll模型
        _epfd = ::epoll_create(128);
        if (_epfd < 0)
        {
            LOG(FATAL, "epoll_create error\n");
            exit(EPCREATE_ERROR);
        }
        LOG(DEBUG, "epoll_create success, epfd: %d\n", _epfd);
        // 3. 将监听套接字添加到 epoll 实例
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = _listensock->SockFd(); 
        epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->SockFd(), &ev);
    }

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

    struct epoll_event _revs[gnum];
};

3.1.3 析构函数

在用完 epoll 模型后,必须将指向 epoll 模型的文件描述符关闭,同时将监听套接字也关闭。

cpp 复制代码
    ~EpollServer()
    {
        _listensock->Close();
        if (_epfd >= 0)
            ::close(_epfd);
    }

3.2 类的执行 Loop

3.2.1 Loop 框架

上面介绍 epoll_wait 时,已经介绍过,使用 epll_wait 后,我们直接传入结构体数组就可以得到底层中已就绪的套接字,相比于 select 反复的遍历,不断的更新,可以说简化了非常多:

可以看到, epoll 调用一下 epoll_wait 就可以完成 select 中对于标识 fd 数组的类成员 _fd_array 的反复遍历。随后再根据 epoll_wait 的返回值(添加到传参数组的 fd 个数)来确认是否执行成功。

因为 epoll_wait 的返回值比较特殊,它返回的是添加到传参数组的 fd 个数,而且就绪队列会将 fd 严格按照数组下标大小添加到数组中,所以在使用具体的处理函数时,只需要遍历其返回值个数次即可。所以这里向处理函数传入其返回值,便于处理函数遍历数组。

cpp 复制代码
    void Loop()
    {
        int timeout = -1;
        while (true)
        {
            int n = ::epoll_wait(_epfd, _revs, gnum, timeout);
            switch (n)
            {
            case 0:
                LOG(DEBUG, "epoll_wait timeout...\n");
                break;
            case -1:
                LOG(DEBUG, "epoll_wait failed...\n");
                break;
            default:
                LOG(DEBUG, "epoll_wait haved event ready..., n : %d\n", n);
                handlerEvent(n);
                break;
            }
        }
    }

3.2.2 handlerEvent

handlerEvent 主要有三层:

第一层:遍历类成员中 struct epoll_event _revs[gnum] 的前 n (epoll_wait的返回值) 个元素

第二层:数组元素中的事件 & 关心事件(EPOLLIN | EPOLLOUT | other...) ---> 判断其是否为关心时间就绪的 fd

第三层:判断 fd 是否为 listensock 。若是,... ;若不是, ...

首先,遍历类成员中 struct epoll_event _revs[gnum] 的前 n (epoll_wait的返回值) 个元素

cpp 复制代码
    void handlerEvent(int num)
    {
        for (int i = 0; i < num; i++)
        {
            
        }
    }

其次,取出数组元素中的事件,并 & 关心事件(EPOLLIN | EPOLLOUT | other...)

cpp 复制代码
    void handlerEvent(int num)
    {
        for (int i = 0; i < num; i++)
        {
            //取出元素中的事件状态
            uint32_t revents = _revs[i].events;
            int sockfd = _revs[i].data.fd;
        }
    }
cpp 复制代码
    void handlerEvent(int num)
    {
        for (int i = 0; i < num; i++)
        {
            uint32_t revents = _revs[i].events;
            int sockfd = _revs[i].data.fd;
            // & 关心事件
            if (revents & EPOLLIN)
            {
                
            }
        }
    }

最后,根据是否为 listen 套接字进行相对应的操作,其实因为之前我们将 listen 套接字设置为了关心读状态,所以这里可能与 echo_server 关心的事件有冲突,当需要执行另外某种操作时,可能不再存在这一步。

当套接字为监听套接字时,就可以对到来的请求进行 accept ,正式完成三次握手;当套接字不是监听套接字时,就可以根据服务端的目的执行其他操作。

cpp 复制代码
    void handlerEvent(int num)
    {
        for (int i = 0; i < num; i++)
        {
            uint32_t revents = _revs[i].events;
            int sockfd = _revs[i].data.fd;
            // & 关心事件
            if (revents & EPOLLIN)
            {
                //监听套接字->执行accept,接收到来的"客户端"
                if (sockfd == _listensock->SockFd())
                {
                    InetAddr clientaddr;
                    int newfd = _listensock->Accepter(&clientaddr); 
                    if (newfd < 0)
                        continue;

                    struct epoll_event ev;
                    ev.events = EPOLLIN;
                    ev.data.fd = newfd;
                    epoll_ctl(_epfd, EPOLL_CTL_ADD, newfd, &ev);
                    LOG(DEBUG, "_listensock ready, accept done, epoll_ctl done, newfd is: %d\n", newfd);
                }
                //其他套接字->执行echo_server
                else
                {
                    char buffer[1024];
                    ssize_t n = ::recv(sockfd, buffer, sizeof(buffer), 0);
                    if (n > 0)
                    {
                        LOG(DEBUG, "normal fd %d ready, recv begin...\n", sockfd);
                        buffer[n] = 0;
                        std::cout << "client say# " << buffer << std::endl;

                        std::string echo_string = "server echo# ";
                        echo_string += buffer;
                        ::send(sockfd, echo_string.c_str(), echo_string.size(), 0);
                    }
                    else if (n == 0)
                    {
                        LOG(DEBUG, "normal fd %d close, me too!\n", sockfd);
                        ::epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr); 
                        ::close(sockfd);
                    }
                    else
                    {
                        ::epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr); 
                        ::close(sockfd);
                    }
                }
            }
        }
    }

四、epoll 的优点

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

但是, epoll 解决不了数据拷贝的问题,这是 select/poll/epoll 都具有的特点。

相关推荐
大耳朵土土垚14 分钟前
【Linux 】开发利器:深度探索 Vim 编辑器的无限可能
linux·编辑器·vim
极客小张22 分钟前
基于STM32MP157与OpenCV的嵌入式Linux人脸识别系统开发设计流程
linux·stm32·单片机·opencv·物联网
x66ccff27 分钟前
【linux】4张卡,坏了1张,怎么办?
linux·运维·服务器
网络研究院40 分钟前
攻击者将恶意软件分解成小块并绕过您的安全网关
网络·网关·安全·攻击·技术·分块·分析
jjb_2361 小时前
LinuxC高级作业2
linux·bash
OH五星上将1 小时前
OpenHarmony(鸿蒙南向开发)——小型系统内核(LiteOS-A)【扩展组件】上
linux·嵌入式硬件·harmonyos·openharmony·鸿蒙开发·liteos-a·鸿蒙内核
小哈里1 小时前
【云网络】软件定义网络SDN的概念与应用(以PVE8用户隔离,TLS证书介绍,自签证书等为例)
网络·https·云计算·虚拟化·sdn
隔窗听雨眠1 小时前
基于Prometheus和Grafana的现代服务器监控体系构建
服务器
拾光师1 小时前
linux之网络命令
linux·服务器·网络
毕设木哥2 小时前
25届计算机专业毕设选题推荐-基于python+Django协调过滤的新闻推荐系统
大数据·服务器·数据库·python·django·毕业设计·课程设计