目录
3.1. epoll_event && epoll_data_t 结构
1. 简单认识下 epoll 相关接口
epoll 也是一种多路转接的方案, 它的核心功能和 select/poll 一模一样,我们知道 IO = 等待事件就绪 + 拷贝数据, 而 epoll 只负责IO过程中的等待事件就绪;
它几乎具备了之前所说的一切优点,被公认为 Linux 2.6 下性能最好的多路I/O就绪通知方法。
按照 man 手册的说法,epoll 是为了处理大量句柄而做了改进的poll,这种说法有待商榷,因为 epoll 和 poll 没有太大关联。
但在这里要强调一点,关于句柄的认识,只要能够标定特定文件资源的对象,我们一般称之为句柄。句柄是一种统称,比如文件描述符就可以被称之为句柄。
epoll 相关的接口有三个:epoll_create、epoll_ctl、epoll_wait;
#include <sys/epoll.h>
int epoll_create(int size);
RETURN VALUE
On success, these system calls return a nonnegative file descriptor.
On error, -1 is returned, and errno is set to indicate the error.
epoll_create: 创建一个epoll模型,并返回一个文件描述符。
自从 Linux 2.6.8 之后,size 参数是被忽略的 (只要大于0就OK)。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
RETURN VALUE
When successful, epoll_ctl() returns zero.
When an error occurs, epoll_ctl() returns -1 and errno is set appropriately.
epoll_ctl:对创建的 epoll 模型进行操作 (增 (EPOLL_CTL_ADD)、删 (EPOLL_CTL_DEL) 、改 (EPOLL_CTL_MOD) )。
它的核心作用是,用户通过 epoll_ctl 告诉内核,哪些文件描述符的哪些事件需要内核关心。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
epoll_wait:在特定的 epoll 模型中,获取已经就绪的事件,返回值就是,已经事件就绪的文件描述符的个数。
epoll_wait 的核心目的就是,内核告诉用户,哪些文件描述符的哪些事件就绪了。
2. epoll 原理
2.1. 前置性认识
- 无论是 select 还是poll,都需要用户自己维护一个数组,来进行保存文件描述符与特定的事件的,而这些成本都是由用户承担;
- select 和 poll 在内核层面和用户层面都需要遍历,而这就是效率低的一种表现;
- select 和 poll 工作模式:
- 用户告诉内核,内核要帮用户关心哪些文件描述符的哪些事件;
- 内核告诉用户,那些文件描述符的哪些事件已经就绪。
epoll 实际上也要做上面这两件事情,那么 epoll 是如何实现的呢?
现在有一个问题,操作系统如何知道,网卡这个硬件有数据了呢?
如果我们想让操作系统知道底层硬件是否有数据,无非就两种方式:
方式一:
操作系统定期去通过驱动程序检查硬件是否有数据。如果定期去查数据,就势必会存在一个问题,当底层数据到来的时候,操作系统可能在处理它自己的事情,即操作系统可能不会及时处理底层数据。
因此实际上,操作系统想知道外设是否存在数据,并不会采用上面的方式。而是采用硬件中断的方式。
方式二:
在硬件层面,当底层数据到来的时候,操作系统可能并不知道,而是网卡这个硬件通过硬件电路、系统总线、以及一些专门处理中断的一些设备 (比如一些电路板),向CPU的针脚发送特定的中断号,当CPU收到这个中断后,就立马意识到,底层硬件来数据了。
同时,在软件层面,操作系统会维护一个数组 (是一个函数指针数组,我们称之为中断向量表,例如 void (*handle)[] ),这个数组的下标就对应的是不同硬件的中断号,这个中断号在中断向量表中所对应的就是一个方法,这个方法就是读写硬件的方法。
总结起来讲,操作系统是如何得知网卡是否有数据呢?
当底层硬件 (网卡) 一旦有数据了,而网卡这个硬件保存数据的能力比较差, 不能长时间保存数据,因此一旦数据就绪后,它就立马向CPU中的特定针脚发送特定的中断号,当CPU收到这个中断号时,就意识到网卡有数据了,CPU立刻将正在运行的执行流的PCB剥离, 通过当前执行流的3 - 4G的内核地址空间以及内核级页表,找到内核代码以及中断向量表,内核通过中断号执行相应的方法,进而将底层硬件 (网卡) 数据读取到操作系统。
而中断向量表中的这些接口,很显然,只能是驱动程序提供的;
但是,将底层硬件中的数据拷贝到操作系统内,并不一定代表就可以告知应用层了, 比如网卡中的数据读取到操作系统内,还需要经历协议栈的交付,比如网络层 (可能还会存在数据组装的过程) ,当数据到达了传输层的接收缓冲区内,这时才可以告知上层,即这需要一个过程
因此,我们现在大概了解了,底层硬件和操作系统是如何进行数据交互的。
2.2. epoll 底层原理
2.2.1. 红黑树
当上层用户调用 epoll_create 创建一个 epoll 模型时,内核针对这个 epoll 模型会维护一个红黑树,因为是操作系统自身维护这颗红黑树,因此,对于用户而言,对这颗红黑树的各种操作的具体细节,用户不需要关心。
那么红黑树中的节点存放的是什么呢?
核心的是两个属性,当然还会存在其他属性,例如:
struct rb_node
{
int fd;
short events;
// 其他属性
};
- 红黑树的每一个节点表示:哪一个文件描述符 (fd) 的哪些事件 (events) 是需要操作系统关心的;
- 因此,这颗红黑树所解决的问题就是:用户告诉内核,用户所关心的哪些描述符的哪些事件需要被操作系统关心;
- 事实上,这颗红黑树就类似于 select 和 poll 中所维护的数组,但是不同的是,此时这颗红黑树是内核维护的,用户不需要自己维护,用户只需要告诉操作系统,用户关心的是哪些文件描述符的哪些事件,并且这颗红黑树只是用户告诉内核,哪些文件描述符的哪些事件需要被操作系统关心。
2.2.2. 就绪队列
可是,当文件描述符的事件就绪后,用户如何得知那些文件描述符的哪些事件就绪了呢?
因此,当用户在上层调用 epoll_create,创建 epoll 模型时,内核也需要维护第二个数据结构: 就绪队列。
既然是一个队列,一定要遵守 FIFO 原则;
那么这个队列的每个节点的属性是什么呢? 最核心的同样是两个字段,当然存在其他字段,例如:
struct queue_node
{
int fd;
short revents;
// 其他属性
};
- 就绪队列的每一个节点:内核告诉用户,哪一个文件描述符的哪些事件就绪了;
- 因此,这个就绪队列的作用就是:内核告诉用户,哪些文件描述符的哪些事件已经就绪;
- 这个就绪队列最初是空的, 当某个文件描述符的事件就绪后,操作系统得知后,会自动形成一个新的节点 (该节点就代表这个文件描述符的事件就绪),并将该节点链入到就绪队列中;
- 而对于用户而言,上述过程是操作系统完成的,用户不知道,也不关心,即对用户透明化,用户在未来只需要通过 epoll_wait 接口检索这个就绪队列即可。
2.2.3. 回调机制
可是存在一个问题,操作系统如何知道哪些文件描述符的哪些事件就绪了,如果操作系统采用定期检查的方案去判断,那么可能会导致,不能及时处理且效率低下,浪费CPU资源。
那么如何处理呢?
因此当上层用户调用 epoll_create() 创建 epoll 模型时,操作系统需要做第三件事情, 操作系统会向底层 (网卡) 驱动程序注册一个回调方法 (在软件层面是可以将方法注册到底层,底层数据一旦就绪,自动调用这个回调), 因此,当底层硬件一旦有数据了,底层硬件会通过中断的方式将数据交付给操作系统,这些数据再通过协议栈的处理,当处理完后 (比如数据到达了传输层的接收缓冲区中),自动调用这个回调函数,并进行如下步骤:
- 根据红黑树上节点要关心的事件,结合已经发生的事件,看是否匹配;
- 如果匹配,那么自动根据文件描述符和已经发生的事件,构建就绪节点,自动将构建好的节点,链入到就绪队列中。
因为采用的是回调方法,就不需要操作系统进行频繁遍历,进而提高了处理效率。
2.3. 红黑树和就绪队列
关于红黑树和就绪队列具体如下:
当一个执行流调用 epoll_create 接口时, Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员,分别是 rbr 和 rdllist,前者代表着红黑树 (用户告诉内核,哪些文件描述符的哪些事件需要被操作系统关心),后者代表着就绪队列 (内核告诉用户,哪些文件描述符的哪些事件已经就绪);
2.3.1. eventpoll 结构
eventpoll 结构在源码中的 <eventpoll.c> 源文件中有如下定义:
/*
* This structure is stored inside the "private_data" member of the file
* structure and rapresent the main data sructure for the eventpoll
* interface.
*/
struct eventpoll {
/* Protect the this structure access */
spinlock_t lock;
/*
* This mutex is used to ensure that files are not removed
* while epoll is using them. This is held during the event
* collection loop, the file cleanup path, the epoll file exit
* code and the ctl operations.
*/
struct mutex mtx;
/* Wait queue used by sys_epoll_wait() */
wait_queue_head_t wq;
/* Wait queue used by file->poll() */
wait_queue_head_t poll_wait;
/* List of ready file descriptors */
struct list_head rdllist;
/* RB tree root used to store monitored fd structs */
struct rb_root rbr;
/*
* This is a single linked list that chains all the "struct epitem" that
* happened while transfering ready events to userspace w/out
* holding ->lock.
*/
struct epitem *ovflist;
/* The user that created the eventpoll descriptor */
struct user_struct *user;
};
其中:
- rbr 成员是红黑树的根节点,这棵树中存储着用户所有添加到epoll中的需要监控的事件;
- rdllist 成员代表着就绪队列 (本质是一个双链表),其存放着将要通过 epoll_wait 返回给用户的满足条件的事件。
- 每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl() 接口向epoll 对象添加进来的事件 (哪些文件描述符的哪些事件需要被用户关心);
- 这些事件会被挂载在红黑树中,同时,也可以将重复添加的事件通过红黑树高效的识别出来 (红黑树的插入时间复杂度 O(lgN),N为数的高度);
- 而所有添加到 epoll 中的事件都会与设备 (网卡) 驱动程序建立回调,换言之,只要文件描述符所关心的事件就绪,会自动调用这个回调;
- 这个回调方法在内核中叫 ep_poll_callback (也是在 eventpoll.c 中定义),它会将发生的事件添加到 rdllist 双链表中;
- 在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体。
2.3.2. epitem 结构
epitem 结构在源码中的 <eventpoll.c> 源文件中有如下定义:
/*
* Each file descriptor added to the eventpoll interface will
* have an entry of this type linked to the "rbr" RB tree.
*/
struct epitem {
/* RB tree node used to link this structure to the eventpoll RB tree */
struct rb_node rbn;
/* List header used to link this structure to the eventpoll ready list */
struct list_head rdllink;
/*
* Works together "struct eventpoll"->ovflist in keeping the
* single linked chain of items.
*/
struct epitem *next;
/* The file descriptor information this item refers to */
struct epoll_filefd ffd;
/* Number of active wait queue attached to poll operations */
int nwait;
/* List containing poll wait queues */
struct list_head pwqlist;
/* The "container" of this item */
struct eventpoll *ep;
/* List header used to link this item to the "struct file" items list */
struct list_head fllink;
/* The structure that describe the interested events and the source fd */
struct epoll_event event;
};
- struct rb_node rbn 成员: 红黑树节点;
- struct list_head rdllink 成员:双向链表节点;
- struct epoll_filefd ffd 成员:事件句柄信息;
- struct eventpoll *ep:指向其所属的 eventpoll 对象;
- struct epoll_event event:期待发生的事件类型。
- 当上层用户调用 epoll_wait() 检查是否有事件就绪时,只需要检查 eventpoll 对象中的 rdllist 双链表中是否有 epitem 元素即可;
- 如果 rdllist 不为空,那么上层就可以得知已经有事件就绪,此时用户的判断操作的时间复杂度是O(1),而以前无论是 select 还是 poll,都是 O(N),因为它们需要遍历;
- 并且,一旦就绪,即就绪队列有节点,那么内核将就会就绪队列的数据拷贝到上层定义的缓冲区内,上层就可以处理这些事件了。
2.4. 总结
上面这一整套机制,我们就称之为 epoll 模型。
- 当上层调用 epoll_create 时:
- 体现在内核层上就是,构建红黑树、就绪队列、回调机制;
- 当上层调用 epoll_ctl 时:
- 对这颗红黑树进行操作 (增删改),比如增加,体现在内核就是,增加一个特定文件描述符关心的事件;
- 当上层调用 epoll_wait 时:
- 从特定的 epoll 模型中, 等待用户关心的文件描述符的IO事件就绪,一旦就绪 (内核构建节点链入到就绪队列中), 上层用户可以通过 epoll_wait 获取相关文件描述符已经就绪的事件。
epoll_create:返回值是一个文件描述符,并且无论是 epoll_ctl 还是 epoll_wait,它们都是通过文件描述符来访问 epoll 模型的,这是怎么做到的呢?
- 首先无论是调用 epoll_ctl,还是 epoll_wait,都是执行流 (进程或者线程) 在调度,因此,调用者都会存在一个PCB,而这个PCB中有个指针 struct files_struct* fs,这个指针指向一张表,这个表中存在一个数组,即struct file* fd_array[],这个数组的下标就是文件描述符;
- 而实际上,在 struct file 内核数据结构中不仅包含着文件描述符,还包含着一个成员 (当然也会存在其他成员):struct list_head f_ep_links; 这个成员就会保存内核创建的 epoll 模型 (epoll 模型在内核的数据结构是 struct eventpoll),换言之,内核通过将文件描述符和 epoll 模型都设计到了内核数据结构 struct file 中,使得文件描述符和 epoll 模型强关联,因此,内核只要将文件描述符返回给上层用户,上层用户通过这个文件描述符,找到相应的 struct file 对象,进而找到 epoll 模型并访问;
- 因此,epoll_create 创建一个epoll 模型时会返回一个文件描述符,并且用户调用 epoll_ctl 和 epoll_wait 都需要一个文件描述符,通过这个文件描述符完成对 epoll 模型的访问,具体就是,前者访问 epoll 模型中的红黑树,后者访问 epoll 模型中的就绪队列。
具体细节:
- 细节一: 红黑树是要有 Key 值的。而文件描述符就是天然的Key值;
- 细节二:用户只需要设置即可 (即只需要告诉内核,哪些文件描述符的哪些事件需要被内核关心),而用户不用再关心任何对文件描述符与事件的管理细节,这些工作全部交给操作系统;
- 细节三:epoll 为什么高效呢?
- 底层是用红黑树管理事件和文件描述符的,效率比较高;
- 在以前,无论是select,还是poll,操作系统要想知道那些文件描述符的哪些事件就绪,就必须要遍历,而对于epoll而言,只要事件就绪就会自动调用回调,告知操作系统哪些文件描述符的哪些事件就绪,操作系统就不需要浪费过多精力在文件描述符的监测上;
- 同时,在以前,无论是 select,还是 poll,一旦有文件描述符的事件就绪,用户自身也需要进行遍历,得知是哪些文件描述符的哪些事件就绪,而对于 epoll 而言,只要有文件描述符的事件就绪,操作系统自动构建节点链入到就绪队列中,对于上层而言,就可以以 O(1) 的时间复杂度判断是否有事件就绪。
- 细节四:底层只要有文件描述符的事件就绪,操作系统得知后,会自动构建节点,链入到就绪队列中,上层只需要不断地从就绪队列中将数据拿走,就完成了获取就绪事件的任务;
- 上述过程本质上就是一个生产者消费者模型,因此就绪队列本质上就是一个共享资源,可是我们知道访问共享资源在多执行流场景下是会存在安全问题 (数据一致性) 的;因此,事实上, epoll 已经保证相关接口 (epoll_create、epoll_ctl、epoll_wait) 都是线程安全的。
- 细节五:因为上述过程是一个生产者消费者模型,那么如果就绪队列中没有就绪事件呢?
- 很简单,应用层应该被阻塞;
这就是我们关于 epoll 原理的大部分认识。
3. epoll 实例
3.1. epoll_event && epoll_data_t 结构
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event
{
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
} __EPOLL_PACKED;
epoll_event 的两个成员:
- events:当 epoll_event 作为 epoll_ctl 的参数时,代表着用户告诉内核,哪些文件描述符的哪些事件是需要被操作系统关心的; 当epoll_event 作为 epoll_wait 的参数时 (输出型参数),内核告诉用户,哪些文件描述符的哪些事件已经就绪;
- data:这个联合体的作用是为 epoll 事件提供一个可选的附加数据字段,用来携带与事件相关的额外信息。具体来说:
- ptr 成员可以指向任意类型的数据,因为它是一个指针。这使得它可以携带任意类型的附加数据;
- fd 成员是一个整数类型,可以存储文件描述符,用于指示与事件相关联的文件描述符;
- u32 和 u64 成员分别是 32 位和 64 位的无符号整数,用于存储与事件相关的额外信息。
uint32_t events 这种设计方式是内核惯用的,通过一个整型来表示不同的方法,本质是通过不同比特位表示不同选项的,它可以是下面几个宏的集合:
- EPOLLIN (0x001):表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
- EPOLLPRI (0x002):表示对应的文件描述符有紧急的数据可读 (URG 标志位);
- EPOLLOUT (0x004):表示对应的文件描述符可以写;
- EPOLLERR (0x008):表示对应的文件描述符发生错误;
- EPOLLHUP (0x010):表示对应的文件描述符被挂断;
- EPOLLET (1u << 31):将 epoll 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的;
- EPOLLONESHOT (1u << 30):只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket 的话,需要再次把这个 socket 加入到 epoll 模型中。
3.2. epoll_ctl 的方法
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
这里的 op 与 events 类似的道理,也是通过不同比特位表示不同方式,具体如下:
#define EPOLL_CTL_ADD 1
#define EPOLL_CTL_DEL 2
#define EPOLL_CTL_MOD 3
- EPOLL_CTL_ADD :将新的文件描述符添加到 epoll 模型中;
- EPOLL_CTL_DEL :从 epoll 模型中删除相应的文件描述符;
- EPOLL_CTL_MOD :从 epoll 模型中修改已经添加的文件描述符的监听事件;
3.3. epoll demo
实现思路:
- 成员属性:
- 作为一款 epoll 服务器,需要监听套接字,端口号,以及 epfd 这个套接字,上层用户通过 epfd 访问 epoll 模型,还需要一个数组 (用户自己定义),用来保存已经就绪的事件。
- constructor
- 获取套接字、绑定、监听;
- 创建 epoll 模型;
- 通过 epoll_ctl 将监听套接字 Load 到 epoll 模型中;
- 定义数组,保存已经就绪的事件。
- start:
- 调度 loopOnce。
- loopOnce:
- 因为此时文件描述符和事件是由内核统一监测的,用户不需要关心,因此,用户直接调用 epoll_wait ,等待事件就绪,内核将就绪节点链入到就绪队列中,上层用户通过 epoll_wait 获得就绪事件;
- 如果事件就绪后,处理即可;
- 注意,epoll_wait 的返回值就代表事件就绪的文件描述符的个数,并且 epoll_wait 会将就绪事件的节点按照 FIFO 的顺序拷贝到上层用户自己定义的数组中,故这个数组中存储的就是已经就绪的事件,用户不需要再遍历判断 (哪些文件描述符的事件是否就绪),只需要依次处理即可,处理的次数就是 epoll_wait 的返回值;
- 如果是读事件,那么需要判断是监听套接字还是服务套接字,前者,获取新连接;后者,拷贝数据。
- 最后,如果客户端关闭连接,服务端也需要关闭连接,具体过程就是,服务端应该先将特定文件描述符从 epoll 模型中 EPOLL_CTL_DEL,因为 epoll_ctl 要求文件描述符必须是有效的,移除后,服务器在 close 这个服务套接字。
#ifndef _EPOLLSERVER_HPP_
#define _EPOLLSERVER_HPP_
#include "Sock.hpp"
#include "Date.hpp"
#include "Log.hpp"
#include "Epoll.hpp"
namespace Xq
{
class EpollServer
{
const static int default_port = 8080;
const static int default_revents_num = 64;
public:
EpollServer(uint16_t port = default_port)
:_port(port)
,_revents_num(default_revents_num)
{
// step 1: 获取套接字, 并进行绑定, 监听
_sock.Socket();
_sock.Bind("", _port);
_sock.Listen();
_listensock = _sock._sock;
// step 2: 创建 epoll 模型 --- epoll_create
// 体现在内核层就是, 创建红黑树, 就绪队列, 回调
_epoll_fd = Xq::Epoll::Create_Epoll();
// step 3: 将监听套接字添加到 epoll 模型中 --- epoll_ctl
// 体现在内核层就是,先找到当前执行流的
// struct files_struct 这张表中的指针数组 struct file fd_array[],
// 找到文件描述符,并通过文件描述符找到epoll 模型,
// 将监听套接字和它要关心的事件,添加到这个epoll 模型中。
Xq::Epoll::Ctl_Epoll(_epoll_fd, EPOLL_CTL_ADD, _listensock, EPOLLIN);
// step 4: 创建 struct epoll_event, 用来存储已经就绪的事件
_revents = new struct epoll_event[_revents_num];
LogMessage(DEBUG, "Server init success");
}
void start(void)
{
// 超时时间
int timeout = 1000;
while(true)
{
loopOnce(timeout);
}
}
// 这里的timeout 在epoll_ctl 中和之前的意义一模一样
void loopOnce(int timeout)
{
// 能直接accept获取连接吗?
// 不能, 因为上层不知道底层的连接事件是否就绪,
// 即是否完成了三次握手过程
// 因此, 需要调用 epoll_wait
// 让内核去监测, 是否有事件就绪
// 关于 epoll_wait 的细节
// 第一个细节: 如果底层就绪的事件非常多, 即就绪队列非常长,
// _revents 这个数组装不下, 怎么办呢?
// 不影响, 一次拿不完, 下一次继续拿就行
// 第二个细节: epoll_wait 的返回值: 依旧代表着有几个文件描述符的IO事件就绪
// epoll_wait 返回的时候,内核会将就绪队列中的 event 按照顺序 (FIFO) 放入到revs数组中(只要revs数组有能力承载)
// 一共有返回值个 (且这个返回值不会超过用户定义这个数组的大小),因此未来用户进行处理就绪事件时
// 只需要遍历返回值的大小,而不用遍历整个数组,即这些遍历操作全部是有意义的遍历。
int n = Xq::Epoll::Wait_Epoll(_epoll_fd, _revents, _revents_num, timeout);
{
if(n == 0)
{
LogMessage(NORMAL, "time out ...");
}
else if(n < 0)
{
LogMessage(ERROR, "errno: %d, errno message: %s", errno, strerror(errno));
}
else
{
// on success
LogMessage(DEBUG, "IO event ready, please handle event");
HandleEvent(n);
}
}
}
private:
void HandleEvent(int num)
{
// 此时 _revents_num 就代表着IO事件就绪的个数
// 遍历_revents_num次, 就会处理_revents_num 个事件
// 因此我们不需要在判断, 哪些文件描述符没有就绪, 那些就绪了
for(int pos = 0; pos < num; ++pos)
{
// 如果读事件就绪
if(_revents[pos].events & EPOLLIN)
{
// 如果是监听套接字的事件就绪, accept 获取新连接即可
if(_revents[pos].data.fd == _listensock)
Accepter();
// 如果是服务套接字, read/recv, 拷贝数据即可
else
Recver(_revents[pos].data.fd);
}
}
}
void Accepter()
{
std::string clientip;
uint16_t clientport;
// 此时一定不会被阻塞
// 获取服务套接字
int serversock = _sock.Accept(clientip, &clientport);
// 需要将服务套接字添加到 epoll 模型中 --- epoll_ctl
// 本质上是内核构建节点, 并链入到红黑树中
// 服务套接字关心的事件暂时还是EPOLLIN, 即读事件
Xq::Epoll::Ctl_Epoll(_epoll_fd, EPOLL_CTL_ADD, serversock, EPOLLIN);
}
void Recver(int serversock)
{
char buffer[1024] = {0};
ssize_t real_size = recv(serversock, buffer, sizeof buffer - 1, 0);
if(real_size == 0)
{
LogMessage(NORMAL, "client close the link : %d, me too ...", serversock);
// 注意: epoll 对文件描述符进行增加、删除、修改,都要求这个文件描述符是有效的
// step 1:
// 因此我们需要先删除这个文件描述符
// 体现在内核层就是, 在底层红黑树中删除特定的节点
Xq::Epoll::Ctl_Epoll(_epoll_fd, EPOLL_CTL_DEL, serversock, 0);
// step 2:
// 关闭这个文件描述符
close(serversock);
}
else if(real_size < 0)
{
LogMessage(ERROR, "errno: %d, errno message: %s, server close the link: %d",\
errno, strerror(errno), serversock);
// step 1:
// 因此我们需要先删除这个文件描述符
// 体现在内核层就是, 在底层红黑树中删除特定的节点
Xq::Epoll::Ctl_Epoll(_epoll_fd, EPOLL_CTL_DEL, serversock, 0);
// step 2:
// 关闭这个文件描述符
close(serversock);
}
else
{
// 假设读到的就是一个完整报文
// 暂不考虑粘包问题
buffer[real_size - 1] = 0;
LogMessage(DEBUG, "client [%d] echo$ %s", serversock, buffer);
}
}
private:
uint16_t _port;
Sock _sock;
int _listensock;
// 上层通过 _epoll_fd 文件描述符, 访问 epoll 模型
int _epoll_fd;
// 数组的最大值
int _revents_num;
// 保存已经就绪的事件
struct epoll_event* _revents;
};
}
#endif
3.4. 总结
epoll 的优点:
- 用户使用更方便:虽然 epoll 将等待事件就绪的工作分为了三个接口,但是对于用户而言,使用起来反而更方便和高效。用户不需要每次轮询时都设置关心的文件描述符及其事件,即文件描述符和事件的管理工作交给了操作系统,用户不需要关心;
- 数据拷贝减少:epoll 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符和事件拷贝到内核中,这个操作并不频繁 (而 select/poll 都是每次轮询都要进行拷贝,将用户数据拷贝到内核),内核数据拷贝到用户,这是少不了的;
- 事件回调机制:在 select 和 poll 中,内核监测文件描述符和用户检测文件描述符,都是需要进行遍历操作的,而对于 epoll 而言,内核采用了回调函数的方式,当事件就绪后,内核将就绪的文件描述符及其事件链入到就绪队列中,因此,对于内核而言,监测文件描述符,不需要遍历,而上层用户,判断哪些文件描述符就绪,也不需要进行遍历;
- epoll_wait 会直接访问就绪队列,进而知道哪些文件描述符就绪,因此,这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会受到影响;
- epoll没有数量限制:即 epoll 监测的文件描述符的数目无上限。
有些人说,epoll 中使用了内存映射机制,内核直接将就绪队列通过mmap的方式映射到了用户态,避免了拷贝内存这样的额外性能开销。
- 这种说法不正确,就拿我们上面的 demo 来说,用户是通过在堆申请了一段空间,调用 epoll_wait,将内核中的就绪队列中的数据拷贝到这段用户空间中;
- 并且我们也知道,操作系统不相信任何用户,因此也不会将这部分内核数据 (就绪队列) 直接暴露给用户,用户想要得到这部分数据,还是要以拷贝的方式得到;
上面的代码有问题吗?
答案,有,上面的 demo (包括之前select 和 poll 的 demo) 最多只能被认为是一个接口的正确使用 + 一些注意细节罢了,这是非常不够的,并且上面的 demo 是存在问题的,
比如:
- 当服务套接字的事件就绪后,上层直接调用 read/recv,此时上层根本就无法保证得到的是一个完整报文,因此,上层用户需要自身定制协议;
- 除此之外, 上面的 epoll 服务器只处理了 EPOLLIN,即读事件,那么其他事件呢?比如EPOLLOUT等等;
因此我们需要谈论 epoll 的工作模式,epoll 的工作模式在下篇文章 LT 和 ET模式 ;