☰
跨进程传递文件描述符:SCM_RIGHTS与Unix域套接字实战指南
2026/10/18 7:10:06 网站建设 项目流程

跨进程传递文件描述符(file descriptor)这事,我最早是在写一个多进程监控模块时碰上的:父进程负责拉起子进程,同时要把已经打开的日志文件、网络连接直接“交给”子进程去读写。不是传路径、不是传文件名,而是让子进程拿到一个全新的、却指向同一内核对象的新fd。实现这个动作的标准答案是Unix域套接字加SCM_RIGHTS辅助消息,配合sendmsg/recvmsg两个系统调用。这篇文章就把 sendfd/recvfd 这两个辅助函数从原理到完整代码拆开讲透,适合正在做多进程架构、需要在服务之间转移socket或文件句柄的C/C++开发者。

1. 为什么必须用SCM_RIGHTS:跨进程fd传递的场景与方案选型

1.1 哪些场景需要“转移”文件描述符

最常见的需求是特权资源分发。某个进程拥有访问权限或已经拿到了关键资源,比如打开的系统日志文件、监听中的socket、已经建立好的TCP连接,现在希望另一个进程来接管它。如果让后者自己去打开或连接,往往会遇到权限不足、资源已被独占、或者根本不知道资源在哪里的问题。

第二个典型场景是连接迁移。反向代理或者网关进程accept了一条来自客户端的TCP连接,后续想把这条连接交给某个工作进程做真正的业务处理。TCP fd本身是内核对象,不能通过进程间普通字节流“编码”后发送给对方,必须让内核直接把引用搬运过去。这个需求在主流事件驱动框架的reuseport或多进程模型里也经常出现。

第三个场景是资源隔离和生命周期管理。父进程统一持有资源句柄,按需分发,子进程用完关闭自己的fd副本,父进程可以决定何时真正释放底层对象。这个模型在监控系统、插件系统和一些沙箱设计里很常见。

1.2 为什么“传递路径”和“重新打开”都行不通

很多人第一反应是直接把文件路径告诉对方,让对方自己打开。这个方案在大部分单机场景下不可行,原因有三个:一是路径权限可能与调用方有关,接收方不具备同等权限;二是很多资源根本没有路径,比如socket fd、pipe fd、eventfd;三是即使有路径,文件在发送方打开后可能已经被删除或替换,接收方按路径打开得到的是完全不同的对象。

还有一种思路是父进程fork的时候子进程自然继承了所有fd。但这只在父子关系明确、且创建子进程那一刻想要复制资源时才成立。服务运行时动态新建进程、或者资源由外部第三方进程掌握时,fork继承机制就失效了。SCM_RIGHTS解决了这个鸿沟:不管两个进程有没有亲缘关系,只要它们拥有一对可以通信的Unix域套接字,就能实现fd的“定向投递”。

1.3 为什么必须靠Unix域套接字加辅助消息

SCM_RIGHTS是内核提供的一种特殊控制消息,全称是Socket Control Message Right。它必须通过Unix域套接字发送,不能用TCP、UDP这类网络套接字。原因很直接:辅助消息里携带的是“内核内部的引用”,而网络协议栈不会把这层转发语义暴露给远端。即使你把两个进程放到同一台机器上用回环地址,TCP也完全忽略SCM_RIGHTS,不会转发。

Unix套接字则不同,它和进程调度、文件系统紧密集成,本质上就是本地进程间通信的内核通道。配合sendmsg/recvmsg,内核可以直接把发送进程fd表中的引用摘下来,放到接收进程的fd表里。整个过程不经过用户态编码,也没有序列化,这就是它高效的根源。

2. 啃下底层API:msg结构、cmsghdr布局和宏的边界

2.1 struct msghdr:数据与“快递单据”分开传输

先看核心结构体struct msghdr,它是所有控制消息传输的入口。

struct msghdr { void *msg_name; // 目标地址,本地通信通常置空 socklen_t msg_namelen; // 地址长度 struct iovec *msg_iov; // 数据缓冲区数组 size_t msg_iovlen; // 数据缓冲区数量 void *msg_control; // 辅助消息缓冲区 size_t msg_controllen; // 辅助消息缓冲区长度 int msg_flags; // 接收时的消息标志 };

msg_iov是普通数据缓冲区,对sendmsg来说就是你要发送的正文;msg_control是辅助消息缓冲区,fd的投递就藏在这里。把它理解成快递的话,msg_iov是包裹里的物品,msg_control是贴在包裹外面的单据。fd并不是物品,而是单据的一部分——因为单据需要由内核来签收和拆解。

2.2 struct cmsghdr:内核附加信息的完整布局

辅助消息缓冲区内的内容不是裸数据,而是由若干个struct cmsghdr组成的链表。每个cmsghdr描述一种辅助信息。

struct cmsghdr { size_t cmsg_len; // 包含本头和数据的长度 int cmsg_level; // 协议层,SCM_RIGHTS对应SOL_SOCKET int cmsg_type; // 类型,SCM_RIGHTS // 紧接着是数据区 };

在Linux上cmsg_len的类型是size_t,但POSIX标准里定义的是socklen_t,所以实际开发永远用宏来填充,不要手写结构体偏移。SCM_RIGHTS类型的cmsg数据区存放的是一个或多个int类型的fd编号。接收方从CMSG_DATA取到这些int后,还要清楚自己拿了一个新fd,自己的fd表里已经多了一项,用完必须close。

2.3 四个CMSG宏:远离未定义行为的正确用法

和cmsghdr配套的有四个宏,理解它们的区别是不出bug的前提。

CMSG_SPACE(len)返回的是:cmsghdr头长度加上len字节数据,再按对齐规则补齐后占用的总空间。这个值是“占地总面积”,用于申请缓冲区。CMSG_LEN(len)返回的是:同样的内容但不含尾部填充的有效长度,用于给cmsg_len赋值时使用。CMSG_DATA(cmsg)返回指向数据区起始位置的指针。CMSG_FIRSTHDR和CMSG_NXTHDR则用来遍历整个辅助消息链表,后者以struct msghdr指针为首参数,按cmsg_len跳进到下一条控制消息。

初学者的经典错误就是:用CMSG_LEN去申请缓冲区,或者用CMSG_SPACE去填cmsg_len。前者会让内核写越界,后者会让接收方解析到错误的长度、把脏数据当成下一个cmsghdr。正确写法是:缓冲区长= CMSG_SPACE(sizeof(int)),cmsg_len = CMSG_LEN(sizeof(int))。

3. sendfd发送端落地:从socketpair打通到完整代码

3.1 C语言版send_fd函数实现

直接把发送函数写出来。

#include <sys/socket.h> #include <sys/uio.h> #include <stdio.h> #include <string.h> #include <unistd.h> int send_fd(int sock, int fd) { char dummy = 'F'; struct iovec iov; iov.iov_base = &dummy; iov.iov_len = 1; char ctrl[CMSG_SPACE(sizeof(int))]; memset(ctrl, 0, sizeof(ctrl)); struct msghdr msg; memset(&msg, 0, sizeof(msg)); msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = ctrl; msg.msg_controllen = sizeof(ctrl); 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), &fd, sizeof(fd)); ssize_t n = sendmsg(sock, &msg, MSG_NOSIGNAL); if (n < 0) { perror("sendmsg"); return -1; } return 0; }

这里有几个关键点需要展开讲。第一,为什么iov里至少要带1字节数据。对SOCK_STREAM类型的Unix套接字,recvmsg在没有任何数据可读时会阻塞等待。如果只发控制消息不发数据,接收端很可能在recvmsg里卡住,根本等不到那个控制消息的“信号”。带一个字节之后,控制消息和字节数据作为一个整体到达接收端的socket缓冲区,接收方的recvmsg立刻返回。第二,flags加了MSG_NOSIGNAL,避免socket关闭时触发SIGPIPE直接干翻整个进程。第三,cmsg缓冲区只申请一个cmsghdr加一个int的空间,但必须用CMSG_SPACE计算,保证对齐和内核填充安全。

3.2 配套测试Demo:父子进程打通收发送

为了验证功能,我用socketpair创建一个本地的Unix流套接字对,接着fork出子进程。父子进程各持一端,子进程调用recv_fd接收,父进程打开一个普通文件并send_fd。

int main(void) { int sv[2]; if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) { perror("socketpair"); return 1; } pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { close(sv[0]); int fd = recv_fd(sv[1]); if (fd < 0) { return 1; } char buf[64]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n > 0) { buf[n] = '\0'; printf("child received fd %d, content: %s\n", fd, buf); } close(fd); close(sv[1]); } else { close(sv[1]); int file_fd = open("/tmp/fd_demo.txt", O_CREAT | O_RDWR, 0644); if (file_fd < 0) { perror("open"); return 1; } write(file_fd, "hello from parent", 17); lseek(file_fd, 0, SEEK_SET); printf("parent sending fd %d\n", file_fd); send_fd(sv[0], file_fd); close(file_fd); close(sv[0]); wait(NULL); } return 0; }

socketpair返回的两个fd是已经连接好的,省去了bind、listen、accept的步骤,适合演示。注意fork之后,父子进程各自持有sv数组的两个fd,因为fd表被复制了。子进程close(sv[0]),父进程close(sv[1]),确保通道只有一端在手,否则双方都可能从自己不需要的端读到数据,导致进程退出时通道引用计数不归零。发送完fd后,父进程可以立刻close(file_fd),因为底层打开文件描述的引用计数已经因为SCM_RIGHTS的发送而增加了,子进程即使晚一点recv_fd,也能照常拿到可用的fd。

3.3 cmsg_len与CMSG_LEN/CMSG_SPACE:发送端最容易写错的两个值

我见过好几个线上问题就出在这两个宏上。有的人图省事,直接把cmsg_len写成sizeof(struct cmsghdr) + sizeof(int),这在某些位宽和编译选项下恰好能跑通,换个系统就炸。因为cmsghdr成员的对齐要求是size_t对齐,数据区并不是简单拼在头后面就结束,末尾还可能有填充位。

正确的心法就一句话:缓冲区大小只看CMSG_SPACE,填写长度只看CMSG_LEN。一个cmsghdr里如果携带N个fd,就是CMSG_LEN(N * sizeof(int))。这段辅助消息在缓冲区里实际占用CMSG_SPACE(N * sizeof(int))字节。如果缓冲区分给了多个cmsghdr,那么后一个的起点必须紧跟前一个的CMSG_SPACE终点,而遍历宏CMSG_NXTHDR自动完成了这件事。

需要补充的是,Linux对单个SCM_RIGHTS控制消息能携带的fd数量有上限,实测批量传超过一两百个就会报EMSGSIZE,具体阈值和内核版本有关。工程上不要试图一次塞一大堆fd,稳妥做法是拆成多批,或者明确设计成每次只传一个fd、配合数据消息头部里的数量字段做控制。

4. recvfd接收端落地:遍历控制消息与多点参数

4.1 C语言版recv_fd函数实现

接收函数比发送函数多一层解析逻辑,因为它要从辅助缓冲区里把内核塞进来的cmsghdr逐个找出来。

int recv_fd(int sock) { char dummy; struct iovec iov; iov.iov_base = &dummy; iov.iov_len = 1; char ctrl[CMSG_SPACE(sizeof(int))]; memset(ctrl, 0, sizeof(ctrl)); struct msghdr msg; memset(&msg, 0, sizeof(msg)); msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = ctrl; msg.msg_controllen = sizeof(ctrl); ssize_t n = recvmsg(sock, &msg, MSG_CMSG_CLOEXEC); if (n < 0) { perror("recvmsg"); return -1; } if (msg.msg_flags & MSG_CTRUNC) { fprintf(stderr, "control message truncated\n"); return -1; } int fd = -1; struct cmsghdr *cmsg; for (cmsg = CMSG_FIRSTHDR(&msg); cmsg != NULL; cmsg = CMSG_NXTHDR(&msg, cmsg)) { if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) { memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd)); break; } } if (fd < 0) { fprintf(stderr, "no file descriptor received\n"); return -1; } return fd; }

接收端必须在一开始就把msg_controllen设置成缓冲区容量,内核会用mmap到用户缓冲区的“辅助数据”来填充;如果容量小于实际需要的CMSG_SPACE,内核只拷贝能装下的部分,并把MSG_CTRUNC标志写进msg_flags。所以检查MSG_CTRUNC是必要条件,不检查的话,你遍历到的cmsghdr可能是一个被截断的半成品,逻辑直接跑飞。

这里我加了MSG_CMSG_CLOEXEC标志,这是Linux 2.6.23之后才有的。它的作用是在接收fd时顺手在新fd上设置close-on-exec,避免子进程exec之后意外把这些fd泄露给新程序。如果你的项目面向老内核,就只能在收到fd后手动设置FD_CLOEXEC。工程上建议无脑打开这个标志,跨进程传fd的场景往往和进程生命周期管理伴生,省一次fcntl是一回事,少一次安全隐患是另一回事。

4.2 支持一次接收多个fd:recv_fds变体

有时候我们需要一批fd一起传,比如一个代理进程把自己打开的客户端连接和监听socket同时交给下游。发送端只要在同一个cmsghdr的数据区里连续写入多个int,接收端就可以一次性全部读出。

int recv_fds(int sock, int *fds, int max_fds) { char dummy; struct iovec iov; iov.iov_base = &dummy; iov.iov_len = 1; char ctrl[CMSG_SPACE(sizeof(int) * max_fds)]; memset(ctrl, 0, sizeof(ctrl)); struct msghdr msg; memset(&msg, 0, sizeof(msg)); msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = ctrl; msg.msg_controllen = sizeof(ctrl); if (recvmsg(sock, &msg, MSG_CMSG_CLOEXEC) < 0) { perror("recvmsg"); return -1; } struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); if (cmsg == NULL || cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) { return -1; } int count = (cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int); if (count > max_fds) { count = max_fds; } memcpy(fds, CMSG_DATA(cmsg), sizeof(int) * count); return count; }

这段代码只处理第一个SCM_RIGHTS控制消息。如果发送端放进了多个cmsghdr,就需要外层继续遍历下去。从cmsg_len里推算fd数量时,用CMSG_LEN(0)取得“头长度”,然后减去它再除以单个int大小。这个写法比直接套结构体强,因为不同架构下cmsghdr头部对齐可能有差异,宏永远比硬编码可靠。注意接收方拿到这一批fd后,同样必须对每个fd执行close,这些fd在接收进程自己的fd表里是全新的条目,不会自动复用或回收。

4.3 接收端注意MSG_CTRUNC与fd生命周期管理

接收端的经典事故是忘了查msg_flags。我见过一个案例:某服务定期从上游接收fd,偶尔一次上游多传了个fd,接收端缓冲区不够,控制消息被截断,程序却照常把第一个int当fd用。结果那个int根本不是fd,只是别的数据残留,后续所有操作全乱套。所以MSGE_CTRUNC这个标志必须成为习惯。

另一个必须养成的习惯是:从接收函数返回的fd,调用方用完必须close。SCM_RIGHTS在接收时不会自动给fd设置任何生命周期的管理,它和你用open创建出来的fd完全一样。接收进程如果每接收一个fd都不close,fd表会一路涨到上限,最终进程崩溃或者无法创建新连接。如果你既要跨进程传fd又不想让接收方长期保管它,可以在业务逻辑处理完之后立刻close,内核会自动把引用减掉。发送方和接收方各持有一个引用,底层文件对象在双方都关闭后才会真正释放。

5. 踩坑实录:别让fd在收发途中“消失”

5.1 高频症状排查速查表

整理一张我在实际调试中遇到的症状表,每一行基本都能对应到具体的代码问题。

症状可能原因处理办法
recvmsg一直阻塞不返回发送端只发辅助消息没带数据字节,或者接收端等待另一条消息确保每条send_fd带1字节数据;用非阻塞模式配合poll
msg_flags带MSG_CTRUNC接收缓冲区小于实际辅助消息长度用CMSG_SPACE计算并申请,千万别用CMSG_LEN
收到fd后读写报EBADF接收端在使用前关闭了fd,或fd被其他线程误关检查close调用位置;fd用局部变量保管
sendmsg返回EMSGSIZE单次携带fd个数超过内核限制分批发送,或调整上层协议按批传递
接收方遍历不到cmsg遍历条件写错,或控制缓冲区未清零严格判断SOL_SOCKET和SCM_RIGHTS;memset辅助缓冲区
子进程exec后出现泄漏fd没有设置close-on-execrecvmsg用MSG_CMSG_CLOEXEC,或fcntl设置FD_CLOEXEC
socketpair通道无法复用配对两端没有各自关闭多余引用fork后父子各自close不需要的一端
传过去的是/被当作普通数据发送端未正确设置cmsg_type检查cmsg->cmsg_type = SCM_RIGHTS

5.2 几个容易被忽略的边界细节

第一个边界细节是SOCK_STREAM和SOCK_DGRAM的选择。流式套接字没有消息边界,如果同一对socket上既有send_fd又有普通业务数据,接收端可能把两条消息的数据粘连或拆开。工程上我建议要么给每条消息封装成“长度头+fd+数据”,要么让send_fd/recv_fd走专用的socketpair,不和业务数据混用。SOCK_DGRAM有天然的消息边界,一个sendmsg对应一个recvmsg,但单条数据报大小有上限,fd个数受限更严重。长期稳定优先用流式套接字配长度头协议,快速原型用DGRAM最舒服。

第二个细节是MSG_PEEK。接收端如果用recvmsg的MSG_PEEK标志去偷看数据,同时也会把辅助消息偷看缓冲区里,但不会消费掉。如果你接着去真正recvmsg,fd会再次出现在返回值里,相当于一次send_fd被接收了两次fd,每次都会创建新的fd副本,造成泄漏。所以跨进程传fd的场景要绝对避免MSG_PEEK。

第三个细节是发送端关闭时机。send_fd调用成功只是把fd引用“挂”到了套接字发送缓冲区,接收端还没真正收到。此时发送进程如果close了自己那份fd,没问题,内核在发送时已经增加引用计数。但发送进程如果close(sock)之后再等接收进程的ack,要注意流式套接字可能先收到EOF数据,业务上的“发送成功不回读”很容易产生误解。标准做法是:send_fd发送成功后,让接收端回一个字节的确认,发送端再关闭sock或继续下一批。

5.3 我的几点工程级建议

从工程落地角度,我给三个建议。

第一,把send_fd和recv_fd封装成独立的传输层原语,不要在业务代码里到处裸写msghdr。跨进程fd传递涉及的底层细节太多,散落各处容易在某个角落里漏掉MSG_CTRUNC检查或忘记close。封装成两个函数后,review的人只需要看这一处,审计成本低很多。

第二,在设计协议时给每条消息加一个明确的消息类型字段。比如在一个字节的数据区,0x1表示“这个控制消息携带一个fd”,0x2表示“这是普通数据”。因为控制消息必须和数据一起发送,数据字节本身就是一个天然的协议标识,能在接收端快速判断这条消息到底有没有fd。

第三,谨慎处理超时和取消。Unix套接字没有直接取消SCM_RIGHTS发送的机制,如果发送端已经调用了sendmsg但接收端卡死,那个fd会在socket缓冲区里悬挂。释放它的唯一办法是关闭这个socket连接或者接收端把它读走。所以生产环境里最好给socketpair两端配上心跳和超时关闭逻辑,不能让悬挂fd拖死进程。

6. 收尾之前的最后一件事:实测验证你的传递通路

写完这组函数之后,强烈建议跑一个最简单的自测用例:父进程打开一个文件,子进程接收后读取同一偏移处的数据,看内容是否一致。如果内容一致,说明你传的确实是同一个内核文件对象,而不是某个碰巧相同编号的fd。这是判断SCM_RIGHTS是否生效的最直接方法,也是我每次改动相关代码后都会执行一遍的回归测试。做一些带超时和错误路径的变体,比如接收端故意不设置足够大的控制缓冲区、发送端故意多传fd,确认程序能按预期报错而不是静默吞掉异常,这套基础打牢之后,后面做连接迁移或者句柄池分发都会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询