引言

在上一篇文章中,我们详细讲解了 select 函数的使用。select 作为最基础的 I/O 多路复用机制,虽然简单易用,但存在两个明显的局限性:

  1. 文件描述符数量限制:默认最多只能监控 1024 个描述符

  2. 每次调用需要重新构建集合fd_set 在 select 返回时会被修改,导致每次循环都需要重建

为了解决 select 的第一个限制(描述符数量上限),POSIX 标准引入了 poll 函数。poll 使用动态数组代替 fd_set 的固定位图,理论上可以监控任意数量的文件描述符。

第一部分:poll 函数基础

一、函数原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

二、核心数据结构:struct pollfd

poll 的核心是 struct pollfd 结构体,每个被监控的文件描述符对应一个结构体:

struct pollfd {
    int   fd;       // 要监控的文件描述符(-1 表示忽略此项)
    short events;   // 请求监控的事件(输入参数,由用户设置)
    short revents;  // 实际发生的事件(输出参数,由内核设置)
};

重要特性events 和 revents 是分离的!

  • events 由用户设置,不会被 poll 修改

  • revents 由内核设置,poll 返回时填充

  • 这意味着不需要每次调用前重建事件,这是比 select 更优秀的设计

三、参数详解

参数 类型 作用
fds struct pollfd* 待监控的文件描述符数组
nfds nfds_t 数组中的有效元素个数
timeout int 超时时间(毫秒)

四、返回值

返回值 含义
>0 就绪的文件描述符数量
=0 超时(没有就绪的描述符)
=-1 调用失败(可通过 errno 获取错误码)

五、timeout 参数的特殊值

含义
-1 永久阻塞,直到有描述符就绪
0 非阻塞轮询,立即返回
>0 等待指定毫秒数后超时

六、与 select 超时设置的对比

// select 使用 timeval 结构体(秒 + 微秒)
struct timeval tv;
tv.tv_sec = 5;       // 5秒
tv.tv_usec = 500000; // 500000微秒 = 0.5秒
// 总超时:5.5秒

// poll 使用整数(毫秒)
int timeout = 5500;  // 5500毫秒 = 5.5秒

poll 的超时设置更加简洁,直接用毫秒整数表示,避免了 select 需要设置两个字段的麻烦。


第二部分:事件类型详解

一、常用事件类型

poll 定义了一系列事件标志,通过位或(|)组合使用:

事件常量 含义 可用于 events 可用于 revents
POLLIN 数据可读(包括普通数据和优先数据)
POLLPRI 有紧急数据可读(TCP 带外数据)
POLLOUT 写操作不会阻塞
POLLERR 发生错误
POLLHUP 连接挂起(对方关闭连接)
POLLNVAL 无效请求(描述符未打开)
POLLRDNORM 普通数据可读
POLLRDBAND 优先带数据可读
POLLWRNORM 普通数据可写
POLLWRBAND 优先带数据可写

二、事件分类说明

三、关键理解:POLLERR、POLLHUP、POLLNVAL

这三个事件只能出现在 revents 中,不需要(也不能)在 events 中设置。内核会自动检测这些异常状态:

// 检查异常事件的惯用写法
if (fds[i].revents & POLLERR) {
    printf("描述符 %d 发生错误\n", fds[i].fd);
}
if (fds[i].revents & POLLHUP) {
    printf("描述符 %d 对方关闭连接\n", fds[i].fd);
}
if (fds[i].revents & POLLNVAL) {
    printf("描述符 %d 无效\n", fds[i].fd);
}

第三部分:poll 的基本使用

一、监控标准输入

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <poll.h>

int main() {
    struct pollfd fds[1];
    char buffer[128];
    
    // 初始化 pollfd 结构体
    fds[0].fd = STDIN_FILENO;    // 监控标准输入
    fds[0].events = POLLIN;      // 监控读事件
    
    while (1) {
        printf("等待输入(5秒超时)...\n");
        
        // 调用 poll,超时 5 秒(5000 毫秒)
        int ret = poll(fds, 1, 5000);
        
        if (ret == -1) {
            perror("poll error");
            break;
        } else if (ret == 0) {
            printf("5秒内没有输入,超时!\n\n");
        } else {
            // 检查标准输入是否有数据可读
            if (fds[0].revents & POLLIN) {
                int n = read(STDIN_FILENO, buffer, sizeof(buffer) - 1);
                if (n > 0) {
                    buffer[n] = '\0';
                    printf("输入内容: %s\n", buffer);
                }
            }
            
            // 检查异常事件
            if (fds[0].revents & POLLERR) {
                printf("标准输入发生错误\n");
                break;
            }
            if (fds[0].revents & POLLHUP) {
                printf("标准输入已关闭\n");
                break;
            }
        }
    }
    
    return 0;
}

二、与 select 实现对比

核心优势poll 的 events 字段保留用户设置,revents 字段由内核填充。因此不需要每次调用前重新初始化,只需要在事件处理完成后清零 revents 即可。


第四部分:poll 实现多客户端服务器

一、数据结构设计

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>

#define PORT 6000
#define MAX_CLIENTS 1024
#define BUFFER_SIZE 128

二、创建监听套接字

int create_listen_socket() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket error");
        return -1;
    }
    
    // 端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    
    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("bind error");
        close(listen_fd);
        return -1;
    }
    
    if (listen(listen_fd, 5) == -1) {
        perror("listen error");
        close(listen_fd);
        return -1;
    }
    
    printf("服务器启动成功,端口:%d\n", PORT);
    return listen_fd;
}

三、pollfd 数组管理

struct pollfd fds[MAX_CLIENTS + 1];  // +1 用于监听套接字
int nfds = 0;                        // 当前有效元素个数

// 添加描述符到监控数组
void add_fd(int fd) {
    if (nfds >= MAX_CLIENTS + 1) {
        printf("已达到最大连接数\n");
        return;
    }
    fds[nfds].fd = fd;
    fds[nfds].events = POLLIN;      // 监控读事件
    fds[nfds].revents = 0;
    nfds++;
}

// 从监控数组中移除描述符
void remove_fd(int index) {
    // 用最后一个元素覆盖要删除的元素(保持数组连续性)
    fds[index] = fds[nfds - 1];
    nfds--;
}

删除策略说明

四、事件处理函数

void handle_events(int listen_fd) {
    // 1. 检查监听套接字(新连接)
    if (fds[0].revents & POLLIN) {
        struct sockaddr_in client_addr;
        socklen_t len = sizeof(client_addr);
        int client_fd = accept(listen_fd, 
                               (struct sockaddr*)&client_addr, &len);
        
        if (client_fd != -1) {
            printf("新客户端连接,fd=%d, IP=%s\n", 
                   client_fd, inet_ntoa(client_addr.sin_addr));
            add_fd(client_fd);
        }
    }
    
    // 2. 检查所有客户端连接(从索引1开始,索引0是监听套接字)
    for (int i = 1; i < nfds; i++) {
        int fd = fds[i].fd;
        
        // 检查读事件
        if (fds[i].revents & POLLIN) {
            char buffer[BUFFER_SIZE];
            int n = recv(fd, buffer, BUFFER_SIZE - 1, 0);
            
            if (n <= 0) {
                // 客户端关闭或出错
                printf("客户端 %d 断开连接\n", fd);
                close(fd);
                remove_fd(i);
                i--;  // 调整索引,因为数组元素被替换了
            } else {
                buffer[n] = '\0';
                printf("收到数据 (fd=%d): %s\n", fd, buffer);
                send(fd, "OK", 2, 0);
            }
        }
        
        // 检查异常事件
        if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
            printf("客户端 %d 异常断开\n", fd);
            close(fd);
            remove_fd(i);
            i--;
        }
    }
}

五、主循环

int main() {
    // 创建监听套接字
    int listen_fd = create_listen_socket();
    if (listen_fd == -1) {
        exit(1);
    }
    
    // 将监听套接字加入监控数组(始终在索引0)
    add_fd(listen_fd);
    
    while (1) {
        // 调用 poll,超时 2 秒
        int ret = poll(fds, nfds, 2000);
        
        if (ret == -1) {
            perror("poll error");
            break;
        } else if (ret == 0) {
            // 超时,可执行定时任务
            printf("poll timeout, 当前连接数: %d\n", nfds - 1);
            continue;
        }
        
        // 处理就绪事件
        handle_events(listen_fd);
    }
    
    close(listen_fd);
    return 0;
}

六、关键设计说明

1. 监听套接字固定放在 fds[0]

2. 监听套接字的 POLLIN 含义

对于监听套接字,POLLIN 表示有新连接到达(accept 不会阻塞):

if (fds[0].revents & POLLIN) {
    int client_fd = accept(listen_fd, NULL, NULL);
    // 不会阻塞,因为一定有新连接
}

第五部分:poll 的注意事项

一、fd 可以设为 -1 来忽略

与 select 不同,poll 允许将 fd 设为 -1 来跳过某个数组元素:

fds[2].fd = -1;  // poll 会忽略此项

这提供了另一种"删除"策略——不删除数组元素,只是标记为无效。但会浪费数组空间。

二、POLLHUP 和 POLLIN 可能同时出现

当对方关闭连接时,poll 可能同时返回 POLLHUP 和 POLLIN

if (fds[i].revents & POLLIN) {
    int n = recv(fd, buffer, size, 0);
    if (n == 0) {
        // 对方关闭连接,recv 返回 0
    }
}

// 同时检查 POLLHUP 作为额外保障
if (fds[i].revents & POLLHUP) {
    // 确认对方已关闭
}

推荐做法:优先处理 POLLIN,通过 recv 返回值判断连接状态;将 POLLHUP 作为异常情况的补充检查。

三、缓冲区截断问题

// 如果接收缓冲区太小,一次只能读取部分数据
char buffer[1];  // 只能读1个字节
recv(fd, buffer, 1, 0);
// 剩余数据还在接收缓冲区中,下次 poll 仍会报告 POLLIN

当客户端发送 "hello"(5个字节),而服务器缓冲区只有1字节时,需要多次 poll 才能读完所有数据。


第六部分:select 与 poll 对比

一、核心对比表

对比项 select poll
描述符集合 fd_set(固定位图) struct pollfd 数组(动态)
最大描述符数 FD_SETSIZE(默认1024) 无内置限制(受系统资源限制)
事件与结果 共用同一集合(被修改) 分离(events/revents)
每次调用前重建 需要 不需要(events 不变)
超时精度 微秒 毫秒
参数含义 nfds=最大值+1 nfds=数组元素个数
可移植性 几乎所有平台 POSIX 系统
空描述符处理 不支持 fd=-1 跳过该项
删除元素 FD_CLR 多种方式(替换、fd=-1)
性能(少量描述符) 相当 相当
性能(大量描述符) 较差(需遍历所有位) 较差(需遍历整个数组)

二、poll 的优势

1. 无描述符数量限制

// select:受 FD_SETSIZE 限制
fd_set readfds;  // 最多 1024 个

// poll:动态数组,无固定上限
struct pollfd *fds = malloc(sizeof(struct pollfd) * 100000);

2. 事件与结果分离

// select:每次都需要重建
while (1) {
    FD_ZERO(&readfds);     // 必须重建
    FD_SET(fd1, &readfds);
    FD_SET(fd2, &readfds);
    select(...);
    // readfds 已被修改
}

// poll:只需设置一次 events
fds[0].events = POLLIN;   // 设置一次
fds[1].events = POLLIN;
while (1) {
    poll(fds, 2, -1);
    // 检查 revents,events 保持不变
}

3. 更简洁的超时设置

// select:需要分离的结构体
struct timeval tv = {5, 0};  // 5秒

// poll:直接毫秒数
int timeout = 5000;           // 5000毫秒

三、poll 的不足

1. 仍然是线性扫描:每次调用 poll,内核需要遍历整个 fds 数组,复杂度 O(n)

2. 大量描述符时内存复制开销大:需要在用户态和内核态之间复制整个 fds 数组

3. 毫秒级精度:相比 select 的微秒精度,poll 只能精确到毫秒(实际应用中通常够用)

4. 不可知具体哪个描述符就绪:与 select 一样,需要遍历检查

第七部分:完整测试代码

服务器端(poll_server.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>

#define PORT 6000
#define MAX_CLIENTS 1024
#define BUFFER_SIZE 128

struct pollfd fds[MAX_CLIENTS + 1];
int nfds = 0;

void add_fd(int fd) {
    fds[nfds].fd = fd;
    fds[nfds].events = POLLIN;
    fds[nfds].revents = 0;
    nfds++;
}

void remove_fd(int index) {
    fds[index] = fds[nfds - 1];
    nfds--;
}

int create_listen_socket() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket error");
        return -1;
    }
    
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    
    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("bind error");
        close(listen_fd);
        return -1;
    }
    
    if (listen(listen_fd, 5) == -1) {
        perror("listen error");
        close(listen_fd);
        return -1;
    }
    
    printf("poll 服务器启动,端口:%d\n", PORT);
    return listen_fd;
}

int main() {
    int listen_fd = create_listen_socket();
    if (listen_fd == -1) exit(1);
    
    // 监听套接字在 fds[0]
    add_fd(listen_fd);
    
    while (1) {
        int ret = poll(fds, nfds, 2000);
        
        if (ret == -1) {
            perror("poll error");
            break;
        }
        
        if (ret == 0) {
            printf("超时,当前连接数: %d\n", nfds - 1);
            continue;
        }
        
        // 处理监听套接字
        if (fds[0].revents & POLLIN) {
            struct sockaddr_in client_addr;
            socklen_t len = sizeof(client_addr);
            int client_fd = accept(listen_fd, 
                                   (struct sockaddr*)&client_addr, &len);
            if (client_fd != -1) {
                printf("新连接: fd=%d\n", client_fd);
                add_fd(client_fd);
            }
        }
        
        // 处理客户端连接
        for (int i = 1; i < nfds; i++) {
            if (fds[i].revents & POLLIN) {
                char buffer[BUFFER_SIZE];
                int n = recv(fds[i].fd, buffer, BUFFER_SIZE - 1, 0);
                
                if (n <= 0) {
                    printf("断开: fd=%d\n", fds[i].fd);
                    close(fds[i].fd);
                    remove_fd(i);
                    i--;
                } else {
                    buffer[n] = '\0';
                    printf("收到 fd=%d: %s\n", fds[i].fd, buffer);
                    send(fds[i].fd, "OK", 2, 0);
                }
            }
            
            if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
                printf("异常断开: fd=%d\n", fds[i].fd);
                close(fds[i].fd);
                remove_fd(i);
                i--;
            }
        }
    }
    
    close(listen_fd);
    return 0;
}

测试

# 编译服务器
gcc poll_server.c -o poll_server
./poll_server

# 另开终端,使用 telnet 或 nc 测试
nc 127.0.0.1 6000
# 输入任意内容,应该收到 "OK" 回复

总结

一、poll 使用流程

二、核心函数速查表

函数/宏 作用
poll() 监控多个文件描述符的状态
POLLIN 数据可读事件
POLLOUT 数据可写事件
POLLERR 错误事件(仅 revents)
POLLHUP 连接挂起(仅 revents)
POLLNVAL 无效描述符(仅 revents)

三、select vs poll 选择建议

场景 推荐
描述符数量 < 1024 两者均可,select 更跨平台
描述符数量可能超过 1024 使用 poll
需要 Windows 兼容 使用 select
需要更简洁的代码 使用 poll(无需重建事件)
需要微秒精度超时 使用 select
追求极致性能(海量连接) 使用 epoll(下一篇文章)

四、poll 的优缺点总结

优点 缺点
✅ 无描述符数量限制 ❌ 仍需线性遍历所有描述符
✅ events/revents 分离,无需重建 ❌ 大量描述符时内存复制开销大
✅ 超时设置简洁(毫秒整数) ❌ 不可知具体就绪的描述符
✅ fd=-1 可跳过无效项 ❌ 精确度到毫秒(select 是微秒)

poll 解决了 select 的描述符数量限制问题,并提供了更优雅的事件管理方式(events 与 revents 分离)。但它仍然需要线性遍历所有描述符,在处理海量连接时效率不够理想。

在下一篇文章中,我们将学习 Linux 下的终极 I/O 多路复用方案 —— epoll,它通过事件驱动和红黑树实现了 O(1) 的性能,是构建高性能服务器的核心技术。

学习建议

  1. 动手实现 poll 版本的服务器,加深理解

  2. 对比 select 和 poll 的代码差异,理解各自的设计思想

  3. 注意 POLLHUP 和 POLLIN 同时出现的情况,正确处理连接关闭

  4. 重点理解"替换法删除"技巧,这是保证 O(1) 删除的关键

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐