IO模型与并发服务器
知识点核心记忆阻塞IO等待+拷贝全程阻塞,最简单非阻塞IOO_NONBLOCK,轮询返回 EWOULDBLOCK,浪费CPUIO多路复用select/poll/epoll 监听多 fd,有就绪才处理select限制最多1024个fd,每次需重置fd_set,O(n)遍历poll改进无 fd 数量限制,但仍需 O(n) 遍历epoll优势红黑树+就绪链表,O(就绪数),支持ET,高并发首选LT vs
一、五种 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 + 事件驱动 实现高并发 |
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)