资讯动态

跨进程传递文件描述符:SCM_RIGHTS与Unix域套接字实战指南

发布时间:2026/10/18 7:10:25 来源:尧图企业网站定制
跨进程传递文件描述符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. 啃下底层APImsg结构、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 支持一次接收多个fdrecv_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都不closefd表会一路涨到上限最终进程崩溃或者无法创建新连接。如果你既要跨进程传fd又不想让接收方长期保管它可以在业务逻辑处理完之后立刻close内核会自动把引用减掉。发送方和接收方各持有一个引用底层文件对象在双方都关闭后才会真正释放。5. 踩坑实录别让fd在收发途中“消失”5.1 高频症状排查速查表整理一张我在实际调试中遇到的症状表每一行基本都能对应到具体的代码问题。症状可能原因处理办法recvmsg一直阻塞不返回发送端只发辅助消息没带数据字节或者接收端等待另一条消息确保每条send_fd带1字节数据用非阻塞模式配合pollmsg_flags带MSG_CTRUNC接收缓冲区小于实际辅助消息长度用CMSG_SPACE计算并申请千万别用CMSG_LEN收到fd后读写报EBADF接收端在使用前关闭了fd或fd被其他线程误关检查close调用位置fd用局部变量保管sendmsg返回EMSGSIZE单次携带fd个数超过内核限制分批发送或调整上层协议按批传递接收方遍历不到cmsg遍历条件写错或控制缓冲区未清零严格判断SOL_SOCKET和SCM_RIGHTSmemset辅助缓冲区子进程exec后出现泄漏fd没有设置close-on-execrecvmsg用MSG_CMSG_CLOEXEC或fcntl设置FD_CLOEXECsocketpair通道无法复用配对两端没有各自关闭多余引用fork后父子各自close不需要的一端传过去的是/被当作普通数据发送端未正确设置cmsg_type检查cmsg-cmsg_type SCM_RIGHTS5.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标志去偷看数据同时也会把辅助消息偷看缓冲区里但不会消费掉。如果你接着去真正recvmsgfd会再次出现在返回值里相当于一次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确认程序能按预期报错而不是静默吞掉异常这套基础打牢之后后面做连接迁移或者句柄池分发都会顺手很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑