面试题:BIO,NIO,AIO 的区别是什么?说说select 和 epoll 工作机制与差异?为何epoll 如此高效

问题概览:

  • 说说看你知道的IO模型有哪些,它们的特点是什么?
  • 阻塞IO(BIO)和非阻塞IO(NIO)的区别是什么?NIO和异步IO(AIO)的区别是什么?
  • 能详细说一下多路复用中 select 和 epoll 的工作机制和性能差异吗?
  • epoll底层的数据结构什么,请结合epoll的底层结构说说epoll的工作机制。以及为什么epoll如此高效?
  • 你刚刚有提到epoll的两种触发模式,请解释一下边缘触发(ET)和水平触发(LT)的区别

    面试官:说说看你知道的IO模型有哪些?

先说说看什么是IO吧。IO表示输入输出,访问外部设备读取数据或者向外部设备写数据。常见的文件读写操作、网络通信操作,其实都是IO的过程。

这里以阻塞式读取为例,分析IO操作所进行的工作:

当read/recv时,如果缓冲区中没有数据,那就阻塞式等待数据就绪 — 等待。

当read/recv时,如果缓冲区中有数据,那么就将数据从缓冲区拷贝到应用层 — 拷贝。

因此,我们可以认为,IO = 等待 + 拷贝。只要执行流(进程/线程)参与了等待和拷贝其中之一,那么就认为这个执行流进行了IO操作。

下图展示了在进行写文件操作时系统所进行的工作,可分为三步:

再来说IO模型,经典的IO模型主要包括以下几种:

一、阻塞IO(BIO)

  • 定义:在BIO模型中,当程序进行IO操作时,它会持续等待直到操作完成。在此期间,线程被阻塞,不能执行其他任务。

  • 特点:

  • 传统的IO模型,适用于连接请求较少且请求处理简单的场景。

  • 在这种模型中,服务器为每个客户端连接创建一个线程,导致线程数量随着客户端的增加而急剧增加,消耗大量系统资源。

  • 二、非阻塞IO(NIO)

  • 定义:为了解决BIO模型的线程阻塞问题,NIO模型引入了非阻塞的概念。在NIO中,当一个线程进行IO操作时,它不会等待操作完成,而是继续执行其他任务。当IO操作完成时,线程会收到通知。非阻塞式IO一般采用轮询检查的方法进行IO操作,即:通过循环,不断检查IO资源是否已经就绪,就绪就读取,不就绪就执行其他的工作。

  • 特点:

  • 提高了线程的利用率,适用于连接请求多且单个请求处理耗时较长的场景。

  • 需要不断轮询或者使用事件通知来检查操作是否完成,可能会占用一定的CPU时间。

  • Java的NIO库提供了基于Reactor设计模式的非阻塞IO操作,也称为IO多路复用。

  • 三、异步IO(AIO)

  • 定义:异步IO就是发起IO的执行流本身不参与IO工作,而是有另外一个执行流来进行IO操作,发起IO的线程只需要等待进行IO的执行流反馈回结果。这样发起IO的执行流就不需要等到,可以处理其他的工作。当操作完成时,系统会通知该线程。

  • 特点:

  • 能够显著提高并发性能,因为多个操作可以同时进行。

  • 适用于高并发、高吞吐量的场景,如网络服务器、大规模并行计算等。

  • Java的NIO2(即AIO)基于Proactor设计模式的异步非阻塞模型。

  • 四、IO多路复用

  • 定义:使用操作系统提供的select、poll或epoll等多路复用机制,允许应用程序同时监视多个IO事件。

  • 特点:

  • 应用程序可以将多个IO请求注册到一个多路复用器上,然后通过轮询或者阻塞等待多路复用器通知事件的发生。

  • 适用于需要同时处理多个连接的场景,提高了系统的并发性能。

  • 有效地管理多个IO操作,减少系统资源的消耗。

  • 五、信号驱动IO

  • 定义:通过自定义对SIGIO信号的处理函数来实现信号驱动式IO,当进程收到SIGIO信号的时候,就调用对应的处理函数来进行IO操作,这样保证在调用IO接口的时候数据一定是就绪的,在没有收到信号时不影响进程进行其他的工作,信号驱动式IO避免了阻塞等待资源就绪,提高了IO效率。

  • 特点:

  • 相比阻塞IO和非阻塞IO更为灵活。

  • 适用于需要处理多个IO事件的场景。

    面试官:阻塞IO(BIO)和非阻塞IO(NIO)的区别是什么?NIO和异步IO(AIO)的区别是什么?

以下是 BIO 和 NIO 的主要差异:

阻塞IO(Blocking IO)

  1. 等待操作完成:
  • 在阻塞IO模型中,当一个线程发起一个I/O请求(如读取文件、网络通信等)时,该线程会被阻塞,直到I/O操作完成并且数据已经准备好。
    1. 线程占用:
  • 在I/O操作进行期间,线程无法执行其他任务,导致线程的利用率降低。如果I/O操作耗时较长,线程可能会长时间处于空闲状态。
    1. 简单性:
  • 阻塞IO模型相对简单,因为开发者不需要处理I/O操作的异步通知和状态检查。
    1. 资源消耗:
  • 高并发场景下,阻塞IO模型可能导致大量的线程被创建和阻塞,从而消耗大量的系统资源(如内存和CPU)。

非阻塞IO(Non-blocking IO)

  1. 立即返回:
  • 在非阻塞IO模型中,当一个线程发起一个I/O请求时,该请求会立即返回,不会阻塞线程。线程可以继续执行其他任务,而无需等待I/O操作完成。

    1. 轮询或回调:
  • 非阻塞IO模型通常需要通过轮询(polling)或回调(callback)机制来检查I/O操作的状态。轮询是主动检查I/O操作是否完成,而回调是当I/O操作完成时由系统通知应用程序。

    1. 复杂性:
  • 非阻塞IO模型相对复杂,因为开发者需要处理I/O操作的异步通知和状态检查,以及管理多个I/O请求的状态。

    1. 资源效率:
  • 在高并发场景下,非阻塞IO模型可以更有效地利用系统资源,因为线程不会被阻塞,可以处理更多的并发请求。然而,由于需要频繁地检查I/O操作的状态,可能会增加CPU的使用率。

    选择

  • 阻塞IO适用于连接请求较少且请求处理简单的场景,或者当I/O操作不会显著影响程序性能时。

  • 非阻塞IO适用于连接请求多且单个请求处理耗时较长的场景,或者当需要处理大量并发请求时。

以下是NIO和AIO的差异:

工作原理

  • NIO :

  • 同步非阻塞I/O模型。

  • 使用一个单独的线程或线程池来处理所有的I/O操作。

  • 当一个操作不能立即完成时,线程不会被阻塞,而是继续执行其他任务。

  • 使用缓冲区(Buffer)来存储数据,并通过选择器(Selector)来监听多个通道(Channel)上的事件,从而实现并发处理多个I/O操作。

  • AIO :

  • 异步非阻塞I/O模型。

  • 不需要通过选择器来监听通道的事件,而是由操作系统在数据准备就绪时通知应用程序。

  • 应用程序在数据读写操作时不会被阻塞,而是在操作完成时收到通知。

  • 引入了异步通道的概念,简化了程序编写,并提高了并发处理能力。

  • 性能与资源消耗

  • NIO :

  • 通过并发处理多个I/O操作来提高性能。

  • 需要合理地管理线程池和缓冲区资源,以避免资源耗尽或性能下降。

  • AIO :

  • 通过异步操作来避免线程阻塞,从而提高了系统的并发处理能力。

  • 在高并发场景下,AIO通常能够表现出更好的性能,因为它能够更有效地利用系统资源。

    编程复杂度

  • NIO :

  • 编程相对复杂,需要处理缓冲区、选择器、通道等组件的交互。

  • AIO :

  • 编程复杂度也较高,但相对于NIO来说,由于引入了异步操作的概念,可以更加灵活地处理I/O操作。

  • 开发者需要熟悉AIO的编程模型,并能够正确地处理异步通知和回调机制。

    面试官:能详细说一下多路复用中 select 和 epoll 的工作机制和性能差异吗?

先说说多路复用。多路复用(Multiplexing)是一种能够同时监控多个文件描述符(如网络连接、文件、管道等),一旦某个描述符就绪(通常指有数据可读或可写),便能够通知程序进行相应的读写操作的技术。

select和epoll是两种常见的I/O多路复用机制,它们的工作机制和性能差异如下:

一、select的工作机制

  1. 基本原理:
  • select函数允许一个进程同时监视多个文件描述符,以查看它们是否有I/O事件发生(如可读、可写或有异常)。

  • 它通过监视一个文件描述符集合,在其中的文件描述符中有I/O事件发生时返回。

    1. 使用方法:
  • 调用select时,需要指定三个文件描述符集合:读集合、写集合和异常集合,以及一个时间限制。

  • select函数会阻塞,直到至少有一个文件描述符准备好进行I/O操作,或者超时发生。

  • 当select返回时,它会通过修改这三个集合来指示哪些文件描述符已经准备好进行I/O操作。

    1. 性能瓶颈:
  • 当监视的文件描述符数量很大时,select的性能会显著下降,因为它需要遍历整个文件描述符集合来查找哪些文件描述符已经准备好进行I/O操作。

  • select有一个内置的文件描述符数量限制(通常是1024个),这在某些高并发场景下可能不够用。

  • select的超时参数是一个时间间隔,而不是一个精确的时间点,这可能导致在某些情况下时间控制不准确。

    二、epoll的工作机制

    1. 基本原理:
  • epoll是Linux内核提供的一种I/O多路复用机制,是select和poll机制的改进版。

  • 它使用事件驱动的方式,只通知那些真正发生了I/O事件的文件描述符。

    1. 核心API:
  • epoll_create():创建一个epoll实例,并返回一个文件描述符。

  • epoll_ctl():将需要监控的文件描述符添加到epoll实例中,或从epoll实例中删除,或对监听事件进行修改。

  • epoll_wait():等待注册的事件发生,返回事件的数目,并将触发的事件写入事件数组中。

    1. 工作流程:
  • 应用程序通过epoll_create()系统调用创建一个epoll实例。

  • 应用程序通过epoll_ctl()系统调用将需要监控的文件描述符添加到epoll实例中。

  • 内核会将这些文件描述符及其感兴趣的事件类型(可读、可写、异常等)记录在内核的事件表中。

  • 应用程序通过epoll_wait()系统调用进入休眠状态,等待内核通知有事件发生。

  • 内核会不断监控这些文件描述符的状态变化,一旦有事件发生,就会将就绪的文件描述符添加到就绪队列中。

  • 当epoll_wait()被唤醒时,内核会返回就绪队列中的文件描述符列表以及就绪的事件类型。

  • 应用程序根据返回的信息来处理对应的I/O操作。

    1. 性能优势:
  • epoll的时间复杂度接近O(1),而select是O(n),因此在处理大量文件描述符时,epoll具有明显的性能优势。

  • epoll没有文件描述符数量的限制,可以支持海量的文件描述符。

  • epoll支持边缘触发(Edge Triggered)和水平触发(Level Triggered)两种工作模式,提供了更灵活的事件通知机制。

    三、select与epoll的性能比较

    1. 时间复杂度:
  • select的时间复杂度为O(n),需要遍历整个文件描述符集合来查找就绪的文件描述符。

  • epoll的时间复杂度接近O(1),因为它使用内核事件表来存储需要监控的文件描述符,并只返回就绪的文件描述符。

    1. 文件描述符数量限制:
  • select有文件描述符数量的限制(通常是1024个),这在某些高并发场景下可能不够用。

  • epoll没有文件描述符数量的限制,可以支持海量的文件描述符。

    1. 系统资源消耗:
  • 当监视的文件描述符数量很大时,select会消耗大量的系统资源,因为它需要复制整个文件描述符集合到用户空间。

  • epoll则不会消耗大量的系统资源,因为它只需要在文件描述符状态发生变化时才更新内核事件表。

    1. 可扩展性:
  • 由于select的性能瓶颈和文件描述符数量限制,它在高并发场景下可能无法提供足够的可扩展性。

  • epoll则具有良好的可扩展性,可以支持大量的并发连接和文件描述符。

    面试官:epoll底层的数据结构什么,请结合epoll的底层结构说说epoll的工作机制。以及为什么epoll如此高效?

epoll底层主要使用了红黑树和就绪队列(双链表)这两种数据结构,其高效性主要源于以下几个方面:

一、epoll底层数据结构

  1. 红黑树:
  • 用于存储所有添加到epoll中的需要监控的事件。
  • 红黑树的插入、删除和查找操作的时间复杂度都是O(log n),其中n是树中节点的数量。这使得epoll能够高效地管理大量的文件描述符。
    1. 就绪队列(双链表):
  • 存放着将要通过epoll_wait返回给用户的满足条件的事件。
  • 当有I/O事件发生时,内核会将对应的事件添加到就绪队列中。
  • epoll_wait在调用时只需要检查就绪队列中是否有事件即可,无需遍历整个红黑树。

再聊聊epoll的具体实现机制。

如下图所示:

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。

struct eventpoll{ 
    .... 
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/ 
    struct rb_root  rbr; 
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/ 
    struct list_head rdlist; 
    .... 
};

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,重复添加的事件就可以通过红黑树log(n)的查找复杂度高效的识别出来(其中n为树的高度)。

所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系。这个回调方法会将就绪的事件添加到rdlist双链表(就绪链表)中。

每一个事件对应一个epitem结构体,如下所示。

struct epitem{ 
    struct rb_node  rbn;//红黑树节点 
    struct list_head    rdllink;//双向链表节点 
    struct epoll_filefd  ffd;  //事件句柄信息 
    struct eventpoll *ep;    //指向其所属的eventpoll对象 
    struct epoll_event event; //期待发生的事件类型 
}

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的就绪队列rdlist中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户进程,最后用户进程根据事件执行相应的读写处理。

二、epoll高效性原因

  1. 事件通知机制:
  • epoll使用事件通知的方式,当监控的文件描述符有事件发生时才会返回。
  • 这减少了不必要的系统调用和开销,因为只有在真正有事件发生时才会进行后续的处理。
    1. 避免数据拷贝:
  • 在使用epoll时,可以将要监听的文件描述符注册到内核中。
  • 之后,用户程序只需等待内核通知就可以,不必在每次调用时传递文件描述符列表。
  • 这降低了用户空间和内核空间之间的数据拷贝,提高了效率。
    1. 支持两种工作模式:
  • epoll支持水平触发(Level Triggered)和边缘触发(Edge Triggered)两种工作模式。
  • 边缘触发模式仅在状态变化时通知应用程序,这允许开发者实现更高效的处理逻辑,因为可以避免重复检测已处理过的事件。
    1. 动态管理文件描述符:
  • epoll没有文件描述符数量的限制,能够动态管理大量的文件描述符。
  • 这使得epoll在需要高并发、高效能I/O操作的网络编程场景中表现出色。

    面试官:你刚刚有提到epoll的两种触发模式,请解释一下边缘触发(ET)和水平触发(LT)的区别?

一、边缘触发(ET)与水平触发(LT)的区别

  1. 触发机制:
  • 边缘触发(ET):只在文件描述符的状态从不可读/不可写变为可读/可写时触发一次。也就是说,它在状态发生变化的时候通知应用程序,且通知仅发送一次。
  • 水平触发(LT):当文件描述符处于可读或可写状态时,会持续通知应用程序,直到文件描述符不再处于该状态。即,只要文件描述符保持在可读/可写状态,epoll就会不断通知应用程序。
    1. 应用程序响应:
  • 边缘触发(ET):要求应用程序在接收到通知后立即且彻底地处理所有可读/可写的数据,否则可能会错过后续数据,导致数据丢失。因此,使用ET模式时,应用程序需要具备更高的复杂性和更精细的控制。
  • 水平触发(LT):应用程序可以按需处理数据,不必立即处理完所有数据。即使只处理部分数据,下次调用epoll_wait时,epoll仍会继续通知应用程序该文件描述符仍然可读/可写。
    1. 适用场景:
  • 边缘触发(ET):适用于高性能场景,因为它减少了不必要的系统调用。同时,它也要求应用程序能够高效、彻底地处理数据。
  • 水平触发(LT):适用于大多数情况,特别是当应用程序需要持续处理就绪事件或处理多个相关事件时。它提供了更简单、直观的通知机制。

以下是一个使用Python的epoll模块来演示ET和LT触发模式的示例:

import os   
import socket   
import select   
import epoll   
# 创建一个TCP/IP套接字   
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)   
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)   
server_sock.bind(('localhost', 12345))   
server_sock.listen(5)   
server_sock.setblocking(0)  # 设置为非阻塞模式   
# 创建一个epoll实例   
epoller = epoll.epoll()   
# 为服务器套接字注册事件,LT模式   
epoller.register(server_sock.fileno(), epoll.EPOLLIN | epoll.EPOLLET if False else epoll.EPOLLIN)  # 将if False改为True以使用ET模式   
# 客户端连接处理函数   
def handle_connection(client_sock):   
    data = client_sock.recv(1024)   
    if data:   
        print(f"Received: {data.decode()}")   
        client_sock.sendall(data)  # Echo the data back to the client   
    else:   
# 没有数据表示客户端已经关闭连接   
        client_sock.close()   
        print("Client disconnected.")   
# 主循环   
try:   
    while True:   
        events = epoller.poll(1000)  # 超时时间为1000毫秒   
        for fileno, event in events:   
            if fileno == server_sock.fileno():   
# 服务器套接字上有新的连接请求   
                client_sock, client_addr = server_sock.accept()   
                client_sock.setblocking(0)  # 设置为非阻塞模式   
                print(f"Accepted connection from {client_addr}")   
# 为客户端套接字注册事件,这里我们根据之前的条件选择ET或LT模式   
                epoller.register(client_sock.fileno(), epoll.EPOLLIN | epoll.EPOLLET if False else epoll.EPOLLIN)   
            else:   
# 客户端套接字上有数据可读或连接关闭等事件   
                client_sock = socket.socket(fileno=fileno)  # 从文件描述符恢复套接字对象   
# 根据ET或LT模式的不同,处理数据的方式也会有所不同   
                if False:  # 如果为True,则表示使用ET模式   
# ET模式下,需要确保一次读取所有可用数据   
                    while True:   
                        try:   
                            data = client_sock.recv(1024)   
                            if not data:   
                                break  # 没有数据表示连接已关闭   
                            print(f"Received (ET): {data.decode()}")   
                            client_sock.sendall(data)  # Echo the data back to the client   
                        except BlockingIOError:   
# 在ET模式下,如果没有更多数据可读,会抛出BlockingIOError异常   
                            break   
                else:   
# LT模式下,每次有数据可读时都会触发事件   
                    handle_connection(client_sock)   
# 如果客户端连接已关闭,则注销该套接字   
                if not client_sock.fileno() in [fd for fd, _ in epoller.poll(0)]:  # 使用非阻塞poll检查连接是否仍然活跃   
                    epoller.unregister(client_sock.fileno())   
                    client_sock.close()   
except KeyboardInterrupt:   
    print("Server stopped by user.")   
finally:   
    epoller.close()   
    server_sock.close()
  1. 代码中通过epoll.EPOLLIN | epoll.EPOLLET来选择ET模式,而仅通过epoll.EPOLLIN来选择LT模式。你可以通过修改条件(即if False改为if True)来切换模式。
  2. 在ET模式下,由于只有在状态变化时才会触发事件,因此我们需要使用循环来读取所有可用的数据,直到recv抛出BlockingIOError异常,表示当前没有更多数据可读。
  3. 在LT模式下,每次有数据可读时都会触发事件,因此我们可以直接调用handle_connection函数来处理连接。
  4. 由于epoll模块是Linux特有的,因此这段代码只能在Linux环境下运行。
  5. 为了简化示例,错误处理和资源清理可能不是最完善的。在实际应用中,你需要更仔细地处理这些情况。
  6. 使用epoll.poll(0)来检查连接是否仍然活跃是一种非阻塞的检查方式,但它可能不是最优雅或最高效的方法。在实际应用中,你可能需要设计更复杂的逻辑来处理连接的状态。

欢迎在评论区留言表达看法和问题,阿沛会一一作出回复。

如果本文对大家有帮助,麻烦大家动动小手点个免费的“赞”或“在看”,大家的鼓励就是阿沛持续更新的动力~

1