fd 只是一个数组下标,为什么一个线程能等一万条连接

文件描述符不是文件本身,而是进程私有的描述符表下标;这个下标指向内核的 struct file,真正的读写、就绪判断都由它背后的 file_operations 完成。I/O 多路复用让一个线程同时等待大量描述符,select 和 poll 每次都要把集合带进内核并线性扫描,epoll 则把关心的事件注册一次,靠回调把就绪项挂进链表,epoll_wait 只取这张就绪表。最常见的误解是以为 epoll 快在红黑树,或者以为就绪就等于能一次读完。读完你能说清 LT 与 ET 的取舍、fd 关闭与复用的坑,以及该在什么规模下选哪种模型。

是什么

文件描述符(file descriptor,fd)是一个非负整数,它是进程打开文件表的下标。调用 open、socket、pipe、eventfd 之后,内核在进程的表里找一个空位,把新描述符填进去并返回下标;read、write、close 拿到的就是这个下标。0、1、2 分别被标准输入、标准输出、标准错误占用,所以新打开的文件通常从 3 开始。

一句话直觉:fd 像储物柜的编号,柜子本身在内核里,编号只在当前进程有效;换一个进程,同一个编号可能对应完全不同的东西。

I/O 多路复用(I/O multiplexing)解决的是另一个问题:一个线程要同时照看很多条连接,但不知道哪一条先来数据。它的做法是把一组 fd 交给内核,让内核在“至少一个就绪”或“超时”时返回,应用再逐个处理。典型场景是 Web 服务器、Redis 这类单线程事件循环、代理与网关,以及任何需要把成百上千条长连接塞进少量线程的服务。

要区分三个容易混的概念:多路复用是“等谁就绪”,不是“数据已经拷完”;多线程一连接一线程是“用线程数换阻塞”,不是多路复用;异步 I/O 则是“内核把数据拷完再通知”。三者解决的问题层次不同。

核心原理

先从一次 read(fd, buf, n) 说起。这个系统调用进入内核后,并不是拿着一个整数去找文件,而是用当前进程的 files_struct 找到 fd 表,再用下标取出指向 struct file 的指针。struct file 里存着偏移量、引用计数、私有数据,以及一张函数表 file_operations。真正的读由 f_op->read 完成;对 socket 来说,它会去检查接收队列里有没有数据。所以 fd 的本质是“进程内的一个索引”,不是全局句柄,也不是文件本身。

“就绪”因此也不是应用层的感觉,而是内核里的一个状态:socket 的接收队列非空、发送缓冲区还有空间、监听 socket 的已完成连接队列非空,这些都算可读或可写。每个打开的文件都有一条等待队列,谁想等这个状态变化,就把自己挂上去,状态变化时由内核唤醒。

select 的做法很直接(语义与限制见 select(2)):调用时把读集合、写集合、异常集合三张位图从用户态拷进内核,内核线性遍历每一个被置位的 fd,调用它对应的 poll 方法算出当前就绪掩码;如果一个都不就绪,就把当前进程挂到所有这些文件的等待队列上睡眠。任何一个 fd 有动静,进程被唤醒,但它不知道是哪一个,只能把整张表再扫一遍,最后把修改过的位图拷回用户态,应用还要再扫第三遍。poll 换成了 pollfd 数组,去掉了位图大小的限制,但“每次拷贝、每次全量扫描”没有变。

epoll 把这两件事拆开了(接口细节见 epoll(7))。epoll_create1 创建一个 eventpoll 对象,里面有一棵红黑树存“我关心哪些 fd”,一条就绪链表存“哪些已经就绪”,还有一条自己的等待队列。epoll_ctl(ADD) 把 fd 和关注的事件插入红黑树,同时在这个 fd 的等待队列上注册一个回调 ep_poll_callback。数据到达时,协议栈唤醒等待队列,回调把对应的节点挂进就绪链表,并唤醒正在 epoll_wait 的线程。epoll_wait 醒来后只看就绪链表,把就绪项拷到用户数组即可,复杂度跟“就绪了多少个”相关,而跟“总共监听了多少个”基本无关。

int ep = epoll_create1(0);
struct epoll_event ev = {.events = EPOLLIN, .data.fd = listen_fd};
epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event ready[64];

for (;;) {
    int n = epoll_wait(ep, ready, 64, -1);   /* 只返回就绪的 */
    for (int i = 0; i < n; i++) {
        int fd = ready[i].data.fd;
        if (fd == listen_fd) {
            int c = accept4(fd, NULL, NULL, SOCK_NONBLOCK);
            if (c < 0) continue;
            struct epoll_event ce = {.events = EPOLLIN, .data.fd = c};
            epoll_ctl(ep, EPOLL_CTL_ADD, c, &ce);
        } else {
            char buf[4096];
            ssize_t k = read(fd, buf, sizeof buf);  /* 仍要处理 EAGAIN */
        }
    }
}

这段代码用的是默认的水平触发(level-triggered,LT):只要缓冲区里还有数据,下一轮 epoll_wait 还会把同一个 fd 报出来,所以漏读不会永久卡死,只是会重复通知。边缘触发(edge-triggered,ET)只在状态“发生变化”的那一刻通知一次,应用必须配合非阻塞 fd,用循环一直读到返回 EAGAIN 为止;少读一次,剩下的数据可能再也没有新事件来提醒。

常见考点与误区

  • 误区一:epoll 快是因为用了红黑树。 红黑树只决定 epoll_ctl 增删查的代价是 O(log n),而等待本身的效率来自“注册一次、回调入队、只取就绪链表”。面试官追问“那红黑树有什么用”,要能答出它只是事件集合的索引结构,跟每次唤醒要扫描多少项无关。
  • 误区二:select 的 1024 是内核硬限制。 1024 来自 libc 的 FD_SETSIZE 默认值,改大要重新编译且不可移植;poll 用数组就没有这个上限,但仍受进程 fd 上限和内存约束。epoll 的容量同样受 ulimit -n 与 fs.file-max 限制,不是“无限”。
  • 误区三:ET 一定比 LT 快。 ET 减少的是重复通知次数,代价是把“排空缓冲区”的责任交给应用;在业务处理本身就慢、或者一次读不完的场景里,ET 写错会直接停摆。绝大多数服务用 LT 就够了,只有在高频小包、通知开销确实可测时再考虑 ET。
  • 误区四:fd 全局唯一,可以跨进程直接用。 fd 是进程私有的下标。fork 之后父子进程有各自的表,但表项指向同一个 struct file,所以共享文件偏移;通过 Unix 域套接字传 fd 时,接收方拿到的是内核新安装的一个编号,不是原来的数字。
  • 误区五:fd 关闭了,epoll 里一定干净。 close 只在最后一个引用消失时才真正释放 struct file;如果还有 dup 出来的副本或子进程持有,注册关系仍在。更麻烦的是 fd 号会被复用:旧连接的事件可能带着同一个数字回来,被误认成新连接。这类 bug 只能靠“关闭前显式 EPOLL_CTL_DEL,并用对象而不是 fd 号做身份”来避免。
  • 误区六:epoll 能加速磁盘文件读写。 普通文件几乎永远处于“就绪”状态,epoll_ctl 会直接返回 EPERM。多路复用适合网络与管道这类会真正阻塞的 fd,磁盘 I/O 要靠页缓存、io_uring 或线程池,不是一个层面的工具。
  • 考点七:就绪等于能一次读完吗。 不等于。可读只保证“现在调用 read 不会阻塞”,能读多少取决于缓冲区;多个线程共享同一个 epoll 时,还可能被别的线程先取走,所以读循环必须处理 EAGAIN 和 EINTR。

实践建议

  • 把进入 epoll 的 fd 全部设成非阻塞,读写都写成“循环直到 EAGAIN”,并区分 EINTR 重试。边缘触发下这是正确性要求,水平触发下这是避免一个慢连接拖住整个循环的性能要求。
  • 关闭连接时先 EPOLL_CTL_DEL 再 close,连接对象自己管生命周期,事件里的 data 放对象指针或连接 ID,不要只放 fd 号。配合引用计数,能同时解决“关闭后仍收到事件”和“fd 复用误匹配”。
  • 按规模选模型:几十上百个 fd、等待时间短的场景,poll 足够,代码简单;上万条长连接、事件稀疏时用 epoll。若事件几乎总是全就绪,任何多路复用的优势都会被业务处理吃掉,先优化处理逻辑。
  • 监听 socket 的并发 accept 要专门设计:多线程同时 epoll_wait 同一个实例是允许的,但会出现惊群;可以用 EPOLLEXCLUSIVE(Linux 4.5 起)或 SO_REUSEPORT 做分片,前者让内核只唤醒一个等待者。
  • 容量要分层检查:进程级 ulimit -n 的 soft/hard、系统级 fs.file-max、以及 ss -s 里的 orphan 与 TIME_WAIT。线上 fd 泄漏最直接的观测点是 /proc/<pid>/fd 的数量曲线,配合 CLOSE_WAIT 计数能快速区分“没 close”和“对端没关”。
  • 排查性能时先用 strace -c 看系统调用分布,再判断时间花在拷贝、扫描还是业务回调。不要一看到“连接数上不去”就换 epoll,瓶颈可能在 fd 上限、内存,或者回调里藏了一次同步查询。

一句话总结:fd 是进程内的索引,多路复用是把“等谁就绪”交给内核;epoll 省掉的是重复拷贝与全量扫描,不是 I/O 本身。

相关阅读

← 返回文章志