IO模型
IO 模型是操作系统层面的核心概念。 理解 IO 模型对于写出高性能服务端程序、排查接口响应慢等问题至关重要。本文从 Socket 通信原理出发,系统讲解五种 IO 模型的工作机制、区别和适用场景。
一、Socket 通信基础
1.1 Socket 是什么?
Socket(套接字)是两台主机之间进程通信的端点。它将底层的 TCP/IP 协议栈封装为一组系统调用,让开发者可以像操作文件一样进行网络通信(一切皆文件)。
核心思想:Socket = IP 地址 + 端口号。IP 定位主机,端口号定位主机上的进程。两者组合唯一标识网络中的一个通信端点。
1.2 服务端 Socket 创建流程
// 1. 创建 socket(指定协议族和传输协议)
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
// AF_INET: IPv4
// SOCK_STREAM: TCP(面向连接、可靠传输)
// SOCK_DGRAM: UDP(无连接、不保证可靠)
// 2. 绑定 IP 地址和端口号
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
server_addr.sin_port = htons(8080); // 端口号(网络字节序)
bind(listenfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 3. 开始监听(将 socket 从 CLOSED 转为 LISTEN 状态)
listen(listenfd, 128);
// 128: 内核中已完成握手的连接队列(backlog)最大长度
// 4. 从内核连接队列中获取一个客户端连接(阻塞等待)
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_len);
// 若无客户端连接,accept() 会阻塞,直到有连接到来
// 5. 读写数据
char buf[1024];
ssize_t n = read(connfd, buf, sizeof(buf)); // 读取客户端发来的数据
write(connfd, "Hello", 5); // 向客户端发送数据
// 6. 关闭连接
close(connfd);
close(listenfd);
1.3 客户端 Socket 创建流程
// 1. 创建 socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 指定服务端地址
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr); // 服务端 IP
server_addr.sin_port = htons(8080); // 服务端端口
// 3. 发起连接(三次握手)
connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 4. 收发数据
write(sockfd, "Hello Server", 12);
char buf[1024];
read(sockfd, buf, sizeof(buf));
// 5. 关闭连接
close(sockfd);
1.4 完整流程图
服务端 客户端
socket() socket()
| |
bind() |
| |
listen() |
| |
accept() ←←←←← 三次握手 ←←←←←←←← connect()
| |
read() ←←←←← 数据请求 ←←←←←←←← write()
| |
write() →→→→→ 数据响应 →→→→→→→→ read()
| |
close() ←←←←← 四次挥手 ←←←←←←←← close()
TCP 三次握手发生在内核层面:客户端 connect() 时内核发起 SYN → 服务端内核回复 SYN+ACK → 客户端内核回复 ACK。握手完成后,服务端的 accept() 才返回已连接的 socket 描述符。
二、多进程与多线程模型
2.1 多进程模型
每个客户端连接到来时,服务端 fork 一个子进程来处理该连接。子进程复制父进程的内存空间(写时复制),独立处理客户端的请求。
// 多进程并发服务端
while (1) {
int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &len);
pid_t pid = fork();
if (pid == 0) {
// 子进程:处理客户端请求
close(listenfd); // 子进程不需要监听 fd
handle_client(connfd); // 读取、处理、响应
close(connfd);
exit(0); // 处理完毕退出
} else {
// 父进程:继续监听
close(connfd); // 父进程不需要连接 fd
}
}
|
特点 |
说明 |
|---|---|
|
隔离性 |
进程间内存隔离,一个子进程崩溃不影响其他连接 |
|
资源开销 |
每个 fork 复制内存页表,创建/销毁开销大 |
|
并发上限 |
受限于系统进程数(一般几千个) |
|
通信 |
子进程间通信需要 IPC(管道/共享内存/消息队列) |
2.2 多线程模型
每个连接创建一个线程来处理。线程共享进程的内存空间,创建/切换开销比进程小。
// 多线程并发服务端(伪代码)
void* handle(void* arg) {
int connfd = (int)(intptr_t)arg;
handle_client(connfd);
close(connfd);
return NULL;
}
while (1) {
int connfd = accept(listenfd, ...);
pthread_t tid;
pthread_create(&tid, NULL, handle, (void*)(intptr_t)connfd);
pthread_detach(tid); // 线程结束自动回收资源
}
2.3 两种模型对比
|
维度 |
多进程 |
多线程 |
|---|---|---|
|
内存 |
独立地址空间,内存隔离 |
共享地址空间 |
|
创建开销 |
大(fork 复制页表) |
小(只创建栈+TCB) |
|
切换开销 |
大(切换页表、TLB 刷新) |
较小(切换寄存器+栈) |
|
稳定性 |
高(一个挂了不影响其他) |
低(一个线程崩溃可能导致整个进程挂掉) |
|
数据共享 |
困难(需 IPC) |
容易(共享堆)但有并发安全问题 |
|
适用场景 |
高稳定性要求、多核 |
高并发、低延迟要求 |
多进程和多线程都有一个共同问题:每个连接需要一个进程/线程。当连接数达到上万个(C10K 问题),频繁创建/销毁 + 上下文切换的开销会变得无法承受。这就是 IO 多路复用要解决的问题。
三、IO 模型核心概念
3.1 内核态与用户态
操作系统将运行空间分为两个区域:
|
区域 |
权限 |
作用 |
|---|---|---|
|
内核态 |
最高权限 |
访问所有硬件资源,管理进程/内存/磁盘/网络,执行系统调用 |
|
用户态 |
受限权限 |
运行用户程序,不能直接访问硬件和内核内存 |
为什么要有这种区分? 隔离保护。用户程序不能直接操作硬件和内核数据结构,必须通过系统调用陷入内核,由内核代为执行。这保证了系统的稳定性和安全性。
3.2 一次 IO 读取的完整过程(两次拷贝)
用户进程 内核 磁盘/网卡
| | |
|--(1)read()系统调用-->| |
| (用户态→内核态) | |
| |--(2)DMA拷贝-------->|
| | 设备→内核缓冲区 |
| |<----数据----------|
| | |
|<--(3)CPU拷贝-------| |
| 内核缓冲区→用户缓冲区 | |
| | |
|--(4)系统调用返回---->| |
| (内核态→用户态) | |
| | |
用户程序拿到数据继续执行
两次拷贝:
- DMA 拷贝(设备 → 内核缓冲区):由 DMA 控制器完成,不消耗 CPU
- CPU 拷贝(内核缓冲区 → 用户缓冲区):由 CPU 完成
零拷贝(Zero Copy) 技术(如 sendfile、mmap)可以省去 CPU 拷贝,让 DMA 直接将数据从内核缓冲区传输到网卡或用户空间映射。
3.3 同步 vs 异步,阻塞 vs 非阻塞
这是两对最容易混淆的概念:
阻塞 vs 非阻塞:描述的是调用者在等待结果时的行为。
- 阻塞:调用后挂起等待,结果返回才继续执行
- 非阻塞:调用后立即返回(无论有没有结果),需要不断轮询
同步 vs 异步:描述的是被调用者通知结果的方式。
- 同步:调用者主动等待结果(数据从内核缓冲区拷贝到用户缓冲区期间,调用者一直在等)
- 异步:被调用者完成后通过回调/信号/事件通知调用者
|
组合 |
典型例子 |
含义 |
|---|---|---|
|
同步阻塞 |
传统 read() |
调用后阻塞等待,数据就绪后主动读取 |
|
同步非阻塞 |
read() + O_NONBLOCK 轮询 |
不断轮询检查,还没就绪就先干别的 |
|
异步阻塞 |
IO 多路复用 select/poll/epoll |
阻塞在多个 fd 的事件上,任意一个就绪就可以处理 |
|
异步非阻塞 |
Linux AIO / Windows IOCP |
发起 IO 操作后立即返回,内核完成后通知 |
四、五种 IO 模型详解
4.1 阻塞 IO(Blocking IO)
工作原理
用户进程调用 recvfrom(),内核开始准备数据。在数据就绪并拷贝到用户空间之前,进程一直阻塞等待,不消耗 CPU。
用户进程 内核
| |
|--recvfrom() 系统调用---->|
| (进程阻塞) |---等待数据(网卡/DMA)-- 数据未就绪
| |---数据就绪
| |---拷贝数据到用户缓冲区
|<---返回成功-------------|
| |
(进程继续处理数据)
代码示例
// 默认的 read/recv 都是阻塞的
char buf[1024];
int n = recv(sockfd, buf, sizeof(buf), 0); // 没有数据到达时,进程挂起等待
// 当 n > 0 时,数据已经到达并拷贝完成
优缺点
|
优点 |
缺点 |
|---|---|
|
实现简单,编程模型直观 |
一个线程只能处理一个连接 |
|
数据未就绪时不消耗 CPU |
高并发场景需要大量线程 |
|
适合连接数少、数据量大的场景 |
线程切换开销大,资源利用率低 |
4.2 非阻塞 IO(Non-Blocking IO)
工作原理
将 socket 设置为非阻塞模式。每次 read() 时:
- 如果内核缓冲区没有数据 → 立即返回 EWOULDBLOCK 错误
- 如果有数据 → 拷贝数据并返回
用户进程需要不断轮询来检查数据是否就绪,这个过程中 CPU 被持续占用。
用户进程 内核
| |
|--recvfrom() 系统调用--------------->| 数据未就绪 → 返回 EWOULDBLOCK
|<--返回 EWOULDBLOCK------------------|
| |
|--recvfrom() 系统调用--------------->| 数据未就绪 → 返回 EWOULDBLOCK
|<--返回 EWOULDBLOCK------------------|
| |
| (轮询期间可以处理其他任务) |---数据到达(DMA 完成)
| |---数据就绪
| |
|--recvfrom() 系统调用--------------->| 拷贝数据到用户缓冲区
|<--返回成功--------------------------|
代码示例
// 设置 socket 为非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
// 轮询读取
char buf[1024];
while (1) {
int n = recv(sockfd, buf, sizeof(buf), 0);
if (n > 0) {
// 有数据,处理
process_data(buf, n);
break;
} else if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 数据还没到,可以顺便处理其他事情
do_other_work();
} else {
// 真正的错误或连接关闭
break;
}
}
优缺点
|
优点 |
缺点 |
|---|---|
|
单线程可以处理多个连接 |
轮询大量 fd 时 CPU 空转严重 |
|
不会阻塞在单个连接上 |
代码复杂度增加,需要处理 EWOULDBLOCK |
4.3 IO 多路复用(IO Multiplexing)
核心思想:用一个线程同时监视多个 socket 的文件描述符,一旦其中任何一个就绪,就通知用户进程进行读写操作。一个线程可以处理成千上万个连接。
工作原理
用户进程调用 select/poll/epoll_wait,阻塞等待多个 fd 中的任意一个变为可读/可写。当有 fd 就绪时,函数返回,用户进程再遍历找到就绪的 fd 逐个处理。
用户进程 内核
| |
|--select() 阻塞等待------>| 监视 fd1, fd2, fd3...
| (等待任意 fd 就绪) |---fd2 数据到达(就绪)
|<--返回就绪 fd 列表------|
| |
|--recv(fd2)------------->| 数据已在内核缓冲区 → 拷贝
|<--返回数据--------------|
| (处理 fd2 的数据) |
| |
|--select() 阻塞等待------>| 继续监视...
4.3.1 select
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds); // 将 fd 加入监视集合
FD_SET(sock2, &readfds);
FD_SET(sock3, &readfds);
// select 阻塞等待,任意 fd 可读时返回
int maxfd = max(sock1, max(sock2, sock3)) + 1;
int ready = select(maxfd, &readfds, NULL, NULL, NULL);
// 遍历检查哪些 fd 就绪
for (int fd = 0; fd < maxfd; fd++) {
if (FD_ISSET(fd, &readfds)) {
// fd 就绪,读取数据处理
handle_read(fd);
}
}
select 特点:
fd_set是位图,默认最大 1024(FD_SETSIZE)- 每次调用需要将
fd_set整体拷贝到内核 - 内核通过遍历整个描述符集合来检查就绪状态
- 返回后用户态也需要 O(n) 遍历找到就绪的 fd
4.3.2 poll
struct pollfd fds[3];
fds[0].fd = sock1; fds[0].events = POLLIN;
fds[1].fd = sock2; fds[1].events = POLLIN;
fds[2].fd = sock3; fds[2].events = POLLIN;
// poll 阻塞等待
int ready = poll(fds, 3, -1); // -1 = 无限等待
// 遍历找出就绪的 fd
for (int i = 0; i < 3; i++) {
if (fds[i].revents & POLLIN) {
handle_read(fds[i].fd);
}
}
poll 特点:
- 使用链表组织
pollfd结构,没有 1024 限制 - 每次调用仍需将结构体数组整体拷贝到内核
- 内核仍通过 O(n) 遍历检查就绪状态
4.3.3 epoll(Linux 高性能方案)
// 1. 创建 epoll 实例(返回 epoll fd)
int epfd = epoll_create1(0);
// 2. 注册要监视的 fd
struct epoll_event ev;
ev.events = EPOLLIN; // 监视可读事件
ev.data.fd = sock1;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev); // 添加
// 同样添加其他 fd...
epoll_ctl(epfd, EPOLL_CTL_ADD, sock2, &ev);
epoll_ctl(epfd, EPOLL_CTL_ADD, sock3, &ev);
// 3. 等待事件(只返回就绪的 fd,不是全部)
struct epoll_event events[64];
int nfds = epoll_wait(epfd, events, 64, -1);
// 4. 直接处理返回的就绪 fd(events 数组里全是就绪的)
for (int i = 0; i < nfds; i++) {
handle_read(events[i].data.fd);
}
epoll 的核心优势:
|
特性 |
select/poll |
epoll |
|---|---|---|
|
fd 数量限制 |
select: 1024 / poll: 无硬限制 |
无限制(受系统资源限制) |
|
fd 组织方式 |
select: 位图 / poll: 链表 |
红黑树(高效增删改查) |
|
每次调用是否拷贝全部 fd |
是(O(n) 拷贝) |
否(只拷贝就绪 fd) |
|
内核查找就绪 fd 方式 |
遍历所有注册的 fd |
回调机制(fd 就绪时自动加入就绪链表) |
|
用户态获取就绪 fd |
遍历所有 fd 逐个检查 |
直接返回就绪列表,O(1) 获取 |
|
工作模式 |
仅水平触发(LT) |
水平触发(LT) + 边缘触发(ET) |
LT vs ET 模式:
// 边缘触发模式(ET):只在 fd 状态变化时通知一次
ev.events = EPOLLIN | EPOLLET; // 必须配合非阻塞 IO
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
// ET 模式下的正确读取方式:循环读到 EAGAIN
while (1) {
int n = read(sockfd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) break; // 读完了
if (n == 0) { /* 连接关闭 */ break; }
process(buf, n);
}
LT(水平触发):只要 fd 缓冲区还有数据,下次 epoll_wait 还会通知。不容易漏事件,更安全。 ET(边缘触发):只在 fd 状态由不可读变为可读时通知一次。必须配合非阻塞 IO 循环读取,否则可能漏数据。但性能更高(减少了 epoll_wait 的返回次数)。
4.3.4 select / poll / epoll 对比总结
|
维度 |
select |
poll |
epoll |
|---|---|---|---|
|
数据结构 |
位图 (fd_set) |
链表 (pollfd[]) |
红黑树 + 就绪链表 |
|
最大 fd 数 |
1024 |
无限制 |
无限制 |
|
每次调用开销 |
O(n) 全量拷贝 |
O(n) 全量拷贝 |
O(1) 只拷贝就绪 fd |
|
就绪 fd 检查 |
O(n) 遍历 |
O(n) 遍历 |
O(1) 直接返回就绪链表 |
|
适用场景 |
连接数 < 1024 |
连接数较多 |
高并发(万级+连接) |
|
工作模式 |
LT |
LT |
LT + ET |
|
跨平台 |
✅ POSIX |
✅ POSIX |
❌ Linux 专属 |
4.4 信号驱动 IO(Signal-Driven IO)
工作原理
- 通过
sigaction注册 SIGIO 信号处理函数 - 调用
fcntl设置F_SETOWN+FASYNC - 进程立即返回,不阻塞,继续执行其他任务
- 当内核缓冲区数据就绪时,内核发送 SIGIO 信号 给进程
- 进程在信号处理函数中调用 recvfrom 读取数据
用户进程 内核
| |
|--sigaction 注册 SIGIO 处理-->|
|--fcntl(F_SETOWN + FASYNC)-->|
|<--立即返回(不阻塞)---------|
| |
| (进程继续执行其他任务) |---数据到达
| |---发送 SIGIO 信号
|<--SIGIO 信号----------------|
| |
|--recvfrom() 读取数据------->| 拷贝数据到用户缓冲区
|<--返回数据------------------|
代码示例
// 1. 注册 SIGIO 信号处理函数
void sigio_handler(int signo) {
// 信号处理函数中读取数据
char buf[1024];
int n = recv(sockfd, buf, sizeof(buf), 0);
if (n > 0) process_data(buf, n);
}
signal(SIGIO, sigio_handler);
// 2. 设置 socket 属主(当 fd 就绪时向哪个进程发信号)
fcntl(sockfd, F_SETOWN, getpid());
// 3. 启用异步通知
int flags = fcntl(sockfd, F_GETFL);
fcntl(sockfd, F_SETFL, flags | FASYNC);
信号驱动 IO 的局限:
- 信号处理函数中能做的事情有限(信号安全函数限制)
- 无法知道是哪个 fd 触发了信号(只有一个 SIGIO)
- 信号可能会合并(多个 fd 同时就绪只收到一个信号)
- TCP 场景下有多种触发条件(可读/可写/带外数据等),信号频率很高
所以信号驱动 IO 在实际中用得很少,主要用于 UDP 场景(每个数据报独立,状态简单)。
4.5 异步 IO(Asynchronous IO / AIO)
工作原理
真正的异步 IO:用户进程发起 aio_read() 后立即返回,内核负责完成全部工作(等待数据 + 拷贝数据到用户缓冲区),完成后通过回调或信号通知用户进程。
这是唯一不阻塞、不需要轮询、不需要信号中断的纯异步模型。 与其他模型的核心区别:数据从内核缓冲区拷贝到用户缓冲区的过程也由内核完成,进程不需要参与。
用户进程 内核
| |
|--aio_read() 发起异步读------>|
|<--立即返回(不阻塞)---------|
| |
| (进程继续执行其他任务) |---等待数据到达
| |---DMA 拷贝到内核缓冲区
| |---CPU 拷贝到用户缓冲区(内核代劳)
| |---发送完成信号
|<--信号/回调通知:数据就绪----|
| |
(直接使用用户缓冲区的数据)
代码示例(Linux AIO)
#include <aio.h>
#include <signal.h>
// 完成回调
void aio_complete_handler(sigval_t sigval) {
struct aiocb *cb = (struct aiocb *)sigval.sival_ptr;
ssize_t n = aio_return(cb); // 获取读取的字节数
if (n > 0) {
process_data((char*)cb->aio_buf, n);
}
}
// 发起异步读取
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd; // 文件描述符
cb.aio_buf = buffer; // 用户缓冲区(数据直接到这里)
cb.aio_nbytes = sizeof(buffer); // 要读取的字节数
cb.aio_offset = 0; // 文件偏移量
// 设置完成通知方式:回调
cb.aio_sigevent.sigev_notify = SIGEV_THREAD;
cb.aio_sigevent.sigev_notify_function = aio_complete_handler;
cb.aio_sigevent.sigev_value.sival_ptr = &cb;
aio_read(&cb); // 发起异步读,立即返回
// 进程可以继续做其他事,数据就绪后自动调用 aio_complete_handler
Linux AIO 的局限:Linux 原生 AIO 主要用于磁盘 IO(direct IO),对网络 socket 的异步支持不完善(需要 O_DIRECT)。Windows 的 IOCP 对异步 IO 支持更好。
在 Linux 网络编程中,epoll + 非阻塞 IO 仍然是当前最主流的异步方案,虽然本质上属于 IO 多路复用(同步非阻塞),但配合 Reactor/Proactor 模式可以间接实现类似异步的效果。新的 io_uring (Linux 5.1+) 正在改变这一局面。
五、五种 IO 模型对比
5.1 一图总结
同步 IO 异步 IO
(调用者参与数据拷贝) (内核完成全部工作)
阻塞IO 非阻塞IO 多路复用 信号驱动 | 异步IO
| | | | | |
阻塞等待 轮询 阻塞等待 注册信号 | 立即返回
| (立即返回) (多fd) (立即返回) | |
| | | | | |
数据到后 数据到后 有fd就绪 信号通知 | 数据到用户缓冲区
拷贝 拷贝 后逐个读取 后读取 | 后通知
| | | | | |
第一阶段:都是阻塞等待数据(阻塞 vs 非阻塞 vs 多路复用 vs 信号)
第二阶段:用户进程主动拷贝数据(recvfrom)
异步IO:两个阶段都由内核完成
5.2 对比总表
|
维度 |
阻塞 IO |
非阻塞 IO |
IO 多路复用 |
信号驱动 IO |
异步 IO |
|---|---|---|---|---|---|
|
第一阶段(等数据) |
阻塞 |
非阻塞(轮询) |
阻塞(多 fd) |
非阻塞(信号) |
非阻塞 |
|
第二阶段(拷贝数据) |
阻塞 |
阻塞 |
阻塞 |
阻塞 |
非阻塞 |
|
进程是否参与拷贝 |
是 |
是 |
是 |
是 |
否 |
|
CPU 消耗 |
无(挂起) |
高(轮询) |
低 |
低 |
极低 |
|
并发能力 |
差 |
一般 |
优秀 |
一般 |
优秀 |
|
编程复杂度 |
简单 |
中等 |
较高 |
高 |
高 |
|
Linux 支持 |
默认 |
需设置 NONBLOCK |
epoll/select/poll |
SIGIO |
aio / io_uring |
|
典型应用 |
简单工具 |
极少使用 |
Nginx / Redis / Netty |
UDP 服务 |
高性能文件 IO |
5.3 适用场景总结
- 阻塞 IO:连接数少(< 10)、对时延要求不高的小工具、脚本
- 非阻塞 IO:几乎没有单独使用的场景(CPU 空转问题)
- IO 多路复用:高性能服务端的标准方案(Nginx、Redis、Netty、Node.js),适合 C10K/C100K 场景
- 信号驱动 IO:特定 UDP 场景,实际很少用
- 异步 IO:高性能文件 IO 服务、Windows IOCP 网络服务、Linux io_uring(未来趋势)
当前工业界主流:Linux 上 epoll + 非阻塞 IO + Reactor 模式(如 Nginx、Redis、Netty)是绝大部分高并发网络服务的底层方案。Windows 上则是 IOCP + Proactor 模式。
未来趋势:Linux io_uring(5.1+)正在统一异步 IO 和存储 IO,性能远超 epoll,是下一代高性能 IO 的方向。
学习建议:理解 IO 模型的关键在于区分两件事——"等数据"和"拷贝数据"。前四种模型在这两个阶段都是同步的(进程参与),只有异步 IO 把第二阶段也交给了内核。实战中优先掌握 epoll + Reactor 模式,这是面试和高性能服务端开发的必备知识。