一、阻塞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_GETFDF_GETFLF_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),等待其中的一个或多个变为可读、可写或发生异常;相比 selectpoll 没有文件描述符数量上限(理论上仅受系统资源限制),且采用数组管理描述符,效率更高。
内核做的事 ① 将用户态的 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 要处理的信号编号(如 SIGINTSIGTERMSIGCHLD 等)
handler 信号处理方式:
① SIG_IGN:忽略该信号
② SIG_DFL:恢复默认处理
③ 自定义函数指针:void func(int sig)
返回值 成功:返回该信号之前的处理函数指针(或 SIG_IGN/SIG_DFL
失败:返回 SIG_ERR(并设置错误码)
作用 设置某个信号的处理方式,即当进程收到 signum 信号时,执行 handler 指定的动作。
注意事项 ① 部分信号(如 SIGKILLSIGSTOP)不能被捕获或忽略;
② 自定义处理函数执行期间,该信号通常会被自动阻塞(具体依赖系统实现);
③ 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 要发送的信号编号(如 SIGINTSIGUSR1SIGKILL 等)
若 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 多路复用 支持海量连接,资源占用少 编程相对复杂
Logo

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

更多推荐