在网络编程的世界里,IO 操作的效率和处理方式是影响系统性能的关键因素。而理解阻塞、非阻塞、同步、异步这四个核心概念,是掌握高性能网络编程的基础。本文将深入剖析这些概念,通过典型的 IO 操作过程,揭示它们的本质区别和应用场景。

目录

一次 IO 的两个典型阶段

一、数据准备阶段

1. 阻塞模式

2. 非阻塞模式

二、数据读写阶段

1. 同步模式

2. 异步模式

三、关键结论

四、与业务中并发的同步异步区分

五、总结:IO 中的阻塞、非阻塞、同步、异步

Linux 上的五种 IO 模型

一、阻塞 IO 模型(Blocking IO)

二、非阻塞 IO 模型(Non-blocking IO)

三、IO 复用模型(IO Multiplexing)

四、信号驱动 IO 模型(Signal-Driven IO)

五、异步 IO 模型(Asynchronous IO)

 六、五种 IO 模型对比

七、关键区别总结


一次 IO 的两个典型阶段

在探讨阻塞、非阻塞、同步、异步之前,我们需要先明确一个典型的网络 IO 操作所包含的两个阶段:数据准备(数据就绪)阶段和数据读写阶段。这两个阶段的处理方式不同,导致了不同的 IO 模型。

一、数据准备阶段

数据准备阶段是指系统 IO 操作检测数据是否就绪的过程。根据系统对 IO 操作就绪状态的处理方式,可分为阻塞和非阻塞两种模式。

1. 阻塞模式

在阻塞模式下,当调用如recv这样的 IO 接口时,如果目标套接字(sockfd)上没有数据到来,当前线程会被阻塞,进入等待状态,直到数据到达。下面是一个典型的阻塞 IO 调用示例:

int size = recv(sockfd, buf, 1024, 0);

在这个例子中,如果sockfd对应的内核 TCP 接收缓冲区中没有数据,recv函数会一直等待,不会返回,直到有数据到达或者连接关闭。这种方式的优点是代码逻辑简单,缺点是线程在等待过程中无法执行其他任务,会造成资源浪费,尤其在高并发场景下问题更为突出。

2. 非阻塞模式

非阻塞模式则不同,当设置套接字为非阻塞模式后,如果调用recv时目标套接字上没有数据,函数会立即返回,而不会阻塞当前线程。此时,需要通过返回值来判断数据是否就绪:

int size = recv(sockfd, buf, 1024, 0);
if (size == -1 && errno == EAGAIN) {
    // 数据尚未准备好,这是正常的非阻塞返回
    // 可以继续执行其他任务或再次尝试读取
} else if (size == 0) {
    // 对端关闭了连接
} else if (size > 0) {
    // 数据已就绪并成功读取
} else {
    // 发生其他错误,需要进行错误处理
}

在非阻塞模式下,通常需要在循环中不断检查返回值,直到数据就绪。这种方式虽然避免了线程阻塞,但如果数据长时间未就绪,会导致 CPU 空转,浪费 CPU 资源。因此,非阻塞 IO 通常需要配合多路复用技术(如 select、poll、epoll)一起使用,以提高效率。

二、数据读写阶段

数据读写阶段是指将数据从内核缓冲区传输到应用程序缓冲区,或者从应用程序缓冲区传输到内核缓冲区的过程。根据应用程序与内核的交互方式,可分为同步和异步两种模式。

1. 同步模式

在同步模式下,数据的读写操作由应用程序自己完成。当调用recv等同步 IO 接口时,如果数据已就绪,函数会将数据从内核的 TCP 缓冲区复制到应用程序提供的缓冲区中(这个过程由应用程序执行)。在这个数据拷贝过程中,代码会阻塞在recv函数处,直到数据拷贝完成才会返回。例如:

int size = recv(sockfd, buf, 1024, 0);

这里的recv就是一个典型的同步 IO 接口。即使在非阻塞模式下,只要数据就绪后进行读写操作时,应用程序仍需要等待数据传输完成,因此非阻塞 IO 在数据读写阶段仍然属于同步 IO。

2. 异步模式

异步模式则完全不同。在异步 IO 中,数据的读写操作由内核负责完成,应用程序只需向内核发起 IO 请求,并指定当操作完成时的通知方式,然后就可以继续执行其他业务逻辑。当内核完成数据的读写操作后,会通过事先约定的方式(如信号或回调函数)通知应用程序。例如:

#include <aio.h>

// 定义异步IO控制块
struct aiocb aiocb;

// 初始化aiocb结构
memset(&aiocb, 0, sizeof(struct aiocb));
aiocb.aio_fildes = sockfd;
aiocb.aio_buf = buf;
aiocb.aio_nbytes = 1024;
aiocb.aio_offset = 0;

// 设置回调函数(当IO完成时调用)
aiocb.aio_sigevent.sigev_notify = SIGEV_CALLBACK;
aiocb.aio_sigevent.sigev_notify_function = my_callback_function;
aiocb.aio_sigevent.sigev_notify_attributes = NULL;

// 发起异步读操作
int ret = aio_read(&aiocb);
if (ret != 0) {
    // 处理错误
}

// 继续执行其他业务逻辑,无需等待IO完成

在这个例子中,aio_read函数会立即返回,不会阻塞当前线程。当数据从内核缓冲区复制到应用程序缓冲区完成后,内核会调用my_callback_function函数通知应用程序。这种方式使得应用程序在 IO 操作进行过程中可以继续执行其他任务,大大提高了并发处理能力。

三、关键结论

需要特别强调的是,在处理 IO 时,阻塞和非阻塞实际上都属于同步 IO 范畴,只有使用像aio_readaio_write这样的特殊 API 才是真正的异步 IO。这是因为,无论是阻塞 IO 还是非阻塞 IO,当数据就绪后进行读写操作时,应用程序都需要等待数据传输完成(即使是非阻塞 IO,也需要通过轮询不断检查状态),而真正的异步 IO 则是由内核完全接管数据传输,应用程序无需等待。

来自muduo作者陈硕:

  ***在处理IO时,阻塞和非阻塞都是同步IO,只有使用特殊的API才是异步IO***

四、与业务中并发的同步异步区分

在业务开发中,也经常会提到同步和异步的概念,但这与 IO 模型中的同步异步有所不同,需要加以区分:

  • 业务中的同步:是指操作 A 需要等待操作 B 完成后才能继续执行后续逻辑。例如,在调用一个远程 API 时,程序会等待 API 返回结果后再继续执行下一步。
  • 业务中的异步:是指操作 A 向操作 B 发起请求,并告知 B 自己感兴趣的事件以及事件发生时的通知方式,然后操作 A 就可以继续执行自己的业务逻辑。当操作 B 监听到相应事件发生后,会按照约定的方式通知操作 A,A 再进行相应的数据处理。例如,在消息队列系统中,生产者发送消息后不需要等待消费者处理结果,可以继续执行其他任务,消费者处理完消息后可以通过回调或消息通知生产者。

五、总结:IO 中的阻塞、非阻塞、同步、异步

综上所述,一个典型的网络 IO 接口调用可以分为 “数据就绪(数据准备)” 和 “数据读写” 两个阶段:

  • 在数据就绪阶段,根据是否阻塞当前线程,分为阻塞和非阻塞两种模式。
  • 在数据读写阶段,根据是由应用程序还是内核负责完成数据传输,分为同步和异步两种模式。

Linux 上的五种 IO 模型

在 Linux 系统中,根据数据准备和数据传输阶段的不同处理方式,可将 IO 模型分为五类。这些模型从简单到复杂,逐步提升系统在高并发场景下的处理能力。

下文图片来源:【Linux高级IO】五种IO模型_【linux】五种io模型之高性能io技术详解-CSDN博客

一、阻塞 IO 模型(Blocking IO)

阻塞 IO 是最基本的 IO 模型,其核心特点是在数据准备和数据传输阶段均会阻塞进程。以网络套接字为例:

// 创建套接字并连接服务器
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));

// 调用 recv 接收数据(默认阻塞模式)
char buffer[1024];
int n = recv(sockfd, buffer, 1024, 0);  // 进程在此处阻塞

// 数据就绪并复制完成后继续执行
process_data(buffer, n);

 

工作流程

  1. 进程调用 recv 进入内核态
  2. 若数据未就绪(TCP 缓冲区为空),进程被挂起(进入睡眠状态)
  3. 数据到达后,内核将数据从网卡复制到内核缓冲区
  4. 内核将数据复制到用户空间缓冲区
  5. recv 返回,进程恢复执行

特点

  • 实现简单,代码逻辑清晰
  • 但同一时间每个进程只能处理一个 IO 请求
  • 在高并发场景下需要大量进程 / 线程,资源消耗大

二、非阻塞 IO 模型(Non-blocking IO)

非阻塞 IO 通过设置套接字为非阻塞模式,避免在数据准备阶段阻塞进程:

// 设置套接字为非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

// 循环尝试读取数据
while (1) {
    int n = recv(sockfd, buffer, 1024, 0);
    if (n == -1 && errno == EAGAIN) {
        // 数据未就绪,继续处理其他任务
        handle_other_tasks();
    } else if (n > 0) {
        // 数据就绪,处理数据
        process_data(buffer, n);
        break;
    } else {
        // 处理错误
        handle_error();
        break;
    }
}

 

工作流程

  1. 进程调用 recv 立即返回(无论数据是否就绪)
  2. 若数据未就绪,返回 EAGAIN (等同于EWOULDBLOCK)错误
  3. 进程可继续执行其他任务,定期轮询检查数据状态
  4. 数据就绪后,再次调用 recv 完成数据复制

特点

  • 避免进程阻塞,可在等待期间处理其他任务
  • 但频繁轮询会消耗大量 CPU 资源
  • 适用于 IO 就绪时间短的场景

三、IO 复用模型(IO Multiplexing)

IO 复用模型通过单个进程同时监视多个文件描述符(FD),提高并发处理能力。常见的实现有 selectpoll 和 epoll

// 使用 select 实现 IO 复用
fd_set readfds;
struct timeval timeout;

// 初始化文件描述符集合
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
FD_SET(other_fd, &readfds);

// 设置超时时间
timeout.tv_sec = 5;
timeout.tv_usec = 0;

// 调用 select 监视多个 FD
int activity = select(max_fd + 1, &readfds, NULL, NULL, &timeout);

if (activity > 0) {
    // 检查哪些 FD 就绪
    if (FD_ISSET(sockfd, &readfds)) {
        // 处理套接字数据
        recv(sockfd, buffer, 1024, 0);
    }
    if (FD_ISSET(other_fd, &readfds)) {
        // 处理其他 FD
    }
}

 

工作流程

  1. 进程调用 select/poll/epoll_wait 进入阻塞状态
  2. 内核监视所有注册的 FD,任一 FD 就绪时唤醒进程
  3. 进程遍历 FD 集合,找出就绪的 FD 进行处理
  4. 对就绪的 FD 调用 recv 完成数据复制

特点

  • 单个进程可同时处理多个 IO 请求
  • 相比多进程 / 线程模型,资源消耗显著降低
  • epoll 在大规模 FD 场景下性能更优(时间复杂度 O (1))

四、信号驱动 IO 模型(Signal-Driven IO)

信号驱动 IO 使用异步通知机制,当数据就绪时通过信号通知进程:

// 安装信号处理函数
void sigio_handler(int signo) {
    // 处理数据就绪事件
    recv(sockfd, buffer, 1024, 0);
}

// 设置信号处理
signal(SIGIO, sigio_handler);

// 设置套接字为异步模式并绑定进程
fcntl(sockfd, F_SETOWN, getpid());
int flags = fcntl(sockfd, F_GETFL);
fcntl(sockfd, F_SETFL, flags | FASYNC);

// 进程继续执行其他任务
while (1) {
    // 处理核心业务逻辑
    process_main_logic();
    // 无需主动检查 IO 状态
}

 

工作流程

  1. 进程通过 fcntl 设置套接字为异步模式并注册信号处理函数
  2. 内核在数据就绪时发送 SIGIO 信号给进程
  3. 进程在信号处理函数中调用 recv 完成数据复制

特点

  • 数据准备阶段非阻塞,进程可继续执行主逻辑
  • 相比轮询方式,减少了 CPU 消耗
  • 但信号处理函数可能干扰主程序执行流程

在第一阶段是异步的,在第二阶段是同步的;与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断轮询检查,减少了系统API调用次数,提高效率。

五、异步 IO 模型(Asynchronous IO)

真正的异步 IO 模型中,进程只需发起 IO 请求,内核完成整个数据传输过程后通知进程:

#include <aio.h>

// 定义异步 IO 控制块
struct aiocb aiocb;

// 初始化控制块
memset(&aiocb, 0, sizeof(aiocb));
aiocb.aio_fildes = sockfd;
aiocb.aio_buf = buffer;
aiocb.aio_nbytes = 1024;
aiocb.aio_offset = 0;

// 设置完成回调
aiocb.aio_sigevent.sigev_notify = SIGEV_THREAD;
aiocb.aio_sigevent.sigev_notify_function = io_complete_handler;
aiocb.aio_sigevent.sigev_notify_attributes = NULL;

// 发起异步读操作
aio_read(&aiocb);

// 进程继续执行其他任务,无需等待
process_other_work();

 

工作流程: 

  1. 进程调用 aio_read 发起异步请求,立即返回
  2. 内核在后台完成数据准备和数据复制操作
  3. 数据完全传输到用户空间后,通过回调函数通知进程

特点

  • 整个 IO 过程(包括数据准备和传输)均非阻塞
  • 进程无需主动干预 IO 操作,效率最高
  • 需操作系统和应用程序共同支持(如 Linux 的 aio 系列函数)

 六、五种 IO 模型对比

IO 模型 数据准备阶段 数据传输阶段 进程状态 典型应用场景
阻塞 IO 阻塞 阻塞 挂起等待 简单单线程应用
非阻塞 IO 非阻塞 阻塞 轮询检查 实时性要求不高的小并发场景
IO 复用 阻塞 阻塞 单进程监视多 FD 高并发网络服务器
信号驱动 IO 非阻塞 阻塞 信号回调 实时性要求较高的场景
异步 IO 非阻塞 非阻塞 完全无感知 高性能数据库、流媒体服务器

七、关键区别总结

  1. 同步 vs 异步

    • 同步 IO(阻塞、非阻塞、IO 复用、信号驱动):进程需要主动参与数据传输过程
    • 异步 IO:内核完全负责数据传输,完成后通知进程
  2. 阻塞 vs 非阻塞

    • 阻塞:进程在数据准备或传输阶段被挂起
    • 非阻塞:进程可继续执行其他任务,通过轮询或回调处理 IO
Logo

GitCode AI社区是一款由 GitCode 团队打造的智能助手,AI大模型社区、提供国内外头部大模型及数据集服务。

更多推荐