一、什么是非阻塞IO
非阻塞io:允许程序在执行输入输出时,不阻塞当前线程。
核心思想:I/O操作无法立即完成时,立即返回一个状态(而不是等待IO操作),即可以让程序执行其他任务,提高资源利用率和系统吞吐量
- 内核缓冲区:由操作系统管理的内存区域,用于缓存硬件设备的数据(如磁盘文件内容、网络接收的数据包等)。
- 用户缓冲区:应用程序在堆或栈中预先分配的内存区域,用于接收
read()
返回的数据
1. 非阻塞 I/O 的核心特点
特性 | 说明 |
---|---|
立即返回 | 无论 I/O 是否就绪,函数调用立即返回结果,不会阻塞线程。 |
需主动轮询或事件驱动 | 程序需通过轮询(如循环检查select)或事件通知(如 epoll )来感知 I/O 就绪状态。 |
高并发支持 | 单线程即可处理大量 I/O 操作,适合高并发场景(如 Web 服务器、实时系统)。 |
编程复杂度较高 | 需处理部分写入/读取、错误重试、缓冲区管理等复杂逻辑。 |
2. 非阻塞 I/O 的工作原理
以非阻塞读(read()
)为例:
- 设置非阻塞模式:通过
fcntl(fd, F_SETFL, O_NONBLOCK)
将文件描述符设为非阻塞。 - 发起读操作:调用
read(fd, buf, size)
。- 如果数据已就绪:立即从内核缓冲区读取数据到用户缓冲区,返回实际读取的字节数。
- 如果数据未就绪:立即返回
-1
,并设置errno
为EAGAIN
或EWOULDBLOCK
。
- 程序处理结果:
- 若读取成功,处理数据。
- 若返回
EAGAIN
,程序可以转而处理其他任务,稍后重试。
非阻塞写(write()
)同理:
- 若内核缓冲区已满,可能部分写入数据或返回
EAGAIN
。
3. 非阻塞 I/O 与阻塞 I/O 的对比
场景 | 阻塞 I/O | 非阻塞 I/O |
---|---|---|
数据未就绪时读操作 | 线程挂起,直到数据到达。 | 立即返回错误,线程继续执行其他任务。 |
内核缓冲区满时写操作 | 线程挂起,直到缓冲区空间释放。 | 可能部分写入或返回错误,线程继续执行。 |
并发能力 | 需多线程/进程处理多个连接,资源消耗大。 | 单线程即可通过事件循环处理大量连接。 |
实时性 | 延迟高(需等待操作完成)。 | 延迟低(可立即处理其他任务)。 |
4. 非阻塞 I/O 的典型使用方式
方式 1:忙等待(不推荐)
// 非阻塞读的忙等待示例(仅用于演示,实际中应避免)
ssize_t ret;
while (1) {
ret = read(fd, buf, size);
if (ret >= 0) break; // 成功读取数据
if (errno != EAGAIN) break; // 发生真实错误
usleep(1000); // 忙等待(浪费 CPU)
}
缺点:CPU空转,资源利用率低
方式2:结合I/O多路复用(推荐)
// 使用 epoll 监听多个非阻塞 fd 的可读事件
struct epoll_event events[MAX_EVENTS];
int epollfd = epoll_create1(0);
// 添加 fd 到 epoll 监听列表
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 监听可读事件 + 边缘触发模式
ev.data.fd = fd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);
// 事件循环
while (1) {
int nready = epoll_wait(epollfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nready; i++) {
if (events[i].events & EPOLLIN) {
// 处理非阻塞读
ssize_t ret = read(events[i].data.fd, buf, size);
if (ret == -1 && errno == EAGAIN) {
// 数据未完全读取,等待下次事件
} else {
// 处理数据
}
}
}
}
缺点:CPU高效利用,适合高并发场景
5. 非阻塞 I/O 的优缺点
优点
- 高吞吐量:单线程处理大量 I/O 操作,减少上下文切换。
- 低延迟:即时响应其他任务,避免线程阻塞等待。
- 资源节约:无需为每个连接创建线程/进程。
缺点
- 编程复杂:需处理部分成功、错误重试、缓冲区管理。
- 需结合多路复用:单独使用非阻塞 I/O 无意义,需配合
select
/poll
/epoll
等机制。 - CPU 可能空转:若设计不当(如忙等待),会导致资源浪费
6.非阻塞I/O vs 异步I/O
- 非阻塞I/O:
同步操作:发起IO后仍需主动检查状态或通过事件感知就绪。
关键点:“不阻塞”仅指调用立即返回这个行为,IO操作本身动作(读/写行为)仍需要编程者写程序主动去完成 (调用read()读数据) - 异步 I/O(AIO):
异步操作:发起 I/O 请求后,由内核完成全部操作(如读取数据到用户缓冲区),完成后通知程序。
关键点:程序只需提交请求和处理结果,无需参与中间过程(无需主动去调用read()和write())(如 Linux 的io_uring
、Windows 的 IOCP)。
7.性能优化:零拷贝技术
传统 read()
的“内核缓冲区 → 用户缓冲区”拷贝存在性能瓶颈。零拷贝技术(如 sendfile()
)可绕过用户缓冲区,直接将数据从内核缓冲区发送到目标(如网络套接字),减少拷贝次数。
传统 read()
+ write()
流程
磁盘 → 页缓存(内核缓冲区) → 用户缓冲区 → 内核套接字缓冲区 → 网卡
(涉及 2 次内核态-用户态切换,2 次数据拷贝)
零拷贝 sendfile()
流程
复制
磁盘 → 页缓存 → 内核套接字缓冲区 → 网卡
(无用户缓冲区参与,减少切换和拷贝)
二、fd默认行为:阻塞模式
**默认情况下:**文件描述符(如通过 open()、socket() 创建)是阻塞的。
-
read()/recv(): 如果内核缓冲区没有数据可读时,调用线程会阻塞,直到数据到达
-
write()/send() : 如果内核缓冲区已满,调用线程阻塞,直到可以写入数据
read()/write()/recv()/send()等系统调用的阻塞行为取决于对文件描述符的设置,并非函数自身的特性,默认行为都是阻塞的。通过配置文件描述符为非阻塞模式或指定特定表示来启用非阻塞行为。
三、非阻塞模式的启用方式
要启用非阻塞行为,需要自己设置文件描述符为非阻塞模式
- 方法1:使用fcntl()设置O_NONBLOCK 标志
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 设置为非阻塞
- 方法2: 创建时直接指定非阻塞模式()
int sockfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
- 方法3:调用时指定非阻塞标志(仅限recv()/send())
recv()
和 send()
支持 MSG_DONTWAIT
标志,临时启用非阻塞模式(不影响文件描述符的全局设置):
ssize_t ret = recv(sockfd, buf, len, MSG_DONTWAIT); // 单次非阻塞读
ssize_t ret = send(sockfd, buf, len, MSG_DONTWAIT); // 单次非阻塞写
四、非阻塞模式下的行为方式
read()
/recv()
:
若没有数据可读,立即返回-1
,并设置errno
为EAGAIN
或EWOULDBLOCK
write()
/send()
:
若内核缓冲区已满,可能部分写入数据(返回已写入的字节数),或返回-1
并设置errno
为EAGAIN
/EWOULDBLOCK
。
五、非阻塞I/O通常与select/poll/epoll等结合使用,避免忙等待
示例:
// 使用 epoll 监听可读事件
struct epoll_event event;
event.events = EPOLLIN | EPOLLET; // 边缘触发模式
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
// 事件循环中处理就绪的 fd
while (1) {
int nready = epoll_wait(epollfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nready; i++) {
if (events[i].events & EPOLLIN) {
// 调用非阻塞 read()
}
}
}
六、非阻塞I/O的实际应用
- Nginx:使用非阻塞 I/O +
epoll
实现高并发 HTTP 服务器。 - Redis:通过事件循环处理客户端请求和网络 I/O。
- Node.js:基于 Libuv 库,利用非阻塞 I/O 实现单线程高并发。