likes
comments
collection
share

细说八股 | NIO的水平触发和边缘触发到底有什么区别?

作者站长头像
站长
· 阅读数 11

但凡你简历上写着熟悉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
  • IPPORT用来指定服务端监控的地址
  • 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()

边缘触发模式的服务端日志: 细说八股 | NIO的水平触发和边缘触发到底有什么区别? 由于服务端缓冲区大小为8字节,并且仅读取一次数据, 客户端缓冲区尚未4个字节未被读取,出现了饥饿现象 (客户端没有发送新的数据过来,EPOLLIN也就不会被再次触发)

等待一段时间后,输出以下日志:(代码中如果此次读取数据不为0的情况下则认为还有数据可读,不会关闭连接,因此会再触发一次) 细说八股 | NIO的水平触发和边缘触发到底有什么区别? 此时服务端已经检查到了客户端被关闭了被触发了各种错误事件


水平触发模式下的服务端日志: 细说八股 | NIO的水平触发和边缘触发到底有什么区别? 观察日志可以看出,第一次轮询仅读取了8个字节的数据, 由于缓冲区中尚有4个字节的数据没有被读取, epoll_wait立即返回告诉你客户端缓冲区中还有数据没有读取完.

测试用例二 描述: 在测试用例1上进行一点微调,客户端发送数据后sleep1秒再关闭连接 客户端代码

...
time.sleep(1)
client.close()

边缘触发模式下的服务端日志 细说八股 | NIO的水平触发和边缘触发到底有什么区别? 观察日志可以看出, 第一次读取数据后,由于没有新数据到达, epoll_wait进入阻塞等待, 1秒后由于客户端关闭了连接触发EPOLLINEPOLLRDHUP事件, 让服务端读取了剩余数据以及关闭客户端连接.


水平触发模式下的服务端日志 细说八股 | NIO的水平触发和边缘触发到底有什么区别? 水平触发模式下服务端连续进行了两次轮询把缓冲区数据读完, 客户端关闭连接之后触发了EPOLLIN以及EPOLLRDHUP事件。

测试用例三 描述:在测试用例一上进行微调, 客户端发送第一条数据后,sleep1秒再发送12字节的消息Hello, Kesan

根据以上例子我们可以推测出, 在缓冲区大小为8字节的情况下,水平触发模式下将触发五次EPOLLIN, 前四次是因为缓冲区中有数据没有读完, 最后一次是因为客户端关闭了连接; 边缘触发模式下将触发三次EPOLLIN, 前两次是因为有新数据到达,最后一次是因为客户端关闭了连接。

边缘触发模式下的服务端日志 细说八股 | NIO的水平触发和边缘触发到底有什么区别?

过了一段时间后(代码中如果此次读取数据不为0的情况下则认为还有数据可读,不会关闭连接,因此会再触发一次) 细说八股 | NIO的水平触发和边缘触发到底有什么区别?


水平触发模式下的服务端日志 细说八股 | NIO的水平触发和边缘触发到底有什么区别?

结论

对以上几个测试用例进行归纳我们可以得出:

水平触发模式下只要缓冲区中还有数据可进行操作就会触发I/O事件, 而边缘触发模式下只有缓冲区中的数据发生变动才会触发IO事件

由此结论,我们又可以推出以下几个需要注意的问题:

0x00 边缘触发模式下如果处理不当,即没有在一个epoll循环内将缓冲区的数据读取完并且客户端

  • 处于STOP THE WORLD状态, 客户端GC中短时间内不会再发送任何新数据
  • 请求已发送完成,等待服务器响应中

则会导致客户都数据没有被完全读取,出现请求得不到及时处理得现象,从而产生饥饿.

0x01 水平触发模式下,尽管不用担心客户端数据没有被全部读取,但如果没有每次都将缓冲区的数据都读取完,则会产生CPU使用率较边缘触发模式下高的现象。

0x02 水平触发模式下,如果需要监听客户端可写的事件EPOLLOUT, 需要谨慎对待,大多数情况下客户都都是可写的,需要监听可写事件一般是在客户端的读缓冲区已满(直观表现为可用窗口为0)换句话讲就是客户都不读取数据,不干活了,才需要监听可写事件,否则在水平模式下可写事件会被频繁触发,把你CPU跑满了都。

关于这个问题,在下一篇文章中我们再进行讨论哈

各位大佬点个赞再走呗,潜水多年,实在想拿点纪念品。 细说八股 | NIO的水平触发和边缘触发到底有什么区别?