开篇介绍:
hello 大家,那么在上篇博客中,我们完成了对OSI、TCP、IP等等网络核心的学习,那么在本篇博客中,我们将探索我们无时无刻不在使用的IO!!!
要搞懂 I/O 模型,先记住一个核心结论:所有 I/O 操作都逃不过两个阶段------
- 「等待就绪阶段」:数据从外部设备(网卡、磁盘)传到内核的 "临时仓库"(内核缓冲区),比如网购包裹先到快递站,还没轮到你取;
- 「数据拷贝阶段」:数据从内核 "临时仓库" 传到进程的 "专属货车"(用户进程缓冲区),比如你把快递站的包裹搬到自己车上。
而IO最耗时的其实就是等待的时间!!!,但是我们又没有办法减少等待的时间,我们能做的只有:让等待等的有意义,让等待所占我们IO操作的时间变少。
五种 I/O 模型的本质区别,就是「在这两个阶段里,进程(相当于你)的 "做事方式" 不同」------ 有的傻等,有的边等边摸鱼,有的靠人通知,有的雇个 "监控员" 帮着盯。
一、阻塞 I/O(Blocking I/O):"傻等到底" 的老实人模型
核心定义
进程发起 I/O 请求后,就像 "钉在原地" 一样暂停工作,直到「等待就绪」和「数据拷贝」两个阶段全部完成,才会继续做后续任务 ------ 全程只专注这一件事,不做任何多余动作。

工作流程(快递员取包裹类比)
- 快递员(进程)接到任务:去 A 仓库(内核)取一批包裹(数据),于是立刻出发;
- 到了 A 仓库门口,仓库管理员说:"包裹还没到(等待就绪阶段),你在这等吧";
- 快递员啥也不做,就站在仓库门口傻等,期间不接新任务、不做其他事,哪怕等半小时、一小时;
- 终于,包裹送到仓库(等待就绪完成),快递员亲自动手把包裹搬到自己车上(数据拷贝阶段);
- 所有包裹拷贝完成,快递员才开车离开,去派送或接下一个任务。
核心特点
- 「双阻塞」:等待数据时进程暂停(阻塞),拷贝数据时进程在主动干活(不算阻塞,但全程被占用),整体来看,从发起请求到结束,进程完全 "绑定" 在这个 I/O 上;
- 「同步属性」:进程需要主动等待 I/O 完成,还得亲自参与数据拷贝,属于 "同步 I/O";
- 优点:逻辑极简,不用处理复杂状态,开发时几乎不用额外考虑什么,稳如老狗;
- 缺点:并发能力为零 ------ 一个进程一次只能处理一个 I/O 请求,比如这个快递员只能等 A 仓库的包裹取完,才能去 B 仓库,要是有 100 个仓库要盯,后面的全得排队,直接卡死。
典型场景
适合「低并发、单一任务」的简单需求,比如:
- 本地脚本读取配置文件(就读一次,读完就结束,不用并发);
- 小型工具的单一数据读取(比如一个脚本定时读取某个日志文件的最后一行);
- 内部测试工具(不用考虑性能,能跑通就行)。
二、非阻塞 I/O(Non-Blocking I/O):"边等边摸鱼" 的灵活派
核心定义
进程发起 I/O 请求后,不会阻塞等待,而是立刻拿到一个 "结果"------ 要么是 "数据还没就绪",要么是 "数据已拷贝完成";如果数据没就绪,进程可以去做其他任务,之后再通过 "反复询问"(轮询)的方式,确认数据是否就绪,直到完成拷贝。
它的核心是「不傻等,但要主动问」,相当于快递员不会在仓库门口死等,而是先去干别的,时不时回来看看包裹到了没。

工作流程
- 快递员(进程)接到任务:去 A 仓库取包裹,同时还得送 3 个小件快递(其他任务);
- 他先去 A 仓库发起 "取包裹请求",仓库管理员秒回:"包裹没到,你先走吧(返回'未就绪')";
- 快递员不傻等,立刻开车去送小件快递(进程去处理其他任务),比如先把小区 B 的快递送了,再去取个同城件;
- 送完小件,快递员绕回 A 仓库,问:"包裹到了吗?"(第一次轮询),管理员说:"还没到,再等等";
- 快递员又去干别的(比如整理车上的包裹、接个新订单),过了 10 分钟,再回 A 仓库问(第二次轮询):"到了吗?",管理员还是说 "没到";
- 反复轮询几次后,快递员再去 A 仓库,管理员说:"到了!"(等待就绪完成);
- 快递员立刻动手,把包裹搬到自己车上(数据拷贝阶段,进程主动参与,此时会被占用,不能做其他事);
- 拷贝完成,快递员继续去送其他快递或接新任务。
关键细节:非阻塞 I/O 的 "轮询" 到底是什么?
轮询是 non-blocking I/O 的核心,也是最容易被误解的点,这里拆透:
- 「轮询的本质」:进程主动、反复地调用 I/O 接口,询问内核 "数据准备好了吗?",就像你每隔 5 分钟给快递站打电话,问 "我的快递到了吗?";
- 「轮询的两种方式」:
- 忙轮询(傻等式轮询):进程不做任何停顿,疯狂询问内核,比如快递员 1 秒问一次仓库 "到了吗?",哪怕包裹要 1 小时才到 ------ 这种方式会把 CPU 占满,比如一个进程不停轮询,CPU 使用率能直接飙到 100%,完全浪费资源;
- 定时轮询(聪明式轮询):进程每问一次,就休息一段时间(比如 100 毫秒),再继续问 ------ 相当于快递员 10 分钟回一次仓库,既不会漏过包裹,又不会浪费精力,实际开发中大多用这种;
- 「轮询的局限」:哪怕是定时轮询,也会有 "时间差"------ 比如包裹在第 5 分钟到了,但快递员要 10 分钟才回来问,这就会导致数据就绪后,进程不能立刻处理,有延迟。
核心特点(重点对比其他模型)
- 「阶段差异」:等待就绪阶段不阻塞(进程能做其他事),数据拷贝阶段阻塞(进程要亲自拷贝,期间被占用);
- 「同步属性」:虽然等待时能摸鱼,但数据拷贝仍需要进程主动完成,所以它还是「同步 I/O」------ 和阻塞 I/O 的区别是 "等待方式不同"(傻等 vs 轮询 + 摸鱼),和信号驱动 I/O 的区别是 "通知方式不同"(主动问 vs 被动等通知);
- 「优点」:
- 进程不会被长时间阻塞,能并行处理多个 I/O 请求 + 其他任务,比如一个快递员能同时盯 A、B 两个仓库,轮询询问,还能穿插送小件;
- 响应速度比阻塞 I/O 灵活,比如 A 仓库的包裹没到,进程可以先处理 B 仓库的就绪数据,不会被 A 仓库卡死;
- 「缺点」:
- 轮询浪费 CPU 资源:哪怕是定时轮询,进程也要频繁调用 I/O 接口,相当于快递员反复跑仓库,没干正经活还占用精力;如果有 1000 个 I/O 请求要轮询,进程会被轮询占满,根本没功夫处理实际任务;
- 并发能力有限:轮询的次数越多,CPU 消耗越大,通常只能支撑千级别的连接(比如 1000 个仓库要盯,快递员跑不过来);
- 有数据延迟:定时轮询的时间差,会导致数据就绪后不能立刻被处理,比如实时性要求高的场景(比如游戏按键响应),可能会有卡顿。
非阻塞 I/O 的 "坑" 与实际优化
实际开发中,很少单独用非阻塞 I/O,因为轮询的 CPU 浪费太致命,通常会搭配其他技术优化:
- 坑 1:忙轮询导致 CPU 飙升 ------ 解决办法:加 "等待时间"(定时轮询),比如用 sleep () 让进程休息,减少轮询频率;
- 坑 2:多连接轮询效率低 ------ 解决办法:和 I/O 多路复用结合(后面会讲),让 "监控员" 帮着盯多个连接,就绪了再通知进程,不用进程自己轮询;
- 坑 3:数据延迟 ------ 解决办法:缩短轮询间隔,但会增加 CPU 消耗,需要在 "延迟" 和 "CPU 占用" 之间找平衡(比如实时游戏用 10 毫秒间隔,普通服务用 100 毫秒间隔)。
典型场景(非阻塞 I/O 的专属适用场景)
- 小型游戏服务器(重点场景):比如 2D 小游戏,需要同时处理 "玩家按键输入""网络数据接收""游戏逻辑更新"------ 用非阻塞 I/O 轮询网络数据,没收到数据时,进程就去处理游戏逻辑(比如玩家移动、碰撞检测),不会因为等网络数据而卡死游戏;
- 低并发实时数据处理:比如工业控制中的传感器数据读取,传感器每秒发送 1 次数据,进程可以每 100 毫秒轮询一次,没数据时就处理其他传感器的状态,不会浪费资源;
- 轻量网络服务:比如本地的小型 API 服务,连接数只有几百个,用非阻塞 I/O 轮询,搭配定时休息,既能支撑并发,又不会占用太多 CPU。
非阻塞 I/O vs 阻塞 I/O:核心差异对比
| 对比维度 | 阻塞 I/O | 非阻塞 I/O |
|---|---|---|
| 等待阶段 | 傻等(阻塞),啥也不做 | 轮询 + 摸鱼(不阻塞),能做其他事 |
| CPU 占用 | 低(等待时不占 CPU) | 中高(轮询会浪费 CPU) |
| 并发能力 | 极低(百级连接) | 较低(千级连接) |
| 响应延迟 | 低(数据就绪后立刻处理) | 中(有轮询时间差) |
| 开发复杂度 | 极低(不用处理轮询) | 低(只需处理轮询 + 错误) |
简单说:阻塞 I/O 是 "一根筋",非阻塞 I/O 是 "灵活派"------ 前者适合简单场景,后者适合需要并行处理轻量任务的场景,但都不适合高并发。
fcntl函数的使用:
一、fcntl 函数的核心定位
fcntl(File Control)是 Linux/Unix 系统提供的「文件描述符控制函数」,核心作用是修改 / 查询已打开文件描述符的各种属性,而不用重新打开文件。
你可以把它理解成:文件描述符(比如fd=3)是一个 "设备遥控器",fcntl就是 "遥控器的设置界面"------ 通过这个界面,你能给遥控器加功能(比如设置非阻塞)、加锁(防止别人操作)、复制遥控器(多一个控制通道)等,而不用重新买一个遥控器(重新打开文件)。
它的适用场景覆盖几乎所有文件描述符操作:
- 设置 / 取消非阻塞 I/O(我们之前讲的非阻塞 I/O 的核心实现方式);
- 给文件加锁(避免多进程 / 线程同时修改文件导致数据混乱);
- 复制文件描述符(替代
dup/dup2,更灵活); - 控制文件描述符的 "执行时关闭"(close-on-exec)属性;
- 查询文件描述符的当前状态(比如是否是非阻塞)。
二、fcntl 函数的基础语法
1. 头文件(必须包含)
使用fcntl前,需要引入 3 个头文件:
#include <unistd.h> // 基础系统调用(如read/write)
#include <fcntl.h> // fcntl的核心头文件(定义宏和函数原型)
#include <errno.h> // 错误码定义(用于错误处理)
2. 函数原型
fcntl是一个「变参函数」(参数个数不固定),原型如下:
cpp
int fcntl(int fd, int cmd, ... /* arg */);
3. 参数解析(核心)
| 参数 | 含义 | 关键说明 |
|---|---|---|
fd |
文件描述符 | 你要操作的目标文件描述符(比如打开文件返回的fd、socket 的fd),必须是已打开的(否则返回 - 1) |
cmd |
操作指令 | 告诉fcntl要做什么(比如 "获取状态""设置非阻塞""加锁"),是预定义的宏(比如F_GETFL) |
... |
可选参数 | 只有当cmd需要额外参数时才传,比如设置非阻塞时需要传O_NONBLOCK,加锁时需要传锁结构体 |
4. 返回值(必须重点处理)
fcntl的返回值分两种情况,错误处理是新手最容易踩坑的点:
- 成功:根据
cmd不同返回不同值:- 比如
F_GETFL(获取状态):返回文件描述符的当前状态标志(整数); - 比如
F_DUPFD(复制描述符):返回新的文件描述符; - 比如
F_SETFL/F_SETFD(设置属性):返回 0;
- 比如
- 失败:固定返回
-1,并设置全局变量errno(通过perror()或strerror()可查看具体错误)。
三、fcntl 的核心功能
fcntl的核心能力都体现在cmd参数的取值上,下面讲解最常用、最核心的 4 类功能,重点聚焦「非阻塞 I/O 设置」。
功能 1:获取 / 设置文件描述符的状态标志(F_GETFL/F_SETFL)
这是fcntl最常用的功能,尤其是F_SETFL结合O_NONBLOCK,是实现非阻塞 I/O的核心方式。
1.1 核心概念:文件描述符的 "状态标志"
文件描述符的状态标志是一组 "开关"(二进制位),常用的有:
O_NONBLOCK:非阻塞标志(核心)------ 设置后,对该 fd 的read/write/accept/connect等操作会变成非阻塞;O_APPEND:追加模式 ------ 每次写数据都会追加到文件末尾(比如日志文件);O_ASYNC:异步 I/O 标志 ------ 数据就绪时会向进程发送SIGIO信号(对应我们之前讲的信号驱动 I/O)。
注意:不是所有标志都能修改 ------ 比如O_RDONLY(只读)、O_WRONLY(只写)、O_CREAT(创建文件)这些 "打开标志",只能在open()时设置,无法通过fcntl修改。
1.2 实战示例 1:获取文件描述符的当前状态
比如查询一个 fd 是否是非阻塞、是否是追加模式:
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
int main() {
// 1. 打开文件(只读模式)
int fd = open("test.txt", O_RDONLY);
if (fd == -1) {
perror("open failed"); // 打印错误原因(比如文件不存在)
return -1;
}
// 2. 使用F_GETFL获取fd的状态标志
int flags = fcntl(fd, F_GETFL);
if (flags == -1) {
perror("fcntl F_GETFL failed");
close(fd);
return -1;
}
// 3. 解析状态标志(通过位运算判断)
printf("文件描述符 %d 的状态:\n", fd);
// 判断是否是非阻塞
if (flags & O_NONBLOCK) {
printf("→ 非阻塞模式(O_NONBLOCK)\n");
} else {
printf("→ 阻塞模式(默认)\n");
}
// 判断是否是追加模式
if (flags & O_APPEND) {
printf("→ 追加模式(O_APPEND)\n");
} else {
printf("→ 非追加模式\n");
}
// 4. 关闭文件描述符
close(fd);
return 0;
}
1.3 实战示例 2:设置文件描述符为非阻塞(核心!)
这是实现非阻塞 I/O 的关键代码,步骤是:「先获取当前标志→加非阻塞位→重新设置」(不能直接覆盖,否则会丢失原有标志)。
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
// 封装一个"设置非阻塞"的函数(实战中常用)
int set_nonblock(int fd) {
// 步骤1:获取当前的状态标志(必须先获取,否则会覆盖原有标志)
int flags = fcntl(fd, F_GETFL);
if (flags == -1) {
perror("fcntl F_GETFL failed");
return -1;
}
// 步骤2:添加O_NONBLOCK标志(位或运算,保留原有标志)
flags |= O_NONBLOCK;
// 步骤3:设置新的状态标志
if (fcntl(fd, F_SETFL, flags) == -1) {
perror("fcntl F_SETFL failed");
return -1;
}
printf("文件描述符 %d 已设置为非阻塞模式\n", fd);
return 0;
}
int main() {
// 1. 打开文件(只读模式)
int fd = open("test.txt", O_RDONLY);
if (fd == -1) {
perror("open failed");
return -1;
}
// 2. 设置为非阻塞
if (set_nonblock(fd) == -1) {
close(fd);
return -1;
}
// 3. 测试非阻塞读取(核心验证)
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
// 非阻塞模式下,无数据时会返回-1,且errno=EAGAIN/EWOULDBLOCK
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("当前无数据可读(非阻塞特性)\n");
} else {
perror("read failed"); // 其他错误(比如权限问题)
close(fd);
return -1;
}
} else {
printf("读取到数据:%.*s\n", (int)n, buf);
}
// 4. 关闭文件描述符
close(fd);
return 0;
}
1.4 关键说明(新手必看)
- 为什么要 "先获取再设置"?比如原有 fd 是
O_APPEND模式,如果你直接fcntl(fd, F_SETFL, O_NONBLOCK),会把O_APPEND覆盖掉,导致原有属性丢失 ------ 位或运算flags |= O_NONBLOCK能保留所有原有标志,只新增非阻塞。 - 非阻塞的表现:对设置了
O_NONBLOCK的 fd 调用read(),如果没有数据,不会阻塞,而是立即返回-1,且errno设为EAGAIN(或EWOULDBLOCK,两者等价)------ 这就是我们之前讲的 "非阻塞 I/O 轮询" 的核心依据。
1.5 实战示例 3:取消非阻塞模式
如果想恢复阻塞模式,只需把O_NONBLOCK位清除(位与取反):
cpp
int set_block(int fd) {
int flags = fcntl(fd, F_GETFL);
if (flags == -1) {
perror("fcntl F_GETFL failed");
return -1;
}
// 清除O_NONBLOCK位(保留其他标志)
flags &= ~O_NONBLOCK;
if (fcntl(fd, F_SETFL, flags) == -1) {
perror("fcntl F_SETFL failed");
return -1;
}
printf("文件描述符 %d 已恢复为阻塞模式\n", fd);
return 0;
}
功能 2:文件记录锁(F_GETLK/F_SETLK/F_SETLKW)
fcntl支持给文件加 "记录锁"(也叫 "字节范围锁"),即只锁定文件的某一段字节,而不是整个文件 ------ 这是多进程 / 线程安全操作文件的核心方式。
2.1 核心概念:锁的结构体
加锁需要用到struct flock结构体(定义在fcntl.h),结构如下:
cpp
struct flock {
short l_type; // 锁类型:F_RDLCK(读锁)、F_WRLCK(写锁)、F_UNLCK(解锁)
short l_whence; // 偏移起始位置:SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件末尾)
off_t l_start; // 锁的起始偏移(字节)
off_t l_len; // 锁的长度(字节),0表示锁定到文件末尾
pid_t l_pid; // 持有锁的进程ID(F_GETLK时返回,F_SETLK时忽略)
};
2.2 实战示例:给文件加写锁(排他锁)
写锁是 "排他锁"------ 同一时间只有一个进程能持有,读锁是 "共享锁"------ 多个进程可同时持有,但和写锁互斥。
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
// 给文件加写锁(排他锁)
int lock_file(int fd) {
struct flock lock;
// 初始化锁结构体:锁定整个文件
lock.l_type = F_WRLCK; // 写锁(排他)
lock.l_whence = SEEK_SET; // 从文件开头开始
lock.l_start = 0; // 起始偏移0
lock.l_len = 0; // 0表示锁定到文件末尾(整个文件)
lock.l_pid = -1; // 无需设置
// F_SETLKW:加锁,若已被锁定则阻塞等待(W=wait);F_SETLK:不等待,失败返回-1
if (fcntl(fd, F_SETLKW, &lock) == -1) {
perror("fcntl lock failed");
return -1;
}
printf("进程 %d 已获取文件写锁\n", getpid());
return 0;
}
// 解锁文件
int unlock_file(int fd) {
struct flock lock;
lock.l_type = F_UNLCK; // 解锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
lock.l_pid = -1;
if (fcntl(fd, F_SETLK, &lock) == -1) {
perror("fcntl unlock failed");
return -1;
}
printf("进程 %d 已释放文件锁\n", getpid());
return 0;
}
int main() {
int fd = open("test.txt", O_RDWR | O_CREAT, 0644);
if (fd == -1) {
perror("open failed");
return -1;
}
// 加锁
if (lock_file(fd) == -1) {
close(fd);
return -1;
}
// 模拟操作文件(睡眠5秒,期间其他进程无法加写锁)
printf("正在操作文件...(5秒后解锁)\n");
sleep(5);
// 解锁
unlock_file(fd);
close(fd);
return 0;
}
2.3 关键说明
F_SETLKvsF_SETLKW:F_SETLK:加锁时如果文件已被锁定,立即返回-1,errno=EAGAIN;F_SETLKW:加锁时如果文件已被锁定,阻塞等待直到锁释放(W=wait);
- 锁的特性:读锁(
F_RDLCK)是共享的(多个进程可同时加读锁),写锁(F_WRLCK)是排他的(和任何锁互斥)------ 遵循 "多读单写" 原则; - 锁的生命周期:进程退出或关闭 fd 时,内核会自动释放锁(避免进程崩溃导致锁残留)。
功能 3:复制文件描述符(F_DUPFD/F_DUPFD_CLOEXEC)
fcntl的F_DUPFD可以复制文件描述符,功能类似dup/dup2,但更灵活 ------ 可以指定新 fd 的最小值。
实战示例:复制文件描述符
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("test.txt", O_RDONLY);
if (fd == -1) {
perror("open failed");
return -1;
}
// F_DUPFD:复制fd,新fd是大于等于10的最小可用值
int new_fd = fcntl(fd, F_DUPFD, 10);
if (new_fd == -1) {
perror("fcntl F_DUPFD failed");
close(fd);
return -1;
}
printf("原fd:%d,新fd:%d\n", fd, new_fd);
// 用新fd读取文件(和原fd共享文件偏移)
char buf[1024];
ssize_t n = read(new_fd, buf, sizeof(buf));
if (n > 0) {
printf("新fd读取到数据:%.*s\n", (int)n, buf);
}
// 关闭两个fd(都要关,因为是独立的描述符)
close(fd);
close(new_fd);
return 0;
}
关键说明
F_DUPFD_CLOEXEC:复制 fd 的同时,给新 fd 设置FD_CLOEXEC标志(执行exec时自动关闭 fd),避免子进程继承无用的 fd;- 复制后的 fd 和原 fd 共享:文件偏移量、文件状态标志(比如非阻塞)、锁等 ------ 修改其中一个的偏移,另一个也会变。
功能 4:获取 / 设置文件描述符的 FD 标志(F_GETFD/F_SETFD)
FD 标志是文件描述符的 "元属性",最常用的是FD_CLOEXEC(close-on-exec):设置后,当进程调用exec执行新程序时,该 fd 会自动关闭(避免子进程继承)。
实战示例:设置 FD_CLOEXEC 标志
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("test.txt", O_RDONLY);
if (fd == -1) {
perror("open failed");
return -1;
}
// 步骤1:获取当前FD标志
int fd_flags = fcntl(fd, F_GETFD);
if (fd_flags == -1) {
perror("fcntl F_GETFD failed");
close(fd);
return -1;
}
// 步骤2:添加FD_CLOEXEC标志
fd_flags |= FD_CLOEXEC;
// 步骤3:设置新的FD标志
if (fcntl(fd, F_SETFD, fd_flags) == -1) {
perror("fcntl F_SETFD failed");
close(fd);
return -1;
}
printf("文件描述符 %d 已设置FD_CLOEXEC标志\n", fd);
close(fd);
return 0;
}
四、实际开发中的注意事项
-
必须检查返回值 :
fcntl几乎所有操作都可能失败(比如 fd 已关闭、权限不足),新手常忽略返回值,导致隐藏 bug------所有 fcntl 调用后,一定要判断是否返回 - 1,并处理 errno。 -
非阻塞设置的坑:
- 套接字(socket)的
connect在非阻塞模式下,会立即返回-1,errno=EINPROGRESS(表示连接正在建立),而不是EAGAIN------ 需要特殊处理; - 管道(pipe)、FIFO 的非阻塞设置和普通文件一致,但无数据时
read返回-1(errno=EAGAIN)。
- 套接字(socket)的
-
文件锁的坑:
fcntl的文件锁是 "劝告锁"(advisory lock)------ 只有所有进程都遵守加锁规则才有效,如果一个进程不检查锁直接写文件,锁会失效;- 无法对只读 fd 加写锁(会返回
EBADF错误),加锁前要确保 fd 有对应权限。
-
标志位操作的正确性:
- 新增标志用
|=,清除标志用&= ~,不要直接赋值(会覆盖原有标志); - 区分 "状态标志(F_GETFL)" 和 "FD 标志(F_GETFD)":前者是文件的属性(比如非阻塞),后者是描述符的属性(比如 close-on-exec)。
- 新增标志用
-
跨平台兼容性:
O_NONBLOCK在部分系统(比如 BSD)可能写成O_NDELAY,两者功能等价;fcntl的部分 cmd(比如F_DUPFD_CLOEXEC)是 Linux 特有,跨平台需做兼容判断。
五、总结(核心要点回顾)
fcntl是 Linux/Unix 操作文件描述符的 "万能工具",核心是通过cmd参数实现不同功能,变参arg根据cmd按需传递;- 最核心的用法是设置非阻塞 I/O(F_GETFL + F_SETFL + O_NONBLOCK),步骤是 "获取原有标志→位或添加非阻塞→重新设置",避免覆盖原有属性;
- 非阻塞模式下,
read/write无数据 / 无空间时会立即返回-1,需判断errno是否为EAGAIN/EWOULDBLOCK; - 其他常用功能:文件锁(F_SETLKW)实现多进程安全操作、FD_CLOEXEC 避免子进程继承无用 fd、F_DUPFD 灵活复制描述符;
- 开发中必须检查返回值,区分 "状态标志" 和 "FD 标志",避免覆盖原有属性。
fcntl是理解 Linux I/O 模型的关键函数 ------ 掌握它,你才能真正落地非阻塞 I/O、实现多进程文件安全操作,也是后续学习 I/O 多路复用(epoll)的基础。
三、信号驱动 I/O(Signal-Driven I/O):"等通知再干活" 的懒汉模型
核心定义
进程发起 I/O 请求后,不用阻塞,也不用轮询,而是继续做其他任务;内核在「等待就绪」完成后,会主动给进程发一个 "信号"(比如手机短信通知),进程收到信号后,再发起数据拷贝请求(拷贝阶段仍需进程参与)。
相当于快递员告诉仓库:"包裹到了给我发个短信",然后就去干别的,收到短信再去取包裹,不用自己跑回来问。

工作流程(快递员取包裹类比)
- 快递员(进程)去 A 仓库取包裹,发起请求后,跟管理员说:"包裹到了给我发个短信(信号),我先去送别的";
- 快递员开车离开,去送小件、接新订单(进程处理其他任务),期间完全不用管 A 仓库;
- 几小时后,包裹到仓库(等待就绪完成),管理员给快递员发了条短信(内核发送信号);
- 快递员看到短信,立刻赶往 A 仓库,动手把包裹搬到车上(数据拷贝阶段,进程主动参与);
- 拷贝完成,快递员继续去做其他任务。
核心特点
- 「阶段差异」:等待就绪阶段不阻塞(进程自由摸鱼),数据拷贝阶段阻塞(进程亲自拷贝);
- 「同步属性」:数据拷贝仍需要进程主动完成,属于「同步 I/O」------ 和非阻塞 I/O 的核心区别是 "通知方式"(被动等信号 vs 主动轮询);
- 优点:无轮询浪费,CPU 利用率比非阻塞 I/O 高;等待时进程能充分利用资源,比如快递员不用反复跑仓库,效率更高;
- 缺点:
- 信号处理逻辑复杂:比如多个信号同时到达(比如 A、B 仓库同时发短信),进程可能处理混乱,不知道先处理哪个;
- 信号不可靠:少数情况下,信号会丢失(比如短信没收到),导致进程不知道数据已就绪,一直不处理;
- 不支持批量监控:只能监控单个 I/O 请求,要是有 100 个仓库,就得注册 100 个信号,管理起来崩溃。
典型场景
适合「低速设备 I/O」或「数据不频繁就绪」的场景,比如:
- 串口通信(比如工业设备的串口数据传输,数据每隔几秒甚至几分钟才来一次,等信号通知即可);
- 低速磁盘读取(比如老式机械硬盘,读取大文件需要很久,进程不用轮询,等信号再处理);
- 单一设备的后台监控(比如监控某个传感器的状态,数据不频繁,用信号驱动不用浪费 CPU)。
四、I/O 多路复用(I/O Multiplexing):"一个监控员管多个仓库" 的高并发模型
核心定义
进程通过一个 "监控员"(内核提供的功能,比如 epoll),同时监控多个 I/O 请求的「等待就绪」状态;进程会阻塞在这个 "监控员" 上,直到至少一个 I/O 请求就绪,再依次处理这些就绪的 I/O(发起数据拷贝)。
相当于快递站雇了一个 "监控员",同时盯 100 个仓库的包裹状态,快递员只需要等监控员通知 "哪些仓库到包裹了",再去逐个取,不用自己盯每个仓库。

工作流程(快递员 + 监控员类比)
- 快递站(进程)雇了一个监控员(内核监控功能),让他同时盯 A、B、C、D 等 100 个仓库(多个 I/O 请求);
- 快递员启动监控员,然后自己阻塞在监控员旁边(不做其他事,只等通知)------ 注意:这里阻塞的是 "等监控员通知",不是等某个具体仓库的包裹;
- 过了一会儿,监控员说:"A、C、D 三个仓库的包裹到了(这三个 I/O 请求就绪)";
- 快递员先去 A 仓库,把包裹搬到车上(数据拷贝);再去 C 仓库、D 仓库,依次完成拷贝;
- 这三个仓库的拷贝都完成后,快递员重新让监控员开始监控,自己继续阻塞等待下一批就绪的 I/O。
核心特点
- 「阶段差异」:等待阶段阻塞在 "监控员" 上(不是阻塞在单个 I/O),拷贝阶段阻塞在单个 I/O(进程亲自拷贝);
- 「同步属性」:数据拷贝仍需要进程主动完成,属于「同步 I/O」------ 很多人误以为它是异步,其实不是,它只是优化了 "等待就绪" 的效率;
- 优点:
- 高并发能力极强:一个进程能同时监控百万级的 I/O 连接(比如监控 100 万个仓库),监控员能快速筛选出就绪的 I/O,不用进程逐个轮询;
- CPU 利用率高:只有当有 I/O 就绪时,进程才会去处理,等待时进程阻塞,不浪费 CPU;
- 生态成熟:几乎所有高并发服务都用它(比如 Nginx、Redis),稳定性和兼容性都经过验证;
- 缺点:
- 开发复杂度中等:需要处理 "监控员" 的接口、事件通知、就绪 I/O 的排序等,比阻塞 / 非阻塞 I/O 复杂;
- 依赖非阻塞 I/O:通常需要和非阻塞 I/O 结合使用(监控员通知就绪后,用非阻塞 I/O 拷贝数据),否则单个 I/O 拷贝会阻塞整个进程。
典型场景(高并发的核心选择)
- Web 服务器(比如 Nginx):需要同时处理百万级的 HTTP 请求,用 I/O 多路复用监控所有连接,就绪的请求才会被处理,支撑高并发;
- 缓存服务器(比如 Redis):需要同时响应十万级的客户端连接,用 I/O 多路复用监控所有客户端的请求,快速处理就绪的命令;
- 消息队列(比如 Kafka):需要同时接收 thousands 个生产者的消息、发送给 thousands 个消费者,用 I/O 多路复用监控所有网络连接,确保高吞吐量;
- 即时通讯服务器(比如微信、钉钉):需要同时维持百万级的用户在线连接,用 I/O 多路复用监控用户的消息发送请求,实时响应。
非阻塞 I/O vs I/O 多路复用:为什么高并发选后者?
非阻塞 I/O 靠进程自己轮询,1000 个连接就要轮询 1000 次,CPU 扛不住;而 I/O 多路复用靠监控员批量监控,100 万个连接也能快速筛选出就绪的,不用进程轮询 ------ 这就是高并发场景选 I/O 多路复用的核心原因。比如:100 万个仓库,非阻塞 I/O 需要快递员逐个跑一遍问 "到了吗?",跑死也跑不完;而 I/O 多路复用的监控员能直接说 "这 1000 个仓库到了",快递员只需要去这 1000 个仓库取包裹,效率天差地别。
五、异步 I/O(Asynchronous I/O):"全包给别人,只等结果" 的甩手掌柜模型
核心定义
进程发起 I/O 请求后,完全不用阻塞,也不用参与任何阶段 ------ 内核会自己完成「等待就绪」和「数据拷贝」两个阶段,等所有操作都完成后,再给进程发一个 "完成通知";进程收到通知后,直接使用拷贝好的数据即可。
相当于快递员跟仓库说:"包裹到了,你直接帮我送到客户家(用户进程缓冲区),送完给我发个短信",然后快递员就去摸鱼,直到收到 "送达通知",再去接下一个任务 ------ 全程不用自己等,也不用自己搬包裹。

工作流程(快递员取包裹类比)
- 快递员(进程)接到任务:让 A 仓库把包裹送到客户家(用户进程缓冲区),于是给 A 仓库发了个请求,说:"送完给我发个短信(完成通知)";
- 快递员直接去摸鱼(进程处理其他任务),不管包裹有没有到仓库,也不管有没有开始送;
- 仓库管理员等包裹到了(等待就绪完成),亲自把包裹搬到客户家(数据拷贝阶段,内核主动完成);
- 送完包裹,管理员给快递员发短信:"已送达"(内核发送完成通知);
- 快递员收到短信,确认任务完成,去接下一个任务。
核心特点
- 「全程不阻塞」:等待就绪和数据拷贝两个阶段,进程都不用参与,完全可以做其他事,是真正的 "非阻塞 I/O";
- 「异步属性」:内核完成所有 I/O 操作后才通知进程,进程无需主动等待或拷贝数据,属于「异步 I/O」------ 这是它和其他四种模型的核心区别(其他四种都是同步);
- 优点:
- 资源利用率最高:进程完全脱离 I/O 操作,专注于业务逻辑,CPU、内存等资源不会被 I/O 等待或拷贝浪费;
- 性能极致:适合磁盘 I/O 密集型场景(比如数据库),磁盘 I/O 的等待时间长,异步 I/O 能让进程不用等,效率远高于其他模型;
- 缺点:
- 开发复杂度极高:需要处理 "异步回调""状态管理""错误处理" 等,比如多个异步 I/O 同时完成,进程要知道哪个任务对应哪个数据,容易混乱;
- 生态不够成熟:目前只有少数高端场景用(比如 MySQL 8.0+、分布式存储),不像 I/O 多路复用那样普及;
- 调试难度大:异步操作的时序复杂,比如某个 I/O 延迟完成,排查问题时很难定位原因。
典型场景(极致性能的高端选择)
- 数据库(比如 MySQL 8.0+):磁盘 I/O 密集型,需要频繁读取 / 写入数据,用异步 I/O 让内核完成 I/O 操作,进程专注于 SQL 解析、事务处理,提升吞吐量;
- 分布式存储(比如 Ceph、GlusterFS):需要同时处理大量磁盘 I/O 和网络 I/O,用异步 I/O 优化 I/O 延迟,提升存储性能;
- 大数据处理(比如 Hadoop):读取 / 写入超大文件(GB/TB 级),异步 I/O 能减少进程等待时间,提高数据处理效率;
- 高频交易系统:对延迟要求极高(毫秒级),用异步 I/O 避免 I/O 等待导致的延迟,确保交易指令能快速执行。
六、五种 I/O 模型终极对比
| 模型类型 | 核心做事方式 | 等待阶段状态 | 拷贝阶段状态 | 同步 / 异步 | 并发能力 | 开发复杂度 | 典型场景 |
|---|---|---|---|---|---|---|---|
| 阻塞 I/O | 傻等,直到 I/O 全完成 | 阻塞(傻等) | 阻塞(亲自拷) | 同步 | 极低(百级) | 极低 | 本地脚本、简单工具 |
| 非阻塞 I/O(重点) | 轮询 + 摸鱼,数据就绪后亲自拷贝 | 不阻塞(轮询) | 阻塞(亲自拷) | 同步 | 较低(千级) | 低 | 小型游戏服务器、轻量实时处理 |
| 信号驱动 I/O | 等信号通知,数据就绪后亲自拷贝 | 不阻塞(等信号) | 阻塞(亲自拷) | 同步 | 较低(千级) | 中高 | 串口通信、低速设备监控 |
| I/O 多路复用 | 雇监控员盯多个 I/O,就绪后亲自拷贝 | 阻塞(等监控员) | 阻塞(亲自拷) | 同步 | 极高(百万级) | 中 | Web 服务器、缓存、消息队列 |
| 异步 I/O | 甩手掌柜,内核全包,只等完成通知 | 不阻塞(摸鱼) | 不阻塞(内核拷) | 异步 | 极高(百万级) | 极高 | 数据库、分布式存储、高频交易系统 |
七、总结:怎么选对 I/O 模型?
- 简单场景(低并发、单一任务):选「阻塞 I/O」------ 不用折腾,稳就完了;
- 轻量并行场景(中低并发、需要穿插处理任务):选「非阻塞 I/O」------ 比如小型游戏服务器,灵活又好开发;
- 低速设备场景(数据不频繁就绪):选「信号驱动 I/O」------ 不用轮询,省 CPU;
- 高并发场景(万级 / 百万级连接):选「I/O 多路复用」------ 这是行业标配,稳定性和性能都靠谱;
- 极致性能场景(磁盘 I/O 密集、低延迟要求):选「异步 I/O」------ 虽然复杂,但能榨干硬件性能。
记住一句话:没有最好的 I/O 模型,只有最适合场景的模型------ 不用盲目追求 "异步" 或 "高并发",简单的场景用简单的模型,才是最高效的选择。
完整示例代码:
cpp
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>
#include <cerrno>
//ok,那么在本文件中,我们就将去探索IO的奥秘,其实呢,是很简单的,
//但是学习它们主要是为了我们后续的select、epoll所做准备的
//那么IO是什么呢?IO就是Input/Output,输入输出的意思,
//在Linux中,IO操作是通过文件描述符来完成的,文件描述符是一个非负整数,
//它代表了系统中的一个打开的文件或设备。
//这个大家也肯定不陌生了,毕竟我们之前没少讲过IO以及文件了,尤其是Linux中的一切皆文件
//像我们平时的键盘输入,就是一个文件,文件描述符就是这个文件的索引,而这个文件就是标准输入
//对应的文件描述符为0,标准输出对应的文件描述符为1,标准错误对应的文件描述符为2
//这个是很简单的,但是呢,我们前面也一直有说,其实IO的效率是不高的,
//就是如果要用到IO的话,其实是会耗时间的,我们之前也进行过了测试
//那么这是为什么呢???因为IO本质上是系统调用?系统调用会耗时???
//这一点是不错的,IO确实是系统调用,会有上下文切换的开销,但是,这个不是它效率低的根本原因,
//我们要想知道IO慢的根本原因,我们就要从IO的工作原理说起,
//IO的工作原理其实很简单,IO操作主要分为以下几个步骤:
//1. 用户空间发起IO请求
//2. 内核空间处理IO请求
//3. 数据在用户空间和内核空间之间传输
//4. IO操作完成,返回结果
//看似是很普通且简单的操作吧,啊,其实也就是四步而已,
//但是,我们在有了前面的网络知识之后,我们知道,当网络双方收发信息的时候,究竟是什么在影响着它们的效率
//是发送速度吗?是接收速度吗?其实都不是
//而是等,对,就是等,你要等人家发送信息,你要等人家接收信息,
//等它发送完,你再接收,等它接收完,你再发送,
//这就是网络中的阻塞IO,阻塞IO的意思就是,当一个进程发起IO请求之后,
//它就会被阻塞,直到IO操作完成,进程才会继续执行
//所以,我们就知道了,其实啊,IO慢的根本原因就是在于等待,等待数据传输完成
//那么很显然,不止是网络IO会有等待的问题,其实你只要是IO,那么你就一定会有等待的问题,
//你进程要从磁盘文件读取文件内容时,你要等,等什么?等磁盘转到文件所在的分区,去将文件内容读出来,
//然后呢,内核才能将获取到的文件,传输到用户空间,那么传输是什么???其实就是拷贝啊!!!!!!!
//你能直接把文件内容给剪切走吗?不能,你只能拷贝!!!
//所以,想想看,IO的本质是什么???
//IO的本质就是等+拷贝,所以,从现在开始,我们的脑海里就要有一个深刻的认知:
//IO=等+拷贝!!!!!!!
//那么我们再想想,所以,IO慢的原因,出在什么地方???
//拷贝???开玩笑,拷贝有什么的,现代CPU那么快,拷贝数据那是分分钟的事情,歘一下,拷贝就搞定了
//所以,IO慢的根本原因就在于等,等什么???
//等对方啊,等磁盘啊,等网卡啊,等设备啊!!!
//等这些东西把数据准备好,然后你才能拷贝数据,不然,你就等吧
//即使你cpu再快,你也没法让磁盘转得更快,网卡收发得更快,设备响应得更快,所以,这就是IO慢的根本原因
//所以,我们要想提高IO的效率,就得想办法减少等待时间,减少等的时间
//可是,等待时间不是我们想减少就减少的,这是硬件的束缚,不是我们想减少就能减少的
//但是,我们是没办法减少等待时间,
//但是,我们能想办法,让等的时间不白等,让等的时间占IO的比重少!!!
//这就是异步IO,非阻塞IO,IO多路复用等等技术的目的所在了
//所以,大家在有了上面的清晰认知之后,接下来,我们就来介绍一下,传奇五大IO模型!
//1.阻塞IO
//2.非阻塞IO
//3.信号驱使IO
//4.IO多路复用
//5.异步IO
//那么,光说这些,大家可能不是很能理解,所以,我们就用钓鱼的例子,来将这五种模型一一形象化
/*
*我们把 "钓鱼的人" 比作「进程」,
"鱼竿" 比作「IO 通道」(比如网络连接、文件句柄),
"鱼" 比作「要读取 / 写入的数据」,
"鱼上钩" 比作「IO 就绪」(数据已准备好),
"拉鱼竿 + 把鱼放进桶里" 比作「IO 操作」(数据读写)。
*
* 一、阻塞 IO(Blocking IO):"守着鱼竿不撒手,啥也不干等鱼上钩"
* 钓鱼场景:
* 你扛着一根鱼竿坐在河边,把鱼钩抛进水里后,就一直盯着鱼竿,什么别的事都不做------
* 不喝水、不玩手机、不聊天,全程保持 "等鱼上钩" 的状态。
* 直到鱼咬钩(鱼上钩 = IO 就绪),你才动手拉鱼竿、把鱼放进桶里(拉鱼竿 = IO 操作)。
* 如果半天没鱼上钩,你就一直等,完全被 "钓鱼" 这件事卡住。
*
* 对应 IO 模型原理:
* 进程发起 IO 请求(比如 "读取文件数据""接收网络数据")后,会一直阻塞(暂停运行),
* 不做任何其他任务,直到内核把数据准备好(IO 就绪),
* 并完成数据从内核缓冲区到进程缓冲区的拷贝(IO 操作完成),进程才会被唤醒,继续往下执行。
* 核心特点:等待 IO 就绪时,进程完全闲置,不能做任何事。
*
* 生活类比:
* 就像你去餐厅吃饭,点完菜后一直坐在座位上等着,不玩手机、不跟人说话,直到服务员把菜端到你面前,你才开始吃 ------ 等待的过程中,你什么都没干,完全被 "等菜" 阻塞了。
* 典型应用:
* 早期的 Socket 编程(比如简单的 TCP 服务器,一次只处理一个连接);
* 本地文件读取(默认是阻塞 IO,比如read()函数不拿到数据就不返回)。
*
* 二、非阻塞 IO(Non-blocking IO):"隔一会儿看一眼鱼竿,没鱼就去干别的"
* 钓鱼场景:
* 你还是扛着一根鱼竿,但不想一直傻等 ------
* 抛钩后,你先坐下来玩手机,过了 10 秒起身看一眼鱼竿 "有没有鱼上钩"(轮询检查);
* 如果没鱼,你又去喝水、吃零食,过 10 秒再回来检查;
* 直到某次检查发现鱼上钩了(IO 就绪),你才拉鱼竿、装鱼。
* 整个过程中,你不会一直等,而是 "轮询检查 + 间隙做别的事"。
*
* 对应 IO 模型原理:
* 进程发起 IO 请求后,内核会立刻返回一个 "数据还没准备好" 的状态(比如EWOULDBLOCK),
* 进程不会阻塞,可以继续执行其他任务(比如处理别的请求、计算数据);
*
* 进程需要自己定期 "轮询"(比如每隔 10ms 调用一次read()),检查 IO 是否就绪;
* 直到某次轮询时,内核返回 "数据已准备好",进程才会执行 IO 操作(数据拷贝);
*
* 核心特点:等待 IO 就绪时,进程可以做其他事,
* 但需要主动轮询检查 IO 状态,那么也就是得用死循环来让IO嘎嘎准备
*
* 生活类比:
* 你去餐厅吃饭,点完菜后没坐着等,而是去隔壁便利店买瓶水、刷会儿短视频,
* 每隔 5 分钟回餐厅问服务员 "我的菜好了吗?";
* 直到服务员说 "好了",你才回到座位开始吃 ------ 等待的过程中你没闲着,但需要主动 "询问"(轮询)进度。
*
* 典型应用:
* 需要同时处理多个连接,但连接数不多的场景(比如小型游戏服务器);
* 注意:轮询会消耗 CPU 资源(就像你频繁跑回餐厅问,也会累),所以不适合高并发场景。
*
* 三、信号驱动 IO(Signal-driven IO):"给鱼竿装个铃铛,鱼上钩了就叫你"
* 钓鱼场景:
* 你嫌轮询麻烦,给鱼竿装了个「铃铛」------
* 抛钩后,你完全不用管鱼竿,直接去旁边树荫下睡觉、看书、聊天(做其他事)。
* 只要鱼一咬钩,鱼竿晃动会触发铃铛响(信号通知 = IO 就绪),
* 你听到铃声后,立刻跑过去拉鱼竿、装鱼(IO 操作)。
* 整个过程中,你不用主动检查,而是被动等待 "铃铛信号"。
*
* 对应 IO 模型原理:
* 进程发起 IO 请求时,会告诉内核:"等数据准备好后,给我发一个信号(比如SIGIO)通知我";
* 内核收到请求后,立刻返回,进程不会阻塞,可以正常执行其他任务;
* 当数据准备好(IO 就绪),内核会给进程发送一个信号,进程收到信号后,
* 会暂停当前任务,转而执行 IO 操作(数据拷贝);
*
* 核心特点:不用轮询,IO 就绪时内核主动发信号通知,但 IO 操作仍需进程自己执行。
*
* 生活类比:
* 你在网上买了个快递,选了 "短信通知"------ 下单后你该上班上班、该休息休息(做其他事),不用一直刷物流;
* 等快递员把快递放到驿站,系统会给你发短信(信号),你收到短信后,再去驿站取快递(IO 操作)。
*
* 典型应用:
* 对实时性要求不高,但不想浪费 CPU 轮询的场景(比如串口通信、某些网络服务);
* 注意:信号处理机制比较复杂(比如信号可能丢失、需要处理信号时序),所以不如 IO 多路复用常用。
*
* 四、IO 多路复用(IO Multiplexing):"一人看 N 根鱼竿,哪个上钩拉哪个"
* 钓鱼场景:
* 你不再守着一根鱼竿,而是同时架起 10 根鱼竿(对应 10 个 IO 通道),
* 手里拿个「监控器」(比如 select/poll/epoll),监控所有鱼竿的状态。
* 你坐在椅子上,不用主动检查每根竿,监控器会告诉你 "哪几根鱼竿有鱼上钩了"(批量通知 IO 就绪)。
* 只要监控器有提示,你就去拉对应的鱼竿、装鱼;
* 没提示的时候,你可以休息(进程阻塞,但阻塞在 "监控器" 上,不是单个 IO)。
* 整个过程中,你不用轮询,也不用主动检查,而是被动等待 "监控器" 通知。
*
* 对应 IO 模型原理:
* 进程通过select()/poll()/epoll()等系统调用,把多个 IO 通道(文件描述符)交给内核监控;
* 进程会阻塞在「监控器」上(不是阻塞在单个 IO),此时 CPU 不会被浪费;
* 当任意一个或多个 IO 通道的数据准备好(IO 就绪),内核会通知进程 "哪些 IO 就绪了";
* 进程收到通知后,只针对就绪的 IO 执行操作(数据拷贝),其他未就绪的 IO 继续被监控;
*
* 核心特点:一个进程同时监控多个 IO 通道,阻塞在监控器上,IO 就绪时批量通知,效率高。
*
* 生活类比:
* 你开了一家 "钓鱼农家乐",同时给 10 个游客准备了鱼竿,你当管理员 ------
* 不用盯着每根竿,而是坐在监控室里看屏幕(监控器),屏幕上会显示 "3 号竿、7 号竿有鱼上钩"。
* 你看到后,去对应的位置帮游客拉鱼竿(或通知游客);没上钩的时候,你可以在监控室休息,不用管鱼竿。
*
* 典型应用:
* 高并发场景(比如 Nginx 服务器、Redis 服务器),需要同时处理成千上万的网络连接;
* 核心优势:用一个进程 / 线程管理大量 IO 通道,比 "一个 IO 一个进程" 节省资源,效率极高。
* 注意:select/poll 适合连接数较少的场景(几百到几千),
* epoll 适合连接数非常多的场景(上万到百万)。
*
* 五、异步 IO(Asynchronous IO):"雇个助手帮你钓鱼,你完全不用管,等着拿鱼就行"
* 钓鱼场景:
* 你不想自己动手,直接雇了个「助手」------ 你告诉助手:"帮我钓 10 条鱼,钓完后把鱼放进桶里,然后叫我"。
* 说完你就去干自己的事(比如去逛街、看电影),完全不用管鱼竿、不用等鱼上钩。
* 助手会全程负责:抛钩、等鱼上钩、拉鱼竿、装鱼,直到钓够 10 条,
* 才会打电话通知你 "鱼钓好了,快来拿"(结果通知)。
* 你收到通知后,直接去拿鱼就行,全程没参与任何钓鱼操作。
* 整个过程中,你完全不用管 "鱼竿"、"鱼"、"装鱼",
* 只等 "助手" 把 10 条鱼都钓好后,才拿出来吃。
*
* 对应 IO 模型原理:
* 进程发起异步 IO 请求时,会告诉内核:"帮我把数据读 / 写好,完成后给我发个通知";
* 内核收到请求后,立刻返回,进程完全不阻塞,可以正常执行其他任务(甚至退出都没关系);
* 内核会全程负责 IO 操作:等待数据准备、把数据从内核缓冲区拷贝到进程缓冲区(整个 IO 流程由内核完成);
* 当 IO 操作完全完成,内核会给进程发一个通知(比如信号、回调函数),进程收到通知后,直接使用数据即可;
*
* 核心特点:进程完全不参与 IO 操作,只等 "IO 完成通知",
* 是真正的 "异步"
* (区别于信号驱动 IO:信号驱动是 "IO 就绪通知",IO 操作仍需进程自己做;异步 IO 是 "IO 完成通知",内核全包)。
*
* 生活类比:
* 你在网上点了一份外卖,下单后你该追剧追剧、该做家务做家务(做其他事),
* 不用管商家做饭、外卖员送餐;等外卖员把餐送到家门口,按门铃通知你(完成通知),
* 你开门拿餐就能吃 ------ 全程没参与 "做饭""送餐" 任何环节。
*
* 典型应用:
* 高并发、高吞吐量的场景(比如磁盘 IO 密集型应用、分布式系统中的数据传输);
* 注意:Linux 下的aio_*系列函数、Windows 的 IOCP,都是异步 IO 的实现;
* Node.js 的 "非阻塞 IO" 本质是 "IO 多路复用 + 回调",不是真正的异步 IO(因为数据拷贝仍需进程参与)。
/*
* 5 种 IO 模型核心区别总结(钓鱼视角)
* IO 模型 核心逻辑(钓鱼场景) 进程是否阻塞 谁负责 "等鱼上钩" 谁负责 "拉鱼竿装鱼" 核心优势
* 阻塞 IO 一人一根竿,守着等上钩,啥也不干 阻塞(等上钩时) 进程自己 进程自己 简单,不用额外处理
* 非阻塞 IO 一人一根竿,隔会儿看一眼,没鱼就干别的 不阻塞(轮询间隙) 进程自己(轮询) 进程自己 可同时做其他事
* 信号驱动 IO 一人一根竿,装铃铛,鱼上钩了叫你 不阻塞(等信号时) 内核 进程自己 不用轮询,节省 CPU
* IO 多路复用 一人看 N 根竿,监控器通知哪个上钩 阻塞(等监控器时) 内核(监控) 进程自己 高并发神器,管理多 IO
* 异步 IO 雇助手钓,钓完通知你,你全程不参与 完全不阻塞 内核 内核 最省心,进程效率最高
* 一句话记住核心差异:
* 前 4 种模型(阻塞、非阻塞、信号驱动、IO 多路复用)的区别,在于 "谁等 IO 就绪" 和 "是否轮询";
* 只有异步 IO,是 "内核全包 IO 操作",进程只等结果 ------ 就像雇了助手,你完全不用动手。
*/
//看到这里,大家就能对这五种IO模型有一个清晰的认知了,其实是很简单的啦
//那么,在这里,我再给大家支个招,就是怎么判断是同步IO还是异步IO呢?
//其实很简单,就是看IO的本质,即IO=等+拷贝
//那么,只要等或者拷贝任何一个是你发起IO的进程在做的,那么就是同步IO
//而要是等和拷贝两个都不是你这个发起IO的进程在做的,那么就是异步IO!!!
//掌握住这一点,那么大家拿下这个就是易如反掌了!!!
//那么其实我们平时一直在用的read、write、send、recv、recvfrom、sendto等等函数,
//其实都是阻塞IO的,就是说,当调用这些函数的时候,进程会等待IO完成,然后返回结果。
//不然就进程就一直挂起,等待IO完成。也就是阻塞等待
//虽然说,在这些函数中,其实我们是可以传入flag参数,来指定是否阻塞的,比如NO_BLOCK,这个大家自己man一下就能看懂
//但是我们这里就不写这个参数了,因为,这个参数,我们一般用不到,因为,这个参数,一般都是由系统来设置的,
//我在这里推荐另一个函数:
//fcntl函数,这个函数,可以设置文件描述符的属性,
//fcntl(file control)是 Linux 系统中用于控制文件描述符属性的核心系统调用,
//你可以把它理解为「文件描述符的万能工具箱」------
//几乎所有对文件描述符的高级操作(比如修改阻塞 / 非阻塞、设置文件锁、控制 close-on-exec 标志等),
//都能通过它实现。
//比如,设置文件描述符为非阻塞,或者设置文件描述符为阻塞,
//所以,我们用这个函数,可就比给一堆函数传入flag标志简单多了,因为利用这个函数
//我们可以直接指定某个文件描述符就得是阻塞or非阻塞了
//这样子无论哪个函数去访问这个文件描述符,他都会按照我们设置的方式去进行IO
//就不用我们一个一个函数的传入flag标志了
//所以,接下来,我们就来看看fcntl函数的用法吧
// #include <unistd.h>
// #include <fcntl.h>
// int fcntl(int fd, int cmd, ... /* arg */ );
// 传入的cmd的值不同, 后面追加的参数也不相同.
// fcntl函数有5种功能:
// 1.复制一个现有的描述符(cmd=F_DUPFD).
// 2.获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
// 3.获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
// 4.获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
// 5.得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW).
// 我们此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞.
/*
* fcntl函数返回值核心解析
* 通用规则:所有操作指令(cmd)失败时均返回-1,内核会设置errno标识错误原因;
* 成功返回值无统一标准,完全由传入的cmd类型决定。
*/
/* ========== 1. 获取类指令(F_GETFL/F_GETFD/F_GETLK/F_GETOWN) ========== */
// 作用:读取文件描述符/文件属性,成功返回属性相关值,失败返回-1
// - F_GETFL:返回文件状态标志值(整数,为O_RDWR/O_APPEND/O_NONBLOCK等的按位组合),需用&解析;
// - F_GETFD:返回文件描述符标志值,0=未设FD_CLOEXEC,1=已设FD_CLOEXEC;
// - F_GETLK:返回0(锁状态通过传入的struct flock结构体判断:无冲突=l_type=F_UNLCK,有冲突=对应锁类型);
// - F_GETOWN:返回接收SIGIO/SIGURG信号的进程ID(正数)或进程组ID(负数)。
/* ========== 2. 普通设置类指令(F_SETFL/F_SETFD/F_SETOWN) ========== */
// 作用:修改文件描述符/文件属性,成功返回0,失败返回-1
// - F_SETFL:设置O_NONBLOCK/O_APPEND等文件状态标志,成功返回0;
// - F_SETFD:设置FD_CLOEXEC等文件描述符标志,成功返回0;
// - F_SETOWN:设置接收SIGIO/SIGURG信号的进程/进程组,成功返回0。
/* ========== 3. 特殊指令(F_DUPFD/F_SETLK/F_SETLKW) ========== */
// 作用:特殊操作(复制fd/文件锁),返回值有专属规则
// - F_DUPFD:复制文件描述符,成功返回新fd(≥指定最小值的未使用最小fd),失败返回-1;
// - F_SETLK:非阻塞加/解锁,成功返回0;锁冲突返回-1,errno=EACCES/EAGAIN;
// - F_SETLKW:阻塞加锁(等待锁释放),成功返回0;被信号中断返回-1,errno=EINTR。
/* ========== 4. 常见错误errno及排查 ========== */
// 返回-1时,核心errno含义与排查方向:
// - EBADF:fd无效,检查fd是否关闭/创建(open/socket)是否成功;
// - EINVAL:cmd/arg非法,核对cmd拼写、arg标志合法性;
// - EACCES/EAGAIN:F_SETLK锁冲突,改用F_SETLKW或处理冲突;
// - EINTR:F_SETLKW被信号中断,捕获信号或重新执行指令。
/* ========== 5. 返回值处理最佳实践 ========== */
// 1. 先判失败:所有场景优先检查返回值是否为-1,避免误判成功;
// 2. 适配解析:获取类用&解析标志值,F_GETLK关注结构体,F_DUPFD接收新fd;
// 3. 关注errno:F_SETLK/F_SETLKW返回-1时,通过errno区分锁冲突与其他错误。
/* ========== 核心总结 ========== */
// - 失败:所有cmd返回-1(必查errno);
// - 成功:获取类返回属性值,普通设置类返回0,特殊指令返回新fd(F_DUPFD)或0(F_SETLK/F_SETLKW);
// - 处理逻辑:先判-1,再按cmd类型解析成功结果,特殊指令结合errno辅助判断。
//但是我们一定要注意的是:
//使用时务必先 F_GETFL/F_GETFD 获取原有标志,再追加新标志(避免覆盖原有属性)
// 因为:
// 文件描述符的状态标志(如 O_NONBLOCK、O_APPEND)和文件描述符标志(如 FD_CLOEXEC),
// 本质是整数的二进制位集合 ------ 每个标志对应一个独立的 "开关位",多个标志共存时,对应位都会置为 1。
// 这个我们之前也有说过
// fcntl 的 F_SETFL/F_SETFD 指令是 "覆盖式设置",而非 "追加式":
// 如果直接传入单个新标志,会将文件描述符的所有标志位统一替换为该新标志的值,
// 导致原有已开启的标志(如之前设置的 O_APPEND)被清空,对应的功能失效
// (比如原本的 "追加写" 变成 "覆盖写")。
// 因此必须遵循 "先读、再改、后写" 的流程:
// 先通过 F_GETFL/F_GETFD 获取当前所有标志(保留已开启的位),
// 再通过 F_SETFL/F_SETFD 用 "按位或"(|)追加新标志(仅将新标志对应的位设为 1,不影响其他位),
// 才能在新增功能的同时,保留原有属性不丢失。
// 所以这就是为什么fcntl是可变参数了
// 若需移除某个标志,同样要先获取原有标志,再用 "按位与 + 按位取反"(& ~)操作,
// 仅将目标标志对应的位设为 0,其他位保持不变,避免误删其他功能。
//然后除此之外,还有一个细节需要我们注意,那就是setfd和setfl的区别
//因为大家可能会好奇,诶,这两个好像差不多呀,那么为什么我们目前只用setfl呢?
//其实啊,这是因为,setfd是用来管理整个文件描述符的,而setfl则是用来管理文件描述符指向的文件状态的
//即文件描述符的IO属性
/*
* fcntl函数中 F_SETFD 与 F_SETFL 指令核心区别解析
* 核心定位:
* F_SETFD → 管理「文件描述符(fd)句柄本身」的属性
* F_SETFL → 管理「fd指向的文件」的IO状态属性
*/
/* ========== 1. 作用对象 ========== */
// F_SETFD(搭配F_GETFD使用):
// 操作的是文件描述符句柄本身的标志,与fd指向的文件无关;
// 即使多个fd指向同一个文件,每个fd的FD标志独立,修改互不影响。
// F_SETFL(搭配F_GETFL使用):
// 操作的是fd指向文件的状态标志,本质修改文件的打开状态;
// 若多个fd指向同一文件,修改FL标志可能影响所有关联fd。
/* ========== 2. 管理的标志类型 ========== */
// F_SETFD:
// 仅管理极少数fd句柄相关标志,核心且唯一常用的是 FD_CLOEXEC;
// FD_CLOEXEC:进程执行exec()系列函数(替换程序)时,自动关闭该fd。
// F_SETFL:
// 管理与文件IO行为直接相关的状态标志,常用如下:
// - O_NONBLOCK:非阻塞IO模式
// - O_APPEND:写入数据自动追加到文件末尾
// - O_ASYNC:信号驱动IO(数据就绪时发送SIGIO信号);
// 无其他常用标志,且标志均直接影响读写等IO操作行为。
/* ========== 3. 修改限制 ========== */
// F_SETFD:
// 对核心标志FD_CLOEXEC无特殊修改限制;
// 只要是有效fd,可随时修改,修改后立即生效。
// F_SETFL:
// 有严格修改限制,仅能修改动态IO行为标志(如O_NONBLOCK/O_APPEND);
// 无法修改以下标志(仅能在open()/socket()创建fd时指定):
// - 读写权限类:O_RDONLY/O_WRONLY/O_RDWR
// - 文件创建类:O_CREAT/O_EXCL/O_TRUNC。
/* ========== 4. 使用场景 ========== */
// F_SETFD(设置FD_CLOEXEC):
// 核心用于进程替换/多进程场景,防止fd泄露给子进程;
// 例:程序执行execvp()启动新程序前,给无关fd设置该标志,避免新程序意外持有。
// F_SETFL(如设置O_NONBLOCK):
// 核心用于调整IO操作行为,是实现非阻塞IO、追加写、信号驱动IO的核心方式;
// 例:将阻塞的socket fd改为非阻塞,适配高并发IO场景。
/* ========== 核心总结 ========== */
// 1. F_SETFD 管「fd句柄本身」,仅控制FD_CLOEXEC,用于防止fd泄露;
// 2. F_SETFL 管「文件IO状态」,控制非阻塞/追加写等IO行为,有修改限制;
// 3. 两者修改的标志无交集,操作互不影响,但均需遵循「先读(F_GETFD/F_GETFL)、再改、后写」规则,避免覆盖原有属性。
//下面我们就来写一个简单的函数来利用fcntl函数来设置传入的文件描述符为非阻塞
int SetNonBlock(int fd)//传入要修改为非阻塞的文件描述符
{
//先使用F_GETFL保存原本的文件描述符所指向文件的状态
int ret_fcntl=fcntl(fd,F_GETFL);
if(ret_fcntl<0)
{
perror("fcntl F_GETFL:");
exit(1);
}
//保存成功,此时ret_fcntl就是原本的文件描述符所指向文件的状态
//将该文件描述符指向的文件状态修改为非阻塞!!!
//注意要上|原本的状态(即ret_fcntl)
int ret=fcntl(fd,F_SETFL,ret_fcntl|O_NONBLOCK);
return ret;
}
//那么我们完成了这个函数之后,我们接下来就用read函数去进行对标准输入流的读取
//看看非阻塞的情况下,会是什么样子的
//毕竟我们知道平时我们用从标准输入流获取数据的函数时,进程都是进行阻塞等待的
//即只要我们没用从键盘输入数据按下回车输入数据,那么进程就会一直阻塞住,啥也不干
int main()
{
char buff[1024];
//先将标准输入的文件描述符修改为非阻塞的
SetNonBlock(0);//标准输入的文件描述符是0哦
//然后就到了核心的一步,就是read函数读取数据
//但是,我们要用死循环(至少要进行循环)
//虽然我们平时阻塞IO也是使用死循环,但是那是为了持续的读取数据
//而在非阻塞这里,循环则是成了必须品!!!
//为什么????????????
//因为非阻塞IO的话,那么一旦没读取到数据,就会直接返回失败,对,这就是非阻塞IO的本质所在
//用在read函数里就是,一旦read函数没有读取到数据,那就返回小于0的值
//这个时候就不运行read函数,会转而去运行别的代码段
//但是问题是,我非阻塞IO只是说让你不要死板的在那边一直等着输入啥也不干,你可以干其他的事情
//不是说让你执行一次之后就罢工了啊!!!
//但是问题也就是,非阻塞就是这样子,系统设置的就是这样子,我们也没办法
//所以,我们能做的就是去轮询查看,就是隔一会,我就再去read读取一下,要是依旧没有,那就再失败
//而要是有了,那就读取成功,爽歪歪,那么,我们要依靠什么去轮询查看探寻呢??
//很显然,就是依靠循环,一般就是死循环,这一点希望大家要清楚
while(true)
{
int ret_read=read(0,buff,sizeof(buff)-1);
if (ret_read>0)//成功读取到数据
{
buff[ret_read-1]='\0';//修改为符合c语言的字符串
//这个修改也是我们老生常谈的一个操作了,大家也肯定不陌生,
//毕竟当read函数的返回值大于0时,就是指成功读取到的字节数
//我们一般设置每次最多读取到字符数组长度-1个字节
//但是,在这里,我们怎么设置为ret_read-1呢??平时都是设置为ret_read呀
//其实啊,是因为我们这里是使用read去对标准输入进行读取
//所以当我们输入字符串之后,是不是还要按个回车符啊,那么read其实是全部照收的
//c和c++的scanf和cin、getline等等不会这样子是因为它们自己就会进行将回车符不读取哦
//这里这个点大家也要注意一下哦
std::cout<<buff<<std::endl;
}
else if(ret_read==0)//文件读取结束
{
break;//文件读取结束了,自然不用再读取了
}
else if(ret_read<0)
{
//然后就是read函数返回值小于0的情况了,那么在我们之前的认知中,
//之所以read函数的返回值是小于0,是因为文件读取失败了
//但是,那是因为那个时候我们调用read函数是进行阻塞IO的,而不是使用非阻塞IO
//当我们使用非阻塞IO之后,当没有数据进行输入时,read函数的返回值也是会返回小于0
//那么这就嘎嘣脆了,当read函数返回值小于0,究竟是代表读取失败,还是暂时没有数据输入呢??
//哈哈,不用慌张,我们要知道,当read函数返回值小于0的时候,其实是会同步设置错误码errno的哦
//所以,错误码errno的设置就成了我们判断到底是读取失败还是非阻塞暂时没有数据
//这个大家可以man一下哦
//那么当是因为非阻塞暂时没有数据而返回值小于0时,错误码errno会被设置为EAGAIN or EWOULDBLOCK
//所以我们可以利用这个进行判断
//要注意是errno,可不是ret_read哦
if(errno==EAGAIN||errno==EWOULDBLOCK)
{
std::cout<<"非阻塞暂时没有数据!!!"<<std::endl;
sleep(1);
//做自己的事情,也就是非阻塞时要干的其他事
std::cout<<"我是帅哥,超级大帅哥!!!"<<std::endl;
continue;//忽略后面的内容从头开始循环
}
//这里再多说一下,还有一个错误码,也不是因为文件读取失败哦:
//EINTR The call was interrupted by a signal before any data was read; see signal(7).
//read() 执行阻塞式读取操作时,还没读取到任何数据,
//就被进程接收到的信号(如 SIGINT、SIGTERM、SIGALRM 等)打断,
//导致 read() 提前返回失败(返回 -1),且内核将 errno 设为 EINTR。
//关键区分:如果 read() 已经读取了部分数据(比如要读 100 字节,已读 20 字节),
//即使此时收到信号,也不会返回 EINTR ------ 而是返回实际读取的字节数(20),
//只有 "完全没读到任何数据" 时被信号中断,才会触发 EINTR。
else if(errno==EINTR)
{
std::cout<<"被信号打断"<<std::endl;
continue;//继续循环
}
}
//因为我们前面用了continue,所以循环就不会执行到这下面了
std::cout<<"test"<<std::endl;
sleep(1);
}
return 0;
}
结语:在 IO 的世界里,懂底层逻辑,方能掌高效之道
当你跟着代码敲完最后一行非阻塞读取的测试用例,看着终端每隔一秒打印 "非阻塞暂时没有数据",又在输入字符后立刻响应 ------ 这一刻,你不仅运行了一段代码,更触碰到了 Linux IO 模型的核心脉搏。从 "傻等到底" 的阻塞 IO,到 "甩手掌柜" 的异步 IO,从 fcntl 函数的 "位运算魔法",到错误码背后的细微差别,我们用一篇博客的篇幅,拆解了这些看似抽象却无处不在的底层技术。
或许你在学习过程中曾有过困惑:为什么非阻塞 IO 必须用循环轮询?fcntl 的 F_SETFL 和 F_GETFD 到底有什么区别?EAGAIN 和 EINTR 的错误处理为什么不能少?这些疑问都很正常 ------IO 模型的本质是 "资源调度的智慧",而底层技术的魅力,恰恰在于 "越深入,越清晰"。当你真正理解 "IO = 等 + 拷贝" 的核心公式,当你能熟练用 fcntl 切换文件描述符的阻塞状态,当你能精准区分同步与异步的边界,你就已经迈出了从 "会用 API" 到 "懂底层逻辑" 的关键一步。
一、五大 IO 模型:一场 "等待" 与 "效率" 的权衡艺术
我们用 "钓鱼" 的类比,把五大 IO 模型的差异讲得明明白白,但背后藏着的,是工程师们对 "效率" 的极致追求:
阻塞 IO 用最简单的逻辑换来了稳定,适合无需并发的简单场景 ------ 它就像老黄牛,踏实可靠,不用你操心额外的状态管理;非阻塞 IO 用轮询的 "小开销" 换来了并行处理的能力,适合小型实时场景 ------ 它像灵活的信使,不会死等,总能在间隙里干些别的;信号驱动 IO 用 "被动通知" 避免了轮询的浪费,却受制于复杂的信号机制 ------ 它像灵敏的警报器,能精准提醒,却需要你懂它的 "脾气";IO 多路复用则用一个 "监控员" 搞定百万级连接,成为高并发服务的标配 ------ 它像高效的调度中心,用最少的资源,管理最多的任务;而异步 IO 则把所有工作交给内核,让进程彻底专注业务 ------ 它像全能的助手,能包办一切,却需要你具备驾驭复杂状态的能力。
这五种模型没有绝对的优劣,只有 "是否适合场景" 的选择。就像生活中,简单的事情适合 "阻塞式" 专注完成,多任务并行适合 "非阻塞式" 穿插处理,高并发场景适合 "IO 多路复用" 统筹规划 ------ 技术的本质,是对现实问题的抽象解决。而你需要做的,就是理解每种模型的核心逻辑,在实际开发中根据需求 "对号入座"。
二、fcntl:Linux IO 的 "万能钥匙",藏着底层操作的精髓
如果说 IO 模型是 "战略思想",那 fcntl 函数就是 "战术工具"------ 它用一个函数、多个指令,实现了对文件描述符的全方位操控。我们花了大量篇幅讲解它的用法,不是因为它复杂,而是因为它是理解 Linux IO 的 "必经之路":
你学会了用 "F_GETFL + F_SETFL + O_NONBLOCK" 的组合,给文件描述符 "解锁" 非阻塞模式,更记住了 "先获取原有标志、再位或添加新标志" 的黄金法则 ------ 这背后是对 "二进制位集合" 的深刻理解,是避免覆盖原有属性的关键;你知道了用 F_SETLKW 给文件加锁,实现多进程的安全操作,理解了 "劝告锁" 的本质是 "君子协定"------ 这教会你在多进程场景下,如何守护数据的完整性;你分清了 F_SETFL 和 F_GETFD 的区别,知道前者管 "IO 状态",后者管 "句柄属性"------ 这让你在处理 fd 泄露、进程替换等场景时,能精准找到问题的根源。
fcntl 的强大,在于它的 "通用性"------ 它能操作普通文件、套接字、管道、设备文件等所有文件描述符,是 Linux "一切皆文件" 哲学的最佳体现。而掌握它的关键,在于 "理解指令的含义、重视返回值的处理、分清标志的边界"。当你能熟练运用 fcntl 解决实际问题时,你就已经具备了 Linux 系统编程的核心能力之一。
三、错误处理:底层编程的 "必修课",细节见真章
在测试非阻塞读取的代码时,我们反复强调 "必须判断 errno"------EAGAIN 是 "暂时没数据",EINTR 是 "被信号打断",EBADF 是 "fd 无效"。这些看似繁琐的错误处理,恰恰是底层编程的 "护城河"。
很多初学者会忽略错误处理,觉得 "代码能跑起来就行",但在生产环境中,一次未处理的 EINTR 可能导致程序卡死,一次误判的 EAGAIN 可能导致数据丢失。Linux 的系统调用设计里,错误码从来不是 "冗余信息",而是内核给进程的 "精准提示"------ 它告诉你 "为什么失败",更告诉你 "该怎么处理"。就像非阻塞模式下,read 返回 - 1 不一定是真的失败,可能只是 "暂时没数据";而 EINTR 也不是错误,只是 "进程需要先处理信号"。
学会解读错误码,学会根据错误码做对应的处理,是从 "入门" 到 "进阶" 的重要标志。这不仅能让你的程序更健壮,更能让你在调试时少走弯路 ------ 当你看到 errno=EAGAIN 时,你会立刻想到 "这是非阻塞的正常现象,需要继续轮询";当你看到 errno=EBADF 时,你会先检查 fd 是否有效、是否被意外关闭。细节决定成败,在底层编程的世界里,这句话尤为真实。
四、学习底层技术:慢一点,深一点,稳一点
IO 模型和 fcntl 函数的学习,可能不如框架开发那样 "立竿见影",但这些底层知识,是你构建技术壁垒的基石。在这个 "框架层出不穷" 的时代,很多人追求 "快速上手",却忽略了底层逻辑的重要性 ------ 但当你遇到高并发场景的性能瓶颈,当你需要调试框架无法覆盖的底层问题,当你想开发属于自己的高效工具时,这些看似 "过时" 的底层知识,会给你最坚实的支撑。
或许你现在觉得 "异步 IO 太复杂,暂时用不到",或许你觉得 "fcntl 的文件锁在业务开发中很少见"------ 但请相信,每一次对底层逻辑的探索,都在悄悄提升你的技术认知。就像 IO 多路复用是 Nginx、Redis 等高性能服务的核心,fcntl 的非阻塞设置是高并发网络编程的基础,这些技术从未过时,只是换了一种方式融入我们的日常开发。
学习底层技术的过程,就像爬山 ------ 越往上,路越陡,风景却越独特。你可能会为了一个位运算的错误调试半天,可能会为了区分两个错误码的差异翻遍 man 文档,可能会为了理解异步 IO 的实现原理查阅内核源码 ------ 但这些付出都是值得的。当你站在底层逻辑的视角看问题时,很多曾经困惑的 "为什么",都会变成清晰的 "原来是这样"。
五、未来可期:从 IO 模型到更高阶的技术探索
掌握了五大 IO 模型和 fcntl 函数,你已经为后续的学习打下了坚实的基础。接下来,你可以进一步探索 IO 多路复用的具体实现 ------select、poll 和 epoll 的差异是什么?epoll 的 "水平触发" 和 "边缘触发" 该如何选择?这些知识会让你真正理解高并发服务的底层实现;你可以深入异步 IO 的世界,探索 Linux 的 aio_* 系列函数,理解 "真正的异步" 是如何让内核包揽所有 IO 操作的;你还可以将这些知识应用到网络编程中,实现一个简单的高并发服务器,感受 IO 模型对性能的巨大影响。
技术的学习是一个循序渐进的过程,没有捷径可走,但只要你保持好奇心和求知欲,一步一个脚印地深入底层,就一定能收获属于自己的成长。或许未来的某一天,你会在开发高并发系统时,下意识地选择 IO 多路复用 + 非阻塞 IO 的组合;你会在处理文件安全问题时,熟练地用 fcntl 给文件加锁;你会在调试 IO 相关的 bug 时,精准地定位到错误码对应的问题 ------ 这一刻,你会感谢现在认真学习的自己。
最后,我想对你说:技术的世界很大,底层的逻辑很深,但请不要畏惧。每一个大牛都曾经历过 "看不懂、学不会、调不通" 的阶段,而区别在于 "是否坚持下去"。IO 模型和 fcntl 函数的学习,只是你 Linux 技术之路的一个起点 ------ 接下来,还有更多精彩的底层技术等着你去探索,更多复杂的问题等着你去解决。
愿你在技术的海洋中,既能 "知其然",更能 "知其所以然";愿你在追求高效的道路上,始终保持对底层逻辑的敬畏与好奇;愿你每一次敲击键盘,都能感受到技术的魅力,每一次解决问题,都能收获满满的成就感。
IO 的世界,底层逻辑是根,高效应用是果。愿你深耕底层,收获硕果,在技术之路上越走越远,越走越稳!