《UNIX网络编程卷1:套接字联网API》第6章 I/O复用:select和poll函数
6.1 I/O复用的核心价值与适用场景
I/O复用是高并发网络编程的基石,允许单个进程/线程同时监控多个文件描述符(套接字)的状态变化,从而高效处理多个客户端请求。其核心价值在于:
- 资源高效利用:避免为每个连接创建独立线程/进程的内存与调度开销;
- 事件驱动模型:仅在数据可读/可写时触发处理逻辑,减少空转;
- 实时性保障:及时响应多个连接的并发事件。
典型应用场景:
- Web服务器(如Nginx):处理数千并发HTTP连接;
- 实时通信系统(如IM):同时管理多个客户端的长连接;
- 嵌入式网关:资源受限设备中处理多路传感器数据。
6.2 select函数深度解析
6.2.1 select函数原型与参数
#include <sys/select.h>
int select(int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict exceptfds,
struct timeval *restrict timeout);
参数详解:
- nfds:需监控的最大文件描述符+1(优化内核遍历效率);
- readfds/writefds/exceptfds:分别监控可读、可写、异常事件的文件描述符集合;
- timeout:超时时间(NULL为阻塞,0为非阻塞,>0为限时等待)。
返回值:
- >0:就绪的文件描述符总数;
- 0:超时无事件;
- -1:错误(如被信号中断)。
6.2.2 文件描述符集合操作宏
void FD_ZERO(fd_set *set); // 清空集合
void FD_SET(int fd, fd_set *set);// 添加描述符到集合
void FD_CLR(int fd, fd_set *set);// 从集合移除描述符
int FD_ISSET(int fd, fd_set *set);// 检查描述符是否就绪
6.2.3 select工作流程
- 初始化集合:使用
FD_ZERO
和FD_SET
设置需监控的描述符; - 调用select:阻塞或等待事件发生;
- 遍历检查:通过
FD_ISSET
轮询所有描述符,处理就绪事件; - 重置集合:select会修改传入的集合,需在每次调用前重新初始化。
代码示例:基于select的TCP服务器框架
fd_set read_set, all_set;
int maxfd = listenfd; // 监听套接字
FD_ZERO(&all_set);
FD_SET(listenfd, &all_set);
for (;;) {
read_set = all_set; // 每次select调用前重置集合
int nready = select(maxfd + 1, &read_set, NULL, NULL, NULL);
if (FD_ISSET(listenfd, &read_set)) { // 新连接到达
int connfd = Accept(listenfd, NULL, NULL);
FD_SET(connfd, &all_set);
maxfd = (connfd > maxfd) ? connfd : maxfd;
}
for (int fd = listenfd + 1; fd <= maxfd; fd++) { // 检查客户端连接
if (FD_ISSET(fd, &read_set)) {
// 处理客户端请求(如read/write)
}
}
}
6.2.4 select的局限性
- 文件描述符上限:
FD_SETSIZE
(通常1024)限制最大监控数量; - 线性扫描效率低:每次需遍历所有描述符,时间复杂度O(n);
- 内核态内存复制:每次调用需将集合从用户态复制到内核态;
- 无法动态扩展:无法在运行中添加/移除描述符,需重新初始化集合。
6.3 poll函数:改进的I/O复用机制
6.3.1 poll函数原型与参数
#include <poll.h>
int poll(struct pollfd *fds,
nfds_t nfds,
int timeout);
参数解析:
- fds:指向
pollfd
结构数组,每个元素描述一个监控的描述符; - nfds:数组长度(即监控的描述符数量);
- timeout:超时时间(毫秒,-1为阻塞,0为非阻塞)。
pollfd结构体:
struct pollfd {
int fd; // 文件描述符
short events; // 监控的事件(POLLIN、POLLOUT等)
short revents; // 返回的事件(由内核填充)
};
6.3.2 poll事件标志
- POLLIN:数据可读(包括TCP连接断开时的EOF);
- POLLPRI:紧急数据可读(如TCP带外数据);
- POLLOUT:数据可写;
- POLLERR:错误发生(自动监控,无需设置);
- POLLHUP:连接挂起(如对端关闭)。
6.3.3 poll工作流程
- 初始化pollfd数组:设置每个元素的
fd
和events
; - 调用poll:等待事件发生;
- 遍历检查revents:处理就绪的描述符;
- 动态维护数组:可动态添加或移除描述符。
代码示例:基于poll的TCP服务器框架
#define MAX_CLIENTS 1024
struct pollfd client_fds[MAX_CLIENTS];
int nfds = 1; // 初始只有监听套接字
client_fds[0].fd = listenfd;
client_fds[0].events = POLLIN;
for (;;) {
int nready = poll(client_fds, nfds, -1);
if (client_fds[0].revents & POLLIN) { // 新连接到达
int connfd = Accept(listenfd, NULL, NULL);
client_fds[nfds].fd = connfd;
client_fds[nfds].events = POLLIN;
nfds++;
}
for (int i = 1; i < nfds; i++) { // 遍历客户端连接
if (client_fds[i].revents & POLLIN) {
// 处理客户端请求
if (read返回0) { // 客户端关闭连接
Close(client_fds[i].fd);
client_fds[i] = client_fds[nfds-1]; // 数组末尾元素覆盖当前
nfds--;
i--; // 重新检查当前位置
}
}
}
}
6.3.4 poll的优势与不足
优势:
- 无描述符数量限制:仅受系统资源约束;
- 动态管理:可随时添加/移除监控的描述符;
- 更精细的事件控制:支持更多事件类型(如带外数据)。
不足:
- 仍为线性扫描:时间复杂度O(n);
- 水平触发模式:未处理事件会持续通知(可能引起忙等)。
6.4 select与poll对比分析
特性 | select | poll |
---|---|---|
描述符上限 | FD_SETSIZE(通常1024) | 仅受系统限制 |
事件类型 | 仅读、写、异常 | 支持更多事件(如POLLPRI) |
效率 | 低(O(n)遍历) | 低(O(n)遍历) |
内存拷贝 | 每次调用需复制整个集合 | 仅传递数组指针 |
可扩展性 | 静态集合,需重新初始化 | 动态数组,可随时修改 |
跨平台兼容性 | 所有UNIX系统 | 多数UNIX系统(Linux、BSD) |
选型建议:
- 嵌入式系统:优先poll(动态管理更灵活);
- 高并发场景:推荐epoll(第7章详解);
- 跨平台需求:select兼容性更好。
6.5 性能优化与陷阱规避
6.5.1 select性能优化
- 减少nfds值:仅传递当前最大描述符+1;
- 分离监控集合:将频繁活动的描述符单独监控;
- 避免阻塞操作:在事件处理函数中使用非阻塞I/O。
6.5.2 poll常见陷阱
- 未重置revents:每次调用poll前需清空revents或重新初始化结构体;
- 数组越界:动态添加描述符时需检查数组上限;
- 忽略POLLHUP:未处理可能导致死循环。
代码示例:非阻塞read处理
// 设置套接字为非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据
} else if (n == 0) {
// 对端关闭连接
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据未就绪,继续监控
} else {
// 其他错误处理
}
6.6 实战:基于select的聊天服务器
6.6.1 功能需求
- 支持多客户端连接;
- 客户端消息广播至所有其他客户端;
- 客户端退出时自动清理资源。
6.6.2 核心代码实现
#include "unp.h"
#define MAX_CLIENTS 1024
int client_fds[MAX_CLIENTS];
void broadcast(int sender_fd, char *msg, int len) {
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] != -1 && client_fds[i] != sender_fd) {
Writen(client_fds[i], msg, len);
}
}
}
int main() {
int listenfd = Socket(AF_INET, SOCK_STREAM, 0);
// 绑定与监听(代码同前)
fd_set all_set, read_set;
FD_ZERO(&all_set);
FD_SET(listenfd, &all_set);
int maxfd = listenfd;
memset(client_fds, -1, sizeof(client_fds));
for (;;) {
read_set = all_set;
int nready = select(maxfd + 1, &read_set, NULL, NULL, NULL);
if (FD_ISSET(listenfd, &read_set)) {
int connfd = Accept(listenfd, NULL, NULL);
// 将新连接加入数组
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == -1) {
client_fds[i] = connfd;
FD_SET(connfd, &all_set);
maxfd = (connfd > maxfd) ? connfd : maxfd;
break;
}
}
}
for (int i = 0; i < MAX_CLIENTS; i++) {
int fd = client_fds[i];
if (fd == -1) continue;
if (FD_ISSET(fd, &read_set)) {
char buf[1024];
ssize_t n = Read(fd, buf, sizeof(buf));
if (n > 0) {
broadcast(fd, buf, n);
} else {
Close(fd);
FD_CLR(fd, &all_set);
client_fds[i] = -1;
}
}
}
}
}
6.7 调试工具与性能分析
6.7.1 使用strace跟踪系统调用
strace -e select,poll,read,write ./server
输出示例:
select(5, [3 4], NULL, NULL, NULL) = 1 (in [3])
read(3, "hello", 1024) = 5
6.7.2 监控文件描述符状态
# 查看进程打开的文件描述符
ls -l /proc/<pid>/fd
6.7.3 性能压测工具
# 使用netcat模拟多客户端
for i in {1..1000}; do
nc 127.0.0.1 9999 &
done
6.8 本章小结与进阶习题
小结:本章深入解析了select和poll的原理、使用场景与优化技巧,通过实战案例展示了高并发服务器的实现方法。
习题:
- 实现基于poll的聊天服务器,支持昵称注册与私聊功能;
- 对比select与poll在1000并发连接下的CPU使用率差异;
- 扩展select服务器支持可写事件监控,实现大文件传输。
说明
- Linux下更详细select函数内容,可参见博主相关博文Linux下select使用
- 嵌入式下如何高效使用select与epoll,可参见博主相关专题博文在嵌入式Linux中实现高并发TCP服务器:从select到epoll的演进与实战
- Linux下更详细epoll函数内容,可参见博主相关博文Linux下epoll函数使用详解
付费用户专属资源:
- 完整聊天服务器代码工程(含Makefile);
- select/poll性能对比测试报告;
- 扩展阅读:《从select到epoll:Linux I/O模型的演进》。
通过本章学习,读者将掌握I/O复用的核心技术,并能够开发高并发的网络应用。