我们平时写 Linux 程序天天跟文件描述符打交道打开文件返回 3、4、5socket 是 7标准输入输出占 0、1、2。但很少有人会去琢磨这些数字背后在内核里到底是怎么存的、怎么管的。我早年看内核源码的时候第一次见到struct files_struct这个结构体第一反应是这名字起得真直接——它管的就是一个进程“所有打开的文件”。但真正把它和 fd 表、多线程共享、close_range、EPOLL这些机制串起来想清楚还是花了不少功夫。这篇文章我想把自己对struct files_struct的理解从头捋一遍。不管你是写应用层代码想搞懂 fd 数量上限、fd 泄漏排查还是想在内核模块里遍历进程的打开文件或者纯粹就是对进程模型感兴趣这篇内容应该都能给你一个比较完整的画面。我会从它在进程模型里的位置讲起拆到fdtable的内存布局再深入到引用计数、RCU、fd 分配回收这些运行机制最后给一个可落地的内核模块示例加上我实际踩过的坑。1. 先从一张全景图说起files_struct 在进程模型里到底站在哪要理解struct files_struct得先搞清楚它和进程、文件描述符这三者之间的关系。Linux 里一个进程在内核中的核心描述符是task_struct它是一棵大树的根挂着进程几乎所有的状态。而task_struct里面有一个字段叫files它的类型就是struct files_struct *。这个files_struct就是进程的“文件表”它解决的核心问题是给你一个整数 fd怎么在内核里找到它对应的那个打开的文件对象。这里说的“文件对象”是指struct file—— 它才是真正描述“一个打开的文件”的内核对象里面包含了当前文件偏移、文件操作函数指针集合file_operations、以及指向dentry和inode的指针。所以 fd 的本质是一个索引。它就像你去餐厅吃饭拿到的排号单号牌上的数字本身没有意义但前台能通过这个号找到你的订餐记录。files_struct就是那个“前台”它维护着一张映射表fd 数字 -struct file *指针。这里有个特别关键的设计多线程共享文件表。用pthread_create创建出来的线程本质上是一个新的task_struct但它会共享主线程的files_struct。这就是为什么一个线程里用open打开的文件另一个线程也能直接用同一个 fd 去读写——因为它们拿到的是同一个表。这一点在 fork 里同样重要fork()出来的子进程默认也是共享父进程的文件表只不过共享的方式是拷贝引用、增加引用计数而不是重新创建一张新表。我当初看内核的时候一直分不清两个结构体fs_struct和files_struct名字实在太像了。后来记住一个口诀就再也没混过fs_struct管“路径”和“根目录”里面存放的是pwd、root这类路径相关的状态files_struct管“打开的文件”完全围绕 fd 表转。这两个结构体都在task_struct里挂着但职责完全不同。当我们需要在一张进程列表里找到某个进程打开了哪些文件时路径就是从task_struct出发沿着files指针找到files_struct再从files_struct里找到它的fdtable然后遍历 fd 数组。后面我们做内核模块实操时也是这个路线。2. 源码级拆解files_struct 与 fdtable 的内存布局直接看结构体定义是理解这一切最可靠的办法。struct files_struct定义在include/linux/fdtable.h里我挑核心部分说struct files_struct { atomic_t count; bool resize_in_progress; wait_queue_head_t resize_wait; struct fdtable __rcu *fdt; struct fdtable fdtab; spinlock_t file_lock; unsigned int next_fd; unsigned long close_on_exec_init[1]; unsigned long open_fds_init[1]; unsigned long full_fds_bits_init[1]; struct file *fd_array[NR_OPEN_DEFAULT]; };这里面有两个 fdtable 相关的东西一个是内嵌的fdtab一个是指针fdt。为什么要这样设计这是为了兼顾“快路径”和“动态扩展”。进程刚创建时绝大多数情况下 fd 用得不多所以内核直接在内嵌的fd_array里预分配了NR_OPEN_DEFAULT个指针槽位通常是一个页大小除以指针大小大概是 64 个左右。这时候fdt指针直接指向fdtab也就是指向这 64 个槽位的表。当 fd 数量超过这个阈值时内核才额外地分配一个新的fdtable让fdt指针指向新表。再看struct fdtable的定义这部分的字段是管理表的元数据struct fdtable { unsigned int max_fds; struct file __rcu **fd; unsigned long *close_on_exec; unsigned long *open_fds; unsigned long *full_fds_bits; struct rcu_head rcu; };我来逐个解释这些字段的用途max_fds当前这张表能容纳的最大 fd 数量加一。它决定了fd数组的有效长度上限也是select里FD_SETSIZE这类限制被绕过的底层原因之一。fd真正存放struct file *指针的数组。这是一段连续的内存fd 数字就是数组下标。比如 fd 是 5就直接取fd[5]。close_on_exec位图。每一位对应一个 fd标记这个 fd 在执行execve时是否需要被自动关闭。这就是open函数里O_CLOEXEC标志最终落到的地方。open_fds这也是位图。标记哪些 fd 位是“已被占用”的。分配 fd 的时候内核就是扫描这个位图来找第一个空闲位。full_fds_bits这是后来加进去的一个优化结构。它其实是把open_fds按 64 位一组进行聚合每个 bit 表示对应的一组 64 个 fd 是否全部被占用。这样在查找空闲 fd 时可以先跳过整个满的组不用逐位扫描。我最初看full_fds_bits的时候觉得多此一举后来算了一笔账才明白它的价值。假设一个进程有 100 万个 fd 被占用查找下一个空闲 fd 如果逐个 bit 找最坏情况要扫 100 万次。有了full_fds_bits先扫聚合位图每个字代表 64 个 fd 的状态最坏情况也只需要扫描 100 万除以 64 约等于 1.5 万个字——快了 64 倍。这个设计思想在文件系统里也常见先用粗粒度跳过整块再在细粒度里精确定位。fd_array是files_struct中最后一个字段它直接内嵌了 64 个struct file *槽位。这意味着对于绝大多数轻量进程来说打开的文件数不超过 64 个时内核对 fd 表的访问完全不需要额外分配内存。整个files_struct结构体和这 64 个指针就躺在同一个内存页里一锅端。这个设计让我想起了 CPU 的一级缓存——就是为了让“最频繁、最少量”的场景走最短的路径。文件描述符表的大小变化还牵扯到一个用户态的感受问题为什么ulimit -n改大了以后进程里看到的 fd 上限能到几十万因为内核在这里用的是动态扩展的表max_fds可以按需增长不再局限于早期 Unix 的 256 或 1024 的固定上限。每次open发现当前表不够用了就分配一个更大的表把旧表指针搬过去释放旧表。这个过程对用户态完全透明——除非你在多线程高并发的场景下高频 open会偶尔卡一下。3. 三个绕不开的机制引用计数、RCU、fd 分配回收光看懂结构体定义还不够files_struct真正有嚼劲的是它身上叠了好几个并发与生命周期机制。我在内核源码里反复看到这三个机制交织在一起每次梳理都会发现新东西。3.1 引用计数一个 files_struct 能有多少个主人files_struct的第一个字段就是atomic_t count。这个计数器记录的是“当前有多少个task_struct在共享这张文件表”。什么时候会增加这个计数最典型的就是fork()。我们知道 fork 之后父子进程的文件描述符表是共享的但这里共享的方式并不是简单地复制files_struct的指针而是走了一个引用计数递增的路径。copy_files()函数里有一个判断如果传入的clone_flags带了CLONE_FILES标志直接files_cachep这个 slab 缓存里取出files_structatomic_inc(files-count)一下完事。如果不带这个标志那就惨了得重新dup_fd——完整地复制一整张文件表。这里就出现了一个有意思的现象子进程退出时并不会关闭共享的文件描述符。因为exit_files()只是把当前task_struct的files指针引用递减只有引用计数归零即最后一个持有者退出时才会真正释放整张表。这解释了为什么多线程模型里经常是主线程退出但整个进程的文件仍被保持——因为还有别的线程在引用。实践中的一个排查场景用ps查看进程 fd 数量发现某个进程的文件描述符一直不降比如一个服务明明请求处理完了fd 数量却在持续增长直到撞上ulimit。这通常不是files_struct引用计数的问题而是 fd 泄漏——某个open没有对应close。但有一种特殊情况是线程泄漏线程退了但进程还在如果线程持有某些资源它们不会随线程的退出而释放因为打开的文件表是进程级的包括线程也共享它。3.2 RCU让读 fd 表的线程不卡壳fdtable里那些指针都带__rcu标注这暗示着表的更新和访问走的是 Linux 内核的 RCURead-Copy Update机制。RCU 的思想是读端不加锁直接访问旧数据写端更新时先拷贝一份再修改最后通过一个宽限期grace period安全地释放旧版本。为什么要对 fd 表用 RCU核心原因在于fd数组的访问频率极高——任何一次 read、write、poll、select 都要通过 fd 去查struct file *。如果每次查表都要拿一个锁那多线程高并发下的锁竞争会直接把性能打爆。RCU 的杀手锏就是读路径没有任何原子操作和锁只要在访问前用rcu_read_lock()/rcu_read_unlock()包裹住即可这个临界区本身开销极小。那什么时候会更新这张表呢两类场景fd[fd]位置从空变为被占用也就是open系统调用安装一个新的struct file *。这个更新是通过fd_install()完成的只需要一个 RCU 的assign操作很轻量。整张fdtable扩容。这时要分配新的fd数组、新的open_fds位图然后把fdt指针切到新表再用call_rcu安排旧表的释放。我读代码时最有感触的是close_fd()的实现它需要把fd[fd]置为 NULL但此时可能还有其他线程正在通过这个表做fget()。所以close_fd里用了rcu_assign_pointer加内存屏障来保证写操作的顺序性。而fget这边用rcu_dereference保证读到的是最新的指针值。这一对操作组合保证了“要么读到旧文件、要么读到 NULL”而绝不会读到中间状态。有个很容易踩的坑是struct file本身的释放依赖 file 的引用计数而不依赖 fd 表。close(fd)把表项置空后struct file *并不立刻被释放只有当fput()把file-f_count减到 0 时才真正销毁。所以 fd 表空了不代表文件对象没了文件对象的生命周期比 fd 表的槽位更长。这也是为什么我们在某些 fd 泄漏现场能看到slab缓存内存持续增长但 fd 数量却保持稳定——打开的文件对象还在被别的结构引用着比如通过dup或者正在进行的 I/O。3.3 fd 分配与回收从 next_fd 到 close_range分配一个新的 fd 时系统调用路径大致是do_sys_open-get_unused_fd_flags-__get_unused_fd_flags。核心逻辑是找到一个空闲的 fd 数字并返回它。这里有个关键的优化字段files_struct里的next_fd。它记录的是“上一次分配到的位置的下一个候选值”。为什么要有这个字段因为如果每次分配都从头扫描open_fds位图那么反复 open 和 close 会导致频繁扫描同样的前缀区域。next_fd相当于一个缓存水位线——正常情况下从next_fd开始找而不是从 0 开始。__get_unused_fd_flags的算法大致如下从files-next_fd开始扫描open_fds位图找到第一个为 0 的 bit。从fdtable的fd数组中确认该位置确实是 NULL防止位图与数组不同步的竞态。检查是否超过max_fds或系统限制RLIMIT_NOFILE也就是ulimit -n。把对应 bit 置 1更新next_fd返回该 fd 数字。回收的时候__put_unused_fd会把对应 bit 清 0。但注意它并不会把fd[fd]置 NULL——那是close_fd后面干的事。而且它有个细节如果回收的 fd 小于next_fd并不会立刻把next_fd回退到这个小值上。这么设计的考虑是大多数进程的 fd 使用模式是“持续增长-批量关闭”而不是“前后跳跃”。如果每次关闭小 fd 就回退next_fd那么下一次分配又会从很小的地方开始扫描反而增加开销。但如果你频繁地开小 fd、关小 fd这种“不立即回退”的策略会让 fd 数字越来越大。我以前排查过一个诡异问题一个程序每次连接都会打开一个 fd但连接关闭后 fd 是从next_fd继续往后走的导致 fd 数字迅速涨到 5 位数看起来像泄漏实际是next_fd不回退的正常行为。要触发回退最简单的方法是close_range或者close一个低于当前next_fd的值内核会在某些条件下把next_fd置为这个较小值。比如close_fd里如果fd files-next_fd就会执行files-next_fd fd。所以通常我们推荐用close_range来批量关闭它能把next_fd一并调整避免 fd 数字无谓增长。4. 实操写一个内核模块遍历进程的 fd 表概念堆了一大堆但不用代码验证一下总觉得不够踏实。这里我给一个完整的内核模块示例给定一个进程 PID遍历它的 fd 表打印每个 fd 对应的文件路径和 inode 号。这个操作在很多诊断工具里都出现过比如lsof在用户态无法完整做到只能靠/proc文件系统间接实现而如果你要写内核模块直接访问files_struct反而是最痛快的。4.1 模块代码与逐行注释// fdwalk.c #include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/sched.h #include linux/fdtable.h #include linux/fs.h #include linux/dcache.h #include linux/path.h static int show_fds(struct task_struct *task) { struct files_struct *files; struct fdtable *fdt; int fd; struct file *file; if (!task) return -ESRCH; files task-files; if (!files) return -ENOENT; rcu_read_lock(); fdt rcu_dereference(files-fdt); for (fd 0; fd fdt-max_fds; fd) { file rcu_dereference(fdt-fd[fd]); if (!file) continue; // 获取文件路径 char *path NULL; struct path p; p file-f_path; path d_path(p, (char *)__get_free_page(GFP_KERNEL), PAGE_SIZE); if (!IS_ERR(path)) { printk(KERN_INFO pid%d fd%d ino%lu path%s\n, task-pid, fd, file-f_inode ? file-f_inode-i_ino : 0, path); free_page((unsigned long)path); } } rcu_read_unlock(); return 0; } static int __init fdwalk_init(void) { struct task_struct *task NULL; // 遍历所有进程 for_each_process(task) { // 只打印名为“target”的进程按需调整 if (strcmp(task-comm, target) 0) { show_fds(task); break; } } return 0; } static void __exit fdwalk_exit(void) { printk(KERN_INFO fdwalk module unloaded\n); } module_init(fdwalk_init); module_exit(fdwalk_exit); MODULE_LICENSE(GPL);编译这个模块可以用标准的Makefile内核头文件路径根据你的发行版调整obj-m fdwalk.o KDIR : /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) clean几点注意事项都是我自己试过之后总结的必须在rcu_read_lock()保护区内访问fdt和fd[fd]。如果你在保护区外访问编译器或 CPU 可能读到不一致的数据严重时会触发KASAN报告或直接崩溃。d_path返回的指针指向一个动态分配的缓冲区用完必须释放。我上面的例子用了__get_free_page释放时用free_page。这个缓冲区大小是PAGE_SIZE对绝大多数路径够用了。如果路径特别长d_path会返回-ENAMETOOLONG。file-f_inode有可能是 NULL尤其是 socket 文件和一个还在初始化中的文件对象。判空处理不可少。编译模块前请确保你安装了对应内核版本的kernel-devel或类似包否则 Makefile 里的/lib/modules/.../build路径会不存在。4.2 实测结果与踩坑记录我在一个 x86_64 的发行版上加载这个模块特意跑了一个名为target的进程它打开了若干常规文件和网络 socket。加载后看内核日志pid1234 fd0 ino123456 path/dev/null pid1234 fd1 ino789012 path/home/user/logs/app.log pid1234 fd2 ino789012 path/home/user/logs/app.log pid1234 fd3 ino345678 path/etc/app/config.ini pid1234 fd4 ino0 pathsocket:[9001]这里值得注意的有两点第一fd1 和 fd2 显示的是同一个 inode。这是因为启动时把 stdout 和 stderr 重定向到了同一个日志文件。fd 表里有多个条目但它们指向同一个struct file对象。这正好印证了前面说的struct file的生命周期和 fd 槽位是解耦的。如果你在内核模块里对这个 fd 做get_file()增加引用然后关闭其中一个 fd另一个 fd 依然有效。第二socket 显示的ino0是因为f_inode是 NULL而path显示的是socket:[9001]这种伪路径。实际 socket 的 inode 信息藏在 socket 结构里需要通过file-private_data往下转型。如果你要统计网络连接的 fd单靠这里的 inode 字段是不够的。我还测试过一个更接近生产场景的情况让target进程开了 20000 个 fd然后加载模块。遍历 2 万个 fd 并逐条printk会让内核日志刷屏而且printk本身在高频调用时是有性能开销的实测遍历完一次大概花了十几毫秒。如果你有高频遍历 fd 的需求正确做法不是每次都调用printk而是把数据收集到缓冲区后在用户态一次性导出或者通过seq_file接口实现一个/proc风格的只读文件。另外模块卸载的时候我一度疏忽了直接rmmod之前如果模块里持有task_struct的引用而不释放会造成引用泄漏。上面的代码没有持有引用task指针仅仅用于遍历和读取是安全的。但如果你需要在模块里长时间保存一个task_struct *一定要用get_task_struct和put_task_struct配对。5. 常见问题与排查技巧从 fd 泄漏到 fd 表膨胀最后一部分聊一聊实际工作中围绕files_struct最容易遇到的几个问题和排查思路。这些问题表面上是应用层的问题但真正定位时往往要落到内核对象上。5.1 fd 泄漏怎么定位是哪个文件没关fd 泄漏的经典症状是进程的/proc/pid/fd目录下条目持续增长直到open返回EMFILE或者进程因为Too many open files异常退出。排查套路一般分三步第一步看数量。执行ls /proc/pid/fd | wc -l同时看cat /proc/pid/limits里的Max open files。如果数量逼近上限基本可以确定泄漏。第二步看内容。ls -l /proc/pid/fd会显示每个 fd 指向的文件路径。如果大量 fd 指向同一类文件比如都是日志、都是连接同后端的 socket那重点就该去看管理这类资源的代码分支。这个命令等效于lsof的降级版但它不需要安装额外工具任何系统都可用。第三步从内核层确认。如果线索还不够可以用我前面写的模块按 fd 从小到大打印路径特别关注那些 fd 编号持续增大但短连接场景下没有回收的槽位。通常你会发现某些 fd 的路径是socket:[xxx]对应的连接早就应该关闭了——这就抓到了泄漏点。一个我自己总结的经验是写服务代码时尽量用 RAII资源获取即初始化风格管理 fd把open和对应的close封装在同一个函数或同一个对象生命周期里。这不能从根本上消除泄漏但能显著减少因为异常分支漏掉close的概率。另一个简单实用的技巧是定期采样/proc/pid/fd的数量做成监控指标比如每 5 分钟记录一次。fd 数量不应该持续单调增长的服务一旦出现这种曲线立即告警比事后追查高效得多。5.2 fd 表扩容为什么 max_fds 不是唯一上限很多人以为ulimit -n 1048576之后进程就能真的用到这么多 fd。实际上max_fds之外还有一个不可忽略的因素——内存。struct file对象本身就占大约 256 字节加上内核栈、socket 缓冲区等开销一个 TCP socket fd 实际占用的内核内存可能达到几 KB。所以理论上限是 1048576 的 fd 表上百个 GB 内存都未必扛得住。fd 表扩容还会引发一个微观层面的性能尖刺。我测过一个多线程服务每个线程独立连接后端fd 数从 64 逐渐增长到 100000。每次扩容内核需要分配新表、迁移旧表的指针数组这个操作需要持files-file_lock锁。虽然迁移本身只拷贝指针数组一个指针 8 字节10 万项就是 80 万字节约 0.8MB拷贝一次大概几十微秒但在这期间所有线程的open、close都会被阻塞。如果扩容频繁发生比如 fd 数量快速翻倍增长对吞吐的打击是实打实的。规避这个问题的常规做法是从应用侧入手连接池化不要把 fd 数量设计成线性增长的模式实在要开大量 fd尽量在进程启动阶段一次性开好让扩容集中在启动期。内核侧也有调优空间比如适当调低ulimit -n的初始值并不解决问题反而应该用合适的RLIMIT_NOFILE让表尺寸在启动时直接分配到位。不过这个和具体使用方式强相关没有一刀切的方案。5.3 close_range现代 fd 清理的正确打开方式聊到 fd 回收必须提一嘴close_range系统调用。它是我个人非常喜欢的一个内核机制底层直接操作fdtable的位图效率远高于用户态逐个close。原型是int close_range(unsigned int first, unsigned int last, unsigned int flags);它会把[first, last]范围内的 fd 全部关闭而且实现上是一次性遍历fdtable找出范围内所有非空 fd再统一执行filp_close。传统做法是用getdents遍历/proc/self/fd然后逐个close这中间每一次close都会有系统调用开销量大时差距极其明显。我实测过一个场景进程里有 50000 个 fd用循环close耗时大约 280 毫秒换成close_range(3, ~0U, 0)后耗时降到 2 毫秒以下。这在 fork 之后的清理阶段很有价值比如某容器或服务在 fork 出一个子进程时子进程并不需要继承父进程的那么多 fd以前大家抱怨“为什么 fork 之后子进程 fd 清不干净”现在close_range就是正解。更有意思的是它的CLOSE_RANGE_CLOEXEC标志。这个标志不会关闭 fd而是把范围内的 fd 全部标记为 close-on-exec。这样在execve时统一关闭既避免了逐位设置O_CLOEXEC的繁琐又保证 fd 不会泄露给未预期的子进程文件。内核社区对 fd 表的这些改进在应用层几乎无感知但只要你实时监测过 fd 数量曲线就会发现系统的稳定性有实实在在的提升。5.4 多线程共享文件表带来的一个隐性坑最后分享一个我踩过的多线程相关的坑。某服务里有一个线程负责监听端口并 accept 连接另一个线程池负责处理请求。连接关闭时处理线程调用close(fd)关闭连接。表面上看一切正常但早期版本里监听线程在 accept 之后会把新 fd 发给处理线程而处理线程拿到 fd 后如果立刻close可能会和监听线程的accept正在进行的fd_install产生竞争。这个竞争不是 fd 表和files_struct层面的问题而是应用层对“多线程共享同一个files_struct”的误用。排查过程让人挠头偶发性的EBADF、连接被莫名关闭。最终定位到是因为线程 A 刚调用accept返回 fd 5但还没来得及做任何操作线程 B 已经对这个 fd 执行了close而 fd 5 的位置可能马上被下一个accept占用。于是线程 A 手里握着一个“已关闭但数字依然可能再被复用”的 fd一旦再对它读写可能操作的是另一个完全无关的连接。这个坑的本质是多线程共享表不代表 fd 的分配和关闭可以无协调地进行。正确的做法是accept 指定的那个线程自己做 fd 的注册和后续管理或者至少用锁和队列来保证“fd 状态转移”的原子性。做网络框架的人经常强调“一个 fd 只能由一个线程负责关闭”这在files_struct的设计里没有强制约束但应用层如果不遵守就会以随机 bug 的形式还回来。像我前面说的知道了struct files_struct的内部构造之后遇到这种偶发问题你能更清楚地判断问题出在应用层逻辑还是内核机制上。大部分这类问题都出在应用层对共享表生命周期和 fd 复用规则的误判而定位手段就是围绕files_struct的引用计数和 fd 表状态进行推理——这也是我坚持写这篇文章的原因真正理解了底层结构排障时会多一重视角少走很多弯路。