细说八股 | NIO的水平触发和边缘触发到底有什么区别?
但凡你简历上写着熟悉NIO
或者熟悉网络编程之类的,或多或少的会被面试官问到 “水平触发和边缘触发到底有什么区别?”。但从业务开发的角度来说,你基本上不会接触到这么底层的系统调用,框架都给你屏蔽了底层的细节,互联网上对这类问题将其调侃为八股文。虽说是八股,但要仔细考究起来也颇费些头发,如果对你有所帮助,麻烦来个赞呗。
水平/边缘触发是系统调用poll
/select
/epoll
下的一个事件触发机制的选项,本文以使用最为广泛的epoll
系统调用为例,对水平触发/边缘触发这两个概念结合实例进行解释。
阅读本文,你至少需要
- 熟悉
Python
, 我们将用其来实现客户端, 用来控制数据收发行为以达到测试目的 - 熟悉
C
, 我们将用其来实现服务端代码 - 知道
Socket
是什么玩意
测试程序
epoll 快速入门
epoll
是Linux系统特有的IO机制, 可以高效的监控数以百万计的的文件描述符上发生的IO事件, 主要包含以下三个系统调用:
epoll_create 用于创建一个epoll instance, 返回该实例的文件描述符, 可以预先告诉内核可能需要监控的文件描述符数量, 但这个参数目前没有特别大的意义,内核有他自己的想法。
int epoll_create(int size );
epoll_ctl 用于向epoll instance设置对指定文件描述符感兴趣的IO事件, 其参数如下所示
epfd
epoll实例的文件描述符op
要执行的操作(添加/删除/修改)fd
要监控IO事件的文件描述符event
在该结构体中定义你感兴趣的IO事件以及附加数据
int epoll_ctl(int epfd , int op , int fd , struct epoll_event * event );
epoll_event
的结构体定义如下所示
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* 感兴趣的IO事件 */
epoll_data_t data; /* 和目标文件描述符绑定的数据, epoll_wait方法返回会附带此数据 */
};
epoll_wait 用于轮询IO事件, 当有IO事件就绪时,该方法会返回就绪的事件数目
epfd
epoll实例的文件描述符events
就绪事件数组, 内核会将就绪的IO事件保存到该数组中(你需要预先定义一个分配好内存的epoll_event
数组maxevents
就绪事件数组的大小timeout
轮询等待时间, 如果为-1
则表示阻塞等待到IO事件发生
int epoll_wait(int epfd , struct epoll_event * events ,int maxevents , int timeout );
除此之外,我们有必要了解一下在epoll叙事体系下内核对于IO事件的定义
EPOLLIN
关联的文件描述符有数据可供读取EPOLLOUT
关联的文件描述符可以写出数据EPOLLRDHUP
对端套接字关闭了连接
最直观的体验是客户端调用了close方法就会触发此事件, 同时也会触发EPOLLIN事件
EPOLLPRI
有紧急数据可读(有外带数据可读)
无实践经验, 可以参考以下文章
EPOLLERR
关联的文件描述符有错误发生EPOLLHUP
关联的文件描述符读写均已关闭
触发模式:
- 默认使用水平触发
EPOLLET
边缘触发
服务端代码
为了方便测试我们需要将服务端的数据收发逻辑以及水平/边缘触发逻辑做成可以通过参数配置, 为此我们至少需要两个参数来控制这种行为,一个用于控制触发模式,一个用于控制数据读写逻辑。
epoll_test IP PORT ET/LT BUFFER_SIZE
IP
和PORT
用来指定服务端监控的地址ET/LT
用来选择触发模式(水平触发/边缘触发)BUFFER_SIZE
用来指定服务端读缓冲区的大小(通过此参数来对比水平触发和边缘触发的区别)
由于代码过长,不利于阅读,在文章中我们仅贴出IO处理部分的代码。(完整代码见评论区)
for(;;) {
ready = epoll_wait(epfd, evlist, EVLIST_SIZE, -1);
if (ready == -1) {
if (errno = EINTR) {
continue;
} else {
errExit("epoll failed\n");
}
}
printf("ready %d\n", ready);
for (i = 0; i < ready; i++) {
if (evlist[i].data.fd == listen_fd) {
client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);
printf("[Server] Accept fd %d addr %s:%d trigger mode %s\n", client_fd,
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), trigger_mode);
ev.events = EPOLLIN | EPOLLRDHUP | epoll_mode;
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
continue;
}
printf("[Client] fd=%d events: %s%s%s%s\n", evlist[i].data.fd,
(evlist[i].events & EPOLLIN) ? "EPOLLIN " : "",
(evlist[i].events & EPOLLHUP) ? "EPOLLHUP ": "",
(evlist[i].events & EPOLLERR) ? "EPOLLERR " : "",
(evlist[i].events & EPOLLRDHUP) ? "EPOLLRDHUP": "");
if (evlist[i].events & (EPOLLHUP | EPOLLERR)) {
printf("[Client] close fd=%d\n", evlist[i].data.fd);
close(evlist[i].data.fd);
continue;
}
if (evlist[i].events & EPOLLIN) {
int s;
s = read(evlist[i].data.fd, buf, max_buf);
if (s > 0 ) {
printf("[Client] fd=%d read %d bytes\n", evlist[i].data.fd, s);
continue;
}
if (s == -1) {
printf("[Client] fd=%d read failed\n", evlist[i].data.fd);
} else if (s == 0 && evlist[i].events & EPOLLRDHUP) {
printf("[Client] fd=%d peer client closed\n", evlist[i].data.fd);
close(evlist[i].data.fd);
}
}
}
}
如以上代码所示,我们的I/O处理部分主要是接受客户端连接并注册对客户端连接感兴趣的I/O事件:可读(EPOLLIN
), 客户端关闭(EPOLLRDHUP
).
如果客户端有数据可读,我们就读取缓冲区中的数据. 如果客户端关闭连接,我们就关闭连接并打印日志。
水平触发(LT) 与 边缘触发(ET) 的区别
测试用例一
描述: 客户端发送12字节的消息Hello World
之后立即关闭客户端连接, 服务端设置缓冲区为8字节观察二者的区 别
客户端代码
import socket
import time
HOST = '192.168.0.15'
PORT = 10089
client = socket.socket()
client.connect((HOST, PORT))
msg = bytes("Hello World\n", encoding='utf8')
print("send %d bytes" % len(msg))
client.send(msg)
client.close()
边缘触发模式的服务端日志:
由于服务端缓冲区大小为8字节,并且仅读取一次数据, 客户端缓冲区尚未4个字节未被读取,出现了饥饿现象
(客户端没有发送新的数据过来,
EPOLLIN
也就不会被再次触发)
等待一段时间后,输出以下日志:(代码中如果此次读取数据不为0的情况下则认为还有数据可读,不会关闭连接,因此会再触发一次)
此时服务端已经检查到了客户端被关闭了被触发了各种错误事件
水平触发模式下的服务端日志:
观察日志可以看出,第一次轮询仅读取了8个字节的数据, 由于缓冲区中尚有4个字节的数据没有被读取,
epoll_wait
立即返回告诉你客户端缓冲区中还有数据没有读取完.
测试用例二
描述: 在测试用例1上进行一点微调,客户端发送数据后sleep
1秒再关闭连接
客户端代码
...
time.sleep(1)
client.close()
边缘触发模式下的服务端日志
观察日志可以看出, 第一次读取数据后,由于没有新数据到达,
epoll_wait
进入阻塞等待, 1秒后由于客户端关闭了连接触发EPOLLIN
和EPOLLRDHUP
事件, 让服务端读取了剩余数据以及关闭客户端连接.
水平触发模式下的服务端日志
水平触发模式下服务端连续进行了两次轮询把缓冲区数据读完, 客户端关闭连接之后触发了
EPOLLIN
以及EPOLLRDHUP
事件。
测试用例三
描述:在测试用例一上进行微调, 客户端发送第一条数据后,sleep
1秒再发送12字节的消息Hello, Kesan
根据以上例子我们可以推测出, 在缓冲区大小为8字节的情况下,水平触发模式下将触发五次EPOLLIN
, 前四次是因为缓冲区中有数据没有读完, 最后一次是因为客户端关闭了连接; 边缘触发模式下将触发三次EPOLLIN
, 前两次是因为有新数据到达,最后一次是因为客户端关闭了连接。
边缘触发模式下的服务端日志
过了一段时间后(代码中如果此次读取数据不为0的情况下则认为还有数据可读,不会关闭连接,因此会再触发一次)
水平触发模式下的服务端日志
结论
对以上几个测试用例进行归纳我们可以得出:
水平触发模式下只要缓冲区中还有数据可进行操作就会触发I/O事件, 而边缘触发模式下只有缓冲区中的数据发生变动才会触发IO事件
由此结论,我们又可以推出以下几个需要注意的问题:
0x00 边缘触发模式下如果处理不当,即没有在一个epoll循环内将缓冲区的数据读取完并且客户端
- 处于
STOP THE WORLD
状态, 客户端GC中短时间内不会再发送任何新数据 - 请求已发送完成,等待服务器响应中
则会导致客户都数据没有被完全读取,出现请求得不到及时处理得现象,从而产生饥饿.
0x01 水平触发模式下,尽管不用担心客户端数据没有被全部读取,但如果没有每次都将缓冲区的数据都读取完,则会产生CPU使用率较边缘触发模式下高的现象。
0x02
水平触发模式下,如果需要监听客户端可写的事件EPOLLOUT
, 需要谨慎对待,大多数情况下客户都都是可写的,需要监听可写事件一般是在客户端的读缓冲区已满(直观表现为可用窗口为0)换句话讲就是客户都不读取数据,不干活了,才需要监听可写事件,否则在水平模式下可写事件会被频繁触发,把你CPU跑满了都。
关于这个问题,在下一篇文章中我们再进行讨论哈
各位大佬点个赞再走呗,潜水多年,实在想拿点纪念品。
转载自:https://juejin.cn/post/7017367179620253727