「Linux 网络编程」基于 poll 的 I/O 复用服务器
0 回顾
I/O 多路复用 允许单个进程同时监视多个文件描述符(如网络套接字),当其中任何一个准备就绪时(可读、可写或发生异常),进程就能得到通知。这种机制避免了为每个连接创建线程或进程的开销。
I/O 多路复用的本质是:一个“消息通知”机制。它的核心工作不是同时处理多个 I/O,而是 高效地查看到哪个 I/O 已经准备就绪。
相比 多进程/多线程 的并发服务器的 真正并行 的实现方式,I/O 多路复用实际上采用的是 时分复用 技术,通过高效地轮询和响应大量任务的事件。
「Linux 网络编程」基于 select 的 I/O 复用服务器
1 poll 函数使用和工作原理
poll 也是一个在 Unix/Linux 系统中用于 I/O 多路复用 的系统调用。它的核心功能是允许一个进程(或线程)同时监视多个文件描述符(如网络套接字、管道等),并 检查其中哪些已经就绪,可以进行非阻塞的读、写或错误处理。
1.1 poll 函数
函数原型:
1 | |
参数说明:
fds: 指向pollfd结构体数组的指针nfds: 数组中元素的数量timeout: 超时时间(毫秒),-1 表示无限等待
返回值:
> 0: 就绪的文件描述符数量0: 超时-1: 出错
1.2 pollfd 结构体
1 | |
成员说明:
fd: 要监视的文件描述符。events: 你关心的事件(例如,POLLIN 表示数据可读,POLLOUT 表示可以写数据)。revents: 由内核返回的事件,表示哪些你关心的事件真正发生了。
常用事件标志:
POLLIN: 数据可读POLLOUT: 数据可写POLLERR: 发生错误POLLHUP: 连接挂起
代码示例:
1 | |
1.3 poll 在服务端的工作流程
-
准备文件描述符列表: 服务端首先准备一个
struct pollfd类型的数组。每个结构体包含三个主要字段:fd: 要监视的文件描述符。events: 关心的事件(例如,POLLIN表示数据可读,POLLOUT表示可以写数据)。revents: 由内核返回的事件,表示哪些你关心的事件真正发生了。
-
发起系统调用: 服务端调用
poll(fds, nfds, timeout)函数,将这个数组、数组长度和一个超时时间传递给内核。调用poll函数并 阻塞等待 有事件发生或超时。 -
内核监视与等待: 内核会挂起这个进程,直到发生以下情况之一:
- 一个或多个被监视的文件描述符 就绪(有数据可读、可以写等)。
- 超时,指定的超时时间到了。
-
返回结果:
poll调用返回,并填充每个struct pollfd中的revents字段。服务端中遍历这个pollfd数组,检查哪些文件描述符的revents被设置了相应标志,然后对这些就绪的文件描述符进行相应的 I/O 操作。
1.4 poll 相比 select 的优势
-
没有文件描述符数量限制
这是最核心的优势。
select:使用一个固定大小的位数组(fd_set)来存储文件描述符。这个数组的大小通常由FD_SETSIZE宏定义(在很多系统上,比如 1024)。这意味着select能监视的文件描述符数量 有硬性上限(例如 1024)。对于需要处理大量并发连接的高性能服务器来说,这是一个致命的缺陷。poll:使用一个由struct pollfd结构体组成的数组,这个数组由调用者分配。理论上,这个数组的大小只受系统内存和进程文件描述符上限的限制。这使得poll能够轻松处理成千上万的并发连接。
-
更高效的事件处理
-
select:它使用三个独立的位集合(readfds)。每次调用时,需要设置这集合,告诉内核你关心哪些事件。当select返回时,它会 修改传入的集合,将其置为就绪的文件描述符。因此,每次调用前,你必须 重置 这些集合(通常通过FD_ZERO和FD_SET),增加了额外的开销。 -
poll:使用一个统一的struct pollfd数组。每个结构体包含:
1
2
3
4
5struct pollfd {
int fd; // 文件描述符
short events; // 我们关心的事件(输入)
short revents; // 实际发生的事件(输出)
};events和revents是分离的。设置events来告知内核你关心什么,内核通过修改revents来返回结果。这意味着 不需要在每次调用poll时重新初始化整个数组,只需根据上一轮的结果重置revents字段(或者直接检查)即可。 -
2 基于 poll 的 I/O 复用服务器
2.1 头文件与宏定义
1 | |
参数说明:
MAX_CLIENTS: 服务器支持的最大并发客户端数BUFFER_SIZE: 数据读写缓冲区大小PORT: 服务器监听端口号
2.2 服务器 Socket 创建和地址配置
1 | |
功能说明:
-
socket(AF_INET, SOCK_STREAM, 0):创建 TCP socketAF_INET:IPv4 地址族SOCK_STREAM:面向连接的 TCP socket
-
setsockopt(SO_REUSEADDR):设置地址重用,避免"client_addr already in use"错误,允许快速重启服务器。 -
bind():将 socket 绑定到指定地址和端口INADDR_ANY:监听所有可用网络接口htons(PORT):将端口号转换为网络字节序
-
listen(server_fd, 128):开始监听连接,backlog=128
2.3 主函数设计
1 | |
流程说明:
- 初始化服务器和 poll 数据结构
- 进入主事件循环
- 调用 poll 等待事件发生
- 处理超时和错误情况
- 遍历检查所有文件描述符的事件
- 分发事件到相应的处理函数
2.4 处理新的客户端连接请求
1 | |
流程说明:
- 接受新的客户端连接
- 检查是否达到最大客户端限制
- 发送欢迎消息
- 将新客户端添加到 poll 监视列表
参数说明:
server_fd: 服务器监听套接字fds[]: poll 文件描述符数组nfds: 当前监视的文件描述符数量(指针)max_clients: 最大客户端数
2.5 处理客户端数据通信
1 | |
流程说明:
- 接收客户端数据
- 处理连接断开情况
- 处理"quit"退出命令
- 回显数据给客户端
参数说明:
client_fd: 客户端套接字描述符index: 客户端在 fds 数组中的索引buffer[]: 数据缓冲区
2.6 客户端移除
1 | |
流程说明:
- 关闭客户端连接
- 从 poll 监视列表中移除客户端
- 维护 fds 数组的紧凑性
2.7 完整实现代码
1 | |
3 BACK_LOG 和 MAX_CLIENTS 的关系
3.1 listen(server_fd, 128): 监听队列大小
1 | |
作用:控制内核中等待被 accept 的连接队列的最大长度
详细解释:
- 当客户端发起连接时,完成 TCP 三次握手后,连接进入
ESTABLISHED状态 - 但此时应用程序还没有调用
accept()获取这个连接 - 这些"已建立但未被接受"的连接存放在内核的监听队列中
backlog=128表示这个队列最多能存放 128 个这样的连接
队列满时的行为:
- 当第11个连接完成三次握手时,内核可能:
- 直接拒绝连接(返回 RST)
- 忽略 SYN 包,让客户端超时重传
- 具体行为取决于操作系统配置
3.2 MAX_CLIENTS 1024: 应用程序管理的连接数
1 | |
作用:控制应用程序已经接受并正在处理的活跃连接数量
详细解释:
- 这些是已经通过
accept()获取并添加到pollfd数组中的连接 - 应用程序需要为每个连接分配资源(内存、文件描述符等)
- 这个限制防止应用程序资源耗尽
3.3 连接建立的生命周期
1 | |
实际场景示例:
1 | |
其实,backlog 大小设置既不能太小也不能太大:
- 太小(如 10):在高并发场景下容易丢连接
- 太大:浪费内核内存,且可能掩盖性能问题
- 推荐:128-1024,根据预期并发连接数调整
4 I/O 复用服务器测试
4.1 测试客户端代码
1 | |
4.2 运行测试
编译和运行:
1 | |
测试结果:
服务端输出:
1 | |
客户端 1 输出:
1 | |
客户端 2 输出:
1 | |
客户端 3 输出:
1 | |