目录
前言
一,epoll的三个系统调用接口
1.1.epoll_create函数
1.1.1.epoll_create函数干了什么
1.2. epoll_ctl函数
1.2.1.epoll_ctl函数函数干了什么
1.3.epoll_wait函数
1.3.1.epoll_wait到底干了什么
1.4.epoll的工作过程中内核在干什么
二,epoll高效的原理
2.1.预备知识的储备
2.2.内核接受网络数据的全过程
2.3.一些小问题
三,简陋版本epoll版本TCP服务器
3.1.准备工作
3.2.EpollServer.hpp
3.3.源代码
前言
我们得知道epoll是e+poll的意思,很明显epoll就是poll的升级版本!!!
epoll 是对 select 和 poll 的改进,解决了“性能开销大”和“文件描述符数量少”这两个缺点,是性能最高的多路复用实现方式,能支持的并发量也是最大。
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。
另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
一,epoll的三个系统调用接口
epoll的使用流程如下
- 创建 epoll 实例:通过 epoll_create 创建一个 epoll 文件描述符。
- 添加文件描述符到 epoll 实例:使用 epoll_ctl 将需要监视的文件描述符(如套接字)添加到 epoll 实例中,并指定关心的事件类型(如可读、可写等)。
- 等待事件发生:通过 epoll_wait 或 epoll_pwait 等待文件描述符上发生指定的事件。这些函数会阻塞调用线程,直到有文件描述符上的事件发生,或者超时。
- 处理事件:根据 epoll_wait 或 epoll_pwait 返回的就绪事件列表,处理相应的文件描述符上的事件。
接下来我们就要好好认识这些接口
1.1.epoll_create函数
epoll_create函数用于创建epoll文件描述符,该文件描述符用于后续的epoll操作。
参数:
- size:目前内核还没有实际使用,只要大于0就行
返回值:
- 返回epoll文件描述符
1.1.1.epoll_create函数干了什么
- epoll需要使用一个额外的文件描述符(epoll文件描述符),来唯一标识内核中的这个事件表。 这个文件描述符使用如下epoll_create函数来创建;
- epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。
- 调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点(epoll_create创建的文件描述符),在内核cache里建了个 红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件.(概括就是:调用epoll_create方法时,内核会跟着创建一个eventpoll对象)
eventpoll
对象也是文件系统中的一员,和socket一样,它也会有等待队列。struct eventpoll {spin_lock_t lock; //对本数据结构的访问struct mutex mtx; //防止使用时被删除wait_queue_head_t wq; //sys_epoll_wait() 使用的等待队列wait_queue_head_t poll_wait; //file->poll()使用的等待队列struct list_head rdllist; //事件满足条件的链表struct rb_root rbr; //用于管理所有fd的红黑树struct epitem *ovflist; //将事件到达的fd进行链接起来发送至用户空间 }
在 Linux 内核中,struct eventpoll 是一个关键的数据结构,用于实现 epoll 机制。这个结构体管理了与 epoll 相关的所有资源和状态。
下面是对 struct eventpoll 结构体的详细解释:
- spin_lock_t lock;:这是一个自旋锁,用于保护对 eventpoll 结构体的并发访问。自旋锁在持有锁的线程(或进程)释放锁之前,会忙等待(即持续检查锁的状态),而不是像互斥锁(mutex)那样将线程阻塞。这使得自旋锁在预期锁持有时间非常短的情况下非常高效。
- struct mutex mtx;:这是一个互斥锁,用于防止在 eventpoll 结构体被使用时被删除。与自旋锁不同,互斥锁在无法立即获取锁时会将线程阻塞,直到锁被释放。这在锁持有时间较长或等待锁释放的线程可能会进行较长时间的睡眠时更为合适。
- wait_queue_head_t wq;:这是一个等待队列的头节点,用于 sys_epoll_wait() 系统调用。当没有事件就绪时,调用 epoll_wait 的进程会被挂起在这个等待队列上,直到有事件发生或超时。
- wait_queue_head_t poll_wait;:这是另一个等待队列的头节点,通常用于支持传统的 poll() 或 select() 系统调用。如果文件描述符(如套接字)同时被添加到 epoll 实例和传统的轮询机制中,这个等待队列就会被用到。
- struct list_head rdllist;:这是一个链表头,用于存储已经准备好进行 I/O 操作(即事件已就绪)的文件描述符。这个链表在 epoll_wait 调用返回给用户空间之前被填充。
- struct rb_root rbr;:这是红黑树的根节点,用于高效地存储和管理所有添加到 epoll 实例中的文件描述符。红黑树是一种自平衡的二叉搜索树,它能够在对数时间内完成插入、删除和查找操作。
- struct epitem *ovflist;:这个成员看起来是用于某种溢出处理的链表头。在某些情况下,如果 epoll 事件表满了(尽管实际上这种情况很少发生,因为 epoll 支持的文件描述符数量通常很大),可能需要一种机制来暂存额外的事件。然而,标准的 Linux epoll 实现中可能并不直接使用这个成员,或者它可能用于特定的内核版本或定制的内核模块中。
请注意,随着 Linux 内核的发展,struct eventpoll 结构体的具体实现和成员可能会发生变化。
- struct list_head rdllist; 和 struct rb_root rbr; 确实是 epoll 机制中常提及的关键组成部分,它们分别对应于 epoll 中的“就绪链表”和“红黑树”。
在epoll机制中,“就绪链表”和“红黑树”各自扮演着关键角色,它们共同协作以实现高效的事件通知和文件描述符管理。
- 就绪链表是epoll中的一个关键数据结构,它主要用于存储那些已经准备好进行I/O操作(即事件已就绪)的文件描述符。当epoll监控的文件描述符(红黑树上面的)上有I/O事件发生时,相应的epoll_event会被添加到这个链表中。epoll_wait函数在调用时,会检查这个就绪链表,如果有事件存在,则将这些事件复制到用户空间提供的数组中,并返回事件的数量。就绪链表的使用大大提高了事件通知的效率,因为它避免了不必要的文件描述符扫描。
- 红黑树是一种自平衡的二叉搜索树,它在epoll中用于高效地存储和管理所有添加到epoll实例中的文件描述符。每个文件描述符在红黑树中都有一个对应的节点,这些节点按照文件描述符的值进行排序。红黑树提供了快速的查找、插入和删除操作,这些操作的时间复杂度都是对数的,即O(log n),其中n是树中节点的数量。这使得epoll能够在处理大量文件描述符时保持较高的性能。
在epoll中,红黑树的主要作用包括:
- 快速查找:当需要检查某个文件描述符是否已经被添加到epoll实例中时,可以通过红黑树快速定位到该文件描述符对应的节点。
- 高效插入和删除:当向epoll实例中添加或删除文件描述符时,红黑树能够确保这些操作在对数时间内完成,从而保持较高的性能。
- 有序管理:红黑树中的节点按照文件描述符的值进行排序,这有助于实现有序的文件描述符管理,虽然epoll本身并不直接依赖于文件描述符的顺序,但在某些情况下,有序性可能有助于优化性能或简化处理逻辑。
总结
在epoll机制中,“就绪链表”和“红黑树”是两个相辅相成的数据结构。
- 就绪链表用于存储已经准备好进行I/O操作的文件描述符,以便epoll_wait函数能够快速返回这些事件;
- 而红黑树则用于高效地存储和管理所有添加到epoll实例中的文件描述符,以确保查找、插入和删除操作的高效性。这两个数据结构的结合使得epoll能够在处理大量并发连接时保持较高的性能。
当调用 epoll_create 时,内核会执行一系列操作来创建一个新的 epoll 实例,并为其分配必要的资源。以下是内核中发生的主要步骤:
- 分配 eventpoll 对象:内核首先会分配一个 struct eventpoll 类型的对象。这个对象将用于存储与 epoll 实例相关的所有信息,包括锁、等待队列、就绪事件列表、红黑树等。
- 初始化数据结构:对 struct eventpoll 对象进行初始化,包括设置锁、等待队列、就绪事件列表(rdllist)和红黑树(rbr)等成员变量的初始状态。
- 分配文件描述符:内核会分配一个未使用的文件描述符(fd),并将其与新建的 struct eventpoll 对象关联起来。这个文件描述符将作为用户空间与内核中 epoll 实例通信的接口。
- 创建文件对象:创建一个 struct file 类型的对象,并将其与 struct eventpoll 对象关联。这个 struct file 对象将包含对 eventpoll 对象的引用,以及一系列文件操作函数(如 file_operations),这些函数定义了针对 epoll 文件描述符的各种操作(如读、写、控制等)。
- 注册文件操作:将 eventpoll_fops(一个包含 epoll 相关文件操作函数的结构体)设置为 struct file 对象的 f_op 成员。这样,当用户空间通过文件描述符对 epoll 实例执行操作时,内核就会调用这些预定义的函数来处理。
- 将文件描述符添加到进程的文件描述符表:将新分配的文件描述符添加到当前进程的文件描述符表中,以便用户空间可以通过标准的文件描述符操作(如 read、write、close 等)来访问 epoll 实例。
- 返回文件描述符:最后,epoll_create 系统调用将新分配的文件描述符返回给用户空间。用户空间程序可以使用这个文件描述符来调用其他 epoll 相关的系统调用(如 epoll_ctl、epoll_wait 等),以添加要监视的文件描述符、等待事件发生以及处理就绪事件。
总结来说,当调用 epoll_create 时,内核会创建一个新的 epoll 实例,并为其分配和初始化必要的资源。这个实例通过一个特殊的文件描述符与用户空间进行交互,允许用户空间程序高效地监视和处理多个文件描述符上的 I/O 事件。
我们只需要知道这个epoll_create会返回一个epoll文件描述符即可。 这个文件描述符也会占用一个fd值,在linux下如果查看/proc/进程id/fd/,能够看到这个fd,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
1.2. epoll_ctl函数
epoll_ctl 函数是 Linux 下 epoll 接口的一个重要组成部分,它用于向 epoll 实例注册、修改或删除文件描述符及其关联的事件。
参数解释
- int epfd:这是由 epoll_create 函数返回的文件描述符,用于标识一个 epoll 实例。通过这个文件描述符,用户空间程序可以与内核中的 epoll 实例进行通信。
- int op:这个参数指定了要执行的操作类型,它是一个宏,决定了 epoll_ctl 函数的具体行为。常见的操作类型包括:
- EPOLL_CTL_ADD:向 epoll 实例注册一个新的文件描述符及其事件。
- EPOLL_CTL_MOD:修改已经注册到 epoll 实例中的文件描述符的事件。
- EPOLL_CTL_DEL:从 epoll 实例中删除一个文件描述符及其事件。
- int fd:这是要操作的目标文件描述符,即用户希望注册、修改或删除的文件描述符。这个文件描述符可以是一个已打开的套接字、管道等。
- struct epoll_event *event:这是一个指向 struct epoll_event 结构体的指针,用于指定要注册或修改的事件信息。这个结构体包含了事件的类型(如可读、可写、错误等)和与该事件相关联的数据。如果操作是删除(EPOLL_CTL_DEL),则这个参数可以为 NULL,因为删除操作不需要指定事件信息。
结构体 epoll_event
#include <sys/epoll.h>// 定义epoll_data_t为union类型 typedef union epoll_data { void *ptr; // 可以指向任何类型的数据 int fd; // 套接字文件描述符 uint32_t u32; // 32位无符号整数 uint64_t u64; // 64位无符号整数 } epoll_data_t; // 定义epoll_event结构体 struct epoll_event { uint32_t events; // epoll事件,参考事件列表(如EPOLLIN, EPOLLOUT等) epoll_data_t data; // 关联的数据,可以是文件描述符、指针或其他 };
struct epoll_event 结构体通常包含以下成员:
- events:这是一个位掩码,用于指定事件的类型。常见的类型包括 EPOLLIN(可读事件)、EPOLLOUT(可写事件)、EPOLLERR(错误事件)等。多个事件类型可以通过位或操作符(|)组合在一起。
- data:这是一个联合体,可以包含不同类型的数据。在实际使用中,它通常用于存储文件描述符或与事件相关联的用户定义数据。当事件被触发时,这些信息会被原样返回给用户空间程序。
epoll事件——events成员
头文件:<sys/epoll.h>enum EPOLL_EVENTS {EPOLLIN = 0x001, //读事件EPOLLPRI = 0x002,EPOLLOUT = 0x004, //写事件EPOLLRDNORM = 0x040,EPOLLRDBAND = 0x080,EPOLLWRNORM = 0x100,EPOLLWRBAND = 0x200,EPOLLMSG = 0x400,EPOLLERR = 0x008, //出错事件EPOLLHUP = 0x010, //出错事件EPOLLRDHUP = 0x2000,EPOLLEXCLUSIVE = 1u << 28,EPOLLWAKEUP = 1u << 29,EPOLLONESHOT = 1u << 30,EPOLLET = 1u << 31 //边缘触发};
返回值
- 如果 epoll_ctl 函数执行成功,则返回 0。
- 如果执行失败,则返回 -1,并设置 errno 以指示错误原因。
1.2.1.epoll_ctl函数函数干了什么
- epoll_ctl函数用于增加,删除,修改epoll事件,epoll事件会存储于内核epoll结构体红黑树中。
- 这个也是epoll的事件注册函数,epoll_ctl向 epoll对象中添加、修改或者删除感兴趣的事件,返回0表示成功,否则返回–1,此时需要根据errno错误码判断错误类型。
- 它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
epoll_ctl 函数是 epoll 接口中用于增加、删除或修改 epoll 监控的事件的系统调用。当你对一个 epoll 实例执行 epoll_ctl 操作时,内核会根据操作类型(增加、删除或修改)来更新内部的数据结构,特别是红黑树(RB-tree)和就绪事件列表(rdllist)。
在 epoll 的上下文中,红黑树主要用于高效地存储和查找所有添加到 epoll 实例中的文件描述符(fd)及其对应的事件。每个文件描述符在红黑树中都有一个对应的节点(通常是 struct epitem 类型的结构体),这些节点按照文件描述符的值进行排序,以便于快速查找。
当你通过 epoll_ctl 向 epoll 实例添加一个新的文件描述符和事件时,内核会做以下几件事:
- 分配并初始化 epitem 结构体:为每个新添加的文件描述符分配一个 epitem 结构体,并初始化其成员变量,包括指向文件描述符的指针、事件类型、回调函数等。
- 将 epitem 添加到红黑树中:根据文件描述符的值,将新的 epitem 结构体插入到红黑树中。这保证了文件描述符的快速查找和排序。
当你通过 epoll_ctl 删除一个事件时,内核会从红黑树中找到对应的 epitem 结构体,并将其从树中删除。同时,如果该事件已经在就绪事件列表中,也需要从列表中删除它。
修改事件通常意味着更改事件的某些属性(如事件类型),这可能需要从红黑树中找到对应的 epitem 结构体,并更新其成员变量。但是,修改操作通常不会改变文件描述符在红黑树中的位置。
就绪事件列表(rdllist)用于存储那些已经满足条件(即事件已经发生)的文件描述符。当 epoll_wait 被调用时,内核会遍历红黑树中的 epitem 结构体,检查是否有事件已经就绪,并将它们从红黑树中移动到就绪事件列表中。然后,epoll_wait 会返回这些就绪事件的列表给用户空间。
需要注意的是,虽然红黑树是 epoll 实现中的一个关键数据结构,但 epoll 的高效性并不仅仅依赖于红黑树。epoll 还使用了其他技术,如内存映射(memory-mapped)的就绪事件列表、边缘触发(edge-triggered)和水平触发(level-triggered)事件模式等,来优化事件通知和减少系统调用的开销。
1.3.epoll_wait函数
注意:在调用
epoll_wait
之前,必须先通过epoll_ctl
向 epoll 实例注册文件描述符及其事件。
等待事件的产生,类似于select()调用。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。
- 第1个参数 epfd是 epoll的描述符。也就是epoll_creat返回的文件描述符
- 第2个参数 events则是分配好的 epoll_event结构体数组,epoll将会把发生的事件复制到 events数组中(events不可以是空指针,内核只负责把数据复制到这个 events数组中,不会去帮助我们在用户态中分配内存。内核这种做法效率很高)。
- 第3个参数指定了 events 数组的最大长度,即 epoll_wait 可以告知调用者的最大事件数量。如果同时有多个事件发生时,epoll_wait 将尽可能多地填充 events 数组,但不会超过 maxevents 指定的数量。通常 maxevents参数与预分配的events数组的大小是相等的。
- 第4个参数 timeout表示在没有检测到事件发生时最多等待的时间(单位为毫秒),如果 timeout为0,则表示 epoll_wait在 rdllist链表中为空,立刻返回,不会等待。
返回值:
- 已经就绪的fd的个数,如返回0表示已超时。如果返回–1,则表示出现错误,需要检查 errno错误码判断错误类型。
1.3.1.epoll_wait到底干了什么
epoll_wait 在内核中主要执行了以下操作,以实现高效的事件通知机制:
1. 等待事件发生
- 当进程调用 epoll_wait 时,它会被阻塞(除非设置了非阻塞模式或超时时间),直到有注册的文件描述符上发生了感兴趣的事件。
- epoll_wait 依赖于内核中维护的数据结构(主要是红黑树和就绪链表)来高效地管理这些文件描述符和它们的事件。
2. 检查就绪链表
- 在内核中,epoll 使用了一个就绪链表(就是我们上面提到的struct eventpoll里面的struct list_head rdllist,通常是一个双向链表)来存储那些已经准备好(即发生了感兴趣的事件)的文件描述符。
- 当 epoll_wait 被调用时,它会检查这个就绪链表。如果链表不为空,说明有事件已经发生。
3. 复制事件到用户空间
- 如果就绪链表中有事件,epoll_wait 会将这些事件从内核空间复制到用户空间提供的 epoll_event 结构体数组中。
- 这个过程会尽可能多地复制事件,但不超过用户指定的 maxevents 数量。
4. 更新内核状态
- 在复制事件后,epoll_wait 会更新内核中的数据结构,以反映哪些事件已经被处理。
- 对于边缘触发(ET)模式,如果事件已经被处理并且没有新的数据到来,那么相应的文件描述符可能会被从就绪链表中移除。
5. 唤醒进程
- 一旦有事件被复制到用户空间,epoll_wait 会唤醒调用它的进程,并返回发生的事件数量。
- 如果在调用 epoll_wait 时设置了超时时间,并且在这段时间内没有事件发生,那么 epoll_wait 也会超时返回。
6. 高效性实现
- epoll 之所以高效,主要是因为它避免了像 select 和 poll 那样的重复扫描和文件描述符限制。
- 它使用红黑树来存储和快速查找文件描述符,使用就绪链表来高效地管理就绪事件。
- 这些数据结构使得 epoll 能够在 O(1) 的时间复杂度内完成大部分操作,从而支持大规模的文件描述符和高效的事件通知。
综上所述,epoll_wait 在内核中主要负责等待事件发生、检查就绪链表、复制事件到用户空间、更新内核状态以及唤醒进程等操作,从而实现了高效的事件通知机制。
1.4.epoll的工作过程中内核在干什么
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用密切相关:
struct eventpoll {.../*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,也就是这个epoll监控的事件*/struct rb_root rbr;/*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/struct list_head rdllist;...
};
我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。
所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。
在epoll中对于每一个事件都会建立一个epitem结构体,如下所示:
struct epitem {...//红黑树节点struct rb_node rbn;//双向链表节点struct list_head rdllink;//事件句柄等信息struct epoll_filefd ffd;//指向其所属的eventepoll对象struct eventpoll *ep;//期待的事件类型struct epoll_event event;...
}; // 这里包含每一个事件对应着的信息。
当调用epoll_wait检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_waitx效率非常高。epoll_ctl在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。
【总结】:
- 一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。
- 执行epoll_create()时,创建了红黑树和就绪链表;
- 执行epoll_ctl()时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
- 执行epoll_wait()时立刻返回准备就绪链表里的数据即可。
二,epoll高效的原理
2.1.预备知识的储备
-
第一步:从硬件的角度看计算机怎样接收网络数据
了解epoll本质的第一步,要从硬件的角度看计算机怎样接收网络数据。
在①阶段,网卡收到网线传来的数据;经过②阶段的硬件电路的传输;最终将数据写入到内存中的某个地址上(③阶段)。这个过程涉及到DMA传输、IO通路选择等硬件有关的知识,但我们只需知道:网卡会把接收到的数据写入内存。
通过硬件传输,网卡接收的数据存放到内存中。操作系统就可以去读取它们。
-
问题二:操作系统是怎么知道网卡是有数据了
了解epoll本质的第二步,要从CPU的角度来看数据接收。要理解这个问题,要先了解一个概念——中断。
计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时(电容可以保存少许电量,供CPU运行很短的一小段时间),它应立即去保存数据,保存数据的程序具有较高的优先级。
一般而言,由硬件产生的信号需要cpu立马做出回应(不然数据可能就丢失),所以它的优先级很高。cpu理应中断掉正在执行的程序,去做出响应;当cpu完成对硬件的响应后,再重新执行用户程序。中断的过程如下图,和函数调用差不多。只不过函数调用是事先定好位置,而中断的位置由“信号”决定。
以键盘为例,当用户按下键盘某个按键时,键盘会给cpu的中断引脚发出一个高电平。cpu能够捕获这个信号,然后执行键盘中断程序。
现在可以回答本节提出的问题了:当网卡把数据写入到内存后,网卡向cpu发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。
- 问题三——操作系统怎么知道红黑树上的哪些节点就绪了
操作系统怎么知道红黑树上的哪些节点就绪了呢?难道操作系统也要遍历整棵红黑树,检测每个节点的就绪情况?操作系统其实并不会这样做,如果这样做的话,那epoll还谈论什么高效呢?你epoll不也得遍历所有的fd吗?和我poll遍历有什么区别呢?红黑树是查找的效率高,不是遍历的效率高,如果遍历所有的节点,红黑树其实和链表遍历在效率上是差不多的,一点都不高效!
那操作系统是怎么知道红黑树上的哪个节点就绪了呢?其实是通过底层的回调机制来实现的,这也是epoll接口公认非常高效的重要的一个实现环节!
当数据到达网卡时,我们知道数据会经过硬件中断,CPU执行中断向量表等步骤来让数据到达内存中的操作系统内部,而所有添加到epoll模型中的事件都会与网卡建立回调关系,当事件就绪时调用这个回调方法,将就绪的事件链接到就绪队列当中,这个回调方法在内核中叫做ep_poll_callback。
2.2.内核接受网络数据的全过程
当数据到达网络设备网卡时,会以硬件中断作为发起点,将中断信号通过中断设备发送到CPU的针脚,接下来CPU会查讯中断向量表,找到中断序号对应的驱动回调方法,在回调方法内部会将数据从硬件设备网卡拷贝到软件OS里。数据包在OS中会向上贯穿协议栈,到达传输层时,数据会被拷贝到struct file的内核缓冲区中,同时OS会执行一个叫做private_data的回调函数指针字段,在该回调函数内部会通过修改红黑树节点中的就绪队列指针的内容,将该节点链入到就绪队列,内核告知用户哪些fd就绪时,只需要将就绪队列中的节点内容拷贝到epoll_wait的输出型参数events即可,这就是epoll模型的底层回调机制!
2.3.一些小问题
- 1.为什么说epoll模型是高效的呢?
因为大部分的工作操作系统都帮我们做了,比如添加节点到红黑树,我们只需要调用epoll_ctrl即可,返回就绪的fd,直接相当于返回就绪队列中的节点即可,上层直接就可以拿到就绪的fd,检测是否就绪的工作也不用遍历,而是当底层数据就绪时,会有回调机制自动将红黑树的节点链入到就绪队列中,操作系统也无须遍历红黑树进行就绪检测,上层在拿到就绪的fd后,可以确定范围的遍历输出型参数struct epoll_event数组,而不是盲目的遍历整个数组的所有元素。
- 2.为什么选用红黑树作为epoll模型的底层数据结构?
因为红黑树的搜索效率非常的高,可以达到logN的时间复杂度,所以无论是epoll_ctl的插入,删除还是修改,这些工作的首要前提是先找到目标节点或目标位置,找到之后,再进行具体的操作,而找到这一步红黑树的效率就非常的高。
有人可能会说红黑树需要旋转调整平衡啊,虽然在逻辑上我们感觉红黑树的旋转调平衡很费时间,可能会造成红黑树的效率降低,但其实并不是这样的,所谓的旋转调平衡只是在逻辑上复杂而已,在实际操作上仅仅只是修改节点内的指针而已,对红黑树的效率影响并不大。
同时红黑树对于平衡的要求并没有AVL高,所以在旋转调平衡的次数上,红黑树要比AVL树少很多,在整体效率上是要比AVL树高的,这也是使用红黑树,不使用AVL树的原因。
- 3.epoll_wait有哪些细节?
- epoll_wait会将所有就绪的fd,依次按照顺序放到输出型参数events中,用户在遍历数组处理就绪的事件时,无须遍历多余的任何一个fd,只需要遍历从0到epoll_wait的返回值个fd即可。
- 如果就绪队列的节点数量很多,epoll_wait的输出型参数数组一次拿不完也不用担心,因为队列是先进先出,下一次在调用epoll_wait时,再拿就绪的事件也可以。
- select poll在使用的时候,都需要程序员自己维护一个第三方数组来存储用户关心的fd及事件,但epoll不需要,因为内核为epoll在底层维护了一棵红黑树,用户直接通过epoll_ctl来对红黑树的节点进行增删改即可,无须自己在应用层维护第三方的数组。
三,简陋版本epoll版本TCP服务器
3.1.准备工作
Socket.hpp
#pragma once #include <iostream>
#include <string>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h> // 定义一些错误代码
enum
{ SocketErr = 2, // 套接字创建错误 BindErr, // 绑定错误 ListenErr, // 监听错误
}; // 监听队列的长度
const int backlog = 10; class Sock //服务器专门使用
{
public: Sock() : sockfd_(-1) // 初始化时,将sockfd_设为-1,表示未初始化的套接字 { } ~Sock() { // 析构函数中可以关闭套接字,但这里选择不在析构函数中关闭,因为有时需要手动管理资源 } // 创建套接字 void Socket() { sockfd_ = socket(AF_INET, SOCK_STREAM, 0); if (sockfd_ < 0) { printf("socket error, %s: %d", strerror(errno), errno); //错误 exit(SocketErr); // 发生错误时退出程序 } int opt=1;setsockopt(sockfd_,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt)); //服务器主动关闭后快速重启} // 将套接字绑定到指定的端口上 void Bind(uint16_t port) { //让服务器绑定IP地址与端口号struct sockaddr_in local; memset(&local, 0, sizeof(local));//清零 local.sin_family = AF_INET; // 网络local.sin_port = htons(port); // 我设置为默认绑定任意可用IP地址local.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口 if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) < 0) //让自己绑定别人{ printf("bind error, %s: %d", strerror(errno), errno); exit(BindErr); } } // 监听端口上的连接请求 void Listen() { if (listen(sockfd_, backlog) < 0) { printf("listen error, %s: %d", strerror(errno), errno); exit(ListenErr); } } // 接受一个连接请求 int Accept(std::string *clientip, uint16_t *clientport) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int newfd = accept(sockfd_, (struct sockaddr*)&peer, &len); if(newfd < 0) { printf("accept error, %s: %d", strerror(errno), errno); return -1; } char ipstr[64]; inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr)); *clientip = ipstr; *clientport = ntohs(peer.sin_port); return newfd; // 返回新的套接字文件描述符 } // 连接到指定的IP和端口——客户端才会用的 bool Connect(const std::string &ip, const uint16_t &port) { struct sockaddr_in peer;//服务器的信息 memset(&peer, 0, sizeof(peer)); peer.sin_family = AF_INET; peer.sin_port = htons(port);inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr)); int n = connect(sockfd_, (struct sockaddr*)&peer, sizeof(peer)); if(n == -1) { std::cerr << "connect to " << ip << ":" << port << " error" << std::endl; return false; } return true; } // 关闭套接字 void Close() { close(sockfd_); } // 获取套接字的文件描述符 int Fd() { return sockfd_; } private: int sockfd_; // 套接字文件描述符
};
main.cc
#include"EpollServer.hpp"
#include<memory>int main()
{std::unique_ptr<EpollServer> svr(new EpollServer());svr->Init();svr->Start();
}
makefile
epoll_server:main.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -rf epoll_server
EpollServer.hpp
#pragma once#include<iostream>
#include<sys/epoll.h>
#include"Socket.hpp"const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IPclass EpollServer
{
public:EpollServer(const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port){}~EpollServer(){listensock_.Close();}void Init(){listensock_.Socket();listensock_.Bind(port_);listensock_.Listen();}void Start(){}private:uint16_t port_; // 绑定的端口号Sock listensock_; // 专门用来listen的std::string ip_; // ip地址
};
我们这里先不在EpollServer.hpp直接调用epoll_create,epoll_ctl,eoll_wait等,我们先对epoll的各类接口进行封装,封装到Epoller.hpp里面。
首先,我们需要保证我们的Epoller对象是不能被复制的
nocopy.hpp
#pragma once class nocopy
{
public: // 允许使用默认构造函数(由编译器自动生成) nocopy() = default; // 禁用拷贝构造函数,防止通过拷贝来创建类的实例 nocopy(const nocopy&) = delete; // 禁用赋值运算符,防止类的实例之间通过赋值操作进行内容复制 nocopy& operator=(const nocopy&) = delete;
};
这个是用来防止epoll被拷贝的
Epoller.hpp
#pragma once#include<iostream>
#include"nocopy.hpp"class Epoller : public nocopy //Eopller是nocpy的子类
{
public:
Epoller()
{}
~Epoller()
{
}private:
int epfd;
};
我们可以测试一下
main.cc
很明显有错误了!!这样子我们的Epoll对象也就不能被复制啦!
Epoller.hpp
#pragma once#include <iostream>
#include <sys/epoll.h>
#include <unistd.h>
#include <cerrno>
#include "nocopy.hpp"class Epoller : public nocopy
{static const int size = 128;public:Epoller(){_epfd = epoll_create(size);if (_epfd == -1){perror("epoll_creat error");}else{printf("epoll_creat successful:%d\n", _epfd);}}~Epoller(){if (_epfd > 0){close(_epfd);}}private:int _epfd;
};
我们回到我们的EpollServer.hpp
EpollServer.hpp
#pragma once#include <iostream>
#include <sys/epoll.h>
#include <memory>
#include "Socket.hpp"
#include "Epoller.hpp"const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IPclass EpollServer
{
public:EpollServer(const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port),listensock_ptr(new Sock()),epoller_ptr(new Epoller()){}~EpollServer(){listensock_ptr->Close();}void Init(){listensock_ptr->Socket();listensock_ptr->Bind(port_);listensock_ptr->Listen();}void Start(){for(;;){}}private:uint16_t port_; // 绑定的端口号std::string ip_; // ip地址std::unique_ptr<Sock> listensock_ptr; // 专门用来listen的std::unique_ptr<Epoller> epoller_ptr;
};
我们编译运行一下,
3.2.EpollServer.hpp
epoll模型可是只负责IO模型里面的等待部分。
为了美观一点,我们接着封装我们的Epoller,首先我们把我们的epoll_wait函数进行封装一下
Epoller.hpp的EpollWait函数
。。。
class Epoller : public nocopy
{。。。int EpollerWait(struct epoll_event revents[],int num){int n=epoll_wait(_epfd,revents,num,3000);return n;}
。。。
private:int _epfd;
};
EpollerServer.hpp
class EpollServer
{const static int num =64;
。。。void Start(){struct epoll_event revs[num];for(;;){int n=epoller_ptr->EpollerWait(revs,num);if(n>0)//有事件就绪{}else if(n==0)//超时了{std::cout<<"time out..."<<std::endl;}else//出错了{std::cerr<<"EpollWait error"<<std::endl;}}}private:uint16_t port_; // 绑定的端口号std::string ip_; // ip地址std::unique_ptr<Sock> listensock_ptr; // 专门用来listen的std::unique_ptr<Epoller> epoller_ptr;
};
我们接着写Epoller.hpp的epoll_ctl函数的封装
class Epoller : public nocopy
{static const int size = 128;int EpollUpDate(int oper,int sock,uint16_t event){int n;if(oper==EPOLL_CTL_DEL)//将该事件从epoll红黑树里面删除{n=epoll_ctl(_epfd,oper,sock,nullptr);if(n!=0){perror("delete epoll_ctl error");}}else{//添加和修改,即EPOLL_CTL_MOD和EPOLL_CTL_ADDstruct epoll_event ev;ev.events=event;ev.data.fd=sock;n=epoll_ctl(_epfd,oper,sock,&ev);if(n!=0){perror("delete epoll_ctl error");}}return n;}private:int _epfd;int _timeout{3000};
};
接下来我们就可以去写我们的代码了
EpollServer.hpp的Start函数
void Start(){//将listen套接字添加到epoll中->将listensock和他关心的事件,添加到内核的epoll模型中的红黑树里面//将listensock添加到红黑树epoller_ptr->EpollUpDate(EPOLL_CTL_ADD,listensock_ptr->Fd(),EPOLLIN);struct epoll_event revs[num];for(;;){int n=epoller_ptr->EpollerWait(revs,num);if(n>0)//有事件就绪{std::cout<<"event happened,fd :"<<revs[0].data.fd<<std::endl;}else if(n==0)//超时了{std::cout<<"time out..."<<std::endl;}else//出错了{std::cerr<<"EpollWait error"<<std::endl;}}}
我们运行一下我们的程序,然后使用telnet工具测试一下
很好!!! 然后我们很快就能写出下面这些代码
EpollerServer.hpp测试版
#pragma once#include <iostream>
#include <sys/epoll.h>
#include <memory>
#include "Socket.hpp"
#include "Epoller.hpp"const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IPclass EpollServer
{const static int num = 64;public:EpollServer(const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port), listensock_ptr(new Sock()), epoller_ptr(new Epoller()){}~EpollServer(){listensock_ptr->Close();}void Init(){listensock_ptr->Socket();listensock_ptr->Bind(port_);listensock_ptr->Listen();}void Accepter(){std::string clientip;uint16_t clientport;int sock = listensock_ptr->Accept(&clientip, &clientport);if (sock > 0) // 连接成功{// 获取连接成功之后,我们应该把这个连接的文件描述符加入到epoll里面,让epoll来关心对应事件epoller_ptr->EpollUpDate(EPOLL_CTL_ADD, sock, EPOLLIN);}}void Receiver(int fd){char in_buff[1024];int n = read(fd, in_buff, sizeof(in_buff) - 1);if (n > 0){in_buff[n] = 0;std::cout << "get message: " << in_buff << std::endl;// 写事件std::string buff=in_buff;std::string echo_str = "server echo:" + buff;write(fd,echo_str.c_str(),echo_str.size());}else if (n == 0) // 客户端关闭连接{// 我们要把这个连接从epoll的红黑树里面移除掉epoller_ptr->EpollUpDate(EPOLL_CTL_DEL, fd, 0);std::cout << "client close connect,fd:" << fd << std::endl;close(fd); // 我服务器也要关闭连接的文件描述符}else // 出现错误{// 我们要把这个连接从epoll的红黑树里面移除掉epoller_ptr->EpollUpDate(EPOLL_CTL_DEL, fd, 0);std::cout << "recv reeor,fd:" << fd << std::endl;close(fd); // 我服务器也要关闭连接的文件描述符}}void HandlerEvent(struct epoll_event revs[], int num) // epoll_wait的返回值n代表有n个事件就绪{for (int i = 0; i < num; i++){int fd = revs[i].data.fd; // 哪个文件描述符就绪了uint32_t event = revs[i].events; // 什么事情就绪了if (event & EPOLLIN) // 是读事件就绪了{if (fd == listensock_ptr->Fd()) // 获取了新连接{Accepter();}else // 其他fd上的普通读事件就绪{Receiver(fd);}}else if (event & EPOLLOUT) // 是写事件就绪了{}else // 其他事件{}}}void Start(){// 将listen套接字添加到epoll中->将listensock和他关心的事件,添加到内核的epoll模型中的红黑树里面// 将listensock添加到红黑树epoller_ptr->EpollUpDate(EPOLL_CTL_ADD, listensock_ptr->Fd(), EPOLLIN);struct epoll_event revs[num];for (;;){int n = epoller_ptr->EpollerWait(revs, num); // 返回值代表有n个事件就绪if (n > 0) // 有事件就绪{std::cout << "event happened,fd :" << revs[0].data.fd << std::endl;HandlerEvent(revs, n); // 事件就绪的本质就是看他的文件描述符在不在就绪队列里面}else if (n == 0) // 超时了{std::cout << "time out..." << std::endl;}else // 出错了{std::cerr << "EpollWait error" << std::endl;}}}private:uint16_t port_; // 绑定的端口号std::string ip_; // ip地址std::unique_ptr<Sock> listensock_ptr; // 专门用来listen的std::unique_ptr<Epoller> epoller_ptr;
};
为了
我们运行一下,来看看
很完美啊!!!
我们第一阶段的代码就写到这里,更深入的问题,我们留到进阶篇来讲解
3.3.源代码
nocopy.hpp
#pragma once class nocopy
{
public: // 允许使用默认构造函数(由编译器自动生成) nocopy() = default; // 禁用拷贝构造函数,防止通过拷贝来创建类的实例 nocopy(const nocopy&) = delete; // 禁用赋值运算符,防止类的实例之间通过赋值操作进行内容复制 nocopy& operator=(const nocopy&) = delete;
};
Epoller.hpp
#pragma once#include <iostream>
#include <sys/epoll.h>
#include <unistd.h>
#include <cerrno>
#include "nocopy.hpp"class Epoller : public nocopy
{static const int size = 128;public:Epoller(){_epfd = epoll_create(size);if (_epfd == -1){perror("epoll_creat error");}else{printf("epoll_creat successful:%d\n", _epfd);}}~Epoller(){if (_epfd > 0){close(_epfd);}}int EpollerWait(struct epoll_event revents[],int num){int n=epoll_wait(_epfd,revents,num,3000);return n;}int EpollUpDate(int oper,int sock,uint16_t event){int n;if(oper==EPOLL_CTL_DEL)//将该事件从epoll红黑树里面删除{n=epoll_ctl(_epfd,oper,sock,nullptr);if(n!=0){perror("delete epoll_ctl error");}}else{//添加和修改,即EPOLL_CTL_MOD和EPOLL_CTL_ADDstruct epoll_event ev;ev.events=event;ev.data.fd=sock;//方便我们知道是哪个fd就绪了n=epoll_ctl(_epfd,oper,sock,&ev);if(n!=0){perror("delete epoll_ctl error");}}return n;}private:int _epfd;
};
EpollServer.hpp
#pragma once#include <iostream>
#include <sys/epoll.h>
#include <memory>
#include "Socket.hpp"
#include "Epoller.hpp"const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IPclass EpollServer
{const static int num = 64;public:EpollServer(const uint16_t port = default_port, const std::string ip = default_ip): ip_(ip), port_(port), listensock_ptr(new Sock()), epoller_ptr(new Epoller()){}~EpollServer(){listensock_ptr->Close();}void Init(){listensock_ptr->Socket();listensock_ptr->Bind(port_);listensock_ptr->Listen();}void Accepter(){std::string clientip;uint16_t clientport;int sock = listensock_ptr->Accept(&clientip, &clientport);if (sock > 0) // 连接成功{// 获取连接成功之后,我们应该把这个连接的文件描述符加入到epoll里面,让epoll来关心对应事件epoller_ptr->EpollUpDate(EPOLL_CTL_ADD, sock, EPOLLIN);}}void Receiver(int fd){char in_buff[1024];int n = read(fd, in_buff, sizeof(in_buff) - 1);if (n > 0){in_buff[n] = 0;std::cout << "get message: " << in_buff << std::endl;// 写事件std::string buff=in_buff;std::string echo_str = "server echo:" + buff;write(fd,echo_str.c_str(),echo_str.size());}else if (n == 0) // 客户端关闭连接{// 我们要把这个连接从epoll的红黑树里面移除掉epoller_ptr->EpollUpDate(EPOLL_CTL_DEL, fd, 0);std::cout << "client close connect,fd:" << fd << std::endl;close(fd); // 我服务器也要关闭连接的文件描述符}else // 出现错误{// 我们要把这个连接从epoll的红黑树里面移除掉epoller_ptr->EpollUpDate(EPOLL_CTL_DEL, fd, 0);std::cout << "recv reeor,fd:" << fd << std::endl;close(fd); // 我服务器也要关闭连接的文件描述符}}void HandlerEvent(struct epoll_event revs[], int num) // epoll_wait的返回值n代表有n个事件就绪{for (int i = 0; i < num; i++){int fd = revs[i].data.fd; // 哪个文件描述符就绪了uint32_t event = revs[i].events; // 什么事情就绪了if (event & EPOLLIN) // 是读事件就绪了{if (fd == listensock_ptr->Fd()) // 获取了新连接{Accepter();}else // 其他fd上的普通读事件就绪{Receiver(fd);}}else if (event & EPOLLOUT) // 是写事件就绪了{}else // 其他事件{}}}void Start(){// 将listen套接字添加到epoll中->将listensock和他关心的事件,添加到内核的epoll模型中的红黑树里面// 将listensock添加到红黑树epoller_ptr->EpollUpDate(EPOLL_CTL_ADD, listensock_ptr->Fd(), EPOLLIN);struct epoll_event revs[num];for (;;){int n = epoller_ptr->EpollerWait(revs, num); // 返回值代表有n个事件就绪if (n > 0) // 有事件就绪{std::cout << "event happened,fd :" << revs[0].data.fd << std::endl;HandlerEvent(revs, n); // 事件就绪的本质就是看他的文件描述符在不在就绪队列里面}else if (n == 0) // 超时了{std::cout << "time out..." << std::endl;}else // 出错了{std::cerr << "EpollWait error" << std::endl;}}}private:uint16_t port_; // 绑定的端口号std::string ip_; // ip地址std::unique_ptr<Sock> listensock_ptr; // 专门用来listen的std::unique_ptr<Epoller> epoller_ptr;
};
main.cc
#include"EpollServer.hpp"
#include<memory>int main()
{std::unique_ptr<EpollServer> svr(new EpollServer());svr->Init();svr->Start();
}
到这里我们对Epoll算是有一定的认识了,但是这不够,我们需要更深入的学习epoll,至于epoll的更深入的知识,我留到下一篇来介绍