这篇文章聊聊Linux里一个挺有意思的机制:fd传递。也就是说,把一个已经打开的文件描述符,通过Unix域套接字从一个进程交给另一个进程,让对方也能读、能写、能accept、能操作同一个打开的文件。这算是Linux进程间通信里比较进阶的一块内容,也是很多后台研发、嵌入式Linux开发会碰到的真实需求。
为什么会需要这个能力?举个最常见的例子:systemd的socket activation。systemd在服务进程启动之前,先把要监听的端口bind好、listen好,等服务进程跑起来之后,把监听socket的fd通过环境变量和辅助数据传过去,服务进程拿到这个fd后直接accept就行,不用自己再去bind,也就避开了“端口已经被占用”“权限不够绑定端口”这一堆破事。类似的机制在nginx的master/worker进程、容器运行时、Wayland图形栈里都很常见。你要是正在准备Linux面试,“fd传递”几乎是进程间通信环节必考的一道进阶题;如果你在做服务治理或者中间件,那它就是你必须掌握的基础件。
这篇文章我会从原理切入,先把fd的本质讲透,再给一个可以直接编译运行的最小实现,最后把我实际开发中踩过的坑和排查思路整理出来。内容不烧脑,建议按顺序读,看完你就能自己动手写一个。
1. 先搞清楚:fd传递到底在传什么
1.1 文件描述符、file对象和inode的关系
先说一个最基础的认知:在Linux里,你看到的“fd = 3”并不等于“3号文件”。fd只是进程文件描述符表(fdtable)里的一个下标,这张表的每个表项存了一个指针,指向内核里的struct file对象。
这里有三层东西,必须分开:
- 文件描述符(fd):一个非负整数,进程私有,是fdtable的下标。
- 打开文件描述(struct file):内核里的一个对象,记录了当前文件偏移量、访问模式、文件状态标志比如O_NONBLOCK,还有引用计数。同一个文件可以open两次,生成两个独立的file对象,偏移量互不干扰。
- inode:真正磁盘或设备上的那个文件实体,一个inode可以被多个file对象引用。
进程A说fd=3,进程B也说fd=3,这两个3对应的file对象可能完全不同;反过来,进程A的fd=3和进程B的fd=7也可能指向同一个file对象。所以fd这个数字,跨进程是没有任何意义的。
为什么要把这个认知放在最前面?因为我见过太多人第一次接触fd传递时,下意识觉得“把fd数字传过去,对方按这个数字操作文件不就行了”。不是这样的。真正的共享单元是file对象,fd传递传的其实是“让接收进程的fdtable也指向发送进程的那个file对象”。
可以打一个生活里的比方:file对象是某个酒店里的一个房间,fd是酒店前台发出的房卡号。每个进程相当于一家独立前台,都有自己的一套房卡编号。同一个房间,进程A的前台给它编号301,进程B的前台可能给它编号207,但两张卡都能打开同一扇门。fd传递做的事情,就是让B进程的前台也登记这个房间,并给出一张属于B自己的房卡。
理解了这个,后面的代码和内核行为就都顺理成章了。
1.2 进程间共享打开文件的几种途径
那有没有别的方式可以做到“让另一个进程操作同一个打开的文件”?
第一是fork继承。fork之后,子进程会复制父进程整个fdtable,两边fd指向相同的file对象。这种方式最简单,但是有两个限制:一是只能在父子进程之间用,二是子进程如果紧接着exec执行新程序,这些fd默认还会留着,除非提前设置了FD_CLOEXEC。nginx的worker其实主要就是靠fork继承监听fd的,这个模式所有做服务端的人都熟。
第二是dup/dup2/dup3。这是在进程内复制fd,也是指向同一个file对象,但它不跨进程,顶多是同一个进程里多个fd共享偏移量。
第三是SCM_RIGHTS,也就是通过Unix域套接字的辅助数据把fd从任意进程传给任意进程。不要求父子关系,不需要fork,是真正的“传递”。
第四是重新打开文件。按路径重新open一次,但这对普通文件还行,对socket、pipe、epoll这类根本没有路径的资源就完全没辙了。
整理一下:
| 方式 | 是否跨进程 | 是否要求父子关系 | 适用范围 |
|---|---|---|---|
| fork继承 | 是,仅限父子 | 是 | 任意fd |
| dup/dup2 | 否 | 否 | 任意fd |
| SCM_RIGHTS | 是,任意进程 | 否 | 任意fd |
| 重新打开 | 是 | 否 | 仅限有路径的文件 |
这也是为什么生产上做fd传递,基本都会选SCM_RIGHTS。它不限关系,覆盖所有fd类型,而且语义干净——传的是“引用”,不是“数据副本”。
1.3 实际场景里为什么要用fd传递
抛开教科书,我列几个真实场景,你感受一下:
systemd socket activation。这是最经典的。systemd以root身份把80端口或443端口先listen好,服务进程只是普通用户,启动后通过sd_listen_fds拿fd,不需要任何特权也能accept客户端连接。这里有个隐含的安全收益:服务进程压根不需要bind权限,也不需要知道端口号,所有端口生命周期由systemd统一管理,服务重启时端口不抖动。
nginx这类master-worker模型的热升级。虽然worker通常是fork出来的,fd天然继承,但在某些平滑迁移场景里,master也可能把listen_fd通过SCM_RIGHTS交给一个全新的、已经exec过的新进程。这样能做到“旧连接不中断,新连接自动落新进程”。
容器运行时。containerd、CRI-O这类组件在拉起容器进程的时候,经常通过Unix socket把一些已经打开的fd传进去,比如终端fd、日志fd、挂载点fd。容器内进程拿到的就是一个现成的、已经配置好的fd,自己不用再做一堆初始化。
共享内存和DMA-BUF的零拷贝传递。在图形栈里,客户端把一块DMA-BUF的fd传给Wayland合成器,合成器拿着fd就能mmap、可以采样,整个过程不拷贝图像数据。fd传递在这里就是“传引用”,真正的大块内存只存在一份。
还有一个有意思的用法是权限降级:特权进程先打开一个高权限文件,然后把fd传给一个低权限进程。接收方虽然没有那个文件的路径权限,但因为fd已经打开了,它依然可以读写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 传递通道:为什么是Unix域套接字加SCM_RIGHTS
2.1 为什么只有Unix域套接字能传fd
这个问题经常有人问:为什么不能用TCP或者UDP传fd?答案其实一句话:因为传fd这件事必须由内核来做,而且必须做安全处理,只有内核有能力把一个进程的file对象引用安全地交给另一个进程。TCP/UDP的协议栈只认识字节流和数据报,根本不知道“文件描述符”是什么概念。
Unix域套接字之所以能干这个活,是因为它整个实现都在内核里,天然认识双方的file对象,并且在sendmsg/recvmsg这套接口里预留了“辅助数据”的通道。而且Unix域套接字只在本机通信,不走网络协议栈,所以它可以携带指向本地内核对象的引用。
打个极端一点的比方:fd传递是“把房卡复制一份给别人”,TCP是“把房间里的东西打包快递过去”,后者对本地内核对象来说根本不可能。
顺带说一句,SCM_RIGHTS只能在同一台机器上生效,跨机器是绝对做不到的。fd是本地概念,不可能序列化到网络包。
2.2 消息头与辅助数据的内存布局
核心API是sendmsg和recvmsg。普通数据走msg_iov,辅助数据走msg_control。消息头长这样:
c复制struct msghdr {
void *msg_name; /* 目的地址,流式socket可以置空 */
socklen_t msg_namelen; /* 地址长度 */
struct iovec *msg_iov; /* 数据缓冲数组 */
size_t msg_iovlen; /* iovec个数 */
void *msg_control; /* 辅助数据缓冲区 */
size_t msg_controllen; /* 辅助数据缓冲区长度 */
int msg_flags; /* 接收时返回的标志,如MSG_CTRUNC */
};
辅助数据缓冲区里装的是一个或多个cmsghdr结构:
c复制struct cmsghdr {
size_t cmsg_len; /* 本控制消息长度,包含头部 */
int cmsg_level; /* 必须为SOL_SOCKET */
int cmsg_type; /* SCM_RIGHTS表示传fd */
/* 紧跟着是数据部分,一个或多个int fd */
};
Linux提供了一组宏来操作这堆结构:CMSG_SPACE、CMSG_LEN、CMSG_DATA、CMSG_FIRSTHDR、CMSG_NXTHDR。
有个坑在这里必须提前说:CMSG_LEN和CMSG_SPACE不相等,因为辅助数据需要内存对齐。CMSG_LEN是用来设置cmsg_len字段的,CMSG_SPACE是用来分配缓冲区大小的。你在代码里分不清这两个,后面写出来的程序就会神不知鬼不觉地丢fd。
2.3 内核收发路径上发生了什么
发送端调用sendmsg后,内核检测到辅助数据里是SOL_SOCKET + SCM_RIGHTS,会去做这样几件事:
第一步,把发送进程fdtable里这些fd数字对应的file对象全取出来,逐个递增引用计数。注意,这里拷贝的绝对不是文件内容,只是file对象的引用。
第二步,把这些file引用挂到当前要发送的这个skb上,然后随数据一起发出去。
到了接收端,recvmsg进入内核之后,内核会从skb上取下这些file引用,然后在接收进程的fdtable里分配一个最小的空闲fd号,调用fd_install把file对象安装进去。安装完成之后,用户态的recvmsg才返回,接收进程拿着新fd就能直接操作同一个file对象。
这个地方有两个容易误解的点。
第一,接收进程拿到的fd号,跟发送进程原来的fd号没有任何关系。接收进程当前开着哪些fd、哪个fd号空闲,完全取决于它自己的fdtable状态。所以“发送端是3,接收端还是3”只是巧合,拿数字去对账一定会产生错觉。
第二,为什么发送方close掉这个fd之后,接收方手里的fd依然有效?因为sendmsg的时候内核已经把引用计数加过了,接收方那边持有一个独立的“票”,发送方close只是把自己的那张票销毁,不会影响接收方的引用。
这里的内核调用路径我就不逐行贴源码了,但你理解这个概念之后,去看内核代码时会很有方向:发送路径大概在unix_stream_sendmsg里处理辅助数据,接收路径在unix_stream_recvmsg里,最终会走到接收进程的fdtable分配和fd_install。
3. 最小实现:父子进程通过fd传递移交监听socket
3.1 代码设计与预期行为
上面说了这么多,现在上一个真正能跑的demo。
我设计一个这样的场景:父进程创建一个TCP监听socket,绑到127.0.0.1:8899,listen好之后,再创建一对Unix域套接字作为传fd的通道。然后fork子进程,父进程通过send_fd把监听fd传给子进程,传完自己close。子进程收到fd后,用这个fd直接accept一个客户端连接,发一句话回去。
这个demo能同时验证三件事:第一,子进程拿到的确实是同一个监听socket,能正常accept;第二,父进程即使已经close了原fd,连接依然能被处理,说明底层file对象引用还在;第三,你可以观察到父子进程打印出来的fd数字不一样,理解“fd号跨进程无意义”这件事。
3.2 可直接编译的完整代码
我直接把完整代码贴出来,你自己保存成fdtransfer_demo.c就能编。
c复制// fdtransfer_demo.c —— Linux fd传递最小可运行示例
// 编译:gcc -Wall -o fdtransfer fdtransfer_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/uio.h>
static int send_fd(int sock, int fd_to_send)
{
struct msghdr msg;
struct iovec iov;
struct cmsghdr *cmsg;
char byte = 'S';
char control[CMSG_SPACE(sizeof(int))];
memset(&msg, 0, sizeof(msg));
memset(control, 0, sizeof(control));
iov.iov_base = &byte;
iov.iov_len = 1;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof(control);
cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(fd_to_send));
if (sendmsg(sock, &msg, 0) < 0) {
perror("sendmsg");
return -1;
}
return 0;
}
static int recv_fd(int sock)
{
struct msghdr msg;
struct iovec iov;
struct cmsghdr *cmsg;
char byte;
char control[CMSG_SPACE(sizeof(int))];
ssize_t n;
memset(&msg, 0, sizeof(msg));
memset(control, 0, sizeof(control));
iov.iov_base = &byte;
iov.iov_len = 1;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof(control);
n = recvmsg(sock, &msg, 0);
if (n <= 0) {
perror("recvmsg");
return -1;
}
if (msg.msg_flags & MSG_CTRUNC) {
fprintf(stderr, "control data truncated\n");
return -1;
}
for (cmsg = CMSG_FIRSTHDR(&msg); cmsg != NULL;
cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET &&
cmsg->cmsg_type == SCM_RIGHTS) {
int fd;
memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd));
return fd;
}
}
return -1;
}
int main(void)
{
int sv[2];
int lfd;
struct sockaddr_in addr;
pid_t pid;
int opt = 1;
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) {
perror("socketpair");
exit(EXIT_FAILURE);
}
lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(8899);
if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(EXIT_FAILURE);
}
if (listen(lfd, 16) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("parent: listening fd=%d\n", lfd);
pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* 子进程 */
close(lfd);
close(sv[1]);
int got = recv_fd(sv[0]);
if (got < 0) {
fprintf(stderr, "child: recv_fd failed\n");
exit(EXIT_FAILURE);
}
printf("child: got fd=%d\n", got);
struct sockaddr_in cli;
socklen_t clen = sizeof(cli);
int cf = accept(got, (struct sockaddr *)&cli, &clen);
if (cf < 0) {
perror("child accept");
exit(EXIT_FAILURE);
}
const char *reply = "hello from child, fd transfer works!\n";
write(cf, reply, strlen(reply));
close(cf);
close(got);
close(sv[0]);
exit(EXIT_SUCCESS);
}
/* 父进程 */
close(sv[0]);
if (send_fd(sv[1], lfd) < 0) {
fprintf(stderr, "parent: send_fd failed\n");
}
printf("parent: fd %d sent\n", lfd);
close(sv[1]);
close(lfd);
wait(NULL);
return 0;
}
这段代码有几个地方是刻意保留的课堂式写法,理解它们比复制粘贴重要。
send_fd里我塞了一个一字节的payload。这个字节不是必须的,但它承担了一个很实际的职责:在SOCK_STREAM上,辅助数据和普通数据是绑定在同一个skb上的,带上一个字节可以帮你确认收端对应的recvmsg确实把这次消息读全了,避免流式socket里的粘包和错位。
recv_fd里我用CMSG_FIRSTHDR配合CMSG_NXTHDR遍历所有辅助数据。只传一个fd时很多人直接取第一个cmsg就完事了,但养成遍历的习惯是好的,因为你不知道对端会不会额外带上SCM_CREDENTIALS之类的凭证信息。
代码里我还检查了MSG_CTRUNC标志。这个标志一旦置位,说明控制数据被截断了,也就是接收缓冲区不够大,fd大概率已经丢了一部分,这种情况下继续往下用是非常危险的。
3.3 跑起来看效果:fd号为什么会变
编译运行:
bash复制gcc -Wall -o fdtransfer fdtransfer_demo.c
./fdtransfer
程序跑起来后会fork子进程,然后子进程阻塞在accept上等连接。这时候另开一个终端:
bash复制curl http://127.0.0.1:8899/
或者用nc:
bash复制echo "test" | nc 127.0.0.1 8899
你会看到类似这样的输出:
code复制parent: listening fd=5
parent: fd 5 sent
child: got fd=4
然后curl那边返回:
code复制hello from child, fd transfer works!
注意看,父进程传的是5,子进程拿到的是4。这不是bug,而是必然现象。
解释一下这个fd号是怎么来的:父进程先调用socketpair,两个fd分配了3和4;接着创建TCP监听socket,分配了5。fork之后子进程继承了3、4、5三个fd。子进程里close了5和4,也就是关掉了自己那份监听fd和socketpair的写端,只剩sv[0]也就是3还开着。当父进程把fd传过来时,内核在子进程的fdtable里找最小空闲fd号,4就是当前最小的空闲位,自然就分配了4。
所以下次你自己写代码时,如果发现父子进程打印的fd数字不一样,不要慌,这恰恰是fd传递的正确表现。真正要检查的是这个fd能不能正常使用,而不是数字等不等于发送端。
4. 细节决定成败:辅助数据的计算与缓冲大小
4.1 CMSG_LEN和CMSG_SPACE别再搞混
这部分是我看到新手最容易翻车的地方,单独拿一节说。
在64位x86平台上,sizeof(struct cmsghdr)一般是16字节,sizeof(int)是4字节。传一个fd时我们来算一下:
- CMSG_LEN(sizeof(int)),意思是“cmsg头部 + 4字节数据”的长度,不加尾部对齐,通常是20。
- CMSG_SPACE(sizeof(int)),意思是“cmsg头部 + 4字节数据 + 尾部对齐填充”后占用的空间,通常是24。
那个4字节的差异,就是对齐填充造成的。
使用原则只有一条:分配缓冲区用CMSG_SPACE,设置cmsg_len用CMSG_LEN。
如果你用CMSG_LEN去分配缓冲区,内核解析辅助数据时很可能越界访问,轻则收不到数据,重则内存踩踏;如果你把cmsg_len填成CMSG_SPACE,又会让内核以为这个cmsg比实际长,进而读错数据位置。这两个宏,谁也别替换谁。
4.2 接收缓冲区到底开多大
传1个fd,缓冲区就开CMSG_SPACE(sizeof(int))。这里注意一个常见的偷懒写法:char control[24];这样写在当前平台可能没问题,但换个架构就没法保证了。正确做法永远是让宏去算,不要让手写魔数。
如果要传多个fd,缓冲区大小是CMSG_SPACE(sizeof(int) * N)。N是你预期能收到的最大fd数量。如果你不确定对端会传几个,就把缓冲区开得足够大,并在收完以后检查msg.msg_flags里的MSG_CTRUNC位。如果这个位被置上了,说明缓冲区还是不够,控制数据被截断了。
另外,接收端在recvmsg之前,缓冲区里的内容是未定义的,初始化不是必须的但能让你调试时少一些随机性,所以我习惯memset清零。
4.3 传多个fd时的正确姿势
如果有N个fd要一起传,可以放在同一个SCM_RIGHTS辅助数据里:
c复制int fds[N];
/* 填充fds */
cmsg->cmsg_len = CMSG_LEN(sizeof(int) * N);
memcpy(CMSG_DATA(cmsg), fds, sizeof(int) * N);
接收端遍历时不要手动算指针,用CMSG_FIRSTHDR和CMSG_NXTHDR这对宏:
c复制for (cmsg = CMSG_FIRSTHDR(&msg); cmsg != NULL;
cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET &&
cmsg->cmsg_type == SCM_RIGHTS) {
/* 处理这一个cmsg里的所有fd */
}
}
要留意的是,CMSG_NXTHDR需要传入原始msg指针,因为宏内部要借助msg_controllen来做边界检查,不能漏。
还有一个历史包袱必须提醒:Linux内核里一次SCM_RIGHTS能传的fd数量有上限,是SCM_MAX_FD,通常值是253。超过这个数量,sendmsg会失败或者被截断。文件描述符不是无限量的,一次传几百个的场景虽然少见,但真遇到时别硬塞,要么分批传,要么换一种设计思路。
5. 实战踩坑与排查实录
5.1 常见问题速查表
先把我在实际开发中遇到过的典型问题整理成一张表,方便你排查时对照。
| 症状 | 原因 | 排查与解决 |
|---|---|---|
| 接收方收不到cmsg,msg_flags有MSG_CTRUNC | msg_control分配得太小 | 用CMSG_SPACE计算缓冲区,尤其传多个fd时 |
| recvmsg返回EMFILE | 接收进程fdtable已经满了 | 让接收进程先关掉不必要的fd,或提高nofile限制 |
| 用read/recv读不到fd | 传fd必须走辅助数据 | 必须用recvmsg,普通read/recv不解析SCM_RIGHTS |
| 收到的fd是-1或者无效 | send_fd之前fd已经被close;或fdtable里索引已释放 | 检查send_fd调用时机,必要时先dup保存一份再传 |
| 接收进程exec之后fd还在 | 接收到的fd没设置FD_CLOEXEC | 接收时用MSG_CMSG_CLOEXEC,或收完后fcntl设置 |
| 一次传太多fd失败 | 超过SCM_MAX_FD上限 | 拆批传,或者重新设计为共享文件表方案 |
| 阻塞在recvmsg不返回 | 对端没发数据,或者你传的fd和payload分离 | 确认双端消息格式一致,payload和cmsg要一起发 |
这里最隐蔽的是第二个EMFILE。接收进程fdtable满了的时候,recvmsg可能不会立刻返回成功,而是在内核安装fd的过程中失败,整个接收流程会变得很奇怪。排查思路是先看接收进程当前打开的文件数,ulimit -n和/proc/
5.2 非阻塞、CLOEXEC与所有权问题
先说CLOEXEC。通过SCM_RIGHTS接收到的fd,默认不会自动设置FD_CLOEXEC。如果你的接收进程之后还要exec一个新程序,而这个fd不应该被子进程继承,那就必须在接收时处理:
c复制int got = recv_fd(sv[0]);
if (got >= 0) {
fcntl(got, F_SETFD, FD_CLOEXEC);
}
Linux还提供了一个更干净的方案:recvmsg的flags参数可以传MSG_CMSG_CLOEXEC,这样内核在安装fd的时候就会顺手设置FD_CLOEXEC,省得你二次调用fcntl,也避免了中间窗口期可能出现的fd泄漏。内核版本够新的话,优先用这个。
再说非阻塞。文件的状态标志比如O_NONBLOCK是挂在file对象上的,所以fd传递之后,O_NONBLOCK状态也会跟着走。如果发送方希望接收方拿到的是非阻塞fd,可以在发之前自己设置;接收方拿到以后也能用fcntl修改。要注意的是,如果你传的是一个监听socket的fd,接收方直接accept一个非阻塞fd,返回的已连接socket也是非阻塞的,很多业务代码默认accept返回的是阻塞fd,这会在后面read/write时踩坑。
还有所有权问题。send_fd成功后,其实是发送方和接收方各持有一个指向相同file对象的引用。如果双方都去读、都去写、都去lseek,文件偏移量是共享的,那行为就会乱。业务上如果约定“交出去”,发送方应该在send之后close掉自己手里那份fd,别再操作它;如果不打算交出去,只是临时共享,那双方必须自己去协调并发访问。
5.3 安全和异常处理的几个原则
SCM_RIGHTS有一个很容易被忽略的安全点:它本身基本不做权限校验,只要你有Unix域套接字的读写权限、能构造辅助数据,就能把fd传给对端。所以不要把Unix域套接字随便暴露给不可信的进程,尤其是权限比自己高的进程。
接收方在解析辅助数据时,一定要校验cmsg_level和cmsg_type。我见过有人直接把第一个cmsg指针取出来就用,根本不判断类型,结果对端塞了一个SCM_CREDENTIALS,代码直接按int去解释一堆乱数据。
恶意进程还可以通过不断传fd来打爆接收方的fdtable,导致接收方后面真正需要分配fd时拿不到资源。如果这个Unix socket会接收外部输入,接收方必须有限额和控制,比如每次只接收固定数量的fd,超了就主动断开。
发送方sendmsg失败时,内核会归还引用计数,不会造成内核对象泄漏,这层不用太担心。但用户态的代码要小心错误路径:有些人在send失败后还继续close了原始fd,结果自己手里的有效fd也关了,这是纯用户态逻辑问题。
5.4 流式socket里的消息边界
最后单独说一下SOCK_STREAM和SOCK_DGRAM的差别,这个是很多人写代码时后知后觉的。
Unix域套接字支持三种类型:SOCK_STREAM和SOCK_SEQPACKET、SOCK_DGRAM。
在SOCK_DGRAM上,每个sendmsg对应一个数据报,recvmsg要么整个收到,要么收不到,消息边界是天然保证的。这时候你不带payload字节也没关系,fd控制信息和数据报是一体的。
但在SOCK_STREAM上就有讲究了。流式socket没有消息边界,内核虽然会把辅助数据挂在某一个skb上,但用户态读取时,可能这个skb的数据被拆两次读走,也可能多个skb的数据被合并一次读完。传fd这种操作,最怕的就是这个边界错位。
所以我在示例里刻意在send_fd时附带一个字节的payload。这样接收端在recvmsg时,至少会以这个字节为锚点,保证每一次fd传递都对应一次完整的recvmsg,不至于在流里错位。
如果内核支持,我实际项目里更倾向于用SOCK_SEQPACKET来传fd。它既有STREAM的可靠有序,又有DGRAM的消息边界,做fd传递的时候语义是最合适的。不过要注意,SOCK_SEQPACKET在部分场景下可用性取决于内核和文件系统支持,落地前先确认环境。
我自己第一次跑通这个demo时,看到父进程打印的是5,子进程打印的是4,第一反应也是“是不是传错了”,后来才意识到这恰恰是fd传递的正常表现——数字只是索引,共享的是file对象。后来在项目里用这套机制做socket平滑迁移,踩过几次坑之后,我反而更喜欢用SOCK_SEQPACKET来传fd,能少操心边界问题。
如果你只是想入门,我建议你先别急着直接抄systemd和nginx那种工程级实现,先把上面这个最小例子编译跑通,打印一下父子进程各自的fd号,再看看内核文档。传fd这件事表面看像玄学,内核里本质上也就一次引用计数增加和一次fd_install。把这两个动作吃透,你后面看任何SCM_RIGHTS相关代码都会轻松很多。
