聊聊常见的IO模型 BIO/NIO/AIO 、DIO、多路复用等IO模型

聊聊常见的IO模型 BIO/NIO/AIO/DIO、IO多路复用等IO模型


在这里插入图片描述

一、前言

常见的I/O模型包括阻塞I/O(Blocking I/O,BIO)、非阻塞I/O(Non-blocking I/O,NIO)、异步I/O(Asynchronous I/O,AIO)、直接I/O(Direct I/O,DIO)以及I/O多路复用(I/O Multiplexing)等。每种I/O模型都有其特点和适用场景,我们今天总结一下聊聊常见IO模型的一些知识,虽然不能学以致用,但也可以知其然知其所以然。

本文参考

  1. 《Boost application performance using asynchronous I/O》及图片来源自本文 https://developer.ibm.com/articles/l-async/
  2. https://notes.shichao.io/unp/ch6/

1. 什么是IO模型

I/O(Input/Output)模型是指计算机系统中用于处理输入和输出操作的一种模式或范式。它描述了数据在计算机系统中的传输和处理方式。

在计算机系统中,输入和输出操作通常涉及与外部设备(如硬盘、网络、键盘、显示器等)的数据交互。I/O模型定义了如何进行这种数据交互以及程序如何与输入和输出设备进行通信。

常见的I/O模型包括以下几种:
在这里插入图片描述

  1. 阻塞式I/O模型(Blocking I/O):应用程序在进行I/O操作时会被阻塞,直到操作完成。在进行阻塞I/O时,应用程序会一直等待,直到数据从设备中读取完毕或数据写入到设备中。

  2. 非阻塞式I/O模型(Non-blocking I/O):应用程序进行I/O操作时,如果设备上没有数据可读取或无法立即写入数据,则应用程序会立即返回,并继续执行其他任务,而不会等待操作完成。

  3. I/O复用模型(I/O Multiplexing):通过使用I/O复用机制(如select、poll、epoll等),应用程序可以同时监视多个I/O操作,并在有数据可读写时进行处理,从而避免了阻塞。这种模型适用于需要同时处理多个I/O通道的情况。

  4. 信号驱动式I/O模型(Signal-driven I/O):应用程序通过注册信号处理函数,在数据就绪时接收操作系统发出的信号,然后进行相应的I/O操作。

    扫描二维码关注公众号,回复: 16834916 查看本文章
  5. 异步I/O模型(Asynchronous I/O):应用程序发起I/O操作后,可以继续执行其他任务,而无需等待I/O操作完成。当I/O操作完成时,系统会通知应用程序,然后应用程序可以处理已完成的I/O操作。

不同的I/O模型适用于不同的应用场景和需求。选择适合的I/O模型可以提高系统的效率和性能。

2. 为什么需要IO模型

在计算机系统中,IO操作是相对较慢的,而应用程序通常需要频繁进行IO操作。不同的IO模型可以提供不同的处理方式,以满足不同的需求。选择适合的I/O模型可以提高系统的性能、并发处理能力和用户体验,使系统能够高效地处理输入和输出操作。
I/O模型是计算机系统中处理输入和输出操作的一种模式,它的存在有以下几个重要原因:

  1. 高效利用资源 I/O操作通常涉及与外部设备的数据交互,而这些设备的访问速度相对较慢。使用合适的I/O模型可以充分利用系统资源,避免浪费CPU时间等待I/O操作完成。

  2. 提高系统吞吐量 通过合理选择I/O模型,可以使系统在等待I/O操作完成时能够处理其他任务,从而提高系统的并发处理能力和吞吐量。

  3. 支持多任务处理 I/O模型使得应用程序能够同时处理多个I/O通道,可以在等待某个I/O操作完成时处理其他I/O操作,提高系统的并发性能。

  4. 响应性和交互性 通过使用非阻塞I/O模型或异步I/O模型,应用程序可以在I/O操作进行的同时继续执行其他任务,从而提高系统的响应性和用户体验。

二、常见的IO模型

1. 同步阻塞IO(Blocking IO,BIO)

同步阻塞I/O(Blocking I/O,BIO)是一种基本的I/O模型,也是最常见的一种。

在同步阻塞I/O模型中,当应用程序发起输入或输出操作时,它会被阻塞(即暂停执行),直到操作完成。在进行I/O操作期间,应用程序无法执行其他任务,必须等待数据的读取或写入完成才能继续执行后续代码。
在这里插入图片描述

同步阻塞I/O模型的基本工作流程:

1. 应用程序发起一个I/O操作(如读取文件、发送网络请求等)。

2. 操作系统内核接收到应用程序的请求,将控制权交给设备驱动程序。

3. 设备驱动程序开始执行I/O操作,它会将请求发送给设备(如硬盘、网络接口等)。

4. 设备开始进行读取或写入操作,这个过程可能需要一定的时间。

5. 在数据操作完成后,设备驱动程序将数据传递给操作系统内核。

6. 操作系统内核将数据传递给应用程序,并解除应用程序的阻塞状态。

7. 应用程序继续执行后续代码,处理接收到的数据。

同步阻塞I/O模型的主要特点是简单易理解,但它的缺点是效率较低。当应用程序发起I/O操作时,它必须等待操作完成,这会导致应用程序的执行被阻塞,无法充分利用CPU资源。特别是在高并发环境下,同步阻塞I/O模型可能会导致系统性能下降,因为一个阻塞的I/O操作可能会阻塞其他任务的执行。

2. 同步非阻塞IO(Non-blocking IO,NIO)

同步非阻塞I/O(Non-blocking I/O,NIO)是一种相对于同步阻塞I/O的改进模型,它提供了一种更高效的I/O处理方式。

在同步非阻塞I/O模型中,当应用程序发起输入或输出操作时,它不会被阻塞等待操作完成,而是立即返回。应用程序可以继续执行其他任务,而不必等待I/O操作的完成。

  • 在这里插入图片描述
    EWOULDBLOCK或EAGAIN是一些常见的错误码,用于表示在非阻塞I/O操作中操作无法立即完成的情况。

EWOULDBLOCK 表示操作将会阻塞。当应用程序以非阻塞方式调用I/O操作时,如果操作无法立即完成而需要等待,操作系统会返回EWOULDBLOCK错误码。这个错误码提示应用程序当前无法进行操作,但并不表示出现了错误。

EAGAIN 表示暂时无法完成操作。类似于EWOULDBLOCK,EAGAIN也是在非阻塞I/O操作中表示操作无法立即完成的错误码。它通常用于某些系统或网络资源已经耗尽,暂时无法满足请求的情况。

同步非阻塞I/O模型的基本工作流程:

  1. 应用程序发起一个I/O操作。

  2. 如果操作可以立即完成(无需等待),操作系统内核将数据传递给应用程序,并应用程序继续执行后续代码。

  3. 如果操作无法立即完成(需要等待),操作系统内核将返回一个错误码(例如EWOULDBLOCK或EAGAIN),通知应用程序操作当前无法完成。

4. 应用程序可以通过轮询或其他方式不断查询操作的状态,以确定何时可以继续进行操作。 这个是为什么叫做同步非阻塞的原因了

  1. 当操作完成后,操作系统内核将数据传递给应用程序,并应用程序继续执行后续代码。

同步非阻塞I/O模型的主要特点是应用程序不会被阻塞,可以继续执行其他任务,从而提高系统的并发性能。相比于同步阻塞I/O模型,它允许应用程序在等待I/O操作完成时处理其他任务,而不会浪费CPU时间。

然而,同步非阻塞I/O模型需要应用程序主动查询操作的状态,这可能涉及轮询或循环等机制,会增加编程复杂性。此外,如果应用程序频繁地查询操作的状态,可能会导致CPU资源的浪费。为了解决这些问题,后续出现了更高级的I/O模型,如I/O复用模型、信号驱动式I/O模型和异步I/O模型,它们提供了更优雅和高效的方式来处理I/O操作。

3. 异步非阻塞IO(Asynchronous IO,AIO)

异步非阻塞I/O(Asynchronous I/O,AIO)是一种高级的I/O模型,与同步阻塞I/O和同步非阻塞I/O有所不同。

在异步非阻塞I/O模型中,应用程序发起I/O操作后可以立即返回,并继续执行其他任务,而不需要等待操作完成或查询操作状态。当I/O操作完成时,操作系统会通知应用程序,应用程序可以通过回调函数或其他方式处理已完成的I/O操作。
在这里插入图片描述

异步非阻塞I/O模型的基本工作流程:

  1. 应用程序发起一个异步I/O操作,并指定一个回调函数。

  2. 操作系统内核接收到应用程序的请求,并开始执行I/O操作。

  3. 应用程序继续执行其他任务,而不需要等待操作完成。

  4. 当I/O操作完成时,操作系统内核会通知应用程序,调用预先指定的回调函数。

  5. 应用程序在回调函数中处理已完成的I/O操作,获取操作结果或进行后续处理。

异步非阻塞I/O模型的主要优势在于允许应用程序以非阻塞且异步的方式进行I/O操作,从而提高系统的并发性能和响应能力。应用程序可以发起多个I/O操作,而无需等待每个操作的完成,从而充分利用CPU资源处理其他任务

使用异步非阻塞I/O模型的关键是处理已完成的I/O操作。应用程序需要通过回调函数或其他方式处理已完成的操作,例如获取操作结果、进行后续处理或发起新的操作。这种异步处理方式可能需要更复杂的编程模式和技巧,但它可以提供更高性能和更好的可扩展性。

3.1. Linux AIO

在Linux操作系统中,异步I/O(AIO)API主要由以下几个组件和函数组成

  1. aio.h头文件头文件定义了与异步I/O相关的结构体和函数原型。
struct aiocb {
    
    

  int aio_fildes;               // File Descriptor
  int aio_lio_opcode;           // Valid only for lio_listio (r/w/nop)
  volatile void ∗aio_buf;       // Data Buffer
  size_t aio_nbytes;            // Number of Bytes in Data Buffer
  struct sigevent aio_sigevent; // Notification Structure

  /∗ Internal fields ∗/
  ...

};
  1. struct aiocb 这是一个用于描述异步I/O操作的结构体。它包含了操作的参数和状态信息,如文件描述符、缓冲区地址、操作类型等。
  2. Linux操作系统的AIO API
API函数 描述
aio_read() 发起异步读取操作
aio_write() 发起异步写入操作
aio_error() 检查异步I/O操作的错误状态
aio_return() 获取异步I/O操作的返回值
aio_suspend() 等待一组异步I/O操作完成
aio_cancel() 取消尚未完成的异步I/O操作
aio_init() 初始化异步I/O运行时环境
aio_fini() 销毁异步I/O运行时环境
struct aiocb 异步I/O操作的参数和状态信息的结构体
aio_buf 异步I/O操作的缓冲区地址
aio_nbytes 异步I/O操作的字节数
aio_offset 异步I/O操作的偏移量
aio_lio_opcode 异步I/O操作的操作类型
aio_sigevent 异步I/O操作完成时的信号事件

通过使用上述函数和结构体,开发者可以在Linux系统上实现异步I/O操作。可以在编程语言中使用对应的系统调用或库函数来调用这些API,如C语言中的系统调用或使用libaio库。

3.2. 我们用C写一个简单的示例

感受一下AIO 的调用方式,说白了就是依赖了Linux 的AIO类库在应用层其实很简单。

使用aio_read函数进行异步读取。打开一个名为aaa.txt的文件,并使用aio_read函数发起异步读取操作。通过设置struct aiocb结构体的相关字段,包括文件描述符、缓冲区地址、读取字节数和偏移量等。然后,使用aio_read函数发起异步读取操作。

在读取操作完成之前,可以使用aio_error函数来检查操作的状态,如果状态为EINPROGRESS表示操作仍在进行中,需要等待。

一旦异步读取操作完成,可以使用aio_return函数获取读取的字节数。如果返回值为-1,则表示读取操作失败。输出读取到的数据并关闭文件。

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <aio.h>
#include <errno.h>
#include <unistd.h>

#define BUFFER_SIZE 1024

int main() {
    
    
    int fileDescriptor;
    struct aiocb aioRequest;
    char buffer[BUFFER_SIZE];

    // 打开文件
    fileDescriptor = open("aaa.txt", O_RDONLY);
    if (fileDescriptor == -1) {
    
    
        perror("Failed to open file");
        exit(1);
    }

    // 设置异步I/O请求
    aioRequest.aio_fildes = fileDescriptor;
    aioRequest.aio_buf = buffer;
    aioRequest.aio_nbytes = BUFFER_SIZE;
    aioRequest.aio_offset = 0;

    // 发起异步读取操作
    if (aio_read(&aioRequest) == -1) {
    
    
        perror("Failed to initiate aio_read");
        exit(1);
    }

    // 等待异步读取操作完成
    while (aio_error(&aioRequest) == EINPROGRESS);

    // 检查异步读取操作的结果
    ssize_t bytesRead = aio_return(&aioRequest);
    if (bytesRead == -1) {
    
    
        perror("Failed to complete aio_read");
        exit(1);
    }

    // 输出读取到的数据
    printf("Read %zd bytes:\n", bytesRead);
    printf("%.*s", (int)bytesRead, buffer);

    // 关闭文件
    close(fileDescriptor);

    return 0;
}

现在大家应该已经了解了 AIO 基本函数,我们再聊聊可用于异步通知的方法。我将通过信号和函数回调来进一步了解异步通知。上一段我们聊到了,AIO的核心是增加了回调通知,那么回调通知到底是怎么一回事。

3.3. 异步通知的实现方式

异步通知是指在异步I/O操作完成时通过通知机制来通知应用程序。它允许应用程序在进行异步I/O操作的同时进行其他任务,而不需要主动轮询或阻塞等待操作完成。

在异步I/O中,异步通知的实现通常涉及以下几个组件:

3.3.1. 信号(Signal)

操作系统可以通过信号机制向应用程序发送信号来通知异步I/O操作的完成。应用程序可以使用信号处理函数来处理接收到的信号。
信号(Signal)是一种在UNIX和类UNIX操作系统中用于进程间通信(IPC)和处理异步事件的机制。它是一种软件中断,用于通知进程发生了某种事件或异常。

信号可以由内核或进程本身生成,并被发送给目标进程。当目标进程接收到信号时,可以采取相应的动作来处理信号。常见的信号动作包括执行预定义的信号处理函数,忽略信号,终止进程等。

每个信号都有一个唯一的数字标识符,通常用整数表示。例如,SIGINT代表终端中断信号,通常由用户在终端上按下Ctrl+C生成。SIGSEGV代表段错误信号,当进程访问无效的内存地址时,会生成该信号。

应用程序可以通过以下方式与信号进行交互:

  1. 捕获信号 应用程序可以注册信号处理函数,当指定信号发生时,操作系统会调用该函数。通过捕获信号,应用程序可以执行自定义的操作来响应信号的发生。

  2. 忽略信号 应用程序可以选择忽略某个信号。当忽略信号时,操作系统不会采取任何动作,信号被丢弃。

  3. 默认动作 每个信号都有一个默认的动作,例如终止进程或产生核心转储文件。应用程序可以选择恢复信号的默认动作。

常见的一些信号包括:

  • SIGINT:终端中断信号,通常由用户在终端上按下Ctrl+C生成。
  • SIGSEGV:段错误信号,当进程访问无效的内存地址时,会生成该信号。
  • SIGTERM:终止信号,用于请求进程正常终止。
  • SIGKILL:强制终止信号,用于立即终止进程,无法被阻塞或忽略。
    在这里插入图片描述

我们用C做一个简单的示例。使用信号来捕获和处理SIGINT信号(终端中断信号)
定义 handle_signal信号处理函数。当接收到SIGINT信号时,该函数会打印一条消息并退出程序。

main函数中,我们使用signal函数将SIGINT信号与handle_signal函数进行关联,即注册信号处理函数。

然后,程序进入一个无限循环,等待信号的发生。当用户在终端上按下Ctrl+C时,会生成SIGINT信号,操作系统会调用注册的信号处理函数来处理该信号。

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

// 信号处理函数
void handle_signal(int signum) {
    
    
    if (signum == SIGINT) {
    
    
        printf("Received SIGINT signal. Exiting...\n");
        // 可在此处执行自定义的退出操作
        exit(0);
    }
}

int main() {
    
    
    // 注册信号处理函数
    signal(SIGINT, handle_signal);

    printf("Press Ctrl+C to send SIGINT signal.\n");

    // 无限循环等待信号
    while (1) {
    
    
        // 等待信号发生
    }

    return 0;
}
3.3.2. 完成端口(Completion Port)

Windows操作系统提供的一种强大的异步通知机制,特别适用于异步I/O操作密集的应用场景。
Completion Port(完成端口)是一种高效的异步通知机制,主要用于Windows操作系统。它提供了一种可扩展的方式来处理异步I/O操作的完成通知和结果获取。

在使用完成端口的模型中,应用程序将异步I/O操作与完成端口关联。当异步I/O操作完成时,操作系统会将完成的结果发送到关联的完成端口。应用程序可以通过调用特定的函数从完成端口获取操作的结果。

完成端口的主要优点是它对于大规模的异步I/O操作非常高效。它使用了一种事件驱动的模型,允许应用程序同时处理多个异步操作的完成事件,而无需阻塞或轮询等待。此外,完成端口还支持多线程并发处理操作结果,提供了更好的性能和可伸缩性。

在使用完成端口时,应用程序需要执行以下步骤:

  1. 创建完成端口:应用程序通过调用特定的函数创建一个完成端口。

  2. 关联异步I/O操作:应用程序将异步I/O操作与完成端口关联,通常使用操作相关的结构体或句柄来标识操作。

  3. 等待操作完成:应用程序通过调用特定的函数等待任意一个或多个操作完成。这个函数会阻塞应用程序,直到有操作完成。

  4. 处理操作结果:一旦操作完成,应用程序可以从完成端口获取操作的结果,包括读取的数据、错误码等。

3.3.3. 事件驱动(Event-driven)

事件驱动模型是一种高效处理异步I/O操作的方式,特别适用于大量并发操作或高吞吐量场景。在事件驱动模型中,应用程序利用事件循环来监听并处理I/O操作的完成事件。以下是事件驱动模型的主要组成部分和工作流程:
在这里插入图片描述
内容参考和图片来源 https://www.scylladb.com/glossary/event-driven-architecture/

  1. 事件循环:事件循环是事件驱动模型的核心组件。它持续运行,监听事件队列中的事件,并在事件发生时调用相应的回调函数。

  2. 事件:事件是应用程序中发生的特定动作或状态改变,例如文件读取完成、连接建立成功等。事件可以在事件队列中排队,等待事件循环处理。

  3. 回调函数:回调函数是事件处理的代码片段。当事件循环检测到事件发生时,它会调用相应的回调函数。回调函数通常需要在事件发生之前注册到事件循环中,以便事件循环知道如何处理特定事件。

事件驱动模型的工作流程如下:

  1. 应用程序启动事件循环。

  2. 应用程序向事件循环注册回调函数,并发起异步I/O操作。

  3. 事件循环监听事件队列中的事件。一旦检测到事件发生,事件循环会调用相应的回调函数。

  4. 回调函数处理完成的操作,并在需要时发起新的异步I/O操作。

  5. 事件循环继续监听事件队列中的事件,直到应用程序结束。

事件驱动模型的一个主要优势是能够更有效地处理大量并发操作,因为它避免了为每个操作创建单独的线程所带来的开销。在高并发和高吞吐量场景下,事件驱动模型可以显著提高应用程序的性能。

3.3.4. 回调函数(Callback)

应用程序可以在发起异步I/O操作时指定一个回调函数。当操作完成时,系统会自动调用该回调函数来通知应用程序。
使用异步通知机制可以提高异步I/O的效率和可扩展性,避免了不必要的轮询和阻塞等待操作完成。应用程序可以在操作完成后立即处理结果或继续进行其他任务,而无需等待操作完成。 。
Java的异步非阻塞I/O(AIO)模型使用NIO.2(Java 7中引入)中的AsynchronousFileChannelAsynchronousSocketChannel等类来实现。在AIO模型中,通知回调函数通过实现CompletionHandler接口来处理异步操作的结果。

我们用个示例程序来理解
ReadCompletionHandler的回调类,当异步文件读取操作完成时,会自动调用它的completedfailed方法。然后,我们使用AsynchronousFileChannel异步读取文件,并将ReadCompletionHandler的实例作为参数传递给read方法。当读取操作完成时,ReadCompletionHandler中的方法将被自动调用,以处理操作结果。

  1. 实现CompletionHandler接口。这个接口有两个方法:completed用于处理操作成功的情况,failed用于处理操作失败的情况。
public class ReadCompletionHandler implements CompletionHandler<Integer, ByteBuffer> {
    
    

    @Override
    public void completed(Integer bytesRead, ByteBuffer buffer) {
    
    
        System.out.println("异步读取完成,读取了 " + bytesRead + " 字节");
        buffer.flip();
        byte[] data = new byte[buffer.limit()];
        buffer.get(data);
        System.out.println("文件内容: " + new String(data));
    }

    @Override
    public void failed(Throwable exc, ByteBuffer attachment) {
    
    
        System.err.println("异步读取失败: " + exc.getMessage());
    }
}
  1. 使用AsynchronousFileChannel来异步读取文件内容。通知回调函数通过将ReadCompletionHandler的实例传递给AsynchronousFileChannelread方法来实现。
public class AsyncFileReader {
    
    
    public static void main(String[] args) {
    
    
        Path filePath = Paths.get("example.txt");
        try (AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(filePath, StandardOpenOption.READ)) {
    
    
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            long position = 0;
            fileChannel.read(buffer, position, buffer, new ReadCompletionHandler());

            // 使程序运行一段时间,以便异步操作完成
            Thread.sleep(3000);
        } catch (IOException | InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
}

4. 直接内存IO(Direct IO,DIO)

  • 基本概念和原理:直接将数据从磁盘读取到应用程序所使用的内存空间,而不需要经过操作系统内核缓冲区。
  • 优点:减少数据的拷贝次数,提高读写性能。
  • 缺点:需要操作系统支持,适用性较低。
  • 应用场景:适用于大文件读写等高性能要求的场景。

3. IO多路复用

3.1. 多路复用的概念和原理

select,poll,epoll都是IO多路复用的机制。所谓I/O多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

3.2. select模型

通过select函数监听多个IO事件,当有事件发生时返回,适用于连接数较少的场景。监视多个文件描述符(例如套接字socket)的状态变化。当我们需要监视多个文件描述符时,select模型可以帮助我们实现高并发和高性能的网络应用。

select使用一个描述符集来保存需要监控的文件描述符(通常为socket)。当某个描述符的状态发生变化(例如,数据可读、可写或异常),select函数返回,告知哪些描述符发生了变化。

select模型的主要优点是跨平台兼容性好,支持多种操作系统。但是在处理大量并发连接时,它存在一些缺点:

  1. 效率较低:每次调用select函数时,都需要将所有的文件描述符集复制到内核空间。当监视的文件描述符数量较多时,这会导致较多的内核态和用户态之间的切换,影响性能。
  2. 可监视的文件描述符数量受限:由于select使用fd_set结构存储文件描述符,这个结构的大小是固定的,限制了select能够监控的最大文件描述符数量。
  3. 事件触发后,需要遍历整个文件描述符集合来确定哪些描述符发生了变化,这在大量并发连接的情况下会造成一定的性能损失。

尽管如此,select仍然是一种简单而实用的I/O复用技术,对于一些并发连接数量较小的应用场景,select模型的性能表现仍然可接受。

3.3. poll模型

poll模型是一种高级的I/O复用技术,与select类似,也用于监视多个文件描述符(例如套接字socket)的状态变化。poll模型和select模型有很多相似之处,但也有一些关键的改进,使其在处理大量并发连接时表现更好。

poll模型使用一个轮询结构数组(pollfd结构数组)来保存需要监控的文件描述符及其关注的事件。当某个描述符的状态发生变化时,poll函数返回,告知哪些描述符发生了变化及其具体的状态变化。

poll模型的主要优点如下:

  1. 没有文件描述符数量限制:与select不同,poll模型不使用固定大小的结构来存储文件描述符,而是使用动态大小的轮询结构数组。因此,poll没有固定的文件描述符数量限制,可以处理更多的并发连接。
  2. 更高效的事件处理:poll在轮询结构数组中直接存储了发生状态变化的描述符及其具体的状态变化信息,因此在事件触发后,我们可以直接访问这些信息,无需像select那样遍历整个文件描述符集合。

然而,poll模型在处理大量并发连接时仍存在一些效率问题:

  1. 执行效率受限于线性轮询:当并发连接数量较大时,需要遍历整个轮询结构数组以查找发生变化的描述符,这会导致一定的性能损失。
  2. 需要与内核进行大量数据交换:每次调用poll函数时,都需要将整个轮询结构数组复制到内核空间。当监视的文件描述符数量较多时,这会导致较多的内核态和用户态之间的切换,影响性能。

poll模型是一种相对于select更加高效的I/O复用技术,适用于处理较大数量的并发连接。然而,在极高并发的场景下,它仍可能面临一些性能问题。针对这些问题,可以考虑使用更高效的I/O复用技术,如epoll(Linux)或IOCP(Windows)。

在操作系统层面,poll模型主要通过系统调用来实现。在Linux和类Unix系统中,poll模型通过poll()系统调用来实现。这个系统调用可以让应用程序轮询多个文件描述符(包括套接字、普通文件等)的I/O状态,从而实现多路复用。下面从操作系统层面详细解释poll模型的工作原理。

  1. 文件描述符 在Unix和类Unix系统中,所有打开的文件、套接字、管道等都用一个非负整数表示,称为文件描述符(file descriptor)。文件描述符用于唯一标识一个打开的文件,并提供一个统一的接口用于读、写和操作文件。

  2. pollfd结构体 poll模型使用一个名为pollfd的结构体数组来存储需要监视的文件描述符及其关注的事件。pollfd结构体包含以下字段:

    • int fd:表示文件描述符。
    • short events:表示关注的事件,例如POLLIN表示关注输入事件(可读),POLLOUT表示关注输出事件(可写)。
    • short revents:表示文件描述符实际发生的事件。当poll()函数返回时,该字段会被设置为发生的事件类型。
  3. poll()系统调用 poll模型通过poll()系统调用来查询文件描述符的状态。poll()函数的原型如下:

    int poll(struct pollfd *fds, nfds_t nfds, int timeout);
    

    其中,fds是指向pollfd结构体数组的指针,nfds表示数组中文件描述符的数量,timeout表示等待时间(以毫秒为单位),-1表示无限等待,0表示立即返回。

当我们调用poll()函数时,操作系统会将pollfd结构体数组复制到内核空间,然后检查每个文件描述符的状态。如果某个文件描述符的状态发生了变化(例如可读或可写),操作系统会将对应的revents字段设置为相应的事件类型。当检查完所有文件描述符后,poll()函数返回,并将pollfd结构体数组复制回用户空间。此时,我们可以遍历数组,检查每个文件描述符的revents字段,以确定发生了哪些事件。

从操作系统的角度来看,poll模型的优缺点

优点:

  1. 可以处理大量文件描述符:由于poll模型使用动态大小的数组来存储文件描述符,因此没有文件描述符数量的固定限制。

  2. 直接返回发生事件的文件描述符:与select模型相比,poll模型在数组中直接存储了发生事件的文件描述符及其具体的事件类型。这使得我们可以更快地处理事件,而无需像select那样遍历整个文件描述符集合。

缺点:

  1. 线性查找效率低:当文件描述符数量较多时,查找发生事件的文件描述符需要遍历整个数组,导致效率较低。

  2. 频繁的用户态和内核态切换:每次调用poll()函数时,都需要将整个pollfd数组复制到内核空间,然后再复制回用户空间。这会导致较多的用户态和内核态切换,影响性能。

3.4. epoll模型

epoll是Linux内核中一种高效的I/O事件处理模型。它是Linux下多路复用I/O接口selectpoll的增强版本。其主要优点是在处理大量并发连接时,性能较高且没有固定限制。以下将详细解释epoll的工作原理、使用方法和优缺点。
许多流行的开源中间件使用了epoll模型来实现高性能的并发处理能力
这些中间件都运用了epoll模型,使得它们能够在高并发环境下表现出卓越的性能。

  1. Nginx采用了epoll模型来实现高并发和高吞吐量。
  2. Redis epoll模型来处理大量的并发连接。
  3. Haproxy 它一个开源的负载均衡器和代理服务器,采用了epoll模型来实现高并发和高可用性。

3.4.1. 工作原理

epoll使用一种事件驱动机制,它会将多个I/O事件注册到一个epoll对象上,然后在事件发生时通知应用程序。与selectpoll不同的是,epoll并不需要遍历整个监听集合,而是基于内核回调来实现,这样就避免了线性扫描的开销。此外,epoll只会返回已经发生的事件,因此处理效率更高。

3.4.2. 使用方法

以下是使用epoll的基本步骤:

  • 创建epoll对象:通过调用epoll_createepoll_create1函数来创建一个epoll对象。
  • 注册事件:使用epoll_ctl函数将需要监听的文件描述符(如socket、文件等)及其关联的事件(如EPOLLIN、EPOLLOUT等)添加到epoll对象中。
  • 等待事件:通过调用epoll_wait函数来等待注册的事件发生。当事件发生时,epoll_wait返回已经就绪的事件集合。
  • 处理事件:根据epoll_wait返回的事件集合,执行相应的事件处理操作。
  • 注销事件:如果不再需要监听某个文件描述符,可以调用epoll_ctl将其从epoll对象中删除。
  • 关闭epoll对象:使用close函数关闭epoll对象。

3.4.3. 优缺点

优点 缺点
事件驱动,只会返回已经发生的事件,避免了线性扫描的开销。 只适用于Linux,不具备跨平台性。
没有固定限制,可以处理大量并发连接。 学习曲线较高,相较于selectpollepoll的API更复杂,学习成本较高。
对于大量连接,epoll性能优于selectpoll 对于少量连接,其优势不明显,而且使用也相对复杂。

3.5. kqueue模型

类似于epoll模型,但在Unix-like系统中使用,适用于高并发的场景。

3.6. IOCP模型

IOCP(I/O Completion Ports)是Windows操作系统下的一种高性能I/O模型。IOCP为高并发、高吞吐量的网络应用提供了高效且可扩展的I/O操作。在IOCP模型中,所有的I/O操作都是异步进行的,核心思想是将I/O操作与处理I/O完成的工作线程分离,使得线程能够专注于处理逻辑。

3.6.1. IOCP的主要组成部分

  1. 完成端口(Completion Port):一种特殊的操作系统对象,用于管理和处理异步I/O操作的完成通知。
  2. 完成例程(Completion Routine):处理异步I/O操作完成通知的回调函数。
  3. OVERLAPPED结构:用于描述异步I/O操作的数据结构,包含了完成例程、完成键和其他相关信息。

3.6.2. IOCP模型的工作流程

  1. 创建一个完成端口 应用程序创建一个完成端口,用于接收和处理I/O完成通知。
  2. 关联文件描述符 将socket(或其他文件描述符)与完成端口关联,使得socket上的I/O操作能够被完成端口处理。
  3. 发起异步I/O操作 当需要进行I/O操作(如发送、接收数据等)时,应用程序发起一个异步I/O操作。这个操作会立即返回,线程继续处理其他任务。
  4. 等待I/O完成 工作线程通过调用GetQueuedCompletionStatus()函数等待I/O操作的完成通知。当有完成通知时,函数返回并提供相关的完成信息。
  5. 处理完成通知 工作线程根据完成信息调用相应的完成例程,处理I/O操作的结果,并进行下一步处理(如继续发起异步I/O操作等)。

3.7. 优缺点比较

实现方式 优点 缺点
select 1. 跨平台支持良好,可移植性高。 2. 简单易用,适合入门学习。 1. 文件描述符数量受FD_SETSIZE限制,默认为1024。 2. 系统调用开销较大,需要遍历整个文件描述符集合。 3. 触发方式为水平触发(Level-Triggered),容易导致性能问题。
poll 1. 跨平台支持良好,可移植性高。 2. 无文件描述符数量限制。 1. 系统调用开销较大,同样需要遍历文件描述符集合。 2. 触发方式为水平触发(Level-Triggered),容易导致性能问题。
epoll 1. Linux平台专属,性能优秀。 2. 无文件描述符数量限制。 3. 系统调用开销较小,可实现事件驱动。 4. 支持边缘触发(Edge-Triggered)和水平触发(Level-Triggered)。 1. 仅适用于Linux平台,可移植性差。 2. 使用边缘触发模式时,编程复杂度较高。
kqueue 1. BSD平台专属,性能优秀。 2. 无文件描述符数量限制。 3. 系统调用开销较小,可实现事件驱动。 4. 支持边缘触发(Edge-Triggered)和水平触发(Level-Triggered)。 1. 仅适用于BSD平台(如FreeBSD、macOS等),可移植性差。 2. 使用边缘触发模式时,编程复杂度较高。
IOCP(Windows) 1. Windows平台专属,性能优秀。 2. 基于完成端口的事件通知机制,可实现事件驱动。 3. 支持异步操作,适用于高并发场景。 1. 仅适用于Windows平台,可移植性差。 2. 编程复杂度较高。

参考文档

  1. 《Boost application performance using asynchronous I/O》及图片来源自本文

  2. https://notes.shichao.io/unp/ch6/

  3. http://www.linuxidc.com/Linux/2012-05/59873p3.htm

  4. http://xingyunbaijunwei.blog.163.com/blog/static/76538067201241685556302/

  5. http://blog.csdn.net/kkxgx/article/details/7717125

  6. https://banu.com/blog/2/how-to-use-epoll-a-complete-example-in-c/epoll-example.c
    在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/wangshuai6707/article/details/133306701