前言
在网络编程中,TCP 服务端如果只处理单客户端连接,实用性很差。实际项目需要支持多个客户端同时接入 ,这就引出 TCP 并发服务器。 本文基于 Linux C 网络编程,讲解多线程并发服务器模型、4 种 IO 模型,重点拆解select、epoll多路复用接口,包含原理、函数说明、优缺点对比,适合嵌入式 / Linux 后台开发同学学习。
阅读范围:TCP 并发服务器模型及其以下知识点
一、TCP 并发服务器模型之多线程
1. 核心思想
TCP 服务端accept()阻塞等待客户端连接,每接收到一个新客户端,就创建一个子线程专门负责和该客户端通信。主线程继续回去 accept 等待下一个客户端接入,以此实现多客户端并发访问服务器。
2. 优缺点
✅优点:
- 逻辑简单,上手容易;
- 线程间内存共享,数据交互方便。
❌缺点:
- 每个线程拥有独立栈空间(Linux 默认 8M),客户端并发数量很高时,创建大量线程会消耗巨大内存,系统有崩溃风险;
- 线程创建、销毁、调度存在 CPU 开销;
- 线程数量不能无限增大。
💡优化方案:实际工程一般搭配线程池,提前创建一批线程复用,避免频繁创建销毁线程。
简单流程梳理
- socket 创建监听套接字;
- bind 绑定 IP 端口;
- listen 开启监听;
- 循环
accept()获取新客户端 cfd; - pthread_create 创建子线程,把 cfd 传给子线程,子线程内完成 recv/send 数据收发;
- 主线程继续 accept 等待下一个连接。
二、四种常见 IO 模型
IO 模型解决的核心问题:程序如何等待文件描述符上可读、可写事件。
在网络编程中文件描述符就是 socket。
1. 阻塞 IO(默认)
- 调用
recv/accept等函数,没有数据到达时,线程直接挂起阻塞,不占用 CPU; - 有数据到来才返回,继续往下执行。
- ✅优点:CPU 开销小,代码简单
- ❌缺点:一个线程只能处理一个 fd,要处理多个 fd 必须开多线程 / 多进程。
2. 非阻塞 IO
把文件描述符设置为非阻塞。调用 IO 函数,如果没有就绪数据,不会阻塞,直接返回错误。 程序需要循环不断轮询,反复调用 recv/accept 查看是否有事件。
- ✅优点:单个线程可以处理多个 fd;
- ❌缺点:CPU 空转轮询,CPU 占用率很高,实际很少直接使用。
3. 信号驱动 IO
将文件描述符设置为异步 IO,内核监测 fd 事件;当 fd 可读可写,内核给用户程序发送SIGIO信号; 程序提前注册信号捕捉函数,在信号处理函数中做 IO 读写。
- ✅不用轮询,数据就绪才通知;
- ❌信号处理逻辑复杂,不适合大量文件描述符场景。
4. IO 多路复用(重点)
单个线程,同时监听大量文件描述符,当集合中任意一个 fd 发生可读 / 可写事件,函数返回,用户再去处理就绪 fd。
两大实现:select /poll/epoll,epoll 是 Linux 高性能 IO 多路复用实现。
三、select 多路复用
1. 核心 API
void FD_CLR(int fd, fd_set *set); // 从集合删除fd
int FD_ISSET(int fd, fd_set *set); // 判断fd是否还在集合(是否产生事件)
void FD_SET(int fd, fd_set *set); // 将fd加入监听集合
void FD_ZERO(fd_set *set); // 清空整个fd集合
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数解析
nfds:最大的被监听文件描述符 + 1;readfds:监听读事件的 fd 集合(可读、新连接到来都属于读事件);writefds:监听写事件 fd 集合;exceptfds:异常事件集合;timeout超时设置:NULL:永久阻塞,直到有事件发生;- 设置时间,超时后直接返回 0。
返回值
-
0:产生事件的文件描述符总个数;
- 0:时间超时,没有事件;
- -1:出错。
2. select 工作机制
- 每次调用 select 前,都要重新构建 fd_set 监听集合;
- 把用户态 fd 集合拷贝到内核;
- 内核阻塞监听,当有事件就绪,内核会修改 fd_set 集合:把没有事件的 fd 从集合清除,只保留发生事件的 fd;
- select 返回,回到用户态,循环
FD_ISSET遍历所有 fd,判断哪个 fd 发生事件,执行业务处理。
3. select 缺点
- fd 集合有最大数量限制,默认 1024;
- 每次调用 select 都要把 fd 集合从用户空间拷贝内核空间;
- 返回之后不知道具体哪个 fd 就绪,必须循环遍历全部 fd;
- 每次调用都要重新初始化 fd 集合。
四、epoll --- Linux 高性能 IO 多路复用
epoll 是 Linux 独有的,解决 select/poll 的性能痛点,高并发网络服务器(Nginx)底层就是 epoll。 epoll 分为 3 个核心函数:epoll_create、epoll_ctl、epoll_wait。
1. epoll_create
int epoll_create(int size);
- 功能:创建内核 epoll 事件监听表,返回 epoll 的文件描述符 epfd;
- 参数 size:旧版本提示内核预估事件数量,新版本已经忽略该参数;
- 返回:成功返回 epfd,失败返回‑1。
2. epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
对内核事件表做增、删、改操作。
参数:
-
epfd:epoll_create 返回的句柄;
-
op 操作类型:
EPOLL_CTL_ADD:向内核事件表添加要监听的 fd;EPOLL_CTL_MOD:修改已经注册 fd 的监听事件;EPOLL_CTL_DEL:从内核事件表删除 fd;
-
fd:要操作的目标 socket 文件描述符;
-
event:告诉内核,我要监听这个 fd 的什么事件。
struct epoll_event {
uint32_t events; // 要监听的事件
epoll_data_t data; // 用户数据,可以保存fd
};// events常用宏
EPOLLIN // fd可读事件
EPOLLOUT // fd可写事件
EPOLLET // 设置边沿触发ET模式,默认是水平触发LT
3. epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
阻塞等待事件发生,获取已经就绪的事件。
参数:
- epfd:epoll 句柄;
- events:数组,内核会把已经就绪的事件拷贝到这个数组,返回给用户;
- maxevents:数组最多能存放多少个事件;
- timeout:超时时间,单位 ms,‑1 代表永久阻塞。
返回值:
-
0:就绪事件数量;
- 0:超时;
- -1:错误。
4. LT 水平触发 VS ET 边沿触发
- LT 水平触发(默认) 只要 fd 缓冲区有数据,epoll_wait 就会反复通知用户。如果一次没有读完数据,下一次 epoll_wait 还会再次返回该事件。编程简单,但容易产生重复事件。
- ET 边沿触发 EPOLLET 只有状态发生变化瞬间才通知一次。缓冲区有剩余数据不会重复通知;
使用 ET 模式,读数据必须循环读,直到
EAGAIN,把缓冲区全部读完,否则会造成数据残留。
5. epoll 为什么性能高(对比 select)
- 没有 1024 上限,支持上万 fd 并发;
- fd 集合只需要向内核添加一次(epoll_ctl),不需要每次调用都拷贝全部 fd;
- epoll_wait 返回直接拿到就绪事件数组,不需要遍历全部 fd,只处理返回过来的就绪事件;
- 内核采用回调机制,事件就绪直接把 fd 放到就绪链表。
五、简单对比总结
表格
| IO 模型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 阻塞 IO | 代码简单,CPU 开销小 | 一个线程处理一个 fd | 简单小并发程序 |
| 非阻塞 IO | 单线程处理多个 fd | CPU 轮询空转,占用高 | 极少单独使用 |
| select | 跨平台,兼容性好 | 上限 1024,每次遍历全部 fd | 并发量不大、需要跨平台 |
| epoll | 海量并发性能好,不用遍历全部 fd | Linux 平台专有 | 高并发服务器,Nginx,后端服务 |
补充:TCP 并发开发注意点
- 多线程版本注意线程安全,共享资源加互斥锁;
- IO 多路复用要注意文件描述符泄漏,连接关闭要 close;
- ET 边沿触发模式,读写要循环直到返回 EAGAIN;
- epoll 不要忘记处理对端关闭连接事件。
结尾
以上内容整理自 Linux C 网络编程学习笔记。IO 多路复用是 Linux 后台、嵌入式网络开发核心知识点。select 适合简单跨平台,生产环境高并发优先 epoll。