『 Linux 』高级IO (三) - Epoll模型的封装与EpollEchoServer服务器

文章目录


前情提要

在上一篇博客『 Linux 』高级IO (二) - 多路转接介绍并完成了两种多路转接方案的介绍以及对应多路转接方案代码的编写,分别为SelectServer服务器与PollServer服务器;

同时在该篇博客中介绍了继select()poll()多路转接方案之后所提出的Epoll多路转接方案;

此处不再赘述;

而在上文中并未对Epoll多路转接方案的代码进行编写,此篇博客进行补充;


Epoll 的封装

在上一篇博客中对Epoll多路转接方案进行了介绍;

本质上Epoll通过内核所维护的三种机制实现多路转接方案;

  • 系统内核内部为Epoll所维护的的红黑树
  • 系统内核内部为Epoll已就绪事件所维护的就绪队列
  • 系统内核内部因Epoll所提供的回调机制

其中红黑树用来管理正在被监听的文件描述符,就绪队列用来管理已触发的文件描述符,回调机制用于检测并推送事件触发的结果到就绪队列中;

其对应的核心函数为epoll_create(),epoll_wait()epoll_ctl();

与操作系统内核相应:

  • epoll_create()

    创建Epoll模型并返回Epoll模型的文件描述符;

  • epoll_ctl()

    控制内核中为Epoll所维护的红黑树的增删改操作;

  • epoll_wait()

    用于进行等待,并返回就绪队列中相应数量的就绪事件给用户提前准备的空间,本质是关心就绪队列本身;

Epoll的函数较为分散,为简化Epoll操作,可以对Epoll模型进行封装;

整体的封装采用RAII(构造即初始化,析构即释放)的风格;

  • 整体结构

    为了防止Epoll模型封装类被拷贝,可以将拷贝构造与拷贝赋值设置为delete,或者创建不可被拷贝的基类(同样设为delete),将Epoll封装设置为其派生类以防止Epoll封装类被拷贝;

    • nocopy.hpp

      cpp 复制代码
      /* nocopy.hpp */
      
      class nocopy
      {
      private:
      public:
          nocopy(){};
          nocopy(const nocopy &) = delete;
          const nocopy& operator=(const nocopy&) = delete;
          ~nocopy(){};
      };
    • Epoller.hpp

      cpp 复制代码
      /* Epoller.hpp */
      
      class Epoller : public nocopy // 防止拷贝 继承防拷贝类 class nocopy
      {
      public:
          Epoller(){}
          ~Epoller(){}
      
      private:
          int _epfd; // Epoll 的描述符
          static const int _timeout = 3000;
      };

    此处使用继承防拷贝类的方式进行防拷贝;

    主要在类中定义了两个成员变量,当epoll_create()被调用时,将返回一个文件描述符,这个文件描述符是Epoll模型的文件描述符,应当进行保存;

    此外_timeout成员为默认定义的timeout时间,可酌情调整;

  • RAII

    RAII为构造即初始化,析构即释放;

    因此Epoll模型创建与释放需要分别在构造函数与析构函数中;

    cpp 复制代码
    class Epoller : public nocopy // 防止拷贝 继承防拷贝类 class nocopy
    {
        static const int _size = 1024;
    public:
        Epoller()
        {
            int size = 128;
            _epfd = epoll_create(_size); // 创建 epoll 模型
            if (_epfd == -1)             // 创建失败
            {
                lg(FATAL, "epoll_create error: %s", strerror(errno));
            }
            else // 创建成功
            {
                lg(INFO, "epoll_create sucess, fd: %d", _epfd); // 创建成功查看对应文件描述符
            }
        }
    
        ~Epoller()
        {
            if (_epfd >= 0)
            {
                close(_epfd);
            }
        }
    
    private:
        int _epfd; // Epoll 的描述符
        static const int _timeout = 3000;
    };

    由于epoll_create()的参数已经被废弃,因此该处参数设置为1024(无意义);

    当析构时只需关闭对应Epoll模型的文件描述符即可;

  • epoll_wait()封装

    epoll_wait()函数在Epoll模型中用来关心就绪队列中是否存在已就绪事件;

    cpp 复制代码
    class Epoller : public nocopy // 防止拷贝 继承防拷贝类 class nocopy
    {
    public:
        int EpollerWait(struct epoll_event revents[], int num)
        {
            // 等待操作在Epoll模型中为将就绪队列中的
            // 已就绪事件文件描述符拷贝至用户预设空间
            int n = epoll_wait(_epfd, revents, num, _timeout);
            return n;
        }
    private:
        int _epfd; // Epoll 的描述符
        static const int _timeout = 3000;
    };

    对应的将其封装为EpollerWait()函数,在参数上需要传递一个用户预设的空间;

    这段用户预设的空间用于epoll_wait()函数将就绪队列中已就绪的事件拷贝至用户层;

    传入的num表示用户预设空间每次能够接受多少就绪队列中的已就绪事件;

    • Ps:

      当内核就绪队列中已就绪事件大于用户所预设空间时,就绪队列本次只会传递用户预设空间响应数量的就绪事件,余下的就绪事件将继续存放至内核就绪队列当中;

  • epoll_ctl()封装

    epoll_ctl()函数在Epoll模型中,主要用于对内核红黑树进行增删改操作;

    其函数声明为:

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

    操作根据参数的传递主要分为两种:

    • 删除操作

      当操作为删除操作时,参数event不需要传参;

    • 增加/修改操作

      当操作不为删除操作时,参数都需要填写,其中op用于表明具体操作,如EPOLL_CTL_MOD修改操作或EPOLL_CTL_ADD增加操作;

      event表示操作所关心的具体事件以及对应的文件描述符(以结构体形式存储);

    cpp 复制代码
    class Epoller : public nocopy // 防止拷贝 继承防拷贝类 class nocopy
    {
        static const int _size = 1024;
    
    public:
        int EpollerUpdate(int op, int sock, uint32_t event)
        {
            int n = 0;
            if (op == EPOLL_CTL_DEL)
            {
                // 删除操作
                n = epoll_ctl(_epfd, op, sock, nullptr); // 删除
                if (n != 0)
                {
                    lg(WARNING, "delete epoll_ctl error");
                }
            }
            else
            {
                // 非删除操作即新增或修改
                struct epoll_event ev;
                ev.events = event;
                ev.data.fd = sock;
    
                n = epoll_ctl(_epfd, op, sock, &ev); // 注册进内核
                if (n != 0)
                {
                    lg(WARNING, "EpollerUpdate Error: %s", strerror(errno));
                }
            }
            return n;
        }
    private:
        int _epfd; // Epoll 的描述符
        static const int _timeout = 3000;
    };

Epoll封装完整代码(供参考)

[半介莽夫 - Gitee For half-intermediate-mangfu/IO/AdvancedIO/EpollEncapsulation]


Epoll Echo Server

同样可以利用Epoll实现一个Echo服务器;

且其实现方式较SelectPoll两种多路转接方案还要简单(尤其是在对Epoll进行封装后);

  • 整体结构与初始化

    同样的Epoll多路转接的Echo服务器作为一款服务器主要分为初始化与运行两个部分;

    cpp 复制代码
    const uint32_t EVENT_IN = EPOLLIN;
    const uint32_t EVENT_OUT = EPOLLOUT;
    const uint32_t EVENT_DEL_OP = 0;
    class EpollServer : public nocopy
    {
    public:
        EpollServer(uint16_t port) // 此处使用智能指针 因此在初始化列表中使用 new 实例化
            : _port(port), _listensocket(new NetSocket), _epoller(new Epoller){}
    
        void Init(){ // 正常的创建 绑定 监听三件套
            _listensocket->Socket();
            _listensocket->Bind(_port);
            _listensocket->Listen();
    
            lg(INFO, "Create listen socket sucess, fd: %d", _listensocket->GetFd());
        }
    
        void Start(){}
    
        ~EpollServer(){
            // 析构函数关闭套接字 (内置封装)
            _listensocket->Close();
        }
    
    private:
        std::shared_ptr<NetSocket> _listensocket; // 使用智能指针
        std::shared_ptr<Epoller> _epoller;
        uint16_t _port;
    };

    既然是网络服务器那么必须使用对应的网络接口,同样这里使用预先封装过的Socket接口;

    成员变量主要为如下:

    • _listensocket

      表示监听套接字对应的实例,监听套接字用来监听新连接的到来;

    • _epoller

      表示Epoll模型,此处使用上文所封装的Epoll模型;

    • _port

      表示监听套接字所绑定的端口号;

    监听套接字与Epoll模型皆采用智能指针使其更加安全与便利;

    定义了三个uint32_t类型常量,主要因为在该程序中为配合Epoll模型封装中的EpollerUpdate()函数进行使用;

    分别用于关心与判断,设置读写事件或是删除操作;

    在初始化列表中分别对三个成员变量进行初始化,并在Init()中对监听套接字进行"三板斧"操作,即创建套接字,绑定端口与设置监听;

    在析构函数中调用封装的Socket中的close()对监听套接字文件描述符进行关闭从而完成监听套接字的清理;

  • Start()运行函数

    在该函数中主要是循环调用epoll_wait()函数传入用户预设空间实现对多个事件进行监听;

    在设置timeout的情况下,该函数的返回值(n)有三种情况:

    • n > 0

      n > 0时表示有n个就绪事件从就绪队列被推送至用户预设空间中;

    • n == 0

      n == 0时表示timeout时间到期,没有事件就绪(就绪队列中没有就绪事件,因此不会有就绪事件被推送至用户预设的事件空间中);

    • n < 0

      n < 0时则表示该函数调用失败;

    由于已经对epoll_wait()函数进行封装,因此只需调用Epoll模型实例中对应的成员函数EpollerWait()即可;

    cpp 复制代码
    class EpollServer : public nocopy
    {
        static const int _num = 64;
        // 表示用户预设就绪事件空间单次最大读取就绪事件数量
    public:
        void Start()
        {
            // 在进行循环前 第一次调用必须保证监听套接字被添加至epoll当中
            // 这里本质是将监听套接字与其所关心的事件添加至内核epoll模型的红黑树当中
            _epoller->EpollerUpdate(EPOLL_CTL_ADD, _listensocket->GetFd(), EVENT_IN);
    
            struct epoll_event revs[_num];
            for (;;) // 运行过程中采用循环
            {
                int n = _epoller->EpollerWait(revs, _num);
                /*
                    n 为返回值多少个
                    所传入的revs数组为输出型参数
                    _num表示每次最多从就绪队列中取多少个
                */
                if (n > 0) // 表有事件就绪
                {
                    Dispatcher(revs, n); // 进行事件派发
                }
                else if (n == 0)
                {
                    lg(INFO, "time out...");
                }
                else
                {
                    lg(WARNING, "EpollerWait error...");
                }
            }
        }
    private:
        std::shared_ptr<NetSocket> _listensocket; // 使用智能指针
        std::shared_ptr<Epoller> _epoller;
        uint16_t _port;
    };

    在该函数中设置了一个_num = 64的常量用于设置用户预设空间(数组)的大小;

    用户预设空间可直接采用struct epoll_event结构体数组的形式;

    这里还有一个细节,第一个被关心事件的文件描述符必然是监听套接字文件描述符;

    当监听套接字文件描述符事件就绪后,对应的事件将被推送至用户预设空间中,之后才能将监听套接字文件描述符中的新连接进行获取并将新连接fd注册进系统内核的红黑树当中(设置观察);

    因此在第一次进行EpollWait()前需要调用EpollerUpdate()将监听套接字文件描述符注册进操作系统内核的文件描述符中;

    根据不同返回值进行下一步决策,当返回值n>0时表示n个就绪事件被推送至用户预设空间(数组)中,但无法在当前情况判断所就绪事件具体是什么事件,因此下一步进行事件派发Dispatcher();

  • Dispatcher()事件派发

    当对应epoll_wait()操作返回值>0时表示有对应就绪事件被推送至用户层;

    但并不清楚就绪事件具体属性,因此需要对事件进行区分且根据具体事件进行事件派发;

    cpp 复制代码
    class EpollServer : public nocopy
    {
    public:
        void Dispatcher(struct epoll_event revs[], int num) // 进行事件派发
        {
            for (int i = 0; i < num; ++i)
            {
                uint32_t events = revs[i].events; // 获取事件
                int fd = revs[i].data.fd;         // 获取文件描述符
    
                if (events & EVENT_IN) // 其他
                {
                    if (fd == _listensocket->GetFd()) // 监听套接字读事件就绪
                    {
                        // Accepter() 获取连接
                    }
                    else // 其他读事件就绪
                    {
                        // Recver() 读取数据
                    }
                }
                else if (events & EVENT_OUT) // 写事件
                {
                    // 暂时不考虑
                }
                else // 其他
                {
                    // 暂时不考虑
                }
            }
        }
    private:
        std::shared_ptr<NetSocket> _listensocket; // 使用智能指针
        std::shared_ptr<Epoller> _epoller;
        uint16_t _port;
    };

    在该程序中主要观察读写两个事件,其中读写事件是方便为了区分,此程序中不对写事件进行处理;

    该函数中的num参数表示EpollWait()函数的返回值,即由就绪队列推送至用户层的就绪事件个数,参数revs则为用户预设的空间(空间内已因EpollWait()存在num个就绪事件);

    对事件进行派发的前提为了解对应事件具体事件,根据num个数循环遍历revs数组即可获得当前已就绪事件;

    以此获取就绪事件对应的文件描述符与具体事件,根据具体事件进行判断;

    若是事件为读事件就绪,其可能性为如下:

    • 监听套接字监听到新连接
    • 其他文件描述符获取到可读数据

    当就绪事件的文件描述符为监听套接字文件描述符时则表示需要调用accept()获取新连接,并将新连接的文件描述符注册至Epoll模型中的红黑树;

    当就绪事件不为监听套接字描述符时则表示其他文件描述符的可读数据已经就绪,需要将数据通过文件描述符拷贝至用户层;

  • Accepter()连接管理器

    当事件为新连接到来时将调用对应的accept()函数获取新连接,并将新连接的文件描述符以关心事件为读事件注册进操作系统Epoll模型的红黑树当中;

    此处直接调用封装的Socket接口完成连接的获取;

    cpp 复制代码
    class EpollServer : public nocopy
    {
    public:
        void Accepter()
        {
            std::string clientip;
            uint16_t clientport;
            int newfd = _listensocket->Accept(&clientip, &clientport);
            if (newfd < 0)
            {
                lg(WARNING, "New fd Accept error: %s", strerror(errno));
            }
            lg(INFO, "New fd Accept Sucess, fd: %d", newfd);
    
            _epoller->EpollerUpdate(EPOLL_CTL_ADD, newfd, EVENT_IN);
        }
    
    private:
        std::shared_ptr<NetSocket> _listensocket; // 使用智能指针
        std::shared_ptr<Epoller> _epoller;
        uint16_t _port;
    };
  • Recver()信息获取与回响

    当读事件对应的描述符不为监听套接字描述符时则表示需要将数据由对应文件描述符中获取,并将数据回响写回客户端上;

    cpp 复制代码
    class EpollServer : public nocopy
    {
    public:
        void Recver(int fd)
        {
            char inbuff[1024];
            int n = read(fd, inbuff, sizeof(inbuff) - 1);
            if (n > 0)
            {
                inbuff[n] = 0;
                printf("Fd %d Get a message: %s", fd, inbuff);
                std::string echo_str = "Server Echo @ ";
                echo_str += inbuff;
                write(fd, echo_str.c_str(), echo_str.size());
            }
            else if (n == 0)
            {
                printf("Fd %d Closed, Me too...\n", fd);
                _epoller->EpollerUpdate(EPOLL_CTL_DEL, fd, EVENT_DEL_OP); // 在进行删除操作时确保文件描述符为一个有效的文件描述符
                close(fd);
            }
            else
            {
                lg(WARNING, "Read error...\n");
                _epoller->EpollerUpdate(EPOLL_CTL_DEL, fd, EVENT_DEL_OP);
                close(fd);
            }
        }
    private:
        std::shared_ptr<NetSocket> _listensocket; // 使用智能指针
        std::shared_ptr<Epoller> _epoller;
        uint16_t _port;
    };

    这里直接调用read()进行数据的提取并进行打印,返回值(n)有三种结果:

    • n>0

      表示正确读取;

    • n==0

      表示对端关闭连接;

    • n<0

      表示read()调用失败;

    当正确读取时对数据进行打印并调用write()回响回对端;

    当对端关闭连接与读取失败时调用日志插件打印出对应日志信息并同样对连接进行关闭;

    关闭连接涉及到移除Epoll中关心的文件描述符与close()关闭连接,这里值得注意的是,在进行epoll_ctl()将文件描述符进行移除时需要确保该文件描述符为一个有效文件描述符,否则调用将失败报错;

    因此需要先移除文件描述符再调用close()对文件描述符进行关闭;

    同时这里直接调用read()会存在一个问题,即数据读取可能不完全的问题(此处只提出问题不进行解决,不再赘述);


Epoll Echo Server 测试及完整代码

从测试结果可以看出,其结果与Select方案Poll方案多路转接所实现的EchoServer相同,且其效率要比另两种方案多路转接方案更为优秀;

相关推荐
什么半岛铁盒3 分钟前
Linux信号的保存
linux·运维·网络
百锦再10 分钟前
大数据技术的主要方向及其应用详解
大数据·linux·网络·python·django·pygame
2301_8035545225 分钟前
vim,gcc/g++,makefile,cmake
linux·编辑器·vim
惜.己1 小时前
Linux常用命令(十四)
linux·运维·服务器
linkingvision2 小时前
H5S 视频监控AWS S3 对象存储
linux·运维·aws·视频监控s3对象存储
BillKu2 小时前
服务器多JAR程序运行与管理指南
运维·服务器·jar
QQ2740287562 小时前
BlockMesh Ai项目 监控节点部署教程
运维·服务器·web3
belldeep2 小时前
WSL 安装 Debian 12 后,Linux 如何安装 vim ?
linux·debian·vim
wqqqianqian3 小时前
国产linux系统(银河麒麟,统信uos)使用 PageOffice自定义Word模版中的数据区域
linux·word·自定义·pageoffice·数据区域
小疆智控3 小时前
数字化工厂升级引擎:Modbus TCP转Profinet网关助力打造柔性生产系统
服务器·网络·tcp/ip