一棵红黑树, 能玩到这个地步也只有epolll了. deep on epoll model(including extensions in libevent)
你现在用 epoll 模型么? 不用. 我用libevent. (笑)
多路IO/IO复用中一种比较厉害的模型, 始终拿红黑树去理解就容易了.
现在的很多库比如libevent都是在其基础上进行的扩展和封装.
(文章主要以TCP协议的基础上总结了一下epoll)
(由于select, pselect, poll从设计机制上就不如epoll, 说它们就没有太多意思)
注意, 本文一定不会去给你剖析底层源码, 请自己翻看源码.
引子
专门说说 Epoll模型, 包括其扩展:
- 相关API, 案例
- 触发模式: ET(边缘)/LT(水平)
- 事件扩展: 泛型指针, 回调机制, libevent的核心(封装event data)
正文
简述
epoll 是 Linux 下多路复用 IO 接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率(有针对的轮询).
(简单说, 特别针对那种连接多, 但是活跃连接数少的情况)
主要原因: (优于select/poll的技术手段)
- 复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合
- 获取事件的时候, 它无须遍历整个被侦听的描述符集, 只要遍历那些(被内核 IO 事件异步唤醒而)加入 Ready 队列的描述符集合(专门有一个数组)
- 提供了边沿触发(Edge Triggered), 这就使得用户空间程序有可能缓存 IO 状态,减少 epoll_pwait 的调用, 提高应用程序效率
- 支持检测更多的socket描述符上限(cat /proc/sys/fs/file-max) (一般是9万多, 取决于硬件条件)
- 利用mmap进行内存映射, 减少了用户空间和内核空间来回拷贝数据的开销
关于修改打开文件描述符的上限:(file-max)
sudo emacs /etc/security/limits.conf
在文件尾部写入以下配置: soft 软限制, hard 硬限制,
1 | * soft nofile 65536 |
这个和 poll
基本类似(但是select不行, 代码实现的时候做了限定, 除非你修改内核代码, 重新编译内核)
极端的情况: 如果你要监听的文件描述符(连接的), 全部活跃(大部分活跃)(有IO请求), 那么select/poll, 和epoll其实没有太多差别(轮询方面).
(但是请求量一旦突破软件限制, 就需要靠硬件解决了, 增核心, 加内存, 集群等)
API
1 |
|
epoll_create()
底层实现就是建立一棵RBTree(平衡二叉树的一种), 用于检测.
int epoll_create(int size)
其中 size 指监听数目, 但这只是对内核的一个建议, 底层分配多少还是内核自己根据算法判断决定.
进程的内核地址空间, 3-4G的用户空间pcb指向全局的文件描述符表, 而epoll_create(int)打开的的epoll fd就成了平衡二叉树(rb-tree)的树根, 而你传入的size就是创建该rb-tree的依据.
epoll_ctl()
这个函数就是相当于把一个个fd节点挂到epoll_create创建的rb-tree上.
(插入的时候, 会进行一定的旋转, 保证左子树和右子树平衡)
1 |
|
(后续的框架会对该函数进行封装)
参数不少, 一个个说.
- epfd: epoll_create 创建的句柄
- op: 表示动作,用 3 个宏来表示:
- EPOLL_CTL_ADD (注册新的 fd 到 epfd),
- EPOLL_CTL_MOD (修改已经注册的 fd 的监听事件)
- EPOLL_CTL_DEL (从 epfd 删除一个 fd)
- fd: 需要操作的文件描述符
- event: 告诉内核需要监听的事件数组(相当于poll中POLLIN, POLLOUT, POLLERR等)
( epoll_event 这里使用结构体, 而不是poll中的整型)
其中event结构体原型如下:1
2
3
4
5
6
7
8
9
10
11struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr; /*注意这个泛型指针*/
int fd; /*有事件发生的时候, 可以立即返回具体的相应文件描述符; 和epoll_ctl()中的fd一致*/
uint32_t u32;
uint64_t u64;
} epoll_data_t;
关于 __uint32_t events; /* Epoll events */
主要还是3个:
- EPOLLIN : 表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)
- EPOLLOUT: 表示对应的文件描述符可以写
- EPOLLERR: 表示对应的文件描述符发生错误
其他不太重要的event:
- EPOLLPRI: 表示对应的文件描述符有紧急的数据可读
- EPOLLHUP: 表示对应的文件描述符被挂断;
- EPOLLET: 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)而言的
- EPOLLONESHOT: 只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个 socket 的话, 需要再次把这个socket 加入到 EPOLL 队列里
epoll_wait
真正开始监听(轮询), 就相当于poll或者select函数调用了, 阻塞检测.
1 |
|
参数说明:
- events: 用来存内核得到事件的数组(传出参数)
- maxevents: 告诉内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create()时的 size,
- timeout: 是超时时间
- -1: 阻塞
- 0 : 立即返回, 非阻塞
0: 指定(毫秒)
返回值: 成功返回有多少文件描述符就绪(返回个数), 时间到时返回0, 出错返回-1.
(返回的同时, 也会把红黑树上有对应事件发生的节点的数据epoll_event节点数据拷贝到events数组中)
总体而言:
创建了一个需要监听的红黑树, 然后真正监听后, 活跃的返回到一个指定的数组, 然后遍历该数组即可.
(遍历的次数就是epoll_wait()返回的个数, 注意一下因为遍历的是事件数组, 从该数组的元素中再去取的具体的fd, 而不是select/poll那种直接找fd, 其中心在event而不再fd)
之后的工作?
判断数组中的fd, 如果是listenfd, 就创建新的连接啊; 如果是已经连接的fd那就要看是什么事件(一般是read).
(很显然, 这种固定的模式, 就可以做成回调函数; 网络框架中也是这么封装的)
简单的示例
1 | int size = 1000; |
(后面省略创建连接或者IO的过程)
完整的案例
请直接参考: (代码太长了, 占版面)
https://github.com/WizardMerlin/network_life/tree/master/code/epoll
触发模式
LT 模式即 Level Triggered 工作模式, 与 ET 模式不同的是,以 LT 方式调用 epoll 接口的时候,它就相当于一个速度比较快的 poll, 无论后面的数据是否被使用.
简单描述
- Edge Triggered (ET) 边缘触发只有数据到来才触发, 不管缓存区中是否还有数据.
- Level Triggered (LT) 水平触发只要有数据都会触发(epoll_wait)
(客户端发来1024字节数据, 服务器端读了512字节, 剩下512字节在缓冲区里面, 如果是ET模式 epoll_wait就不会触发(除非客户端再写一次数据, 才会引发), 如果是LT就立马触发epoll_wait以期下一次读操作)
详细解释
- LT(level triggered): LT 是缺省的工作方式, 并且同时支持
block
和non-block
socket. 在这种做法中, 内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作. 如果你不作任何操作, 内核还是会继续通知你的, 所以这种模式编程出错误可能性要小一点. 传统的 select/poll 都是这种模型的代表. - ET(edge-triggered): ET 是高速工作方式, 只支持 no-block socket. 在这种模式下, 当描述符从未就绪变为就绪时, 内核通过 epoll 告诉你. 然后它会假设你知道文件描述符已经就绪, 并且不会再为那个文件描述符发送更多的就绪通知. 请注意, 如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪), 内核不会发送更多的通知(only once).
(LT是默认的模式)为什么引入ET模式?
(LT相当于持续触发, 如果用户空间缓冲区里面有数据, 那么就会不停的调用epoll检测以触发后续读操作. 如果是同步IO, 如果读操作没有完成, 就一直阻塞直到完成(读完为止), 特别是客户端写了太多数据的时候, 它可能会带一个消息头, 通过消息头判断有没有必要读取剩余的数据, 此时采用异步IO+ET触发模式就很有用(先读指定字节的消息头, 以此判断是否继续读); 另一种最常见的情况是, read函数指定要读1024个字节, 但是缓冲区里面不够, 那么LT模式此时也会等待, 直到读满才返回; 如果客户端后面没有写, 就会一直等待而不去触发epoll_wait(), 不触发则不能再读, 死锁了, 此时ET模式就很有用了)
简单一句话: 配合异步IO, 减少epoll_wait的调用次数, 降低cpu负担.
使用注意
LT模式比较简单(也是默认的方式), 并且支持阻塞和非阻塞两种方式.
ET模式, 使用要非常注意:
- epoll 工作在 ET 模式的时候, 必须使用非阻塞套接口, 以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死
- read或者write的结果>0但是小于指定的size, 就可以认为缓冲区已经没有数据了(读完毕了), 而不是非要等到返回EAGAIN.
总结一下就是, 把原来需要多次调用epoll_wait的代价转嫁到了异步IO上了(减少了epoll_wait调用).
案例
(异步IO + ET触发模式)
1 | /* server.c */ |
client.c
1 | /* client.c */ |
事件的扩展
简述思想
epoll事件结构体的第二个参数, 可以从 void *ptr
这个泛型指针做扩展(这也是很多网络库的核心思想, 比如libevent)
1 | typedef union epoll_data { |
相关扩展你就可以理解成(libevent的核心思想): 服务器写回之前要判断一下是否可以写回去, 之后再检测客户端请求.
(中间对于事件读写, 即检测的红黑树节点有多次IO状态的变化)
具体:
epoll的原始使用方式是客户端发送请求过来, epoll_wait()返回, 这个时候connfd是可读的, 一般epoll的处理逻辑就是马上进行process一些列服务器处理, 处理完毕之后写回给客户端, 但是如果写端忙(写端IO阻塞)那么就需要等待.而扩展的事件模型是, connfd可读的时候, 先把它从红黑树上摘下来, 即 epoll_ctl()
选择EPOLL_CTL_DEL选项, 重新设置connfd监听, 监听它的写事件; 设置完毕之后, 在进行服务器端相关业务操作也不迟, 业务操作完毕之后, 等待epoll_wait()的写事件返回, 返回后再进行回写给客户端, 写事件完毕之后, 再设置相关的读事件(等待客户端的数据). 每次读写前, 由于上一次已经重置了, 所以当真正读写之后才再次重置EPOLLIN或者EPOLLOUT.
中间工序划分更加细致了(epoll_wait()一返回之后, 在服务器真正业务操作之前, 先设置connfd的写操作, 在让epoll_wait()监听), 或者换句话说, 服务器再写回之前, 还要判断是否可以写(并且写完之后, 还要还原检测读事件)
因为服务器端处理完毕业务, 不一定能回写客户端(缓冲区大小限制了, 或者说滑动窗口已满)
简要代码
1 | events[i].events = EPOLLIN; |
反正是ptr一个void*泛型指针, 想传入哪种结构体指针可以自定义, 但是至少应该包含这样几个字段
1 | struct { |
详细的参考可以这样定义:
1 | struct event_data { |
关于回调, 例如listenfd的回调, 可能就是accept函数;
并且传递给回调函数的指针 void* arg
指向该结构体本身, 例如是 &event_data[i]
.
(因为后续要拿到ready数组元素的具体状态, 或者成员数据)
详细代码
(大概就是libevent的核心代码了 epoll_loop.c
)
请参考: (又是大片代码)
https://github.com/WizardMerlin/network_life/blob/master/code/epoll_extend/epoll_loop.c
尾巴
现在有的公司在使用现成的框架, 但是还有的公司是在自己封装epoll代码.
epoll模型在网络, 服务器开发中非常重要.
就这么多吧, 后续专门说说 libevent
, 看到这个 event
就感觉有意思.(大致加上了错误处理, 异常检测等, 但核心应该差不多)