文章目录
-
- 前情提要
- [Epoll 的封装](#Epoll 的封装)
- [Epoll Echo Server](#Epoll Echo Server)
-
- [Epoll Echo Server 测试及完整代码](#Epoll Echo Server 测试及完整代码)
前情提要
在上一篇博客『 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
模型创建与释放需要分别在构造函数与析构函数中;cppclass 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
模型中用来关心就绪队列中是否存在已就绪事件;cppclass 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
模型中,主要用于对内核红黑树进行增删改操作;其函数声明为:
cppint epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
操作根据参数的传递主要分为两种:
-
删除操作
当操作为删除操作时,参数
event
不需要传参; -
增加/修改操作
当操作不为删除操作时,参数都需要填写,其中
op
用于表明具体操作,如EPOLL_CTL_MOD
修改操作或EPOLL_CTL_ADD
增加操作;event
表示操作所关心的具体事件以及对应的文件描述符(以结构体形式存储);
cppclass 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
服务器;
且其实现方式较Select
与Poll
两种多路转接方案还要简单(尤其是在对Epoll
进行封装后);
-
整体结构与初始化
同样的
Epoll
多路转接的Echo
服务器作为一款服务器主要分为初始化与运行两个部分;cppconst 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()
即可;cppclass 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
时表示有对应就绪事件被推送至用户层;但并不清楚就绪事件具体属性,因此需要对事件进行区分且根据具体事件进行事件派发;
cppclass 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
接口完成连接的获取;cppclass 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()
信息获取与回响当读事件对应的描述符不为监听套接字描述符时则表示需要将数据由对应文件描述符中获取,并将数据回响写回客户端上;
cppclass 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
相同,且其效率要比另两种方案多路转接方案更为优秀;