系统编程

Linux 文件描述符与 I/O 多路复用

在 Linux 中,一切皆文件。网络套接字、管道、终端输入输出都通过文件描述符(fd)来操作。当程序需要同时处理多个连接时,I/O 多路复用技术成为核心方案。本文从 fd 基础出发,对比 select、poll、epoll 的实现差异与适用场景。

一、文件描述符的本质

文件描述符是一个非负整数,进程通过它访问内核中的打开文件表。每个进程默认拥有 0(stdin)、1(stdout)、2(stderr)。

/* 查看当前进程打开的文件描述符 */
int fd = open("data.txt", O_RDONLY);
printf("fd = %d\n", fd);  /* 通常返回 3 */
close(fd);

每个 fd 在内核中对应一个 struct file,包含文件位置、访问模式、inode 指针等。fd 只是进程 fd 表中的索引。

二、阻塞 vs 非阻塞 I/O

默认情况下,套接字是阻塞的:read() 没有数据时会挂起进程,直到数据到达。非阻塞模式通过 fcntl 设置:

int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

/* 非阻塞 read:无数据立即返回 -1,errno 设为 EAGAIN */
ssize_t n = read(sockfd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
    /* 暂无数据,可处理其他 fd */
}
非阻塞 I/O 必须与多路复用配合使用,否则 CPU 会空转在轮询中。

三、select:最古老的方案

select 通过三个 fd_set 位图监控可读、可写、异常事件。最大限制是 FD_SETSIZE(通常为 1024)。

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);

struct timeval tv = {5, 0};  /* 超时 5 秒 */
int ret = select(sockfd + 1, &readfds, NULL, NULL, &tv);
if (ret > 0 && FD_ISSET(sockfd, &readfds)) {
    /* sockfd 可读 */
}

select 的缺陷:每次调用都要把 fd_set 从用户态拷贝到内核态;返回后需要遍历所有 fd 检查状态;最大 fd 数受限。

四、poll:移除上限,但仍需遍历

poll 使用数组替代位图,没有 1024 限制,但本质上仍是线性扫描。

struct pollfd fds[2];
fds[0].fd = sockfd;
fds[0].events = POLLIN;

int ret = poll(fds, 2, 5000);  /* 超时 5000ms */
if (ret > 0 && fds[0].revents & POLLIN) {
    /* 可读 */
}

五、epoll:事件驱动,真正的 scalable

epoll 通过红黑树管理 fd,通过回调机制在事件发生时主动通知,避免了每次遍历全部 fd。

/* 1. 创建 epoll 实例 */
int epfd = epoll_create1(0);

/* 2. 注册 fd 到 epoll */
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

/* 3. 等待事件 */
struct epoll_event events[10];
int n = epoll_wait(epfd, events, 10, -1);
for (int i = 0; i < n; i++) {
    if (events[i].data.fd == sockfd) {
        /* 处理新连接或数据 */
    }
}

六、LT 与 ET 模式

  • LT(水平触发):只要 fd 处于可读状态,epoll_wait 就会持续报告。默认模式,编程简单。
  • ET(边缘触发):仅在状态变化时报告一次。要求必须一次性读写完所有数据,配合非阻塞 fd 使用。
/* ET 模式:events 加入 EPOLLET */
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
ET 模式减少了 epoll 触发次数,理论上更高效,但代码复杂度高。Nginx 使用 ET,Redis 使用 LT。

七、三种方案对比

  • select:兼容性好,但受 1024 限制,性能随 fd 增加线性下降
  • poll:无数量限制,但仍需遍历全部 fd,O(n) 复杂度
  • epoll:事件回调,O(1) 活跃通知,适合高并发(C10K/C10M)

八、极简 echo 服务器

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <arpa/inet.h>

int main(void) {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listen_fd, 128);

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

    while (1) {
        int n = epoll_wait(epfd, events, 64, -1);
        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == listen_fd) {
                int conn = accept(listen_fd, NULL, NULL);
                ev.events = EPOLLIN;
                ev.data.fd = conn;
                epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);
            } else {
                char buf[1024];
                int fd = events[i].data.fd;
                ssize_t r = read(fd, buf, sizeof(buf));
                if (r <= 0) { close(fd); continue; }
                write(fd, buf, r);
            }
        }
    }
}
编译:gcc -o echo echo.c && ./echo,另开终端用 nc localhost 8080 测试。