一、五种 IO 模型

IO 的本质 = "等待数据就绪" + "将数据从内核拷贝到用户空间",五种模型的区别在于这两个阶段如何等待。

1.1 阻塞 IO(Blocking IO)

用户进程                    内核
  │                          │
  │── recvfrom ─────────────→│
  │    阻塞等待               │ 等待数据就绪
  │    (进程挂起)             │
  │                          │ 数据就绪,拷贝到用户空间
  │←────────── 返回数据 ──────│
  │ 继续执行                  │
  • 特点:整个 IO 过程(等待+拷贝)全程阻塞,进程被挂起
  • 优点:编程简单,无需轮询
  • 缺点:一个进程同一时刻只能处理一个 IO,并发能力差

1.2 非阻塞 IO(Non-Blocking IO)

用户进程                    内核
  │── recvfrom ────────────→ │ 数据未就绪
  │←── 立即返回 EWOULDBLOCK ──│
  │── recvfrom ────────────→ │ 数据未就绪
  │←── 立即返回 EWOULDBLOCK ──│
  │── recvfrom ────────────→ │ 数据就绪
  │    等待拷贝               │ 拷贝到用户空间
  │←────────── 返回数据 ──────│
  • 特点:recvfrom 立即返回,数据未就绪时返回错误码 EWOULDBLOCK,应用层需不断轮询
  • 优点:不会阻塞进程,可以处理其他事情
  • 缺点:需要持续轮询,消耗大量 CPU

📌 设置非阻塞方式:fcntl(sockfd, F_SETFL, O_NONBLOCK);


1.3 IO 多路复用(IO Multiplexing)⭐

用户进程                    内核
  │── select/poll/epoll ───→ │ 监听多个 fd
  │    阻塞等待               │ 等待任意 fd 就绪
  │←── 有 fd 就绪,返回 ──────│
  │── recvfrom ────────────→ │ 数据就绪,拷贝
  │←────────── 返回数据 ──────│
  • 特点:用一个线程同时监听多个 fd,任意一个就绪则返回处理
  • 优点:单线程可以处理大量并发连接,高效
  • 缺点:比单纯阻塞 IO 多了一次系统调用(select/epoll)
  • 典型函数:select()poll()epoll()

1.4 信号驱动 IO(Signal Driven IO)

用户进程                    内核
  │── 设置 SIGIO 信号处理函数 │
  │── sigaction ───────────→ │ 注册信号
  │ 继续执行其他代码           │ 等待数据就绪
  │                          │
  │←────────── SIGIO 信号 ───│ 数据就绪,发信号
  │── 在信号处理函数中 recvfrom │ 拷贝数据
  │←────────── 返回数据 ──────│
  • 特点:内核在数据就绪时发送 SIGIO 信号通知进程,进程在信号处理函数中读取数据
  • 优点:等待阶段不阻塞,CPU 利用率较高
  • 缺点:大量 IO 时信号处理函数频繁触发,调试复杂,实际使用较少

1.5 异步 IO(Asynchronous IO)

用户进程                    内核
  │── aio_read ───────────→  │ 注册异步读请求
  │ 继续执行其他代码           │ 等待数据就绪
  │                          │ 数据就绪 + 自动拷贝到用户空间
  │←─── 完成信号通知 ─────────│ 整个 IO 完成
  │ 直接使用数据               │
  • 特点:整个 IO(等待+拷贝)全部由内核完成,完成后通知进程,进程无需等待
  • 优点:真正的"全程非阻塞",CPU 利用率最高
  • 缺点:Linux 下支持有限(AIO 接口功能较弱),编程复杂

1.6 五种 IO 模型对比

IO 模型 等待数据阶段 数据拷贝阶段 编程复杂度 性能
阻塞 IO 阻塞 阻塞 最简单 最低
非阻塞 IO 非阻塞(轮询) 阻塞 简单 低(CPU浪费)
IO 多路复用 阻塞(select等) 阻塞 中等
信号驱动 IO 非阻塞(信号) 阻塞 复杂 较高
异步 IO 非阻塞 非阻塞(内核完成) 最复杂 最高

📌 实际开发常用:阻塞IO(简单场景)+ IO多路复用(高并发场景)


二、IO 多路复用 —— select

2.1 select 函数

项目 内容
所需头文件 #include <sys/select.h> #include <sys/time.h>
函数原型 int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
功能 同时监听多个文件描述符,等待其中任意一个就绪

参数说明:

参数 说明
nfds 监听的所有 fd 中最大 fd 值 + 1
readfds 监听可读事件的 fd 集合(NULL 表示不监听)
writefds 监听可写事件的 fd 集合(NULL 表示不监听)
exceptfds 监听异常事件的 fd 集合(NULL 表示不监听)
timeout 超时时间:NULL=永久阻塞;{0,0}=立即返回(轮询);指定值=超时返回

返回值:

情况 返回值
成功 返回就绪的 fd 总数
超时 返回 0
失败 返回 -1,设置 errno

⚠️ select 调用后会修改 fd_set,每次调用前必须重新设置 fd_set!


2.2 fd_set 操作宏

宏函数 功能
FD_ZERO(fd_set *set) 清空 fd 集合(使用前必须先清零)
FD_SET(int fd, fd_set *set) 将 fd 加入集合
FD_CLR(int fd, fd_set *set) 将 fd 从集合中移除
FD_ISSET(int fd, fd_set *set) 判断 fd 是否在集合中就绪(返回非0表示就绪)

2.3 struct timeval 结构体

c

复制

struct timeval {
    long tv_sec;    // 秒
    long tv_usec;   // 微秒(1秒 = 1000000微秒)
};

2.4 select 使用示例(TCP 服务端监听)

c

复制


2.5 select 的局限性

缺点 说明
最大 fd 数量限制 FD_SETSIZE 默认为 1024,最多同时监听 1024 个 fd
每次需重置 fd_set select 调用后会修改 fd_set,每次循环都要重新设置,效率低
O(n) 遍历 返回后需遍历所有 fd 找出就绪的,fd 数量大时效率低
内核态/用户态数据拷贝 每次调用都需要将 fd_set 从用户态拷贝到内核态

三、IO 多路复用 —— poll

3.1 poll 函数

项目 内容
所需头文件 #include <poll.h>
函数原型 int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能 监听多个 fd 上的事件,克服 select 的 fd 数量限制

参数说明:

参数 说明
fds struct pollfd 数组,每个元素描述一个要监听的 fd 及其事件
nfds 数组中元素的个数(监听的 fd 总数)
timeout 超时时间(毫秒):-1=永久阻塞;0=立即返回;>0=等待指定毫秒

返回值: 就绪 fd 数(成功),0(超时),-1(失败)


3.2 struct pollfd 结构体

c

复制

struct pollfd {
    int   fd;         // 监听的文件描述符
    short events;     // 注册要监听的事件(输入参数)
    short revents;    // 实际发生的事件(输出参数,内核填写)
};

events / revents 常用标志:

标志 含义
POLLIN 0x0001 有数据可读(最常用)
POLLOUT 0x0004 可以写入数据
POLLERR 0x0008 fd 发生错误(仅出现在 revents)
POLLHUP 0x0010 对端挂断(连接断开)
POLLNVAL 0x0020 fd 无效(非合法描述符)

3.3 poll vs select 对比

对比点 select poll
fd 数量 最多 1024(受 FD_SETSIZE 限制) 无上限(由内存决定)
fd 集合重置 每次调用前需重置 不需要,通过 revents 区分
监听方式 三个 fd_set 位图 pollfd 数组
底层遍历 O(n) O(n)

📌 poll 解决了 select 的 1024 fd 限制,但两者都需要 O(n) 遍历,并发量大时性能仍有瓶颈


四、IO 多路复用 —— epoll ⭐(推荐)

4.1 epoll 三个核心函数

① epoll_create —— 创建 epoll 实例
项目 内容
所需头文件 #include <sys/epoll.h>
函数原型 int epoll_create(int size);
功能 在内核中创建一个 epoll 事件表(红黑树 + 就绪链表)
参数 size Linux 2.6.8 后该参数已被忽略,传入大于 0 的数即可(如 1)

返回值: 成功返回 epoll 文件描述符(epfd),失败返回 -1


② epoll_ctl —— 管理监听列表
项目 内容
所需头文件 #include <sys/epoll.h>
函数原型 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能 向 epoll 实例中添加/修改/删除要监听的 fd

参数说明:

参数 说明
epfd epoll_create 返回的 epoll fd
op 操作类型(见下表)
fd 要操作的目标 fd
event 要监听的事件(op=EPOLL_CTL_DEL 时可传 NULL

op 操作类型:

op 值 含义
EPOLL_CTL_ADD 添加新的 fd 到 epoll 监听列表
EPOLL_CTL_MOD 修改已监听 fd 的事件
EPOLL_CTL_DEL 从 epoll 监听列表中删除 fd

返回值: 成功返回 0,失败返回 -1


③ epoll_wait —— 等待事件就绪
项目 内容
所需头文件 #include <sys/epoll.h>
函数原型 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能 阻塞等待,直到有 fd 就绪,将就绪事件填入 events 数组

参数说明:

参数 说明
epfd epoll fd
events 输出参数,存储就绪事件的数组(调用方分配内存)
maxevents events 数组的最大容量
timeout 超时时间(毫秒):-1=永久阻塞;0=立即返回;>0=等待指定毫秒

返回值:

情况 返回值
成功 返回就绪事件的数量(只需遍历这几个,不用遍历所有 fd!)
超时 返回 0
失败 返回 -1

4.2 struct epoll_event 结构体

c

复制

struct epoll_event {
    uint32_t     events;   // 监听/就绪的事件类型(EPOLLIN、EPOLLOUT 等)
    epoll_data_t data;     // 用户数据(通常存 fd)
};

typedef union epoll_data {
    void        *ptr;
    int          fd;       // 最常用:存储 fd
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

events 常用标志:

标志 含义
EPOLLIN 有数据可读
EPOLLOUT 可以写入
EPOLLERR fd 发生错误
EPOLLHUP 对端挂断
EPOLLET 设置为边缘触发模式(Edge Triggered)
EPOLLONESHOT 只触发一次,触发后需重新注册

4.3 LT 模式 vs ET 模式

模式 全称 触发条件 特点
LT(默认) Level Triggered 水平触发 只要缓冲区中有数据未读,就一直通知 编程简单,不易漏数据;效率相对低
ET Edge Triggered 边缘触发 只在数据到来的瞬间通知一次 效率高;但必须一次性读完所有数据,否则不再通知,容易漏数据

⚠️ ET 模式下必须将 fd 设为非阻塞,并用循环读完所有数据:

c

复制

fcntl(fd, F_SETFL, O_NONBLOCK);  // 设置非阻塞
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) { ... }

4.4 epoll 使用示例

c

复制


五、select / poll / epoll 对比

对比项 select poll epoll
fd 上限 1024(FD_SETSIZE) 无限制 无限制
fd 集合重置 每次调用前必须重置 不需要(revents自动更新) 不需要(内核维护)
底层实现 fd_set 位图 pollfd 数组 红黑树 + 就绪链表
遍历就绪 fd O(n) 遍历全部 O(n) 遍历全部 O(就绪数量),效率高
内核/用户态拷贝 每次都拷贝 每次都拷贝 仅在注册时拷贝一次
触发模式 仅 LT 仅 LT LT + ET 两种
适用场景 连接数少,可移植性要求高 连接数多,跨平台 高并发(推荐,Linux 专用)
性能 低(fd多时急剧下降) 中等 最高

📌 实际工程首选 epoll,Nginx/Redis 都使用 epoll 实现高并发 IO


六、并发服务器 —— 多进程模型

6.1 多进程模型原理

主进程
  │── accept() 接收新连接 ──→ 新客户端
  │── fork() 创建子进程
  │       │
  │      子进程1:负责与客户端1通信(recv/send循环)
  │
  │── accept() 接收新连接 ──→ 新客户端
  │── fork() 创建子进程
  │       │
  │      子进程2:负责与客户端2通信
  │
(父进程只负责accept+fork,子进程负责具体通信)

关键要点:

要点 说明
父进程关闭 connfd fork 后父进程必须 close(connfd),否则 fd 泄漏
子进程关闭 listenfd 子进程不需要监听,必须 close(listenfd)
僵尸进程处理 子进程退出后父进程需回收,用 SIGCHLD 信号 + waitpid(-1, NULL, WNOHANG) 处理

6.2 多进程并发服务器代码框架

c

复制


七、并发服务器 —— 多线程模型

7.1 多线程模型原理

主线程
  │── accept() ──→ 新连接 connfd
  │── pthread_create() 创建子线程
  │       │
  │      子线程1:recv/send 与客户端1通信
  │
  │── accept() ──→ 新连接 connfd
  │── pthread_create() 创建子线程
  │       │
  │      子线程2:recv/send 与客户端2通信

与多进程区别:

对比项 多进程 多线程
资源开销 大(进程独立内存空间) 小(共享内存)
数据共享 需 IPC(管道/共享内存等) 直接共享全局变量(需加锁)
创建速度
健壮性 高(一个进程崩溃不影响其他) 低(一个线程崩溃影响全程序)

7.2 多线程并发服务器代码框架

c

复制

⚠️ 传递 connfd 给线程时,不能直接传栈地址(如 &connfd),应用堆内存或结构体,避免多线程竞争!


八、并发服务器 —— IO 多路复用模型

用单个进程 + epoll,同时管理成千上万个连接,是高并发服务器的核心方案

8.1 epoll 并发服务器完整流程

单线程主循环
  │
  ├── epoll_ctl(ADD, listenfd, EPOLLIN)    // 监听新连接
  │
  └── while(1):
        epoll_wait() 阻塞等待就绪事件
          │
          ├── listenfd 就绪 → accept() 接收新连接 → epoll_ctl(ADD, connfd, EPOLLIN)
          │
          └── connfd 就绪  → recv() 读数据 → 处理 → send() 回复
                           → 若 recv() 返回0 → epoll_ctl(DEL) → close()

九、三种并发模型对比

对比项 多进程 多线程 IO 多路复用(epoll)
并发原理 每个连接一个进程 每个连接一个线程 单线程管理所有连接
资源消耗 (进程开销大) (线程比进程轻量) (无额外线程/进程)
最大并发 受系统进程数限制(几百~几千) 受线程栈内存限制(几千) 极高(十万+连接)
数据共享 隔离(需IPC) 共享(需加锁) 单线程无竞争问题
编程复杂度 中(需处理僵尸进程) 中(需注意线程安全) 高(需事件驱动思维)
健壮性 最好(进程隔离)
适用场景 低并发、强隔离需求 中等并发、数据共享频繁 高并发(Nginx/Redis 方案)

十、总结速记卡

知识点 核心记忆
阻塞IO 等待+拷贝全程阻塞,最简单
非阻塞IO O_NONBLOCK,轮询返回 EWOULDBLOCK,浪费CPU
IO多路复用 select/poll/epoll 监听多 fd,有就绪才处理
select限制 最多1024个fd,每次需重置fd_set,O(n)遍历
poll改进 无 fd 数量限制,但仍需 O(n) 遍历
epoll优势 红黑树+就绪链表,O(就绪数),支持ET,高并发首选
LT vs ET LT=有数据就通知(默认);ET=数据到来时通知一次(需非阻塞读完)
epoll三函数 epoll_create → epoll_ctl(ADD/MOD/DEL) → epoll_wait
多进程并发 fork后父关connfd、子关listenfd;SIGCHLD+waitpid防僵尸
多线程并发 pthread_detach分离;传参用堆内存,防止竞争
epoll并发 单线程+事件驱动,并发量最大,适合高性能服务器
Nginx/Redis 均采用 epoll + 事件驱动 实现高并发
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐