Linux fd跨进程传递:SCM_RIGHTS原理、Demo与工程踩坑

发布时间:2026/10/8 2:43:21
Linux fd跨进程传递:SCM_RIGHTS原理、Demo与工程踩坑 如果你搜过“linux fd传递”大概率会看到SCM_RIGHTS、UNIX域套接字、sendmsg/recvmsg这几个词。上周我就因为没吃透这个主题栽了一个跟头老进程要把自己监听的TCP端口交接给新进程第一版方案很朴素用管道把fd的整数值write过去新进程拿到数字直接accept。结果每次都是EBADF查了半天才发现文件描述符根本不是跨进程的“全局编号”。这篇文章把这条链路彻底捋一遍从fd在内核里的本质、fork继承的边界到SCM_RIGHTS辅助数据的工作方式再给一份可以直接编译运行的传递Demo最后是我在实际工程里踩过的几个坑。适合正在做进程间通信、热升级、socket激活这类功能的Linux开发也适合面试前想弄懂fd底层逻辑的同学。1. 先搞清楚fd 本质上是一张进程私有的映射表1.1 从“整数”到“内核对象”的一层索引文件描述符在用户态就是个int0、1、2默认是标准输入输出普通文件从3开始往上分配。但它不是普通整数它是fdtable这张进程私有表的索引。内核通过这个下标找到struct file实例再通过file指针找到具体的inode、socket、pipe等底层对象。每个进程都有自己独立的fdtable所以同一个数字在不同进程里可能指向完全不同的东西。你进程里的fd 7可能是TCP socket我进程里的fd 7可能是一块磁盘文件两者之间没有任何关系。这就是为什么“传一个整数过去”的想法从根上就是错的整数只是索引不是对象本身。换个好理解的类比fd数字像是酒店房间号。你在A酒店拿到“1203”这个号码它只对A酒店有意义。你把“1203”写在纸条上递给隔壁B酒店的人对方拿着这个号码根本找不到对应的房间因为两家酒店的房号体系是各自独立的。Linux的fd传递要做的事情相当于把A酒店“1203房门卡”真正交给B酒店的人让他在B酒店的大楼里也能走进同一个房间。在内核里这个“房间”就是struct file它是打开文件、socket、pipe等对象的公共外壳。引用计数管理着这个对象什么时候能释放。fdtable里的每个条目持有对struct file的一次引用close(fd)的本质是把这个引用减掉减到0时内核才真正销毁对象。1.2 fork 的继承fd 只能沿着父子链延续很多人会问既然fd不能跨进程传那为什么fork之后父子进程都能用同一个fd读写同一个文件这是另一条机制。fork的时候子进程会复制一份父进程的fdtable复制出来的条目和父进程的条目指向同一个struct file引用计数加一。所以父子进程能用同一个数字操作同一个内核对象。exec的时候除了打了FD_CLOEXEC标志的fd会被关闭其余fd会原样保留。沿着这条链子孙进程、曾孙进程都能继承。但这套继承有个硬边界它只沿着fork的父子血缘延续。你无法用它把fd交给一个“八竿子打不着的”进程。哪怕对方是亲兄弟进程同一个父进程fork出来的两个子进程对方也不会自动拥有你这边新打开的fd。兄弟之间的fdtable在fork完成那一刻就分道扬镳了。所以需要区分两个场景维度fork/exec 继承SCM_RIGHTS 传递进程关系仅限父子/孙进程链任意两个通过AF_UNIX连接的进程传递时机fork/exec创建时一次性运行期任意时刻协作要求无需额外动作双方按协议收发控制消息对端关闭影响引用计数各自1互不影响同样是引用1互不影响典型场景常规进程创建socket activation、连接热迁移、特权fd下发搞明白这点你就不会再问“为什么用管道传fd整数不行”了。管道传过去的只是一串字节接收进程的fdtable里根本没有对应的表项内核自然无法把字节变成一个可用的fd。1.3 直接传整数的翻车现场回到开头那个热升级场景。老进程把fd12这个整数通过pipe写过去新进程拿到12直接调用accept。如果新进程的fdtable里没有12内核返回EBADF这个错误还算明显。但噩梦版本是新进程恰好开着fd 12而且指向另一个已打开的文件那么accept不会报错它会在一个完全错误的对象上执行操作。这种bug比EBADF隐蔽得多。程序不崩、不报错只是行为完全错乱——accept成功返回了但连接状态、缓冲区全是乱的。我当时排查的时候一度以为是线程竞争问题最后用strace跟到新进程的accept调用才发现fd 12对应的是一个日志文件而不是socket。那一刻才意识到问题出在“把fd当数字传”这个设计上。正确做法必须让内核参与不是传数字而是通过内核原生机制把struct file的引用“安装”进目标进程的fdtable。这个机制就是SCM_RIGHTS。2. 正解AF_UNIX 套接字上走 SCM_RIGHTS 辅助数据2.1 内核实际上做了什么SCM_RIGHTS是SOL_SOCKET层的一种控制消息类型。当你把一个fd塞进sendmsg的辅助数据里发出去内核在接收端不是“复制数字”而是在接收进程的fdtable里分配一个新的空闲fd让它指向发送端那个fd对应的同一个struct file引用计数加一。关键点有三个第一接收端的fd编号由内核重新分配通常取当前进程最小的空闲fd号。发送端传出去的是fd 7接收端拿到手里可能是fd 12这是完全正常的。任何依赖“两边fd号相同”的代码都是在给自己埋雷。第二这个传递是“复制引用”不是“转移所有权”。发送端close掉自己的fd之后接收端手里的fd照常能用因为引用计数还是正的。反过来也一样接收端close不影响发送端。这跟fork继承的语义是一致的大家共享同一个内核对象各自负责自己的引用。第三SCM_RIGHTS只能通过本地套接字传递。TCP、UDP这类网络套接字不支持因为跨主机时不同内核之间无法共享struct file。能用的是AF_UNIX域套接字无论是SOCK_STREAM、SOCK_DGRAM还是SOCK_SEQPACKET都支持。2.2 msghdr一次 sendmsg 里的两套数据sendmsg之所以特殊是因为它比write多了“辅助数据”这个通道。struct msghdr里有两套数据msg_iov/msg_iovlen普通数据就是你要发的字节流。msg_control/msg_controllen辅助数据也叫控制消息内核会解析这里的内容。普通数据就是纯字节拷贝接收方拿到什么就是什么。辅助数据则不同它携带的是“元信息”内核会根据cmsg_type字段执行特定动作。SCM_RIGHTS就是让内核把fd安装进接收进程类似的还有SCM_CREDENTIALS用来传递进程凭据做对端身份认证。struct msghdr { void *msg_name; /* 目标地址UDP用 */ socklen_t msg_namelen; /* 地址长度 */ struct iovec *msg_iov; /* 普通数据缓冲区 */ size_t msg_iovlen; /* iovec数量 */ void *msg_control; /* 辅助数据缓冲区 */ size_t msg_controllen; /* 辅助数据缓冲区长度 */ int msg_flags; /* 接收时返回的标志 */ };注意sendmsg的返回值只统计普通payload的字节数辅助数据不计算在内。所以如果msg_iov里放了一个字节sendmsg返回1哪怕辅助数据里塞了一个fd。很多人第一次写的时候会疑惑“为什么我只发了1字节fd却传过去了”原因就在这里。2.3 CMSG 宏家族与内存对齐最容易翻车的地方辅助数据的结构是struct cmsghdr开头紧跟一段数据区。64位Linux下struct cmsghdr通常长16字节cmsg_len占8字节cmsg_level和cmsg_type各占4字节然后按8字节对齐。数据区紧跟在头之后。内核和glibc提供了一组宏来操作这段区域不是让你手工做指针偏移CMSG_SPACE(len)分配缓冲区时用。返回“头数据对齐填充”的总长度。CMSG_LEN(len)给cmsg_len字段赋值时用。返回“头数据”的逻辑长度不含尾部填充。CMSG_DATA(cmsg)返回数据区起始地址。CMSG_FIRSTHDR(msg)/CMSG_NXTHDR(msg, cmsg)遍历辅助数据。最容易写错的地方是分配msg_control缓冲区时用了CMSG_LEN而不是CMSG_SPACE。比如传一个int fdCMSG_LEN(sizeof(int))在64位系统算出来是20而CMSG_SPACE(sizeof(int))是24。如果你按20分配缓冲区msg_controllen也填20发送时表面上能发出去但内核在解析时可能因为无法容纳下一个cmsg的对齐结果而触发MSG_CTRUNC接收方就收不到完整数据。我的习惯是凡是分配缓冲区一律用CMSG_SPACE凡是给cmsg_len赋值一律用CMSG_LEN。这个习惯帮我省掉了无数次莫名其妙的截断问题。3. 跑一个能用的 demo把 /tmp/test.txt 的 fd 从 A 进程送到 B 进程3.1 通信骨架命名 AF_UNIX socket 的搭建先创建一个命名AF_UNIX套接字。所谓“命名”就是给套接字绑一个文件系统路径其他进程可以通过这个路径来连接。路径长度限制在sun_path数组内Linux上一般是108字节别用太深的路径。#define SOCK_PATH /tmp/fd-pass.sock int sock; struct sockaddr_un addr; memset(addr, 0, sizeof(addr)); addr.sun_family AF_UNIX; snprintf(addr.sun_path, sizeof(addr.sun_path), %s, SOCK_PATH); sock socket(AF_UNIX, SOCK_STREAM, 0);服务端需要bind、listen、accept客户端connect。bind之前最好unlink(SOCK_PATH)否则上次程序退出没清理文件bind会报Address already in use。这个文件只是给客户端一个“找得到门”的入口一旦连接建立它的使命就完成了。3.2 send_fd() 发送端实现下面这个函数是核心把fd塞进辅助数据发给对端static int send_fd(int sock, int fd) { char buf[1] {X}; struct iovec iov { .iov_base buf, .iov_len sizeof(buf), }; char control[CMSG_SPACE(sizeof(int))]; memset(control, 0, sizeof(control)); 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), fd, sizeof(int)); if (sendmsg(sock, msg, 0) 0) { perror(sendmsg); return -1; } return 0; }为什么msg_iov里要放一个字节辅助数据本身可以不带任何payload但socket编程里习惯带上一个字节的“信号”这样接收端的recvmsg返回值有意义也能顺便确认连接是活的。无payload的sendmsg返回0容易和EOF混淆徒增排查难度。buf[1] {X}这个字节就是纯信号本身没有业务含义。真正有价值的是辅助数据里的那个fd。3.3 recv_fd() 接收端实现接收端的动作是镜像对称的准备同样大小的控制缓冲区recvmsg然后扫描辅助数据static int recv_fd(int sock) { 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, msg, 0); if (n 0) { perror(recvmsg); return -1; } if (msg.msg_flags MSG_CTRUNC) { fprintf(stderr, control data truncated!\n); return -1; } struct cmsghdr *cmsg; int fd -1; 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(int)); break; } } return fd; }遍历辅助数据时判断条件是cmsg_level SOL_SOCKET cmsg_type SCM_RIGHTS这两个字段缺一不可。拿到数据区后通过memcpy把int取出来。注意这里我假设只传了一个fd如果传多个fd数据区会连续放多个int需要按sizeof(int)步进逐个读取。接收时检查msg_flags MSG_CTRUNC是必须的。这个标志表示辅助数据被截断了后面第4章会展开讲。3.4 编译运行与结果验收把send_fd和recv_fd包进一个单文件程序根据命令行参数扮演不同角色。先准备测试文件再编译echo hello fd-pass /tmp/test.txt gcc -Wall -o fdpass fdpass.c终端1启动服务端./fdpass s终端2启动客户端./fdpass c服务端会先打开/tmp/test.txt拿到一个fd打印“server: sending fd 5”之类的信息然后通过socket把fd传出去。客户端收到后可以试着用这个fd去read文件内容预期输出client: got fd 3 client: read from fd: hello fd-pass注意两边的fd编号很可能不一样服务端可能是5客户端可能是3。这是正常的内核在接收进程里重新分配了编号。只要client能用这个fd读到文件内容就说明传递成功。这个demo里还隐藏了一个验证点服务端发送完fd之后主动close了本地fd客户端依然能正常read。这就证明了SCM_RIGHTS传递的是“共享引用”不是“所有权转移”。4. 真实工程里最容易踩的四个坑4.1 MSG_CTRUNC缓冲区不够fd 被“安装”了但你找不到有次我在一个高并发的网关程序里接收fdrecvmsg返回正常数据也能读出来但CMSG_FIRSTHDR返回的cmsg是空的。排查了半天发现msg_flags里带着MSG_CTRUNC标志——控制缓冲区分配小了辅助数据被内核截断。最阴险的地方在于fd可能已经被内核安装进当前进程的fdtable了但因为你拿不到数据区的int值你不知道这个fd的编号是多少。它就像凭空多出来的一个fd一直占着fdtable的坑直到进程退出才释放。这是典型的隐蔽fd泄漏。处理方式没有捷径控制缓冲区给足不要精打细算。传单个fd时CMSG_SPACE(sizeof(int))是24字节但你用CMSG_SPACE(sizeof(int) * 8)也就是传8个fd的缓冲空间成本几乎可以忽略。接收端缓冲区大一点能避免绝大多数截断问题。4.2 流式 socket 下控制消息和数据会“捆绑”或“错位”AF_UNIX的SOCK_STREAM是流式协议没有消息边界。你连续发三次sendmsg每次带一个fd对端可能在一个recvmsg里收到三个控制消息也可能拆成两次收到。更麻烦的是辅助数据是跟随后续数据的第一个字节到达的如果业务上做了分包fd和数据之间的对应关系很容易错乱。我在demo里故意用了SOCK_STREAM因为它贴近日常使用习惯。但如果你是自己搭建的专用通道我强烈建议改用SOCK_DGRAM或SOCK_SEQPACKET。这两个类型都有消息边界一次sendmsg对应一次recvmsg一个数据报携带的辅助数据天然和这个数据报绑定不会再出现“半包”或者“粘包”问题。SOCK_SEQPACKET更好一点它比DGRAM多了有序和可靠的语义又保留了消息边界。AF_UNIX完整支持这个类型可以作为默认选择。4.3 收到 fd 别忘了考虑 FD_CLOEXECMSG_CMSG_CLOEXEC通过SCM_RIGHTS到达接收进程的fd默认是不会自动设置FD_CLOEXEC标志的。这意味着如果一个多线程进程的某个线程调用了exec这个fd会原样泄漏到新程序里去。很多人在写接收端代码时根本不会想到这层。因为传统open的fd你可以在open时加O_CLOEXEC但SCM_RIGHTS收到的fd是在recvmsg的过程中由内核动态安装的你没有任何机会在“安装之前”打标志。Linux 2.6.23以后提供了MSG_CMSG_CLOEXEC标志。recvmsg时带上它内核在安装fd的同时自动设置FD_CLOEXECssize_t n recvmsg(sock, msg, MSG_CMSG_CLOEXEC);需要#define _GNU_SOURCE才能让这个宏可见。如果不想依赖这个标志也可以接收后立即fcntl(fd, F_SETFD, FD_CLOEXEC)但中间有个时间窗口多线程下不保险。我建议直接用标志位一行解决。4.4 所有权是“共享引用”而不是“转移”发送端close不影响接收端前面demo里验证过发送端close后接收端照常使用。这个特性很有用但也容易让人误解成“fd已经交给对方了我这边不用管了”。实际上双方各自持有一份引用。谁close谁就把自己的那份引用减掉。只有当所有进程都把这个fd关闭struct file的引用计数归零内核才会真正关闭底层对象。所以在设计协议时要明确“谁负责关闭”。比如热升级场景里老进程把监听socket传给新进程后老进程必须记得close否则监听socket永远不会释放。反过来如果接收端想独占这个fd需要在接收后主动关闭几个socket的连接或者和发送端约定好“你发完就关我收到后用”。我在代码审查时看到过不少“两边都不关”或者“两边都关”的问题。两边都不关fd泄漏两边都关可能把另一个进程还在用的引用也给减没了当然还有别的引用对象不会立刻消失但语义会变得混乱。最好在接口注释里写明所有权规则。5. 把 fd 传递放进真实架构典型场景与扩展5.1 systemd socket activation监听socket的交接fd传递最著名的生产级应用就是systemd的socket activation。systemd作为pid 1在系统启动早期就创建好监听socket绑定好端口甚至可以绑定1024以下的特权端口。但真正的服务进程不需要现在就启动——等有连接请求时systemd再把监听fd通过SCM_RIGHTS传给服务进程。服务进程拿到这个fd后不用自己创建socket、bind、listen直接accept即可。这里传的fd就是socket类型。好处很明显特权端口提前绑定避免服务进程以低权限身份启动时无法绑定按需启动服务节省系统资源服务重启时监听socket不中断连接不丢。这套机制和“热升级”是同一个套路。服务进程要平滑重启老进程监听在fd上新进程拿到同一个监听socket的引用两边可以同时accept一段时间再让老进程优雅退出。5.2 /proc/ /fd/N 为什么不是替代方案讨论fd传递时总会有人提到“直接打开/proc/123/fd/12不就行了”这个思路在某些特殊场景下确实能用但它不是SCM_RIGHTS的替代品更像是“从外部偷引用”。打开/proc/pid/fd/N本质上是通过路径解析在当前进程里新建一个file引用。它要求调用进程对目标进程有ptrace权限通常是同uid或者CAP_SYS_PTRACE而且目标fd所指的对象必须支持通过路径重新打开。对于普通文件这招有效但对于socket、pipe、eventfd这类没有“可打开路径”语义的对象通过/proc打开的路径解析往往失效或者拿不到原对象。就算能打开你得到的也是一个全新的、独立的引用和目标进程的fd没有共享关系。SCM_RIGHTS是双方协作、内核原生支持的机制/proc是一条侵入式的排查路径。真正做工程时用SCM_RIGHTS不要为了省事去读/proc。5.3 还能传什么socket、eventfd、timerfdSCM_RIGHTS能传的不止普通文件。任何背后有struct file内核对象的fd都能传已经accept的TCP连接、pipe、eventfd、timerfd、signalfd、epoll fd都可以。我最常用的场景是连接迁移高并发网关进程accept到连接后直接把这个连接socket的fd传给后端工作进程由工作进程继续读写。这样网关可以专注做接入和分发工作进程专注业务不用走reuseport那一套也能实现连接级负载均衡。另一个场景是跨进程事件唤醒一个进程持有eventfd把它传给另一个进程接收方写eventfd就能唤醒发送方等待的epoll。性能和可读性都比自定义IPC协议好得多。嵌入式linux的守护进程升级、容器里特权fd下发、D-Bus的fd传递底层全是这同一个机制。把SCM_RIGHTS吃透了遇到“把内核对象使用权交给另一个进程”这类需求基本就是顺手的事。我现在的习惯是工具性质的fd传递统一封装成一个库底层用AF_UNIX的SOCK_SEQPACKET控制缓冲区固定给CMSG_SPACE(sizeof(int) * 8)接收端一律加MSG_CMSG_CLOEXEC发送端传完默认不关fd、由业务方自己决定所有权。这套组合跑了两年基本没有因为fd传递本身出过崩溃。如果你第一次接触别急着追求最省内存的写法先按我demo里的路数跑通再把缓冲区加大、加上CLOEXEC——坑都写在上面了照着做能省一个通宵。