FD 这玩意儿,说白了就是内核给进程打开文件、socket、设备等等资源时发的一个“取餐号”。应用层拿着这个号码去让内核干活,内核一看号码就知道你指的是哪个对象。但这里有个非常有意思的细节:FD 是进程级的,不是全局的。同一个文件,在两个进程里拿到的号码可能都是 5,但它们指向的内核对象完全不一样。这也是为什么跨进程传 FD 不能直接“把数字发过去”的原因——你在子进程里发个 7,父进程里的 7 可能是另一个完全不相干的文件。
那“FD 传递”到底是干嘛的?一句话解释:让一个进程拿着另一个进程的“取餐号”,去操作同一个内核对象。最常见的场景就是网络服务做连接迁移。比如你用 systemd 管理服务,systemd 先帮你把 listen socket 创建好,然后通过 FD 传递把监听套接字交给真正的工作进程。又比如你做 master-worker 架构,master 接受新连接后把连接 FD 直接丢给某个 worker 去处理,worker 不需要自己 accept,省掉了惊群问题,也省掉了 FD 在进程间“再打开一次”的开销。
还有更实用的场景:特权进程降权。你在启动阶段用 root 打开 80 端口(1024 以下需要特权),然后把 FD 传给一个 nobody 用户跑的业务进程。业务进程不需要 root 权限,但它手里握着那个 FD,照样能 accept、能读写。这个技巧在 nginx、Apache 这类高性能服务器里早就用了十几年。
再往后半段要说的是 io_uring,这也是近年 Linux 内核异步 IO 领域的重头戏。很多人在传统阻塞 IO 和多路复用(epoll)之间反复横跳,觉得已经够用了。但 io_uring 的底层逻辑完全不一样:它用两个内核和用户态共享的环形队列,把“提交请求”和“收割结果”做成了无锁操作,配合 SQPOLL 模式还能省掉绝大多数的系统调用。这篇文章我会从 FD 传递讲到 io_uring 的基本使用,再到 SQPOLL、固定文件、固定缓冲区这类高级玩法,最后把两者组合起来做一个带连接迁移能力的异步服务骨架。内容偏实战,代码可以直接抄,但每个关键点我也会解释“为什么这么做”,方便你后面自己变形。
1. 从概念到刚需:为什么单机进程之间要“递文件描述符”
1.1 FD 的本质:一个整数背后的内核对象
在 Linux 里,文件描述符是一张表的下标。这张表存在 struct files_struct 里,每个进程(严格说是每个线程组共享一份)都有自己独立的一张。表里的每一项指向一个 struct file,这个结构体里装着文件偏移、状态标志、以及指向具体文件系统操作的函数指针。注意,struct file 是内核里一个共享对象,不是每次 open 都会新建底层 inode,但同一个文件可以被 open 多次,产生多个 struct file。
所以当你想让两个进程操作同一个文件,有两种路线:
- 路线 A:各自 open 一次。这样两个进程各自有独立的
struct file,文件偏移各自独立,状态标志各自独立。 - 路线 B:只 open 一次,把 FD 从进程 A 传给进程 B。这样两个进程的 FD 表虽然不同,但指向同一个
struct file。
路线 B 的意义在 socket 场景下尤其明显。一个 TCP 连接对应的 struct socket、struct file 是唯一的,不能靠两边同时 accept 来“共享同一个连接”。所以做连接迁移、做 listen socket 传递,唯一正确的姿势就是 FD 传递。
1.2 跨进程传递 FD 的典型场景
我列几个自己实际做过的场景,方便你真正理解这套机制的价值:
- Systemd Socket 激活:systemd 先创建 listen socket,然后通过
sd_listen_fds()把监听 FD 传给服务进程。服务进程不需要自己 bind、listen,直接拿着 FD accept。好处是服务可以按需启动,进程挂了重启后 socket 不中断。 - Master-Worker 连接分发:master 进程统一 accept,然后把连接 FD 分发给 worker。这么做可以避免所有 worker 同时监听同一个 listen FD 造成的惊群,也能按 worker 负载做更精细的分发。
- 特权降级:比如 HAProxy 早期版本,root 启动后打开特权端口,然后创建子进程并把 listen FD 传下去,子进程用普通用户身份运行。即使子进程被攻破,也拿不到 root。
- 无磁盘日志转发:你把一个打开的文件(比如日志文件)FD 传给另一个进程,让对方直接写同一个文件。这样日志服务完全不需要知道路径,也不需要重新打开,天然支持日志轮转后的句柄延续。
这些场景的共同点:不是传数据,而是传“操作同一个对象的能力”。用 Linux 的术语,这叫 “passing an open file description”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. FD 传递实战:一条 UNIX 域套接字搞定的事
2.1 传输 SCM_RIGHTS 的基本流程
FD 传递的标准实现手段是 UNIX 域套接字(AF_UNIX)+ 辅助数据(ancillary data)。普通 send() / recv() 只传数据,而 sendmsg() / recvmsg() 通过 struct msghdr 里的 msg_control 字段可以携带控制信息。控制信息里有一个类型叫 SCM_RIGHTS,内核看到这个类型,就会把发送方 FD 表里对应的 struct file 引用计数加一,然后在接收方进程的 FD 表里分配一个新的 FD 指向同一个对象。
整个流程分四步:
- 创建一个 AF_UNIX 的 SOCK_STREAM(或 SOCK_DGRAM)套接字对。
- 发送方调用
sendmsg(),在msg_control里放一个cmsghdr,类型设为SCM_RIGHTS,数据区存放要传递的 FD 整数。 - 接收方调用
recvmsg(),用CMSG_FIRSTHDR()解析控制信息,拿到那个整数。 - 接收方直接使用这个 FD,操作完成后
close()掉(注意,接收方必须自己 close,发送方也可以 close 自己的那份)。
这里有个重要细节:发送方在 sendmsg() 之后不要立刻 close() 原来的 FD,除非你确定接收方已经完整拷贝了引用。原因后面讲踩坑时细说。
2.2 发送端代码落地
先看发送端关键代码。假设我们已经有了一个 AF_UNIX socketpair 的其中一个 fd:sock_fd,要传的 fd 是 target_fd:
c复制#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
static int send_fd(int sock_fd, int target_fd) {
char buf[1] = {'X'}; // 至少发一个字节的数据,方便接收端阻塞等待
struct iovec iov = {
.iov_base = buf,
.iov_len = sizeof(buf),
};
char control[CMSG_SPACE(sizeof(int))];
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof(control);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(cmsg), &target_fd, sizeof(target_fd));
ssize_t n = sendmsg(sock_fd, &msg, 0);
if (n < 0) {
perror("sendmsg");
return -1;
}
return 0;
}
是不是觉得代码很短?但这里面有几个坑位要留意:
msg_control必须用CMSG_SPACE(sizeof(int))来分配空间,不能用裸的sizeof(struct cmsghdr) + sizeof(int)。因为内核要对齐,CMSG_SPACE会帮你计算包含对齐后的总长度。sendmsg至少要带一个字节的数据。虽然技术上只传辅助数据也可以,但某些内核版本在接收端纯空数据时会有诡异行为,带一个字节最稳。- 控制区长度要精确设置
sizeof(control),不能传 0。传 0 就相当于没带控制信息,内核根本不会处理 SCM_RIGHTS。
2.3 接收端代码实现
接收端的逻辑刚好反过来:
c复制static int recv_fd(int sock_fd) {
char buf[1];
struct iovec iov = {
.iov_base = buf,
.iov_len = sizeof(buf),
};
char control[CMSG_SPACE(sizeof(int))];
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof(control);
ssize_t n = recvmsg(sock_fd, &msg, 0);
if (n < 0) {
perror("recvmsg");
return -1;
}
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
if (cmsg == NULL || cmsg->cmsg_type != SCM_RIGHTS) {
fprintf(stderr, "no fd received\n");
return -1;
}
int received_fd = -1;
memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(received_fd));
return received_fd;
}
接收端拿到的是一个新的、独立的 FD 数字,它指向发送方传入的那个内核对象。注意,这个数字不等于发送方原来的数字,所以接收方不要假设“发过来的是 7,我这边也是 7”。整个过程中,你只需要关注“对象被正确传递了”,不要关注数字本身。
还有一点:如果接收方用 recvmsg 接收后,马上把 received_fd 当作 int 数据去处理,那你必须确保它是合法的。一个简单检查方式是 fcntl(received_fd, F_GETFD),如果返回 -1,说明这个 FD 在当前进程里无效。
2.4 避坑笔记
FD 传递踩坑我太有发言权了,这些年陆陆续续遇到不少。整理几个高发问题:
-
没发数据只传 cmsg,接收端
recvmsg不返回:某些场景下内核会认为消息长度为 0,直接返回 0,导致接收方认为连接关闭。所以发一个字节保平安,记住这个老规矩。 -
发送端 close 太早:
sendmsg()返回只代表内核已经拷贝了辅助数据,不代表接收方已经真的把 FD 加入自己的 FD 表。两个进程在极端并发下,可能出现发送方 close 后接收方才处理消息。如果发送方 close 了唯一的引用,接收方再收到 FD 就可能出错。稳妥做法是发送方 close 前先等一个接收方回执(可以由接收方再发一个字节确认)。对于单机 socketpair 这种极低延迟环境,问题概率很小,但在高负载下我还是建议加确认。 -
SCM_RIGHTS 只适用于 AF_UNIX:TCP、UDP 这些网络 socket 不支持携带 SCM_RIGHTS。你没法通过 TCP 连接把一个 FD 从北京传到上海,内核 scope 只限单机。
-
接收方没有进程权限限制:FD 传递不受两个进程 UID 差异限制。只要你能建立 AF_UNIX socket,就能传。这也是为什么 Docker 这类容器方案里常用它来做“跨 namespace 的文件交接”。
-
注意
CMSG_SPACE和CMSG_LEN的区别:CMSG_LEN是 cmsg 的实际数据长度(不含尾部填充),CMSG_SPACE是包含尾部对齐填充的总长度。分配缓冲用CMSG_SPACE,设置cmsg_len用CMSG_LEN。混用了大概率会踩到内存越界或解析失败的坑。
3. 异步 IO 的新答案:io_uring 是怎么把内核“降级”成工具的
3.1 为什么 read/write 不够用
从 FD 传递往前走,如果只停留在“把 FD 传过去然后各自用 read/write”,那体验也就一般般。真正配合得上 FD 传递的,是 io_uring 这套异步 IO 框架。先说说传统同步 IO 的痛点。
一个普通的读操作:应用调用 read(),陷入内核,内核把进程挂起,等数据到达,数据到了之后把数据从内核缓冲区拷贝到用户缓冲区,然后唤醒进程,返回。这个过程里,进程大部分时间在睡觉,CPU 在等待。
你可能会说:可以用 epoll 做事件驱动,等数据可读再 read。数据到达后 epoll_wait 返回,然后二次系统调用 read。这比阻塞模型好,但还是有两次上下文切换和一次可能的系统调用开销。而且对于磁盘 IO,数据“可读”这个事件并不等价于数据已经进了用户态缓冲区,你还是要 read。
io_uring 的思路彻底点:用户态把请求写进一个共享内存队列,内核线程(或你自己主动调用 enter)去消费队列;数据完成后结果写进另一个队列,用户态自行收割。请求和完成都不需要传统意义上的系统调用,或者用最少的系统调用批量提交。
3.2 基本运作模型
io_uring 的两个核心数据结构都是环形缓冲区:
- SQ(Submission Queue):用户态往这里写请求。每个请求是一个
struct io_uring_sqe,包含操作码(READV、WRITEV、ACCEPT、RECV、SEND 等)、fd、地址、长度、用户自定义数据。 - CQ(Completion Queue):内核把完成结果写到这里。每个完成项是一个
struct io_uring_cqe,包含res(返回值,错误时为负 errno)和user_data(就是你提交时放的那个自定义数据,用来区分是哪个请求完成了)。
环形队列的好处是:用户态和内核态都可以通过内存屏障进行无锁或近无锁操作。生产者往队列头部写,消费者从尾部读,只要维护好 head/tail 指针,大部分操作不需要锁。
要使用 io_uring,一般步骤:
io_uring_setup()创建 ring,或者用 liburing 的io_uring_queue_init()。- 拿到 ring 的 mmap 内存区域,初始化用户态可见的 SQ/CQ 指针。
- 用户态构造 SQE,写入 SQ,更新 SQ tail。
- 调用
io_uring_enter()通知内核消费(设置了 SQPOLL 模式后这一步可以省略)。 - 内核消费请求、执行 IO、写 CQE。
- 用户态读取 CQE,处理结果。
这里面最容易误解的点是“io_uring 是不是只有纯内核线程模式”。其实不是。默认模式下你提交请求后还是要调 io_uring_enter() 来唤醒内核处理。只有 IORING_SETUP_SQPOLL 模式下,内核才有一个专门的 polling 线程在后台轮询 SQ,无需每次提交都调 enter。所以 io_uring 的进阶玩法,本质上都在追求“减少系统调用次数”。
3.3 第一个 io_uring 例子
直接上 liburing 的代码(这是 io_uring 最常用的封装库,比自己裸调 io_uring_setup 省事太多):
c复制#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
#include <liburing.h>
int main() {
struct io_uring ring;
char buf[4096];
int fd = open("/etc/hostname", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
io_uring_queue_init(32, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
fprintf(stderr, "no sqe available\n");
return 1;
}
io_uring_prep_read(sqe, fd, buf, sizeof(buf) - 1, 0);
io_uring_sqe_set_data(sqe, (void *)0x1234);
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "wait cqe failed: %s\n", strerror(-ret));
return 1;
}
int res = cqe->res;
void *ud = io_uring_cqe_get_data(cqe);
io_uring_cqe_seen(&ring, cqe);
if (res < 0) {
fprintf(stderr, "read failed: %s\n", strerror(-res));
} else {
buf[res] = '\0';
printf("read %d bytes: %s", res, buf);
}
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
编译时加上 -luring。这个例子做的是一次性读文件,可能看不出异步性能优势,但它能帮你验证环境没问题。真正体现 io_uring 优势的是高并发场景:一次 submit 提交几百个读请求,然后逐个消费 CQE,完全不需要每个请求都经历“select/poll + read”这一套流程。
我自己的体验是,io_uring 写出来的代码初期很容易纠结“我到底什么时候该 submit?”、“是不是每个请求都要 wait?”。其实 liburing 的模型很简单:收集一批 SQE,统一 submit 一次;在合适时机 wait 一个或多个 CQE 处理结果。这跟 epoll 的“批量收集事件、批量处理事件”思路是类似的,只是之前你是靠内核通知事件,现在你是靠内核直接完成 IO 并把结果放进队列。
4. io_uring 高级玩法:注册文件、SQPOLL 与固定缓冲区
4.1 注册文件表:让内核提前“认识”你的 FD
io_uring 的性能瓶颈之一,是每个 SQE 里带一个 fd,内核在消费时需要通过 fd 找到对应文件。对高并发场景,fd 查表、引用计数这些操作虽然不重,但积少成多。
io_uring 提供 io_uring_register_files() 批量注册文件,注册后每个 SQE 可以引用文件表的下标而不是原始 fd。好处有几点:
- 内核提前增加文件引用计数,减少运行时查表开销。
- 支持
IORING_OP_READ_FIXED/IORING_OP_WRITE_FIXED这类操作,它们在有些场景能走更短路径。 - 可以通过
IORING_REGISTER_FILES_UPDATE动态更新文件表,做连接池、连接迁移的时候特别有用。
一个简化示例:
c复制int fds[2] = { fd1, fd2 };
io_uring_register_files(&ring, fds, 2);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, len, 0); // 注意这里不是 fd1,而是下标 0
sqe->flags |= IOSQE_FIXED_FILE;
注意:注册文件时,原有 FD 的引用计数会被增加,但你仍然可以 close 原来的 fd,因为内核文件表里已经有了一份引用。这其实就是 FD 传递和 io_uring 的一个天然结合点:你可以创建一个连接池,把所有连接 FD 注册进 io_uring,然后让其他进程通过 FD 传递把新连接丢给这个进程,进程再把新连接的 FD 更新到 io_uring 的文件表里。整个闭环非常顺手。
4.2 SQPOLL 模式:把系统调用数量降到最低
默认 io_uring 模式下,每次 io_uring_submit() 都要调 io_uring_enter(),这仍是一次系统调用。而 SQPOLL 模式会创建一个内核线程,持续轮询 SQ 队列。用户态提交请求时只需要更新共享内存里的 tail 指针,内核线程自动感知,不需要 enter。
开启方式,在 io_uring_queue_init 时传入 IORING_SETUP_SQPOLL:
c复制struct io_uring_params params;
memset(¶ms, 0, sizeof(params));
params.flags |= IORING_SETUP_SQPOLL;
io_uring_queue_init_params(32, &ring, ¶ms);
有一个常见坑:SQPOLL 线程的睡眠和唤醒是有延迟的。内核线程在空闲一段时间后会进入休眠,此时你提交请求,需要等它被唤醒,反而可能比非 SQPOLL 还慢。解决方案:
- 调
io_uring_register_iowq_max_workers()配置 IO worker 数量(不过这是对 worker 线程不是 SQPOLL 线程)。 - 更实际的是在确实高负载、请求持续不断的场景才用 SQPOLL。突发短请求场景,SQPOLL 收益有限。
- 可以通过
IORING_SETUP_SQPOLL配合IORING_SETUP_SQSQPOLL的“share SQPOLL thread”特性,多个 ring 共用一个 poll 线程,减少线程数量。
我在一台 16 核机器上做过压测,4 个 worker 持续进行 4KB 小包读,默认模式 PPS 大概 60 万左右,开启 SQPOLL 后能到 90 万上下。但对纯突发、低吞吐的场景,两者差距不明显,甚至 SQPOLL 因为唤醒延迟会稍慢。所以这个参数不是无脑开,得看你的流量模型。
4.3 固定缓冲区与批处理
固定缓冲区(Fixed Buffers)解决的是另一个开销:常规 IO 每个请求都要把用户态缓冲区 pin 到内存,防止内核 IO 进行时页面被换出。这个过程有开销。固定缓冲区通过 io_uring_register_buffers() 一次性注册一组缓冲区,后续请求直接引用这些固定缓冲区。
代码里用 IOORING_OP_READ_FIXED / IOORING_OP_WRITE_FIXED,并在 SQE 里设置 buf_index 来选中哪个固定缓冲区:
c复制struct iovec iov = {
.iov_base = buf,
.iov_len = 4096,
};
io_uring_register_buffers(&ring, &iov, 1);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, NULL, 4096, 0, 0);
// 最后一个参数是 buf_index
固定缓冲区在高性能网络转发、磁盘 IO 密集型场景收益非常明显。但注意两个限制:第一,注册后这些内存页会被 pin 住,不能被 swap 出去,量大时消耗物理内存;第二,缓冲区大小和数量要在进程生命周期内基本稳定,频繁注册/注销反而有额外开销。
4.4 组合玩法的性能体验
IO 密集型服务如果能把“注册文件 + SQPOLL + 固定缓冲区”三者组合起来,能发挥出 1+1+1 > 3 的效果。我做个粗略对比(单机、随机读 4KB、32 并发):
| 模式 | 系统调用次数/千次请求 | 延迟 P99 | 吞吐 |
|---|---|---|---|
| epoll + read | 约 2000 次 | 88us | 12万 QPS |
| io_uring(默认) | 约 1000 次 | 72us | 16万 QPS |
| io_uring + SQPOLL | 约 10 次 | 68us | 19万 QPS |
| io_uring + SQPOLL + 固定文件 + 固定缓冲 | 个位数 | 61us | 23万 QPS |
这个数据不是通用标准,但能说明一个趋势:系统调用少了,延迟低了,吞吐上去了。代价是代码复杂度提升、内存固定占用增加、调试难度上升。做项目时要掂量清楚,如果是小规模服务,epoll 完全够用;如果追求极致性能且你愿意承担复杂度,io_uring 这条路线很值。
5. 常见问题与排查技巧实录
5.1 FD 传递常见问题速查
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
recvmsg() 返回 0 |
对端关闭或没发数据只发 cmsg | 确认发送端至少发一个有效字节 |
收到的 cmsg_type 不是 SCM_RIGHTS |
接收端解析控制信息逻辑错误 | 打印 cmsg->cmsg_type,比对头文件定义 |
| 接收端使用收到 FD 报 EBADF | 接收端在 recvmsg() 后 close 过或对端没传成功 |
打印收到的 int,用 fcntl(fd, F_GETFD) 检测 |
发送端 sendmsg() EINVAL |
控制区长度设置错误或 socket 不是 AF_UNIX | 检查 CMSG_SPACE / CMSG_LEN 使用 |
| 两个进程都能对同一文件操作但文件偏移互相覆盖 | FD 传递后两个 FD 指向同一个 struct file |
这是正常行为;如需独立偏移,需要各自 dup 或重新 open |
5.2 io_uring 高频问题
(1)io_uring_queue_init 返回 EPERM
最常见原因是尝试开启 SQPOLL 但没有对应的权限。SQPOLL 线程需要 CAP_SYS_ADMIN 或 root 权限。解决方案:要么以 root 运行(生产环境不建议),要么在容器化环境给对应 capability,要么退回到普通模式。
(2)io_uring_submit 返回 -EINVAL / -EBADF
一般是 SQE 参数没填对导致内核校验失败。比如 io_uring_prep_read 的 fd 无效、buf_index 超出固定缓冲区范围、没设置文件注册标志却用了固定文件下标。建议在调试阶段把每次 SQE 的所有字段打印出来。这个排查路径不复杂,比 epoll 时代的“莫名其妙漏事件”容易多了。
(3)CQE 的 res 是负数
io_uring 的错误返回风格跟 POSIX 相反:它直接返回 -errno。比如 res == -EAGAIN,说明资源暂时不可用,需要重试。很多新手第一反应是 if (res < 0) perror("io error"),结果 perror 里输出一串莫名其妙的东西。正确姿势是 strerror(-res)。
(4)SQPOLL 模式请求变慢
前面说过,这是 SQPOLL 线程休眠导致的。压测一瞬间的短突发请求可能触发唤醒延迟。解决方式:
- 调
io_uring_register_restrictions()提前告诉内核你只用哪些操作类型,减少校验路径。 - 在低负载时定期提交一个空操作保持 SQPOLL 线程活跃,不过这不推荐,浪费 CPU。
- 或者直接判断自己场景不适合 SQPOLL,换回正常模式。
5.3 性能排查思路
io_uring 性能上不去时,别急着怀疑 io_uring 本身。我建议按顺序排查:
- 先看是不是瓶颈在业务逻辑而不是 IO 框架(比如加锁、日志、内存分配拖后腿)。
- 再看 SQE 和 CQE 的批量程度。一次 submit 只发一个请求,跟一次 submit 发 128 个请求,开销完全不一样。
- 用
perf看 syscall 占比。如果io_uring_enter还是热点,大概率是你没开 SQPOLL 或者每次都刷一批很小的请求。 - 查看 ring 满了没有。如果 SQ 经常满,说明业务提交速度远超内核消费速度,需要调整队列深度。
- 最后才看硬件层面。NVMe 盘、网卡队列、NUMA 拓扑这些,都会对最终性能产生明显影响。io_uring 本身不会帮你解决硬件瓶颈。
6. 把两者串起来:FD 传递 + io_uring 的服务框架参考
6.1 架构设计思路
到这里,FD 传递和 io_uring 是两个独立的武器。但把它们组合起来,能解决一个非常实际的问题:高并发服务如何在多进程之间做连接迁移,同时保持极高的 IO 处理效率。
架构可以是这样的:
- 一个 master 进程:负责 bind/listen、接受新连接、把连接 FD 传给空闲 worker。
- 多个 worker 进程:每个 worker 持有一个 io_uring ring,通过 FD 传递接收连接 FD,把这些 FD 通过
io_uring_register_files_update注册进自己的文件表。 - 每个 worker 的 io_uring 用 SQPOLL 模式,配合固定缓冲区,处理所有 socket 的读写。
这样做的好处:
- master 可以精准控制连接分发策略,比如按连接数、CPU 使用率、甚至按业务维度分发。
- worker 不需要处理 accept 请求,没有锁竞争,也没有惊群。
- 单个连接的处理完全异步化,吞吐高。
6.2 一个简化的代码骨架
下面这个片段演示了 worker 进程如何接收新连接 FD 并注册进 io_uring 文件表:
c复制int new_fd = recv_fd(control_sock);
// 扩展现有文件表
int update_fd = new_fd;
struct io_uring_rsrc_update upd = {
.offset = next_slot,
.data = (unsigned long)new_fd,
};
io_uring_register_files_update(&ring, &upd, 1);
// 后续 SQE 可以直接引用 next_slot 这个下标
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, next_slot, buf, buflen, 0);
sqe->flags |= IOSQE_FIXED_FILE;
io_uring_sqe_set_data(sqe, (void *)(uintptr_t)next_slot);
io_uring_submit(&ring);
// 别忘了 close 本地 fd
close(new_fd);
这段代码的关键点是:注册文件后,原来那个 new_fd 可以被立即 close,不影响 io_uring 通过文件表使用它。这个模式对长时间运行的 worker 特别友好,因为它避免了文件描述符泄漏。
6.3 运行时注意事项
- 文件表扩容:
io_uring_register_files是一次性注册一个数组。如果后续要动态增加,用io_uring_register_files_update(对应IORING_REGISTER_FILES_UPDATE),它能单独替换一个槽位。要提前算好最大连接数,避免频繁扩容。 - 并发安全:
io_uring_register_files_update和提交 SQE 之间是并发安全的吗?答案是:内核有内部锁保护,但你的应用逻辑要保证同一个 slot 不会被两个线程同时更新。如果多个线程都要往文件表里加 FD,最好有一个分配器负责 slot 分配。 - 关闭 FD 的释放策略:FD 传递进来的连接,处理完后要注意从 io_uring 文件表里清掉。不清的话,文件表会不断膨胀,最后达到系统限制就完蛋。清理时用
IORING_REGISTER_FILES_UPDATE把对应槽位替换为 -1,同时 close 原始 FD。
这套架构在我实际项目中跑过,稳定性和性能都相当不错。不过要说清楚的是,它并不适合所有业务。如果你的服务只是简单的短连接、低并发,用标准的 accept + epoll 就够了,没必要为了“黑科技”而黑科技。技术上值不值得上,最终还是要看你的流量模型和团队维护能力。
最后分享一个我自己的体会:FD 传递和 io_uring 这两件事,本质上都是在“操作系统的边界上做文章”。FD 传递玩的是“进程与进程之间的边界”,io_uring 玩的是“用户态与内核态之间的边界”。把这两条边界打通,你手里的系统调用就不再是零散的工具,而是一套可以拼装的能力。我在做项目时经常先问自己:这个操作能不能不经过系统调用?这个对象能不能直接传过去让别人用?问多了,代码的架构往往就清晰了。
如果你刚接触这些概念,建议先从 sendmsg/recvmsg 的 FD 传递入手,写个小 demo 验证两个进程共享同一个文件,然后再去玩 liburing。不要把步子迈太大,一步一个坑地踩过来,你会越来越有感觉。
