Linux网络学习【五种IO模型与⾮阻塞IO】


🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统与网络知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》 《Python知识内容》 《cpolar知识学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

在Linux网络编程中,让程序能够收发数据只是起点.当服务器需要同时处理大量连接时,更关键的问题是:数据尚未到达时,线程是否应该一直等待?如何兼顾多个连接?收到"可以读取"的通知,是否意味着数据已经读取完成? 理解这些问题,需要从具体的读写接口进一步深入IO模型,弄清应用程序与内核之间如何协作.理解IO模型,可以从一次典型的套接字读取入手:等待数据就绪,再将数据从内核缓冲区复制到用户缓冲区 .阻塞 IO、非阻塞IO、IO多路复用、信号驱动IO和异步IO,在等待方式、调用返回时机以及通知含义上各有区别.其中,非阻塞不等于异步,就绪通知也不等于操作完成 .非阻塞调用在数据未就绪时可以立即返回,但程序仍需决定何时再次尝试,并处理部分读写、连接关闭和错误等情况,而不是简单地不停循环读取.本文将以 "读写的两个阶段 → 五种 IO 模型对比 → 非阻塞 IO 实践" 为主线,梳理各类模型的执行流程与关键差异,重点介绍非阻塞模式的设置、返回值判断及读写循环的组织方式,并结合多路复用说明如何通过就绪事件安排读写、避免空转轮询.通过将模型原理与实际程序行为对应起来,为后续学习事件驱动与高并发服务器开发打下基础.

目录

一、五种IO模型

Linux/Unix 网络编程中,常见的五种I/O模型分别是:

  1. 阻塞 I/O(Blocking I/O)
  2. 非阻塞 I/O(Non-blocking I/O)
  3. I/O 多路复用(I/O Multiplexing)
  4. 信号驱动 I/O(Signal-driven I/O)
  5. 异步 I/O(Asynchronous I/O)

理解 I/O 的两个阶段

以 Socket 的 recvfrom() 读取数据为例:

  • 阶段一:等待数据就绪,例如等待网络数据到达内核缓冲区.
  • 阶段二:数据拷贝,将数据从内核缓冲区复制到用户空间.

五种 I/O 模型的核心区别,就是这两个阶段由谁等待、何时通知,以及调用线程是否阻塞.


1.1阻塞I/O

线程调用 recvfrom() 后,如果数据未就绪,就一直阻塞,直到数据准备好并拷贝完成.

特点: 实现简单,但线程等待期间无法继续执行后续代码.


1.2非阻塞I/O

线程调用 recvfrom(),如果数据尚未准备好,会立即返回 EAGAIN 或 EWOULDBLOCK,而不是一直等待.

程序可以反复尝试读取,直到数据就绪.

特点: 不必在等待数据阶段阻塞,但持续轮询会消耗CPU;实际读取数据时仍可能阻塞.


1.3多路复用I/O

通过一个线程监控多个 Socket 的就绪状态.

常见实现:

  • select
  • poll
  • epoll(Linux)

基本流程:

复制代码
多个 Socket
     ↓
select / poll / epoll
     ↓
等待某个 Socket 就绪
     ↓
调用 recv 读取数据
     ↓
处理业务逻辑

特点: 一个线程可以管理大量连接,是高并发网络服务器常用的技术.

注意:epoll 通知的是 I/O 就绪,而不是数据已经被拷贝到用户空间,所以它仍属于同步I/O.


1.4信号驱动I/O

应用程序注册 SIGIO 信号处理函数.

数据尚未就绪时,线程可以继续执行其他任务.数据就绪后,内核通过信号通知应用程序,再由程序调用 recvfrom() 读取数据.

特点: 不需要主动轮询,但信号处理机制较复杂,在常见网络服务中使用较少.


1.5异步I/O

应用程序提交异步读取请求后立即返回.

内核负责等待数据并完成数据读取,待整个 I/O 操作完成后再通知应用程序.

特点: 应用程序不必同步等待数据就绪和拷贝完成.


1.6五种I/O模型对比

I/O 模型 等待数据阶段 数据拷贝阶段 同步/异步
BIO 阻塞 阻塞 同步
NIO 非阻塞 同步读取,可能阻塞 同步
I/O 多路复用 通常阻塞等待就绪事件 同步读取 同步
信号驱动 I/O 非阻塞,信号通知 同步读取 同步
AIO 异步执行 异步完成 异步

二、⾼级IO重要概念

2.1同步通信VS异步通信

核心区别:同步通信需要等待对方返回结果后才能继续相关流程;异步通信发出请求后,不必等待结果,可以先执行其他任务,结果完成后再通过回调、事件、消息等方式处理.

1.形象理解

假设你去餐厅点餐:

2.工作流程对比

3.同步通信与异步通信对比

对比维度 同步通信 异步通信
请求发出后 等待结果 可以继续执行
结果获取 通常直接返回 回调、事件、消息等
实现复杂度 较低 较高
资源利用 可能产生等待开销 更容易利用等待时间
业务流程 直观、容易追踪 需要管理异步状态
典型场景 同步 HTTP、RPC 调用 MQ、异步 HTTP、事件通知

2.2阻塞VS⾮阻塞

核心区别:当程序执行I/O操作时,如果数据尚未准备好,当前线程是否需要等待.

  • 阻塞 I/O(Blocking I/O): 数据没准备好,线程就一直等待,直到操作完成或发生错误.
  • 非阻塞 I/O(Non-blocking I/O): 数据没准备好,立即返回,线程可以继续执行其他任务.

1.形象理解

假设你去快递站取快递.

2.工作流程对比

3.阻塞与非阻塞核心对比

对比维度 阻塞 I/O 非阻塞 I/O
数据未准备好 等待 立即返回
当前线程 被阻塞 可以继续运行
CPU 消耗 等待时通常不占用 CPU 频繁轮询可能消耗 CPU
编程复杂度 简单 相对复杂
典型应用 传统 Socket NIO、事件循环
数据读取 由当前调用完成 由当前调用尝试完成

4.代码示例

阻塞 I/O

java 复制代码
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(true);

ByteBuffer buffer = ByteBuffer.allocate(1024);

// 如果没有数据可读,就阻塞等待
int bytesRead = channel.read(buffer);

System.out.println("读取完成");

如果 Socket 没有数据到达,read() 通常不会立即返回,后面的代码需要等待。

非阻塞 I/O

java 复制代码
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);

ByteBuffer buffer = ByteBuffer.allocate(1024);

// 没有数据时立即返回 0
int bytesRead = channel.read(buffer);

if (bytesRead > 0) {
    System.out.println("读取到数据");
} else if (bytesRead == 0) {
    System.out.println("暂无数据,先做其他事情");
} else {
    System.out.println("连接已到达流结束状态");
}

注意:Java 非阻塞 SocketChannel.read() 在暂时没有数据时通常返回 0;而 Linux 非阻塞 recv() 通常返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK.

5.阻塞/非阻塞与同步/异步的区别

概念 关注点
阻塞 / 非阻塞 调用不能立即完成时,线程是否等待
同步 / 异步 I/O 完成结果通过什么机制获取

例如:

  • 阻塞 I/O:同步 + 阻塞.
  • 非阻塞 I/O:同步 + 非阻塞.
  • epoll:同步 I/O多路复用,通常配合非阻塞 Socket.
  • AIO:异步 I/O,操作完成后再通知应用程序.

特别注意:非阻塞I/O不等于异步 I/O.非阻塞只是没有数据时不必等待,应用程序仍然需要主动读取数据.


2.3其他⾼级IO

除了前面学习的五种I/O模型(BIO、NIO、I/O 多路复用、信号驱动 I/O、AIO),Linux还提供了一系列高级I/O技术.

这些技术的核心目标是:减少数据拷贝、降低系统调用开销、提高网络传输效率,以及更高效地管理文件描述符和事件.

1.高级 I/O 技术总览

技术 常用函数 主要作用
管道通信 pipe、socketpair 进程间传输数据
文件描述符管理 dup、dup2、fcntl 复制、重定向和控制 FD
分散读、集中写 readv、writev 一次操作处理多个缓冲区
零拷贝 sendfile、splice 减少用户态与内核态之间的数据拷贝
内存映射 mmap、munmap 将文件映射到进程地址空间
管道数据复制 tee 在两个管道之间复制数据
事件通知 eventfd、timerfd、signalfd 将通知整合进事件循环
现代异步 I/O io_uring 批量提交 I/O 请求并获取完成结果

它们并不是五种 I/O 模型之外的"新模型",而是可以与不同 I/O 模型组合使用的系统调用和机制.

2.零拷贝(Zero Copy)

零拷贝是一种减少数据在用户空间和内核空间之间重复拷贝的技术,常用于文件传输和高性能网络服务.

sendfile() 可以在文件描述符之间传输数据,避免传统 read() + write() 过程中不必要的用户态数据拷贝.具体内核拷贝次数取决于系统实现和硬件条件.

另外两种相关技术是:

  • splice():借助管道,在文件描述符之间传输数据,避免数据经过用户空间.
  • tee():在两个管道之间复制数据引用,不消耗源管道中的数据.

3.readv / writev:分散读与集中写

这两个函数经常用于网络协议处理.

例如,HTTP 响应由响应头和响应体组成.使用 writev 可以把两块内存通过一次调用进行集中写入,无需先拼接成一块连续内存.需要注意实际写入可能只完成一部分.

4.mmap:内存映射

mmap() 可以将文件内容映射到进程的虚拟地址空间,使程序像访问内存一样访问文件数据.

复制代码
普通文件 I/O:
read() → 内核复制数据 → 用户缓冲区

mmap:
文件 → 虚拟内存映射 → 通过内存地址访问

它适合随机访问文件、共享内存和大文件处理,但并不意味着所有访问都不会发生数据拷贝,也可能发生缺页异常和磁盘读取.

5.事件通知:eventfd / timerfd / signalfd

它们的共同价值是:把不同类型的通知转化为文件描述符上的事件,从而统一交给 epoll 监听.

函数 用途
eventfd 线程间或内核与用户态之间的事件通知
timerfd 定时器到期通知
signalfd 通过 FD 接收指定信号

例如,一个服务器事件循环可以同时监听 Socket 数据、定时器到期和线程唤醒事件.

6.io_uring:现代 Linux 异步 I/O

io_uring 是 Linux 提供的异步 I/O 接口,它利用用户空间与内核空间之间的共享环形队列来提交请求和获取完成结果.

它支持异步提交和批量处理多种 I/O 操作,有利于减少系统调用开销,但实际收益仍取决于负载、内核版本和具体操作.


三、⾮阻塞IO

3.1fcntl

fcntl(File Control)是Linux提供的文件描述符控制函数,主要用于操作和管理文件描述符(File Descriptor,简称FD).

在Linux高级I/O编程中,fcntl() 最重要的用途是:将 Socket 设置为非阻塞模式(Non-blocking I/O).

除此之外,它还可以用于文件描述符复制、文件加锁、文件状态管理以及信号驱动 I/O.

1.fcntl() 函数原型

c 复制代码
#include <fcntl.h>

int fcntl(int fd, int cmd, ...);

参数说明:

参数 作用
fd 要操作的文件描述符
cmd 控制命令,决定执行什么操作
... 可选参数,根据 cmd 决定

返回值:

  • 成功:返回值由具体的 cmd 决定.
  • 失败:返回 -1,并设置 errno.

2.fcntl() 常用命令

命令 作用
F_GETFL 获取文件状态标志
F_SETFL 修改文件状态标志
F_GETFD 获取文件描述符标志
F_SETFD 设置文件描述符标志
F_DUPFD 复制文件描述符
F_GETLK 检查文件锁
F_SETLK 设置非阻塞式文件锁
F_SETLKW 设置文件锁,必要时阻塞等待
F_SETOWN 设置接收 I/O 信号的进程或进程组

其中,F_GETFL、F_SETFL 和 O_NONBLOCK 是重点.

3.fcntl() 如何设置非阻塞 I/O?

假设服务器通过 accept() 获取了一个客户端 Socket.

默认情况下,如果 Socket 是阻塞模式,调用 recv() 时没有数据可读,就会阻塞当前线程.

我们可以使用 fcntl() 将其修改为非阻塞模式.

1.工作流程

2.代码实现

c 复制代码
#include <fcntl.h>
#include <stdio.h>

int setNonBlocking(int fd) {

    // 1. 获取原有的文件状态标志
    int flags = fcntl(fd, F_GETFL, 0);

    if (flags == -1) {
        perror("F_GETFL");
        return -1;
    }

    // 2. 添加非阻塞标志
    flags |= O_NONBLOCK;

    // 3. 更新文件状态标志
    if (fcntl(fd, F_SETFL, flags) == -1) {
        perror("F_SETFL");
        return -1;
    }

    return 0;
}

重点理解:

c 复制代码
flags |= O_NONBLOCK;

这里使用按位或运算,保留原有标志,并添加 O_NONBLOCK 标志.

如果要恢复阻塞模式:

c 复制代码
// flags 为通过 F_GETFL 成功获取的原有标志
flags &= ~O_NONBLOCK;

fcntl(fd, F_SETFL, flags);

实际开发时也需要检查 F_SETFL 的返回值.

4.设置非阻塞后,recv() 会发生什么?

假设客户端暂时没有发送数据.

阻塞 I/O

c 复制代码
char buffer[1024];

int n = recv(fd, buffer, sizeof(buffer), 0);

printf("读取结束\n");

如果 Socket 中没有数据,线程通常会阻塞在 recv(),直到有数据到达、连接关闭或者发生错误.

非阻塞 I/O

c 复制代码
char buffer[1024];

ssize_t n = recv(fd, buffer, sizeof(buffer), 0);

if (n > 0) {
    printf("读取到 %zd 字节\n", n);
}
else if (n == 0) {
    printf("对端正常关闭连接\n");
}
else if (errno == EAGAIN ||
         errno == EWOULDBLOCK) {
    printf("暂无数据,稍后再读\n");
}
else {
    perror("recv");
}

需要包含 <sys/socket.h> 和 <errno.h> 等头文件.

场景 阻塞 Socket 非阻塞 Socket
数据未就绪 等待数据 立即返回
数据已就绪 读取数据 读取数据
暂时无法读取 通常阻塞 返回 -1,设置 EAGAIN/EWOULDBLOCK
对端正常关闭 返回 0 返回 0

注意:非阻塞不代表不需要读取数据,只是没有数据时不会一直等待.

5.fcntl() 和 epoll 的关系

两者的作用不同:

fcntl: 负责控制 Socket 的状态,例如将 Socket 设置为非阻塞模式.

epoll: 负责监听多个 Socket 的就绪事件,通知程序哪些 Socket 可以进行读写操作.

典型网络服务器的工作流程:

复制代码
socket()
    ↓
bind()
    ↓
listen()
    ↓
accept()
    ↓
fcntl() 设置非阻塞
    ↓
epoll_ctl() 注册 Socket
    ↓
epoll_wait() 等待事件
    ↓
recv() / send() 处理数据

为什么 epoll 经常结合非阻塞 I/O?

假设使用 epoll 监听 1000 个 Socket.

当某个 Socket 可读时,epoll 通知应用程序.

如果应用程序执行 recv() 时发生阻塞,就可能导致事件循环无法及时处理其他 Socket.

因此,高性能网络服务器通常采用:

epoll + 非阻塞 Socket + 事件驱动机制

特别是在 epoll 的 ET(边缘触发)模式下,通常要循环读取,直到返回 EAGAIN,避免数据没有读完却无法及时收到新的通知.

6.F_GETFL 与 F_GETFD 的区别

这两个命令容易混淆.

对比 F_GETFL / F_SETFL F_GETFD / F_SETFD
操作对象 文件状态标志 文件描述符标志
常见标志 O_NONBLOCK、O_APPEND FD_CLOEXEC
主要作用 控制 I/O 行为 控制 FD 的相关属性
dup 后是否共享 共享 不共享

其中:

O_NONBLOCK:表示非阻塞 I/O.

O_APPEND:表示追加写入模式.

FD_CLOEXEC:表示成功执行 exec 系列函数加载新程序时,关闭该文件描述符.

需要注意,dup() 复制出的文件描述符共享同一个打开文件描述(open file description),因此文件状态标志是共享的,而文件描述符标志各自独立.


3.2实现函数SetNoBlock

使用 fcntl() 将文件描述符 fd 设置为非阻塞模式(Non-blocking I/O).

c 复制代码
#include <fcntl.h>

// 设置文件描述符为非阻塞模式
// 成功返回 0,失败返回 -1
int SetNoBlock(int fd)
{
    // 1. 获取原有文件状态标志
    int flags = fcntl(fd, F_GETFL, 0);

    if (flags == -1) {
        return -1;
    }

    // 2. 添加非阻塞标志
    flags |= O_NONBLOCK;

    // 3. 设置新的文件状态标志
    if (fcntl(fd, F_SETFL, flags) == -1) {
        return -1;
    }

    return 0;
}

核心代码解析

代码 作用
F_GETFL 获取原有文件状态标志
flags |= O_NONBLOCK 添加非阻塞标志,保留其他标志
F_SETFL 更新文件状态标志

使用示例

c 复制代码
int clientfd = accept(listenfd, NULL, NULL);

if (clientfd == -1) {
    perror("accept");
} else if (SetNoBlock(clientfd) == -1) {
    perror("SetNoBlock");
    close(clientfd);
}

使用前还需包含 <sys/socket.h>、<unistd.h> 和 <stdio.h>.

设置成功后:

  • Socket 没有数据时,recv() 不会一直阻塞.
  • 如果暂时没有数据可读,recv() 通常返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK.
  • 可以配合 epoll 实现高并发网络通信.

3.3轮询⽅式读取标准输⼊

在Linux 中,可以通过 fcntl() 将标准输入设置为非阻塞模式,再使用 while 循环不断调用 read(),实现轮询读取标准输入.

核心思想:

  • 有数据:读取并处理.
  • 没有数据:立即返回,不阻塞线程.
  • 程序可以继续执行其他任务,稍后再次读取.

1.实现原理

2.完整代码实现

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

// 设置文件描述符为非阻塞模式
int SetNoBlock(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);

    if (flags == -1) {
        return -1;
    }

    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main()
{
    char buf[1024];

    // 保存标准输入原有标志
    int oldflags = fcntl(STDIN_FILENO, F_GETFL, 0);

    if (oldflags == -1) {
        perror("fcntl");
        return 1;
    }

    // 1. 设置标准输入为非阻塞
    if (SetNoBlock(STDIN_FILENO) == -1) {
        perror("SetNoBlock");
        return 1;
    }

    // 2. 不断轮询标准输入
    while (1) {

        ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));

        if (n > 0) {
            // 成功读取数据
            printf("读取到 %zd 字节: ", n);
            fwrite(buf, 1, (size_t)n, stdout);
            fflush(stdout);
        }
        else if (n == 0) {
            // 标准输入结束(EOF)
            printf("\n输入结束\n");
            break;
        }
        else if (errno == EAGAIN ||
                 errno == EWOULDBLOCK) {
            // 暂无数据,可以执行其他任务
            usleep(100000);  // 休眠 100 毫秒
        }
        else if (errno == EINTR) {
            // 被信号中断,重新读取
            continue;
        }
        else {
            perror("read");
            break;
        }
    }

    // 恢复原来的文件状态标志
    fcntl(STDIN_FILENO, F_SETFL, oldflags);

    return 0;
}

3.read() 返回值详解

返回值 含义 处理方式
n > 0 成功读取 n 字节 处理数据
n == 0 到达文件结束位置(EOF) 退出循环
n == -1,EAGAIN 当前没有数据 稍后重试
n == -1,EINTR 被信号中断 重新调用
其他错误 I/O 操作失败 处理错误

4.运行效果

编译并运行:

bash 复制代码
gcc poll_stdin.c -o poll_stdin
./poll_stdin

示例输出:

复制代码
hello
读取到 6 字节: hello
world
读取到 6 字节: world

注意:Linux 终端默认通常处于规范模式(Canonical Mode),输入需要按回车后,read() 才能读取到这一行数据.

如果需要输入一个字符就立即读取,需要进一步配置终端的 termios 属性.

5.为什么需要 usleep()?

如果不加休眠:

c 复制代码
while (1) {
    read(STDIN_FILENO, buf, sizeof(buf));
}

程序会不断尝试读取数据,即使没有数据,也可能高速循环.

这叫作 忙轮询(Busy Polling),可能导致 CPU 占用率升高.

加入:

c 复制代码
usleep(100000);

可以降低 CPU 消耗,但也会增加最多约100毫秒的额外轮询等待时间(实际延迟还受到调度影响).

🚀真正的勇者不是流泪的人,而是含泪奔跑的人!


敬请期待下一篇文章内容


每日心灵鸡汤: 努力学习,是为了遇见更好的自己!

努力不一定会成功,但不努力连成功的机会都没有.现在不吃一点苦,将来就得受更多的苦,还会受到别人的白眼和嘲讽.为啥要好好学习?因为将来想去的地方很远,想买的东西很贵.学习可不是为了还父母的恩情,而是为了自己站得更高、飞得更远.

相关推荐
未力忘1 小时前
S7-200 SMART Modbus TCP 通信实践
网络·网络协议·tcp/ip
Likeadust1 小时前
说开就开:临时发起的私有化音视频实时通信EasyDSS视频会议,让跨城协作不再“等排期”
网络·音视频·easydss
愤怒火龙果2 小时前
AI攻防 AIRT补充篇|侦察指纹 · 多模态越狱 · Agent 与 MCP 安全
网络·人工智能·ai安全
无心水2 小时前
20、大数据架构设计理论与实践 核心考点与学习笔记
大数据·笔记·学习·架构师·系统架构设计师·软考系统架构设计师·架构设计师
北邮刘老师2 小时前
智能体手机的成败关键——智能体互联生态
网络·人工智能·智能手机·智能体·智能体互联网
程序员梅雨2 小时前
【从零开始学习数据仓库 | 第三篇】: Flume——数据采集基石
java·大数据·数据仓库·后端·学习·flume
手可摘星陳×2 小时前
10-运维排障篇-无线排障框架与常见故障-诊断矩阵速查
运维·网络
wuminyu2 小时前
JVM执行引擎学习总结:热点字节码编译成本地机器码
java·linux·c语言·jvm·c++
Z21-X2 小时前
云原生运维实战笔记:华为云计算与网络类服务全景解析
运维·笔记·学习·云原生·华为云