1. TCP 通信程序基础流程
TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。编写 TCP 通信程序需要分别实现服务端和客户端。
1.1 TCP 服务端流程
TCP 服务端的主要职责是监听客户端连接请求,并为每个客户端创建独立的通信通道。基本流程如下:
- 创建套接字:使用
socket()函数创建监听套接字 - 绑定地址:使用
bind()函数将套接字与本地 IP 地址和端口绑定 - 开始监听:使用
listen()函数将套接字设置为监听状态 - 获取新连接:使用
accept()函数接受客户端连接请求,返回新的通信套接字 - 收发数据:通过新获取的通信套接字与客户端进行数据收发(
recv()/send()) - 关闭套接字:通信结束后关闭套接字
注意:TCP 服务端会为每一个客户端创建一个新的连接套接字(newfd)与其通信。
1.2 TCP 客户端流程
TCP 客户端主动发起连接请求,与服务端建立通信。基本流程如下:
- 创建套接字:使用
socket()函数创建通信套接字 - 绑定地址:可选步骤,通常由系统自动分配
- 连接服务器:使用
connect()函数向服务端发起连接请求 - 收发数据:连接建立后与服务端进行数据交换
- 关闭套接字:通信结束后关闭套接字
2. TCP 单执行流服务端的问题
在 socket API 中,accept()获取新连接、recv()接收数据、send()发送数据默认都是阻塞接口(条件不满足就一直等待)。
阻塞接口导致单执行流无法同时和多个客户端持续通信:
- 要么只能和一个客户端持续通信(accept 后进入收发循环)
- 要么只能和一个客户端通信一次(accept 后收发一次数据)
3. 解决方案:并发服务器
并发服务器目标:同时和多个客户端通信
核心思路:为每一条客户端新连接创建独立执行流进行通信
3.1 两种基础解决方案
- 将所有接口设置为非阻塞:使用
fcntl()设置文件描述符为非阻塞模式 - 将每一个阻塞操作放到单独的执行流中(推荐)
3.2 多执行流方案
- 多进程:稳定性高、健壮性更强,但资源开销大
- 多线程:资源开销更低,线程间通信更方便
3.3 TCP 并发服务端通用流程
- 创建套接字
- 绑定地址
- 开始监听
- 循环获取新连接
- 为新连接创建独立执行流
- 执行流内部循环:接收数据 → 发送数据
- 连接断开则退出循环,关闭套接字
3.4 多进程注意事项
- 使用信号处理(SIGCHLD),避免产生僵尸进程
- 父进程创建子进程之后,关闭新连接 fd(父进程不再使用)
3.5 多线程注意事项
- 创建线程后调用
pthread_detach()分离线程,无需主线程 join 等待回收 - 主线程拿到 newfd 不能 close:线程共享进程文件描述符表,主线程关闭会导致工作线程无法使用 socket
3.6 多执行流方案的缺陷
- 不适合海量客户端场景:每一条客户端连接都要创建进程/线程
- 执行流数量过多,CPU 上下文切换开销巨大
4. IO 模型基础概念
4.1 几组关键名词区分
4.1.1 阻塞 / 非阻塞
描述接口特性:调用函数后是否立即返回
- 阻塞:条件不满足,一直等待不返回
- 非阻塞:条件不满足,直接报错立即返回
4.1.2 同步 / 异步
描述任务完成方式:操作由谁完成
- 同步:任务操作由调用者自己完成
- 异步:任务操作由其他主体完成
注意:IO 操作分为两大阶段:①等待 IO 就绪;②拷贝数据
4.2 四种经典 IO 模型
4.2.1 阻塞 IO
- 流程:发起 IO 调用 → 内核等待 IO 就绪 → 拷贝数据 → 返回
- 优点:逻辑简单,编码容易
- 缺点:阻塞等待,浪费 CPU 资源
4.2.2 非阻塞 IO
- 流程:发起 IO 调用,若未就绪直接返回错误;用户循环重试轮询
- 优点:CPU 利用率相比阻塞 IO 更高
- 缺点:需要循环轮询,代码逻辑复杂
4.2.3 信号驱动 IO
- 流程:预先注册 IO 就绪信号;IO 就绪内核发送信号通知进程;进程收到信号后发起 IO 拷贝
- 优点:相比非阻塞轮询更加实时,CPU 利用率更高
- 缺点:流程更加复杂
4.2.4 异步 IO
- 流程:发起 IO 调用立即返回;内核全权负责【等待就绪 + 数据拷贝】;全部完成后通知进程
- 优点:资源利用率最高
- 缺点:控制逻辑复杂度最高
5. 多路复用/多路转接 IO 模型
5.1 核心功能
同时监控大量文件描述符,检测哪些 fd 发生 IO 就绪事件(可读、可写、异常)。
5.2 解决原有单执行流服务器痛点
原始单执行流问题:没有新连接时accept()阻塞、没有数据时recv()阻塞,程序卡在一处,无法处理其他客户端。
多路复用思想:单执行流内统一监控所有 fd,只对已经就绪的描述符执行 IO 操作;不会被未就绪的阻塞接口卡住,单进程单线程即可实现并发服务器。
6. select 模型基础示例
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> #include <stdlib.h> #include <sys/select.h> int main() { // 1. 定义事件集合,并对集合进行初始化 fd_set rfds; FD_ZERO(&rfds); // 清空集合 // 2. 将描述符添加到集合中 FD_SET(0, &rfds); int maxfd = 0; // 最大的描述符 // 4. 开始监控 while(1) { // 3. 定义监控超时时间 struct timeval tv; tv.tv_sec = 3; // 设置3s的监控超时时间 tv.tv_usec = 0; // select(maxfd+1, rfds, wfds, efds, timeout) fd_set tmp_rfds = rfds; // 每次创建临时集合进行监控 // 因为select调用返回时,会修改集合,会移除没有就绪的描述符 int ret = select(maxfd + 1, &tmp_rfds, NULL, NULL, &tv); if (ret < 0) { perror("select error"); continue; } if (ret == 0) { printf("监控超时,没有描述符就绪\n"); continue; } // 5. 通过判断描述符是否在集合中,确定是否描述符就绪了事件 for (int i = 0; i <= maxfd; i++) { if (FD_ISSET(i, &tmp_rfds)) { // 6. 若就绪了事件,根据事件进行处理 char buffer[1024] = {0}; read(i, buffer, 1023); printf("获取可读数据: %s\n", buffer); } } } return 0; }7. select 模型优缺点总结
7.1 select 模型的优点
- 监控超时阻塞时间可以精确到微秒:select 使用
struct timeval结构体,可以精确设置秒和微秒级别的超时时间。 - 跨平台兼容性好:select 不仅仅在 Linux 下可以使用,在 Windows、macOS 以及其他 Unix-like 系统上也得到广泛支持。
7.2 select 模型的缺点
- 监控描述符数量有限:select 所能监控的描述符数量受限于
__FD_SETSIZE宏,默认值为 1024。 - 操作稍显麻烦:每次监控都需要重置超时时间和监控集合,增加了编程复杂度。
- 监控性能偏低:内核进行监控时,会涉及多次描述符集合的遍历,效率不高。
- 需要程序员再次遍历:select 监控完成后,程序员需要遍历所有描述符,通过
FD_ISSET宏判断哪些描述符就绪了事件。
7.3 select 使用场景
适用于监控描述符数量不多,甚至只有一个(仅用于进行超时控制)的场景,或者需要支持跨平台的场景。
8. poll 模型
8.1 poll 模型功能
poll 模型也是针对大量描述符进行监控的多路复用技术,与 select 类似但接口设计有所不同。
8.2 头文件与函数原型
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout);8.3 参数说明
fds:pollfd 结构体数组
struct pollfd { int fd; // 要监控的描述符 short events; // 该描述符需要监控的事件 short revents; // 监控返回时设置的实际就绪事件 };事件宏:POLLIN-可读,POLLOUT-可写;异常事件:POLLHUP 挂断,POLLERR 出错,POLLNVAL 无效请求。
nfds:fd 数组的有效元素数量
timeout:监控阻塞超时时间(毫秒)
- -1:阻塞直到有就绪
- 0:非阻塞
- >0:指定的阻塞时间
返回值:<0 出错;==0 超时;>0 实际就绪的描述符数量。
8.4 poll 操作流程
- 定义描述符事件结构体数组(数组的大小根据实际场景确定)
- 将需要监控的描述符以及所要监控的事件,添加到数组中
- 发起 poll 监控调用
- 监控原理与 select 并无差别
- 将数组数据,拷贝到内核
- 对所有的有效元素进行遍历,判断是否有就绪
- 有就绪则直接返回,并且设置对应元素的 revents 成员
- 没有就绪的,就阻塞进程
- 当有描述符就绪的时候,唤醒进程阻塞,再次遍历数组,重置 revents 成员(设置为实际就绪的事件标志位)
- 调用返回后,遍历数组,根据数组中 revents 确定实际就绪的事件进行 IO 操作
8.5 poll 优缺点总结
8.5.1 优点
- 相较于 select 在操作上有所简化,不需要定义多个事件集合,也不需要每次重置集合
- 所能监控的描述符数量,是没有限制的
8.5.2 缺点
- poll 的监控实现原理与流程和 select 并无差别,因为监控时存在多次遍历,因此性能相对较低
- 每次监控完毕,同样需要遍历数组,找出就绪的描述符与事件才能进行操作,操作上麻烦,效率上也低(可能存在大量空轮询)
8.6 poll 使用场景
与 select 雷同,描述符数量较少,或者只有一个(进行超时管理)的场景。
8.7 额外知识点:SIGPIPE 信号处理
网络通信程序编写时都要先忽略 SIGPIPE 信号。当 TCP 对端关闭了连接,或者关闭了读操作,继续 send 写入数据就会触发 SIGPIPE 异常,导致程序异常退出。
signal(SIGPIPE, SIG_IGN);注意:TCP 连接断开后,继续 recv 就会返回 0 不再阻塞;继续 send 就会触发 SIGPIPE 异常。
9. epoll 模型
9.1 epoll 操作流程与原理
创建 epoll 实例:通过epoll_create在内核中创建 eventpoll 结构
struct eventpoll { spinlock_t lock; struct mutex mtx; wait_queue_head_t wq; wait_queue_head_t poll_wait; struct list_head rdlist; // 就绪链表 struct rb_root rbr; // 红黑树根节点 // ... };9.2 epoll 核心 API
9.2.1 epoll_create / epoll_create1
int epoll_create(int size); int epoll_create1(int flags);功能:在内核创建eventpoll对象,并返回文件描述符作为 epoll 操作句柄。
epoll_create的size:早期版本用于提示最大监控 fd 数量;Linux 2.6 之后仅作为提示,传大于 0 数值即可。epoll_create1常用标志:EPOLL_CLOEXEC:执行 exec 创建新程序时,自动关闭 epoll 句柄,防止句柄泄露。
9.2.2 epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* ev);功能:向内核 eventpoll 结构添加 / 修改 / 删除 fd 监控。
参数:
epfd:epoll_create返回句柄op:操作类型EPOLL_CTL_ADD:新增 fd 监控EPOLL_CTL_MOD:修改已有 fd 监控事件EPOLL_CTL_DEL:移除 fd 监控
fd:目标待监控文件描述符ev:事件结构体
struct epoll_event { uint32_t events; // 监控事件:EPOLLIN / EPOLLOUT __u64 data; // 联合体 union { int fd; void *ptr; } data; };使用示范:
struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listen_fd;9.2.3 epoll_wait
int epoll_wait(int epfd, struct epoll_event* evs, int maxevents, int timeout);功能:阻塞等待,获取就绪 fd 对应的事件结构。
参数:
epfd:epoll 句柄evs:用户定义的 epoll_event 数组,内核把就绪事件填入该数组maxevents:数组最大长度,防止越界timeout:超时时间(毫秒)-1:永久阻塞0:立即返回,非阻塞>0:指定阻塞毫秒
返回值:<0出错;≥0返回就绪 fd 数量。
9.3 epoll 操作流程与内核原理
epoll_create在内核生成eventpoll结构体。- 定义
struct epoll_event,设置监控事件与 fd。 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev)添加监控。内核核心机制:为 fd 注册回调函数;fd 就绪时,自动将 epoll_event 拷贝加入
rdlist就绪双向链表。epoll_wait()获取就绪事件- 内核只查看就绪链表
rdlist - 链表为空 → 进程阻塞
- 有 fd 就绪,触发回调,数据放入就绪链表,唤醒进程
- 将就绪链表内事件批量拷贝到用户数组 evs
- 内核只查看就绪链表
epoll_wait返回,遍历 evs 数组处理就绪 fd。
9.4 epoll 两种触发模式
select/poll 只支持水平触发 LT;epoll 支持 LT 默认、ET 边沿触发。
9.4.1 水平触发 LT(EPOLLLT,默认模式)
只要缓冲区存在未读完数据,每次epoll_wait都会持续上报事件。
可读事件:接收缓冲区有数据就持续触发;
可写事件:发送缓冲区存在空闲空间持续触发。
9.4.2 边沿触发 ET(EPOLLET)
仅新数据到达瞬间触发一次事件。
特点:
- 数据到达只会通知一次;缓冲区残留数据不会再次触发事件;
- 如果本次事件没有读完缓冲区全部数据,残留数据不会再次唤醒
epoll_wait; - 规范写法:必须一次性循环读完缓冲区全部数据;
- ET 模式必须搭配非阻塞 IO(
MSG_DONTWAIT),防止最后一次 recv 阻塞卡死程序。
典型代码范式:
while(1) { ret = recv(fd, buf+total, 1024, MSG_DONTWAIT); if(ret <= 0) { if(errno == EAGAIN) break; // 缓冲区无数据 } total += ret; }9.5 epoll 优缺点
优点
- 性能最优;采用事件回调机制;
- 内核维护红黑树(保存所有 fd)+ 就绪链表(仅保存就绪 fd);
epoll_wait只会返回就绪 fd,无全体 fd 遍历,不存在大量空轮询;性能不会随着监控 fd 数量上涨急剧衰减;
缺点
- Linux 专有,跨平台兼容性差;
- fd 数量极少场景下,内核维护红黑树、就绪链表会带来微小额外开销;
适用场景
大量文件描述符同时监控,但同一时刻只有少量 fd 活跃的高并发网络程序。
9.6 epoll 惊群问题
现象
父进程创建 epoll;随后 fork 多个子进程,所有子进程复制 epoll 句柄。当 fd 就绪,所有阻塞在epoll_wait的子进程同时被唤醒,造成大量无效唤醒、CPU 飙升。
解决方案
EPOLLEXCLUSIVE:epoll_ctl添加监控时设置排他唤醒;内核保证就绪事件只会唤醒其中一个进程;- 使用
SO_REUSEPORT端口复用;多个进程各自创建 listen 套接字监听同一端口; - 用户态加锁,多进程竞争锁,只有抢到锁的进程处理就绪事件。
9.7 select / poll / epoll 横向对比总结
| 特性 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | 默认 1024 | 无限制 | 无限制 |
| 内核机制 | 每次全量拷贝 fd 集合、轮询遍历 | 每次拷贝 pollfd 数组、轮询遍历 | 红黑树管理 fd + 就绪链表 |
| 回调触发方式 | 仅水平触发 LT | 仅水平触发 LT | LT(默认)/ ET 边沿触发 |
| 性能表现 | fd 越多性能下降越快 | fd 越多性能下降越快 | 大量 fd 场景性能稳定 |
| 跨平台 | 好 | 一般 | Linux 独有 |
| 使用开销 | 每次调用重建 fd 集合 | 简化于 select | 初始化稍重,高并发收益巨大 |