跳转到内容

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)系统调用返回---->|                    |
   |   (内核态→用户态)    |                    |
   |                    |                    |
   用户程序拿到数据继续执行
📌

两次拷贝

  1. DMA 拷贝(设备 → 内核缓冲区):由 DMA 控制器完成,不消耗 CPU
  2. 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 是位图,默认最大 1024FD_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) 直接返回就绪链表

适用场景

连接数 &lt; 1024

连接数较多

高并发(万级+连接)

工作模式

LT

LT

LT + ET

跨平台

✅ POSIX

✅ POSIX

❌ Linux 专属


4.4 信号驱动 IO(Signal-Driven IO)

工作原理

  1. 通过 sigaction 注册 SIGIO 信号处理函数
  2. 调用 fcntl 设置 F_SETOWN + FASYNC
  3. 进程立即返回,不阻塞,继续执行其他任务
  4. 当内核缓冲区数据就绪时,内核发送 SIGIO 信号 给进程
  5. 进程在信号处理函数中调用 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:连接数少(&lt; 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 模式,这是面试和高性能服务端开发的必备知识。