Linux 网络编程:TCP 通信基础、并发服务器与 IO 多路复用详解
2026/8/24 6:22:01 网站建设 项目流程

1. TCP 通信程序基础流程

TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。编写 TCP 通信程序需要分别实现服务端和客户端。

1.1 TCP 服务端流程

TCP 服务端的主要职责是监听客户端连接请求,并为每个客户端创建独立的通信通道。基本流程如下:

  1. 创建套接字:使用socket()函数创建监听套接字
  2. 绑定地址:使用bind()函数将套接字与本地 IP 地址和端口绑定
  3. 开始监听:使用listen()函数将套接字设置为监听状态
  4. 获取新连接:使用accept()函数接受客户端连接请求,返回新的通信套接字
  5. 收发数据:通过新获取的通信套接字与客户端进行数据收发(recv()/send()
  6. 关闭套接字:通信结束后关闭套接字

注意:TCP 服务端会为每一个客户端创建一个新的连接套接字(newfd)与其通信。

1.2 TCP 客户端流程

TCP 客户端主动发起连接请求,与服务端建立通信。基本流程如下:

  1. 创建套接字:使用socket()函数创建通信套接字
  2. 绑定地址:可选步骤,通常由系统自动分配
  3. 连接服务器:使用connect()函数向服务端发起连接请求
  4. 收发数据:连接建立后与服务端进行数据交换
  5. 关闭套接字:通信结束后关闭套接字

2. TCP 单执行流服务端的问题

在 socket API 中,accept()获取新连接、recv()接收数据、send()发送数据默认都是阻塞接口(条件不满足就一直等待)。

阻塞接口导致单执行流无法同时和多个客户端持续通信:

  • 要么只能和一个客户端持续通信(accept 后进入收发循环)
  • 要么只能和一个客户端通信一次(accept 后收发一次数据)

3. 解决方案:并发服务器

并发服务器目标:同时和多个客户端通信

核心思路:为每一条客户端新连接创建独立执行流进行通信

3.1 两种基础解决方案

  1. 将所有接口设置为非阻塞:使用fcntl()设置文件描述符为非阻塞模式
  2. 将每一个阻塞操作放到单独的执行流中(推荐)

3.2 多执行流方案

  • 多进程:稳定性高、健壮性更强,但资源开销大
  • 多线程:资源开销更低,线程间通信更方便

3.3 TCP 并发服务端通用流程

  1. 创建套接字
  2. 绑定地址
  3. 开始监听
  4. 循环获取新连接
  5. 为新连接创建独立执行流
  6. 执行流内部循环:接收数据 → 发送数据
  7. 连接断开则退出循环,关闭套接字

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 操作流程

  1. 定义描述符事件结构体数组(数组的大小根据实际场景确定)
  2. 将需要监控的描述符以及所要监控的事件,添加到数组中
  3. 发起 poll 监控调用
  4. 监控原理与 select 并无差别
  5. 将数组数据,拷贝到内核
  6. 对所有的有效元素进行遍历,判断是否有就绪
  7. 有就绪则直接返回,并且设置对应元素的 revents 成员
  8. 没有就绪的,就阻塞进程
  9. 当有描述符就绪的时候,唤醒进程阻塞,再次遍历数组,重置 revents 成员(设置为实际就绪的事件标志位)
  10. 调用返回后,遍历数组,根据数组中 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_createsize:早期版本用于提示最大监控 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 监控。

参数

  • epfdepoll_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 操作流程与内核原理
  1. epoll_create在内核生成eventpoll结构体。
  2. 定义struct epoll_event,设置监控事件与 fd。
  3. epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev)添加监控。

    内核核心机制:为 fd 注册回调函数;fd 就绪时,自动将 epoll_event 拷贝加入rdlist就绪双向链表。

  4. epoll_wait()获取就绪事件
    • 内核只查看就绪链表rdlist
    • 链表为空 → 进程阻塞
    • 有 fd 就绪,触发回调,数据放入就绪链表,唤醒进程
    • 将就绪链表内事件批量拷贝到用户数组 evs
  5. 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)

仅新数据到达瞬间触发一次事件。

特点

  1. 数据到达只会通知一次;缓冲区残留数据不会再次触发事件;
  2. 如果本次事件没有读完缓冲区全部数据,残留数据不会再次唤醒epoll_wait
  3. 规范写法:必须一次性循环读完缓冲区全部数据;
  4. 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 优缺点

优点

  1. 性能最优;采用事件回调机制;
  2. 内核维护红黑树(保存所有 fd)+ 就绪链表(仅保存就绪 fd);
  3. epoll_wait只会返回就绪 fd,无全体 fd 遍历,不存在大量空轮询;性能不会随着监控 fd 数量上涨急剧衰减;

缺点

  1. Linux 专有,跨平台兼容性差;
  2. fd 数量极少场景下,内核维护红黑树、就绪链表会带来微小额外开销;

适用场景

大量文件描述符同时监控,但同一时刻只有少量 fd 活跃的高并发网络程序。

9.6 epoll 惊群问题

现象

父进程创建 epoll;随后 fork 多个子进程,所有子进程复制 epoll 句柄。当 fd 就绪,所有阻塞在epoll_wait的子进程同时被唤醒,造成大量无效唤醒、CPU 飙升。

解决方案

  1. EPOLLEXCLUSIVEepoll_ctl添加监控时设置排他唤醒;内核保证就绪事件只会唤醒其中一个进程;
  2. 使用SO_REUSEPORT端口复用;多个进程各自创建 listen 套接字监听同一端口;
  3. 用户态加锁,多进程竞争锁,只有抢到锁的进程处理就绪事件。
9.7 select / poll / epoll 横向对比总结
特性selectpollepoll
fd 上限默认 1024无限制无限制
内核机制每次全量拷贝 fd 集合、轮询遍历每次拷贝 pollfd 数组、轮询遍历红黑树管理 fd + 就绪链表
回调触发方式仅水平触发 LT仅水平触发 LTLT(默认)/ ET 边沿触发
性能表现fd 越多性能下降越快fd 越多性能下降越快大量 fd 场景性能稳定
跨平台一般Linux 独有
使用开销每次调用重建 fd 集合简化于 select初始化稍重,高并发收益巨大

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询