一、前言概念
IO=拷贝+等待
1. 同步(Synchronous) vs 异步(Asynchronous)
-
核心区别:关注的是消息通知的机制。
-
同步:调用方主动等待结果,需持续检查任务是否完成。
-
异步:调用方发起任务后无需等待,被调用方完成后主动通知(如回调函数、信号)。
-
生活例子:
-
同步:
你去餐厅点餐后,坐在座位上一直盯着取餐屏,直到显示你的订单号,然后去取餐。 -
异步:
你通过外卖APP下单,之后去做其他事,外卖小哥送到后打电话通知你。
2. 阻塞(Blocking) vs 非阻塞(Non-Blocking)
-
核心区别:关注的是等待时的线程状态。
-
阻塞:调用方在等待结果时线程被挂起,无法执行其他操作。
-
非阻塞:调用方在等待结果时线程可继续执行其他任务,需通过轮询或事件驱动获取结果。
-
生活例子:
-
阻塞:
你在快递柜前排队取快递,队伍不动时你干站着等待,无法做其他事。 -
非阻塞:
你在快递柜扫码后,发现快递未到,系统让你“稍后再试”,于是你先去买菜,过会儿再来扫码查看。
组合场景:同步/异步 + 阻塞/非阻塞
例子对比:
组合类型 | 生活场景 | 编程类比 |
---|---|---|
同步阻塞 | 排队等咖啡,队伍不动时你一直盯着柜台,不能玩手机。 | read() 调用后线程挂起等待。 |
同步非阻塞 | 等水烧开时,你每隔1分钟去看一眼,其他时间可以看书。 | 轮询检查文件是否可读。 |
异步非阻塞 | 你启动扫地机器人打扫房间,它完成后自动发消息通知你,期间你正常办公。 | 异步I/O + 回调函数。 |
关键总结
-
同步 vs 异步:
-
同步需要主动关注结果,异步由对方通知结果。
-
同步的例子:刷微博等待页面加载;异步的例子:下载大文件时后台运行,完成后弹窗提醒。
-
-
阻塞 vs 非阻塞:
-
阻塞会卡住当前流程,非阻塞允许并行处理其他任务。
-
阻塞的例子:ATM机转账时界面卡住;非阻塞的例子:微信发消息后界面仍可操作。
-
-
常见误区:
-
非阻塞 ≠ 异步:非阻塞可能仍需轮询(同步),而异步一定依赖通知机制。
-
同步可以是非阻塞:比如边轮询边做其他事(如等洗衣机时拖地)。
-
技术场景举例
-
同步阻塞:传统HTTP请求(等待服务器响应时页面卡住)。
-
同步非阻塞:游戏循环中轮询键盘输入,同时更新画面。
-
异步非阻塞:Node.js通过回调函数处理高并发网络请求。
二、五种I/O
1. 阻塞I/O(Blocking I/O)
-
原理:程序发起I/O操作后,线程被挂起,直到数据完全准备好并拷贝到用户空间。
-
特点:全程等待,无法执行其他任务。
-
生活例子:
你去餐厅点餐后,站在柜台前一直等待,直到厨师做好并递给你,期间不能做其他事。 -
2. 非阻塞I/O(Non-Blocking I/O)
-
原理:程序发起I/O操作后立即返回状态(未就绪则报错),需轮询检查数据是否就绪,就绪后仍需等待数据拷贝。
-
特点:轮询消耗资源,但等待期间可处理其他任务。
-
生活例子:
点餐后你回到座位,每隔5分钟去问“好了吗?”,期间可以玩手机。但餐好后仍需在柜台等待打包(拷贝数据阶段阻塞)。 -
3. I/O多路复用(I/O Multiplexing)
-
原理:通过
select
/epoll
等机制,单线程监控多个I/O事件,任一就绪时通知程序处理。 -
特点:高效管理多个连接,适合高并发场景。
-
生活例子:
餐厅安排一个服务员监听多桌顾客的需求,当某桌的餐准备好时,服务员主动通知该桌取餐。 -
4. 信号驱动I/O(Signal-Driven I/O)
-
原理:数据准备阶段内核发送信号(如
SIGIO
)通知程序,但数据拷贝阶段仍需程序主动处理(可能阻塞)。 -
特点:避免轮询,但信号处理复杂且可能延迟。
-
生活例子:
点餐后餐厅给你一个叫号器,餐准备好时震动提醒,但取餐时仍需等待店员打包(拷贝阶段阻塞)。 -
5. 异步I/O(Asynchronous I/O)
-
原理:程序发起I/O操作后立即返回,内核完成**全部操作(准备+拷贝)**后通知程序。
-
特点:全程无阻塞,效率最高。
-
生活例子:
你通过外卖APP下单,之后继续工作。外卖小哥从制作到送货全程处理,送到后敲门通知你。 -
总结对比
模型 | 等待阶段 | 数据拷贝阶段 | 生活场景类比 |
---|---|---|---|
阻塞I/O | 全程阻塞 | 阻塞 | 柜台前干等餐 |
非阻塞I/O | 轮询检查 | 阻塞 | 反复询问餐是否做好 |
I/O多路复用 | 单线程监控多路 | 阻塞 | 服务员监听多桌需求 |
信号驱动I/O | 信号通知 | 阻塞 | 叫号器提醒后取餐 |
异步I/O | 完全不阻塞 | 内核完成 | 外卖全程无需等待,送货上门 |
通过以上例子,可以直观理解不同I/O模型在资源利用和响应方式上的差异。
三、阻塞及非阻塞I/O函数
1. 阻塞I/O的read
和write
功能:
-
read
:从文件描述符(如文件、套接字、管道等)读取数据,若数据未就绪,线程会被挂起,直到数据到达或发生错误。 -
write
:向文件描述符写入数据,若缓冲区已满,线程会被挂起,直到缓冲区可用或发生错误。
特点:
-
同步阻塞:调用后线程无法执行其他任务,直到操作完成。
-
简单易用:适合简单场景,但高并发时性能差。
代码示例:
// 阻塞读取数据
char buffer[1024];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
if (bytes_read == -1) {perror("read error");
}// 阻塞写入数据
ssize_t bytes_written = write(fd, buffer, bytes_read);
if (bytes_written == -1) {perror("write error");
}
生活例子:
-
你打电话给朋友,对方未接听时,你一直举着手机等待,直到对方接听或挂断(类似
read
阻塞等待数据)。
2. 非阻塞I/O的fcntl
函数
功能:
-
fcntl
(File Control):用于修改文件描述符的属性,例如设置非阻塞模式。 -
关键操作:通过
F_SETFL
命令设置O_NONBLOCK
标志,使后续的read
/write
调用变为非阻塞。 -
fcntl函数有5种功能:
复制一个现有的描述符(cmd=F_DUPFD) . 获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW)
特点:
-
控制文件描述符状态:不直接执行I/O操作,而是修改I/O行为。
-
需配合循环检查:非阻塞模式下,需处理
EAGAIN
或EWOULDBLOCK
错误(表示数据未就绪)。
代码示例:
#include <fcntl.h>// 将文件描述符设置为非阻塞模式
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);// 非阻塞读取数据
char buffer[1024];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
if (bytes_read == -1) {if (errno == EAGAIN || errno == EWOULDBLOCK) {// 数据未就绪,稍后重试} else {perror("read error");}
}// 非阻塞写入数据(需处理部分写入情况)
ssize_t bytes_written = write(fd, buffer, bytes_read);
if (bytes_written == -1) {if (errno == EAGAIN || errno == EWOULDBLOCK) {// 缓冲区已满,稍后重试} else {perror("write error");}
}
生活例子:
-
你给朋友发短信后,每隔几分钟检查手机是否有回复,期间可以处理其他事情(类似非阻塞
read
轮询检查数据)。 -
实现函数SetNoBlock
void SetNoBlock(int fd) { int fl = fcntl(fd, F_GETFL); if (fl < 0) { perror("fcntl"); return; } fcntl(fd, F_SETFL, fl | O_NONBLOCK); }
轮询方式读取标准输入
void SetNoBlock(int fd) {int fl = fcntl(fd, F_GETFL);if (fl < 0){perror("fcntl");return;}fcntl(fd, F_SETFL, fl | O_NONBLOCK); } int main() {SetNoBlock(0);while (1){char buf[1024] = {0};ssize_t read_size = read(0, buf, sizeof(buf) - 1);if (read_size < 0){perror("read");sleep(1);continue;}printf("input:%s\n", buf);}return 0; }
阻塞 vs 非阻塞I/O的核心区别
特性 | 阻塞I/O | 非阻塞I/O |
---|---|---|
线程状态 | 调用后线程挂起,直到操作完成 | 调用后立即返回,线程可执行其他任务 |
错误处理 | 通常直接返回错误 | 需检查EAGAIN 或EWOULDBLOCK |
适用场景 | 简单任务、低并发 | 高并发、实时响应需求 |
代码复杂度 | 简单 | 需处理轮询或事件驱动机制 |
关键注意事项
-
非阻塞I/O的局限性:
-
非阻塞
read
/write
可能只处理部分数据(如TCP套接字),需循环调用直到完成。 -
需结合
select
/poll
/epoll
等I/O多路复用技术,避免忙等待(CPU空转)。
-
-
fcntl
的常见用途:-
设置非阻塞模式(
O_NONBLOCK
)。 -
获取/设置文件描述符状态(如
F_GETFD
/F_SETFD
)。
-
总结
-
阻塞I/O:通过
read
/write
实现简单同步操作,但线程效率低。 -
非阻塞I/O:通过
fcntl
设置O_NONBLOCK
标志,使read
/write
立即返回,需结合轮询或事件驱动机制。 -
实际应用:非阻塞I/O常用于高性能服务器(如Nginx、Redis)或需要实时响应的场景。
四、I/O多路转接select
1. select
的作用
select
是一种 同步I/O多路复用 机制,允许程序同时监听多个文件描述符(如套接字、管道等),并在一或多个描述符就绪(可读、可写、异常)时通知程序处理。
-
核心目标:避免为每个I/O操作创建独立线程,用单线程高效管理多个I/O任务。
-
适用场景:网络服务器、需要同时处理多客户端请求的场景。
2. select
函数原型
#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数解析:
参数 | 说明 |
---|---|
nfds | 监听的文件描述符最大值 +1(例如最大描述符为5,则 nfds=6 )。 |
readfds | 监听可读事件的文件描述符集合。 |
writefds | 监听可写事件的文件描述符集合。 |
exceptfds | 监听异常事件的文件描述符集合(如带外数据)。 |
timeout | 超时时间(NULL 表示阻塞等待,0 表示非阻塞立即返回,其他为具体时间)。 |
返回值:
-
成功:返回就绪的文件描述符总数。
-
超时:返回
0
。 -
错误:返回
-1
,并设置errno
。
3. select
的工作流程
-
初始化描述符集合:
使用FD_ZERO
、FD_SET
等宏操作fd_set
结构,设置需要监听的文件描述符。 -
fd_set
是select
实现多路复用的核心数据结构,本质是 位数组。
// 通常定义在 <sys/select.h>
#define FD_SETSIZE 1024 // 最大支持的文件描述符数量typedef struct {unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;
-
调用
select
:
阻塞或非阻塞等待监听的文件描述符就绪。 -
检查就绪状态:
通过FD_ISSET
宏遍历所有描述符,确定哪些描述符已就绪。 -
处理就绪的I/O:
对就绪的描述符执行读/写操作,处理完成后重置监听集合。
4. 关键宏函数
// 清空集合
FD_ZERO(fd_set *set);// 将描述符加入集合
FD_SET(int fd, fd_set *set);// 将描述符移出集合
FD_CLR(int fd, fd_set *set);// 检查描述符是否在集合中
FD_ISSET(int fd, fd_set *set);
5. 代码示例(TCP服务器监听客户端连接和数据)
#include <sys/select.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int main() {int server_fd = socket(AF_INET, SOCK_STREAM, 0);// 绑定、监听等操作(省略)...fd_set readfds;int max_fd = server_fd;while (1) {FD_ZERO(&readfds);FD_SET(server_fd, &readfds); // 监听服务器套接字// 设置超时时间为5秒struct timeval timeout = {5, 0};// 调用select监听可读事件int ret = select(max_fd + 1, &readfds, NULL, NULL, &timeout);if (ret == -1) {perror("select error");exit(1);} else if (ret == 0) {printf("Timeout, no data.\n");continue;}// 检查服务器套接字是否有新连接if (FD_ISSET(server_fd, &readfds)) {int client_fd = accept(server_fd, NULL, NULL);FD_SET(client_fd, &readfds);max_fd = (client_fd > max_fd) ? client_fd : max_fd;}// 遍历其他客户端套接字检查数据for (int fd = server_fd + 1; fd <= max_fd; fd++) {if (FD_ISSET(fd, &readfds)) {char buffer[1024];ssize_t bytes = read(fd, buffer, sizeof(buffer));if (bytes > 0) {// 处理数据...} else {close(fd);FD_CLR(fd, &readfds);}}}}return 0;
}
6. select
的优缺点
优点:
-
跨平台:支持所有主流操作系统(Linux、Windows、macOS等)。
-
简单易用:适合少量并发连接的管理。
缺点:
-
性能瓶颈:
-
文件描述符数量限制(通常为
FD_SETSIZE=1024
)。 -
每次调用需遍历所有描述符,时间复杂度为
O(n)
。
-
-
重复初始化:每次调用需重新设置监听集合。
-
内核-用户态拷贝:描述符集合需在用户态和内核态之间拷贝。
7. 生活例子
想象一个 客服中心 的场景:
-
select
的作用:一个客服人员(单线程)同时监听多个来电(文件描述符)。 -
流程:
-
将所有来电号码加入监听列表(
FD_SET
)。 -
每隔一段时间检查哪些电话已接通(
select
返回就绪描述符)。 -
处理接通的电话(读/写数据),挂断后移除监听列表(
FD_CLR
)。 -
若有新来电,加入监听列表(
FD_SET
)。
-
8. select
的替代方案
-
poll
:改进文件描述符数量限制,但仍有性能问题。 -
epoll
(Linux):高效的事件驱动模型,支持海量并发连接。 -
kqueue
(BSD/macOS):类似epoll
的高性能机制。
总结
-
select
适用场景:小型服务器、跨平台程序或对并发要求不高的场景。 -
核心思想:单线程通过轮询管理多个I/O任务,避免多线程资源竞争。
-
学习意义:理解多路复用的基础原理,为掌握更高效的
epoll
/kqueue
打下基础。 -