IO多路转接之epoll

目录

1. 简单认识下 epoll 相关接口

2. epoll 原理

2.1. 前置性认识

2.2. epoll 底层原理

2.2.1. 红黑树

2.2.2. 就绪队列 

2.2.3. 回调机制

2.3. 红黑树和就绪队列

2.3.1. eventpoll 结构

2.3.2. epitem 结构

2.4. 总结

3. epoll 实例

3.1. epoll_event && epoll_data_t 结构

3.2. epoll_ctl 的方法

3.3. epoll demo

3.4. 总结


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 工作模式:
    1. 用户告诉内核,内核要帮用户关心哪些文件描述符的哪些事件;
    2. 内核告诉用户,那些文件描述符的哪些事件已经就绪。

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 模型时,操作系统需要做第三件事情, 操作系统会向底层 (网卡) 驱动程序注册一个回调方法 (在软件层面是可以将方法注册到底层,底层数据一旦就绪,自动调用这个回调), 因此,当底层硬件一旦有数据了,底层硬件会通过中断的方式将数据交付给操作系统,这些数据再通过协议栈的处理,当处理完后 (比如数据到达了传输层的接收缓冲区中),自动调用这个回调函数,并进行如下步骤:

  1. 根据红黑树上节点要关心的事件,结合已经发生的事件,看是否匹配;
  2. 如果匹配,那么自动根据文件描述符和已经发生的事件,构建就绪节点,自动将构建好的节点,链入到就绪队列中。

因为采用的是回调方法,就不需要操作系统进行频繁遍历,进而提高了处理效率。

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 返回给用户的满足条件的事件。
  1. 每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl() 接口向epoll 对象添加进来的事件 (哪些文件描述符的哪些事件需要被用户关心);
  2. 这些事件会被挂载在红黑树中,同时,也可以将重复添加的事件通过红黑树高效的识别出来 (红黑树的插入时间复杂度 O(lgN),N为数的高度);
  3. 而所有添加到 epoll 中的事件都会与设备 (网卡) 驱动程序建立回调,换言之,只要文件描述符所关心的事件就绪,会自动调用这个回调;
  4. 这个回调方法在内核中叫 ep_poll_callback (也是在 eventpoll.c 中定义),它会将发生的事件添加到 rdllist 双链表中;
  5. 在 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 模型的,这是怎么做到的呢?

  1. 首先无论是调用 epoll_ctl,还是 epoll_wait,都是执行流 (进程或者线程) 在调度,因此,调用者都会存在一个PCB,而这个PCB中有个指针 struct files_struct* fs,这个指针指向一张表,这个表中存在一个数组,即struct file* fd_array[],这个数组的下标就是文件描述符;
  2. 而实际上,在 struct file 内核数据结构中不仅包含着文件描述符,还包含着一个成员 (当然也会存在其他成员):struct list_head f_ep_links; 这个成员就会保存内核创建的 epoll 模型 (epoll 模型在内核的数据结构是 struct eventpoll),换言之,内核通过将文件描述符和 epoll 模型都设计到了内核数据结构 struct file 中,使得文件描述符和 epoll 模型强关联,因此,内核只要将文件描述符返回给上层用户,上层用户通过这个文件描述符,找到相应的 struct file 对象,进而找到 epoll 模型并访问;
  3. 因此,epoll_create 创建一个epoll 模型时会返回一个文件描述符,并且用户调用 epoll_ctl 和 epoll_wait 都需要一个文件描述符,通过这个文件描述符完成对 epoll 模型的访问,具体就是,前者访问 epoll 模型中的红黑树,后者访问 epoll 模型中的就绪队列。

具体细节:

  1. 细节一: 红黑树是要有 Key 值的。而文件描述符就是天然的Key值;
  2. 细节二:用户只需要设置即可 (即只需要告诉内核,哪些文件描述符的哪些事件需要被内核关心),而用户不用再关心任何对文件描述符与事件的管理细节,这些工作全部交给操作系统;
  3. 细节三:epoll 为什么高效呢?
    1. 底层是用红黑树管理事件和文件描述符的,效率比较高;
    2. 在以前,无论是select,还是poll,操作系统要想知道那些文件描述符的哪些事件就绪,就必须要遍历,而对于epoll而言,只要事件就绪就会自动调用回调,告知操作系统哪些文件描述符的哪些事件就绪,操作系统就不需要浪费过多精力在文件描述符的监测上;
    3. 同时,在以前,无论是 select,还是 poll,一旦有文件描述符的事件就绪,用户自身也需要进行遍历,得知是哪些文件描述符的哪些事件就绪,而对于 epoll 而言,只要有文件描述符的事件就绪,操作系统自动构建节点链入到就绪队列中,对于上层而言,就可以以 O(1) 的时间复杂度判断是否有事件就绪。
  4. 细节四:底层只要有文件描述符的事件就绪,操作系统得知后,会自动构建节点,链入到就绪队列中,上层只需要不断地从就绪队列中将数据拿走,就完成了获取就绪事件的任务;
    • 上述过程本质上就是一个生产者消费者模型,因此就绪队列本质上就是一个共享资源,可是我们知道访问共享资源在多执行流场景下是会存在安全问题 (数据一致性) 的;因此,事实上, epoll 已经保证相关接口 (epoll_create、epoll_ctl、epoll_wait) 都是线程安全的。
  5. 细节五:因为上述过程是一个生产者消费者模型,那么如果就绪队列中没有就绪事件呢?
    • 很简单,应用层应该被阻塞;

这就是我们关于 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 的两个成员:

  1. events:当 epoll_event 作为 epoll_ctl 的参数时,代表着用户告诉内核,哪些文件描述符的哪些事件是需要被操作系统关心的; 当epoll_event 作为 epoll_wait 的参数时 (输出型参数),内核告诉用户,哪些文件描述符的哪些事件已经就绪;
  2. data:这个联合体的作用是为 epoll 事件提供一个可选的附加数据字段,用来携带与事件相关的额外信息。具体来说:
    1. ptr 成员可以指向任意类型的数据,因为它是一个指针。这使得它可以携带任意类型的附加数据;
    2. fd 成员是一个整数类型,可以存储文件描述符,用于指示与事件相关联的文件描述符;
    3. 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

实现思路:

  1. 成员属性:
    1. 作为一款 epoll 服务器,需要监听套接字,端口号,以及 epfd 这个套接字,上层用户通过 epfd 访问 epoll 模型,还需要一个数组 (用户自己定义),用来保存已经就绪的事件。
  2. constructor
    1. 获取套接字、绑定、监听;
    2. 创建 epoll 模型;
    3. 通过 epoll_ctl 将监听套接字 Load 到 epoll 模型中;
    4. 定义数组,保存已经就绪的事件。
  3. start:
    1. 调度 loopOnce。
  4. loopOnce:
    1. 因为此时文件描述符和事件是由内核统一监测的,用户不需要关心,因此,用户直接调用 epoll_wait ,等待事件就绪,内核将就绪节点链入到就绪队列中,上层用户通过 epoll_wait 获得就绪事件;
    2. 如果事件就绪后,处理即可;
    3. 注意,epoll_wait 的返回值就代表事件就绪的文件描述符的个数,并且 epoll_wait 会将就绪事件的节点按照 FIFO 的顺序拷贝到上层用户自己定义的数组中,故这个数组中存储的就是已经就绪的事件,用户不需要再遍历判断 (哪些文件描述符的事件是否就绪),只需要依次处理即可,处理的次数就是 epoll_wait 的返回值;
    4. 如果是读事件,那么需要判断是监听套接字还是服务套接字,前者,获取新连接;后者,拷贝数据。
    5. 最后,如果客户端关闭连接,服务端也需要关闭连接,具体过程就是,服务端应该先将特定文件描述符从 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 是存在问题的,

比如:

  1. 当服务套接字的事件就绪后,上层直接调用 read/recv,此时上层根本就无法保证得到的是一个完整报文,因此,上层用户需要自身定制协议;
  2. 除此之外, 上面的 epoll 服务器只处理了 EPOLLIN,即读事件,那么其他事件呢?比如EPOLLOUT等等;

因此我们需要谈论 epoll 的工作模式,epoll 的工作模式在下篇文章 LT 和 ET模式 ;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值