poll为什么使用poll_list链表结构而不是数组 - 深入内核源码分析

发布于:2025-04-16 ⋅ 阅读:(10) ⋅ 点赞:(0)

一:引言

在Linux内核中,poll机制是一个非常重要的I/O多路复用机制。它允许进程监视多个文件描述符,等待其中任何一个进入就绪状态。poll的内部实现使用了poll_list链表结构而不是数组,这个设计选择背后有其深层的技术考量。本文将从内核源码层面深入分析这个设计决策的原因。

二:poll的基本工作原理

poll系统调用的基本接口如下:

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

fds 是一个 struct pollfd 数组,每个元素包含一个文件描述符及其事件掩码。

nfds 是数组的大小。

timeout 是等待的时间(以毫秒为单位),如果为-1则无限期等待。

struct pollfd 结构体定义如下:

struct pollfd {
    int   fd;         // 文件描述符
    short events;     // 请求的事件
    short revents;    // 返回的事件
};

在Linux内核中,poll 的实现主要位于 fs/select.c 文件中。我们可以通过以下步骤来追踪 poll 的执行流程:

1、用户空间到内核空间的转换:

用户空间的 poll 调用通过系统调用进入内核,内核调用 sys_poll 函数。

2、文件描述符集合的构建:

内核需要将用户传递的 pollfd 数组转换为内核可以处理的数据结构。

3、轮询文件描述符:

内核遍历文件描述符集合,检查每个文件描述符是否满足指定的事件条件。

4、结果返回:

将结果返回给用户空间。

其中第2点,应用层我们传递的是pollfd数组,要转换为内核的数据结构,内核对应的数据结构为:(V5.15版本)

// include/linux/poll.h
struct poll_list {
    struct poll_list *next;   // 指向下一个poll_list节点
    int len;                  // 本节点包含的pollfd数量  
    struct pollfd entries[]; // 弹性数组成员
};

// 文件系统层面的poll实现接口
struct file_operations {
    ...
    __poll_t (*poll) (struct file *file, struct poll_table_struct *wait);
    ...
};

poll_list 是一个动态分配的链表节点,每个节点包含一个 poll_table_entry 数组,用于存储文件描述符及其相关的等待队列

三:为什么选择链表而不是数组

1、内存分配的灵活性

使用链表结构的第一个重要原因是内存分配的灵活性。让我们看看内核是如何为poll_list分配内存的:

#define POLLFD_PER_PAGE  ((PAGE_SIZE-sizeof(struct poll_list)) / sizeof(struct pollfd))
//这个宏定义计算了在一个页面大小内可以容纳多少个 struct pollfd 结构体。PAGE_SIZE 是系统页面的大小,
//sizeof(struct poll_list) 是 poll_list 结构体的大小,sizeof(struct pollfd) 是 pollfd 结构体的大小。
//这个计算结果表示在一个页面内除了存储 poll_list 结构体本身外,还能存储多少个 pollfd 结构体。

static struct poll_list *alloc_poll_list(int size)
{
    struct poll_list *p;
    int entries = POLLFD_PER_PAGE;
    
    if (size < entries) {
        entries = size;
    }
    
    p = kmalloc(struct_size(p, entries, sizeof(struct pollfd)), GFP_KERNEL);
    if (!p)
        return NULL;
    p->next = NULL;
    p->len = entries;
    return p;
}

从上面的代码可以看出:

poll_list使用页对齐的内存分配,每个节点尽量利用一个完整的页面

通过链表结构,可以根据实际需要的pollfd数量动态分配多个节点

避免了一次性分配大块连续内存的问题

如果使用数组结构,则存在以下问题:

// 假设使用数组方式
struct poll_array {
    int len;
    struct pollfd entries[]; // 需要一次性分配足够大的连续内存
};

// 问题演示
struct poll_array *alloc_poll_array(int size) 
{
    // 1. 需要一次性分配大块连续内存
    // 2. 可能导致内存碎片
    // 3. 在内存压力大时更容易分配失败
    return kmalloc(sizeof(*p) + size * sizeof(struct pollfd), GFP_KERNEL);
}

2、支持大量文件描述符

链表结构的第二个重要优势是能够支持大量文件描述符。内核中的实现:

// fs/select.c
static int do_poll(struct poll_list *list, struct poll_wqueues *wait,
                  struct timespec64 *end_time)
{
    poll_table* pt = &wait->pt;
    int error = 0;
    
    for (;;) {
        struct poll_list *walk;
        for (walk = list; walk != NULL; walk = walk->next) {
            struct pollfd * pfd = walk->entries;
            int len = walk->len;
            
            for (int i = 0; i < len; i++) {
                // 处理每个文件描述符
                error = do_pollfd(&pfd[i], pt, ...);
            }
        }
        
        if (!pt->qproc)
            break; // 所有fd都已就绪或出错
            
        if (signal_pending(current))
            break;
            
        if (end_time && time_after64(ktime_get(), *end_time))
            break; // 超时
            
        schedule(); // 让出CPU
    }
    
    return error;
}

通过链表结构:

可以支持远超过单页内存大小的文件描述符数量

每个节点的大小限制在一个页面内,便于内存管理

遍历性能损失可以忽略不计,因为poll本身就是一个相对耗时的操作

如果使用数组:

// 使用数组的问题
struct poll_array {
    int len; 
    struct pollfd entries[MAX_POLL_FDS];
};

// 1. 需要预定义最大FD数量
// 2. 过大的数组导致栈内存浪费
// 3. 难以支持动态扩容

3、高效的插入和删除操作

链表的优势:

常数时间复杂度:链表在插入和删除节点时具有常数时间复杂度 O(1),而数组需要移动大量元素,时间复杂度为 O(n)。

减少数据移动:链表不需要移动其他元素,只需要修改指针即可完成插入和删除操作。

数组的限制:

移动元素:数组在插入或删除元素时需要移动大量元素,特别是在数组中间位置进行操作时,性能下降明显。

复杂度高:数组的操作复杂度较高,不适合频繁的插入和删除操作

总结

  1. 动态扩展性:链表可以根据实际需要动态分配和释放内存,避免了固定大小数组带来的内存浪费或不足问题。

  2. 高效的插入和删除操作:链表在插入和删除节点时具有常数时间复杂度,而数组需要移动大量元素,导致性能下降。

  3. 优化内存管理:页对齐的内存分配可以简化内存管理操作,提高内存管理的效率。


网站公告

今日签到

点亮在社区的每一天
去签到