IO模型与并发服务器设计
·
一、阻塞IO与非阻塞IO
1.1 阻塞IO
| 层面 | 步骤 | 说明 |
|---|---|---|
| 用户层 | 调用recv(读阻塞) | 执行 recv(fd, buf, sizeof(buf), 0),程序卡在这里等待数据 |
| 内核第1步 | 检查缓冲区 | 检查socket接收缓冲区是否有数据 |
| 内核第2步 | 无数据时睡眠 | 将进程状态设为 TASK_INTERRUPTIBLE(可中断睡眠),移出CPU运行队列,调度其他进程运行 |
| 内核第3步 | 数据到达并唤醒 | 网卡触发硬件中断 → 内核将数据拷贝到接收缓冲区 → 唤醒进程(状态改为 TASK_RUNNING),重新加入运行队列 |
char buf[1024];
recv(fd, buf, sizeof(buf), 0);
// 程序卡在这里等待数据
1.2 非阻塞IO
| 层面 | 步骤 | 说明 |
|---|---|---|
| 用户层 | 设置非阻塞 | fcntl(fd, F_SETFL, O_NONBLOCK) 设置非阻塞标志 |
| 用户层 | 循环轮询 | while循环中调用 recv(),若返回值 ≥0 则数据就绪退出循环 |
| 用户层 | 错误处理 | 若返回 -1 且 errno != EAGAIN,则退出程序 |
| 用户层 | 避免占满CPU | sleep(1000) 短暂休眠,防止忙等待占用CPU |
| 内核第1步 | 检查缓冲区 | 检查接收缓冲区,无论是否有数据都立即返回结果 |
| 内核第2步 | 无数据时 | 返回 EAGAIN 错误码,进程继续执行(不进入睡眠) |
fcntl(fd, F_SETFL, O_NONBLOCK); // 设置非阻塞标志
while (1) {
int n = recv(fd, buf, sizeof(buf), 0);
if (n >= 0) break;// 数据就绪
if (errno!= EAGAIN) exit(1); // 错误处理
sleep(1000);
// 避免CPU占满
}
会造成的问题
| 术语 | 定义 |
|---|---|
| CPU空转 | 进程占用CPU不断循环检查条件是否满足,期间不执行有效工作,也不主动让出CPU。 |
| 内核轮询 | 内核主动遍历检查多个文件描述符的状态(是否可读、可写、异常),并返回就绪结果给用户程序。 |
1.3 fcntl函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <fcntl.h>#include <unistd.h> |
| 函数原型 | int fcntl(int fd, int cmd, ... /* arg */); |
| 作用 | 对文件描述符进行各种控制操作 |
| fd | 要操作的文件描述符 |
| cmd | 控制命令(如 F_SETFL 设置状态标志、F_GETFL 获取状态标志) |
| arg | 可选参数,int类型;取决于 cmd 的具体命令(获取则不需要,设置就需要) |
| 成功返回值 | 取决于 cmd 命令(如 F_GETFL 返回文件状态标志) |
| 失败返回值 | 返回 -1(并重置错误码) |
| cmd 命令 | 命令含义 | arg 常见值 | arg 常见值含义 |
|---|---|---|---|
| F_DUPFD | 复制文件描述符 | 0 |
指定新描述符的最小可用值(如0表示取最小的未用描述符) |
| F_SETFD | 设置文件描述符标志(close-on-exec) | FD_CLOEXEC |
执行新程序时自动关闭该描述符 |
0 |
不清除 close-on-exec 标志(默认行为) | ||
| F_SETFL | 设置文件状态标志(读写、非阻塞等) | O_NONBLOCK |
设置非阻塞模式 |
O_APPEND |
设置追加模式(每次写入末尾) | ||
O_SYNC |
设置同步写入(等待数据落盘) | ||
| F_SETOWN | 设置接收 SIGIO/SIGURG 信号的进程/进程组 | 正整数 |
接收信号的进程ID(PID) |
负整数 |
接收信号的进程组ID(绝对值) | ||
| F_SETLK | 设置记录锁(非阻塞) | 指向锁结构体的指针 | 设置读锁或写锁,冲突时立即返回错误 |
| F_SETLKW | 设置记录锁(阻塞) | 指向锁结构体的指针 | 同上,但冲突时会阻塞等待直到锁可用 |
F_GETFD、F_GETFL、F_GETOWN 等获取类命令不需要 arg
二、IO多路复用
2.1 IO多路复用
| 项目 | 内容 |
|---|---|
| 定义 | 单线程或单进程管理多个文件描述符(如套接字)的技术 |
| 核心机制 | 通过系统调用(如select、poll、epoll)监视多个IO操作的状态 |
| 通知方式 | 当某个IO操作就绪(可读、可写或发生异常)时,通知应用程序进行处理 |
| 提高并发性能 | 单线程可处理大量连接,无需为每个连接创建线程/进程 |
| 高效管理大量连接 | 避免资源浪费,降低系统开销 |
| 与非阻塞IO协作 | 结合使用可实现更高效的异步处理模式 |

2.1.1 三种方式对比
| 对比项 | 阻塞 I/O | 非阻塞 I/O | I/O 多路复用 |
|---|---|---|---|
| 含义 | 没数据就等着,直到有数据才返回 | 没数据立刻返回,告诉你“还没好” | 同时等多个,哪个好了就处理哪个 |
| 进程状态 | 阻塞(不占 CPU) | 不阻塞,立即返回 | 阻塞在 select/poll/epoll 上 |
| 处理多个连接 | 需要多线程/多进程 | 单进程可以轮询,但浪费 CPU | 单进程/单线程就能处理大量连接 |
| 代码复杂度 | 简单 | 较复杂(需处理 EAGAIN) | 中等 |
| 典型场景 | 简单客户端/服务端 | 需要边等数据边做其他事 | 高并发服务器(如 Web 服务器) |
2.2 select函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <sys/socket.h>#include <sys/types.h>#include <sys/select.h> |
| 函数原型 | int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); |
| nfds | 要监视的最大文件描述符 + 1(遍历并检查从 0 到该最大值之间的所有文件描述符) |
| readfds | 要监视的读文件描述符集合,不关心则传 NULL |
| writefds | 要监视的写文件描述符集合,不关心则传 NULL |
| exceptfds | 要监视的异常文件描述符集合,不关心则传 NULL |
| timeout | 超时时间,NULL 表示永久阻塞 |
| 成功返回值 | 返回就绪的文件描述符个数 |
| 失败返回值 | 返回 -1(并重置错误码) |
| 作用 | 同时监视多个文件描述符,等待其中任意一个变为“可读”、“可写”或发生“异常”;避免程序阻塞在单个描述符上,实现 I/O 多路复用与超时控制。 |
| 内核做的事 | ① 将用户态的 fd_set 拷贝到内核态;② 遍历 0 到 nfds-1 的所有文件描述符,检查其对应的事件是否就绪;③ 若没有描述符就绪,则让进程休眠(阻塞),直到:有事件发生 / 超时时间到 / 被信号中断; ④ 当有事件发生时,唤醒进程,再次遍历所有描述符,将就绪(可读)的描述符保留在集合中,未就绪的清除; ⑤ 将修改后的 fd_set 拷贝回用户态,并返回就绪个数。 |
把用户态的内存拷贝到内核态会造成内存开销大(时间复杂度O(n))
select() 返回后,传入的 fd_set 集合会被内核修改(只保留就绪的描述符,未就绪的被清除)
如果要在下一次循环中重新监视相同的描述符,必须重新注册(重新 FD_ZERO + FD_SET 或恢复原始集合)
2.3 poll函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <poll.h> |
| 函数原型 | int poll(struct pollfd *fds, nfds_t nfds, int timeout); |
| fds | 指向 pollfd 结构体数组的指针,描述要监视的文件描述符及其事件 |
| nfds | fds 数组中有效描述符元素的数量 |
| timeout | 等待时间,单位为毫秒(-1 为阻塞,0 立即返回) |
| 成功返回值 | 返回结构体中 revents 域不为 0 的文件描述符个数 |
| 超时返回值 | 超时前没有任何事件发生,返回 0 |
| 失败返回值 | 返回 -1(并重置错误码) |
| 作用 | 同时监视多个文件描述符(类似 select),等待其中的一个或多个变为可读、可写或发生异常;相比 select,poll 没有文件描述符数量上限(理论上仅受系统资源限制),且采用数组管理描述符,效率更高。 |
| 内核做的事 | ① 将用户态的 pollfd 数组拷贝到内核态;② 内核遍历数组中的每个文件描述符,检查其对应的事件是否就绪; ③ 若没有描述符就绪,则让进程休眠(阻塞),直到:有事件发生 / 超时时间到 / 被信号中断; ④ 当有事件发生时,唤醒进程,再次遍历所有描述符,将就绪的事件回填到 revents 字段,未就绪的 revents 置 0;⑤ 将修改后的 pollfd 数组拷贝回用户态,并返回就绪个数 |
使用的是链表结构
2.3.1 pollfd 结构体
struct pollfd {
int fd; // 文件描述符
short events; // 等待的事件
short revents; // 实际发生的事件
};
| 事件分类 | 常值 | 说明 |
|---|---|---|
| 读事件 | POLLIN |
普通或优先带数据可读 |
| 读事件 | POLLPRI |
高优先级数据可读 |
| 写事件 | POLLOUT |
普通或优先带数据可写 |
| 写事件 | POLLWRNORM |
普通数据可写 |
| 错误事件 | POLLERR |
发生错误 |
| 错误事件 | POLLHUP |
发生挂起 |
| 错误事件 | POLLNVAL |
文件描述符没有打开(无效描述符) |
2.4 select与poll对比
| 类别 | 项目 | |
|---|---|---|
| 相同点 | 机制类型 | 都是 I/O 多路复用机制 |
| 遍历方式 | 需应用程序主动遍历描述符找出就绪项 | |
| 内核检查方式 | 每次调用内核都遍历检查所有描述符 | |
| 大规模效率 | 描述符数量大时效率下降 |
| 不同点 | select | poll |
|---|---|---|
| 描述符数量限制 | 有硬限制,通常最大 1024 | 无上限,仅受系统资源限制 |
| 数据结构 | 固定大小的位图(fd_set) | 动态数组(struct pollfd *) |
| 内核是否修改集合 | 会修改,每次需重新设置 | 不修改,无需重新初始化 |
| 事件类型丰富度 | 较少(仅读、写、异常) | 更丰富(可区分普通数据和高优先级数据) |
| 接口易用性 | 需计算最大描述符值传入 | 无需计算最大描述符值 |
三、并发服务器
3.1 服务器模型
| 项目 | 循环服务器 | 并发服务器 |
|---|---|---|
| 特点 | 同一时刻只能响应一个客户端的请求 | 同一时刻可以响应多个客户端的请求 |
| TCP服务器 | 默认是循环服务器(因为 accept 和 recv 两个阻塞函数相互影响) | 可通过多线程/多进程实现 |
| UDP服务器 | - | 默认是并发服务器(只有一个阻塞函数 recvfrom) |
3.1.1 strcat函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <string.h> |
| 函数原型 | char *strcat(char *dest, const char *src); |
| dest | 目标字符串数组(必须有足够空间容纳拼接后的结果) |
| src | 源字符串(要追加到目标字符串末尾的内容) |
| 返回值 | 返回 dest 的指针(即拼接后目标字符串的首地址) |
| 作用 | 将源字符串 src 的内容追加到目标字符串 dest 的末尾,并自动添加结束符 \0 |
| 注意事项 | ① 必须确保 dest 有足够空间(否则会缓冲区溢出);② 目标字符串原有的结束符 \0 会被覆盖,新字符串末尾会加上新的 \0;③ dest 和 src 不能重叠(C99 标准未定义重叠行为)。 |
3.2 多线程并发服务器
| 角色 | 步骤 | 说明 |
|---|---|---|
| 主线程 | 创建监听Socket | socket() → bind() → listen() |
| (客户端) | 循环接收连接 | while(1) { accept() 接收新连接 } |
| 创建子线程 | 接收到新连接后,创建子线程处理该连接 | |
| 子线程 | 处理请求 | recv()/send() 处理客户端请求 |
| (服务端) | 关闭连接 | close() 关闭连接 |
主从结构
3.3 多进程并发服务器
3.3.1 fork多进程并发服务器实现原理
| 模式 | 角色 | 步骤 | 说明 |
|---|---|---|---|
| fork模型 | 主进程 | 创建监听Socket → bind() → listen() | 主进程负责监听连接 |
| fork模型 | 主进程 | while(1) { accept() → fork() } | 接收新连接后创建子进程处理 |
| fork模型 | 子进程 | close(监听Socket) | 关闭继承自父进程的监听Socket |
| fork模型 | 子进程 | recv()/send() → exit() | 处理客户端请求后退出 |
| fork模型 | 特点 | 父子进程完全独立,崩溃互不影响 | - |
| 预fork优化 | 启动时 | 预先创建多个子进程 | 类似Apache架构 |
| 预fork优化 | 负载均衡 | 通过共享监听socket(SO_REUSEPORT)实现 | - |
3.3.2 signal函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <signal.h> |
| 函数原型 | void (*signal(int signum, void (*handler)(int)))(int); |
| signum | 要处理的信号编号(如 SIGINT、SIGTERM、SIGCHLD 等) |
| handler | 信号处理方式: ① SIG_IGN:忽略该信号② SIG_DFL:恢复默认处理③ 自定义函数指针: void func(int sig) |
| 返回值 | 成功:返回该信号之前的处理函数指针(或 SIG_IGN/SIG_DFL)失败:返回 SIG_ERR(并设置错误码) |
| 作用 | 设置某个信号的处理方式,即当进程收到 signum 信号时,执行 handler 指定的动作。 |
| 注意事项 | ① 部分信号(如 SIGKILL、SIGSTOP)不能被捕获或忽略;② 自定义处理函数执行期间,该信号通常会被自动阻塞(具体依赖系统实现); ③ signal() 在不同 Unix 系统上行为可能有差异,推荐使用更安全的 sigaction();④ 信号处理函数中应只调用异步信号安全的函数(如 write(),避免 printf())。 |
3.3.3 kill函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <signal.h>#include <sys/types.h> |
| 函数原型 | int kill(pid_t pid, int sig); |
| pid | 目标进程ID: ① pid > 0:发送给指定PID的进程② pid = 0:发送给同一进程组的所有进程③ pid = -1:发送给所有有权限发送的进程(除init和自身)④ pid < -1:发送给指定进程组(PID = |pid|)的所有进程 |
| sig | 要发送的信号编号(如 SIGINT、SIGUSR1、SIGKILL 等)若 sig = 0,则不发送信号,仅检查进程是否存在 |
| 返回值 | 成功:返回 0失败:返回 -1(并设置错误码,如 ESRCH 进程不存在、EPERM 无权限) |
| 作用 | 向指定进程或进程组发送信号。 |
| 注意事项 | ① 发送信号需要拥有目标进程的权限(通常是相同用户或root); ② sig = 0 常用于检查进程是否存在;③ SIGKILL 和 SIGSTOP 不能被捕获、忽略或阻塞;④ 进程可以 kill(getpid(), sig) 向自身发送信号。 |
3.4 IO多路复用并发服务器
| 角色 | 步骤 | 说明 |
|---|---|---|
| 主线程 | 第1步 | 创建监听Socket → bind() → listen() |
| 主线程 | 第2步 | 初始化 fd_set 集合,将监听Socket加入集合 |
| 主线程 | 第3步 | while(1) 循环调用 select() 监听所有fd |
| 主线程 | 第4步 | select() 返回就绪的fd数量 |
| 主线程 | 第5步 | 遍历每个就绪的fd |
| 主线程 | 判断分支 | 如果是监听Socket → accept() 新连接并加入 fd_set |
| 主线程 | 判断分支 | 如果是普通Socket → recv()/send() 处理数据 |
流程
| 步骤 | 作用 |
|---|---|
| 1. 创建监听套接字 | 获得一个用于监听客户端连接的描述符 |
| 2. 初始化集合并加入监听套接字 | 告诉 select 要监视监听套接字的读事件(新连接) |
| 3. 设置最大描述符 | 让 select 知道需要遍历的范围(从0到该值) |
| 4. 调用 select 阻塞等待 | 内核监视所有描述符,直到有事件发生才返回 |
| 5. 遍历所有描述符 | 找出哪些描述符已就绪(有事件需要处理) |
| 6. 处理新连接 | 接受客户端,将新套接字加入监视集合 |
| 7. 处理客户端数据 | 接收数据,回显,若断开则移除并关闭套接字 |
| 8. 循环 | 重复步骤4~7,持续处理新的请求和数据 |
3.4.1 FD_ZERO函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <sys/select.h> |
| 函数原型 | void FD_ZERO(fd_set *set); |
| set | 指向 fd_set 类型集合的指针(要清零的文件描述符集合) |
| 返回值 | 无返回值(void) |
| 作用 | 将文件描述符集合 set 全部清零(初始化为空集合,不包含任何文件描述符)。 |
| 注意事项 | ① 在使用 FD_SET()、FD_CLR()、FD_ISSET() 之前,必须先调用 FD_ZERO() 初始化集合;② FD_ZERO 是一个宏,不是真正的函数;③ fd_set 通常有大小限制(如 FD_SETSIZE,一般为 1024),超出范围可能导致未定义行为。 |
3.4.2 FD_SET函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <sys/select.h> |
| 函数原型 | void FD_SET(int fd, fd_set *set); |
| fd | 要添加到集合中的文件描述符(非负整数) |
| set | 指向 fd_set 类型集合的指针(要添加的目标集合) |
| 返回值 | 无返回值(void) |
| 作用 | 将文件描述符 fd 添加到集合 set 中,使其被 select() 监视。 |
| 注意事项 | ① FD_SET 是一个宏,不是真正的函数;② fd 必须小于 FD_SETSIZE(通常为 1024),否则可能导致数组越界;③ 在调用 select() 之前,需要先通过 FD_ZERO() 初始化集合,再用 FD_SET() 添加要监视的描述符;④ select() 返回后,集合会被修改,通常需要重新调用 FD_ZERO() 和 FD_SET() 重新设置。 |
3.4.3 FD_ISSET函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <sys/select.h> |
| 函数原型 | int FD_ISSET(int fd, fd_set *set); |
| fd | 要检查的文件描述符 |
| set | 指向 fd_set 类型集合的指针 |
| 返回值 | ① 非 0(真):fd 在集合中(对应位为 1)② 0(假):fd 不在集合中(对应位为 0) |
| 作用 | 测试文件描述符 fd 是否仍在集合 set 中。通常用于 select() 返回后,判断哪个描述符已就绪。 |
| 注意事项 | ① FD_ISSET 是一个宏,不是真正的函数;② select() 返回时会修改集合(未就绪的描述符会被清除),FD_ISSET 用于检查描述符是否仍然被设置;③ 使用时传入的 set 通常是 select() 处理后的集合,而不是原始的 fd_set;④ 必须先用 FD_ZERO() 和 FD_SET() 初始化集合,再调用 select(),最后用 FD_ISSET() 判断。 |
3.4.4 FD_CLR函数
| 项目 | 内容 |
|---|---|
| 头文件 | #include <sys/select.h> |
| 函数原型 | void FD_CLR(int fd, fd_set *set); |
| fd | 要从集合中移除的文件描述符 |
| set | 指向 fd_set 类型集合的指针 |
| 返回值 | 无返回值(void) |
| 作用 | 将文件描述符 fd 从集合 set 中删除(将对应的位设置为 0),使其不再被 select() 监视。 |
| 注意事项 | ① FD_CLR 是一个宏,不是真正的函数;② 常用于客户端断开连接时,将该客户端的套接字从读/写集合中移除; ③ 如果 fd 原本就不在集合中,调用 FD_CLR 没有影响(不会报错);④ 使用前需确保 fd 有效(非负且小于 FD_SETSIZE)。 |
3.5 三种模型对比
| 模型 | 定义 | 适用场景 |
|---|---|---|
| 多进程 | 每个客户端 fork 一个子进程处理 |
连接数少,对稳定性要求高 |
| 多线程 | 每个客户端创建一个线程处理 | 中等连接数 |
| I/O 多路复用 | 单线程用 select/poll/epoll 监视多个连接 |
高并发服务器(如 Web、聊天室) 数据库连接池等。 |
| 模型 | 优点 | 缺点 |
|---|---|---|
| 多进程 | 隔离性好,一个崩溃不影响其他 | 开销大,进程数有限 |
| 多线程 | 资源开销小,线程间通信方便 | 需处理同步(加锁),一个崩可能全崩 |
| I/O 多路复用 | 支持海量连接,资源占用少 | 编程相对复杂 |
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)