目录
一、高级IO
1.1 概念
1.2 五种IO模型
1.3 小结
二、多路转接的实用派
2.1 epoll 接口
2.1.1 epoll_create
2.1.2 epoll_ctl
2.1.3 epoll_wait
2.2 epoll 底层原理
2.2.1 epoll_ctl
2.2.2 epoll_wait
2.2.3 epoll_create
三、 epoll 类的编写
3.1 类的框架
3.1.1 私有成员
3.1.2 构造函数
3.1.3 析构函数
3.2 类的执行 Loop
3.2.1 Loop 框架
3.2.2 handlerEvent
四、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
int epoll_create(int size);
创建一个 epoll 的句柄
• 自从 linux2.6.8 之后, size 参数是被忽略的.
• 用完之后, 必须调用 close()关闭.
2.1.2 epoll_ctl
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
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 提供输入参数。
#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 中的错误原因新增了一条
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 模型的文件描述符关闭,同时将监听套接字也关闭。
~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 严格按照数组下标大小添加到数组中,所以在使用具体的处理函数时,只需要遍历其返回值个数次即可。所以这里向处理函数传入其返回值,便于处理函数遍历数组。
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的返回值) 个元素
void handlerEvent(int num)
{
for (int i = 0; i < num; i++)
{
}
}
其次,取出数组元素中的事件,并 & 关心事件(EPOLLIN | EPOLLOUT | other...)
void handlerEvent(int num)
{
for (int i = 0; i < num; i++)
{
//取出元素中的事件状态
uint32_t revents = _revs[i].events;
int sockfd = _revs[i].data.fd;
}
}
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 ,正式完成三次握手;当套接字不是监听套接字时,就可以根据服务端的目的执行其他操作。
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 的优点
- 接口使用方便: 虽然拆分成了三个函数,但是反而使用起来更方便高效。不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离
- 数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,这个操作并不频繁 (而 select/poll 都是每次循环都要进行拷贝)
- 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪。这个操作时间复杂度 O(1)。即使文件描述符数目很多,效率也不会受到影响。
- 没有数量限制: 文件描述符数目无上限。
但是, epoll 解决不了数据拷贝的问题,这是 select/poll/epoll 都具有的特点。