Linux文件描述符传递:Unix域套接字与SCM_RIGHTS实战解析

📅 发布时间:2026/10/1 3:25:55
Linux文件描述符传递:Unix域套接字与SCM_RIGHTS实战解析
做Linux服务端和嵌入式开发的朋友迟早会遇到“把文件描述符fd从一个进程交给另一个进程”的需求。我第一次认真研究fd传递是在给一个网关程序做平滑重启的时候旧进程的监听socket不能断新进程又得拿到这个已经listen好的句柄。一开始我以为fd就是一个int把号码发给新进程不就行了结果当然不行后来才明白文件描述符必须由内核“搬运”用户态能调用的正确工具就是Unix域套接字配合SCM_RIGHTS辅助数据。这套机制是Linux进程间通信IPC里既实用又有门槛的玩法也是Nginx、HAProxy、systemd这类基础设施级组件能够平滑升级、socket activation的基础。这篇文章会把“为什么需要传fd、内核到底帮你做了什么、自己怎么用C语言实现”讲透并给出可直接编译运行的完整代码。适合正在学Linux系统编程、做服务端中间件、或想搞懂这类组件背后原理的开发者面试时遇到“Linux进程间通信”或“fd传递”相关题目也能聊得比教科书更深。1. 文件描述符的真相一个整数背后的内核对象1.1 fd在进程里到底是什么在Linux眼里几乎所有IO对象都是“文件”差别只是底层实现不同。Socket、普通文件、管道、设备节点、eventfd它们的共同点是都能用“文件描述符”来操作。而这个文件描述符在用户空间仅仅是一个intopen返回3、socket返回5看起来平平无奇。但这个int并不是我们想传就能随意传的“值”。在每个进程的内部内核维护着一张进程级fd表fd表项指向一个更底层的“打开文件描述”open file description。注意这个词它对应内核里的struct file。而struct file又有自己的inode引用、文件位置、状态标志等。画个简化地图进程A的fd 3 - 内核中的一个struct file - dentry/inode - 实际文件或socket对象。所以fd本身的价值在于它指向了一个由内核管理、可以被安全共享的对象。要让另一个进程获得同样的能力本质不是“传数字”而是让另一个进程的fd表新增一项并让这一项指向同一个struct file。我给新同学的比喻是fd相当于“图书馆的借书卡号”但这张卡号只在你自己手里有效别人拿到卡号没用必须让图书馆系统在对方名下重新登记一张卡对方才能刷卡借书。SCM_RIGHTS就是让内核帮你做这个“重新登记”的动作。1.2 fork继承与真正的fd传递有什么区别最简单的“共享fd”方式是fork子进程会自动获得一份父进程fd表的副本fd编号一一对应指向同一批struct file。凡是fork之前的fd子进程都能用。这确实算“传递”可惜有天然限制。首先只有父子进程之间能用而且乙进程必须是甲进程fork出来的子进程其次必须在fork之前就把fd打开最后场景往往是“同时运行的两份代码”。而实际工程项目里更常见的需求是两个完全独立的进程之间比如一个后端管理进程和若干个业务进程让业务进程“接盘”管理进程已经打开好的监听端口。这种场景只能借助真正的fd传递。另外很多人对“复制”有误解fork后父子进程都关闭各自的fd副本不会影响对方吗其实不会因为底层struct file的引用计数会做增减只有归零才真正销毁。所以fork出来的两个fd看似独立实际上仍然共享同一个文件偏移、同一份socket状态。这跟dup的语义是同一回事。1.3 到底哪些场景需要跨进程传fd我粗略总结一下最常见的几类需求平滑重启与无缝迁移。Nginx的reload机制中老worker的监听socket如果直接close再重新listen会存在短暂的端口空窗期并发连接也可能全部断掉。正确姿势是老进程保留监听fd新进程拿到这个fd后立即接管流量无缝切换。特权资源的开放。启动阶段用root身份打开低端口或敏感设备然后降权到普通用户运行。降权之后你往往已经没有权限重新打开这些资源但通过fd传递可以把已打开的fd转交出去。连接分发。高并发服务里一个主进程统一accept后把已建立的连接socket分发给各worker进程处理避免多进程同时accept的惊群效应。事件驱动服务的“连接热迁移”。运行中的服务可以把一部分连接转给新启动的实例实现动态扩缩容。这些场景归结起来就是一句话一个进程手里的“内核对象使用权”需要作为参数交给另一个进程。光传数字没用必须靠内核来背书。2. 地基Unix域套接字与SCM_RIGHTS机制2.1 为什么必须是Unix域套接字Linux下IPC选择很多管道、消息队列、共享内存、信号量各有适用场景。但涉及“传文件描述符”这个需求时Unix域套接字几乎是从设计上唯一正路。原因很简单普通管道和TCP/UDP socket的send/recv语义里只传输用户数据字节没有给“文件句柄”留位置。而Unix域套接字的sendmsg/recvmsg接口专门支持“辅助数据”ancillary data内核允许你在普通数据之外附带系统级信息比如SCM_RIGHTS传递文件描述符、SCM_CREDENTIALS传递进程凭证。其中SCM_RIGHTS就是直接告诉内核“我在传输数据的同时想把我手上这个fd的引用能力送给对端。”另外一个原因是Unix域套接字本身就在内核里工作传的又是内核对象整个操作可以做得高效且安全不需要陷入用户态做序列化不需要走网络协议栈本质只是内核内部把fd从一个进程悄悄“过户”给另一个进程。2.2 msghdr与cmsghdr到底长什么样要实现fd传递调用的核心函数是sendmsg和recvmsg它们收发的载体是一个msghdr结构体。struct msghdr { void *msg_name; /* 目标地址Unix域连接可不填 */ socklen_t msg_namelen; struct iovec *msg_iov; /* 普通数据缓冲区 */ size_t msg_iovlen; void *msg_control; /* 辅助数据缓冲区 */ size_t msg_controllen; int msg_flags; };msg_iov这部分就是我们平时send/recv传的那块普通字节流即使只想传fd也至少要传1字节的“空数据”因为收发两端需要靠普通数据来同步消息边界。msg_control是辅助数据缓冲区里面放的是一串cmsghdr头。cmsghdr结构是struct cmsghdr { size_t cmsg_len; /* 包括本头在内的总长度 */ int cmsg_level; /* 协议层这里固定SOL_SOCKET */ int cmsg_type; /* 类型传fd用SCM_RIGHTS */ /* 后面紧接着数据比如int类型的fd数组 */ };在代码里不要手动拼这个结构要用CMSG_FIRSTHDR、CMSG_NXTHDR、CMSG_DATA、CMSG_LEN、CMSG_SPACE这一组宏来操作它们帮你处理对齐和各种边界。这也是新手最容易栽跟头的地方后面实战部分我会细说。2.3 内核具体做了什么事当我们调用sendmsg发送SCM_RIGHTS辅助数据时内核会执行一次“fd引用的克隆”。简化到关键步骤发送方在sendmsg时内核从发送进程的fd表中取出对应struct file对象并把它的引用计数加1。接收方在recvmsg时内核会扫描接收进程的fd表找到最小的空闲编号让这个编号指向前面那个struct file然后把新的fd编号写入辅助数据缓冲区返回到用户态。整个过程中发送方原来的fd编号仍然保留接收方拿到的是自己进程内的新编号两个编号的值很可能不一样但都指向同一张“底牌”。有人会问那这不是“复制”吗对就是复制引用。关键点在于底层struct file的引用计数变为2所以发送方可以关掉自己的fd接收方依然能正常操作反过来接收方关闭发送方也不受影响。只有双方都关闭真正资源才释放。这让“传递”变成了一种安全的移交方式。另外要注意单个消息传fd有数量上限。Linux内核里硬编码了一个SCM_MAX_FD值是253。如果你一次性想传254个fd内核会直接返回EINVAL。批量场景就要分批发送我在第4.4节会详细讲。2.4 和TCP传数据的本质区别很多新手问为什么不用TCP传我可以通过socketpair或者普通socket传数据。道理其实很浅显TCP传的是字节流接收方拿到的只是“一坨数据”没有任何fd载体。文件描述符本质上是一个进程私有状态TCP协议包里根本没有表达它的字段。而Unix域套接字的辅助数据是在内核内部传递的“带外”信息天然属于进程与内核之间的独特交互。再说得直白一点Unix域套接字本质上是“同一台机器内核里的IPC通道”文件描述符这种内核对象只有在内核里搬运才有意义。跨机器根本不可能直接传fd因为另一端的内核不认你这个文件是同一个东西。3. 手把手实现一个最小fd传递工具3.1 方案选型解析我会用socketpair来连接父进程和子进程。socketpair创建一对互相连接的Unix域套接字无需bind、listen、accept在fork之前创建好父子进程各拿一端代码最干净。不选AF_UNIX加路径监听的原因有二一是socketpair是内核直接创建的连接没有命名文件不会在/tmp留下垃圾二是演示“父子进程间传fd”的场景不需要考虑连接方发现对方路径的问题。如果你想让任意两个独立进程间传递可以替换为AF_UNIX地址绑定加accept核心的send_fd/recv_fd函数一行不用改。整套流程是socketpair创建通道 - fork - 父进程打开文件test.txt - 通过send_fd传给子进程 - 子进程拿到fd后向文件写入内容 - 子进程退出父进程回收。3.2 发送端实现发送端的核心就是把fd塞进cmsghdr然后调用sendmsg。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include sys/types.h #include fcntl.h #include sys/wait.h static int send_fd(int sockfd, int fd_to_send) { char buf[CMSG_SPACE(sizeof(int))]; struct iovec iov; struct msghdr msg; struct cmsghdr *cmsg; char dummy x; memset(buf, 0, sizeof(buf)); iov.iov_base dummy; iov.iov_len sizeof(dummy); memset(msg, 0, sizeof(msg)); msg.msg_iov iov; msg.msg_iovlen 1; msg.msg_control buf; msg.msg_controllen sizeof(buf); 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_to_send, sizeof(fd_to_send)); return sendmsg(sockfd, msg, 0); }这里有个细节msg_controllen用的是CMSG_SPACE(sizeof(int))不是CMSG_LEN(sizeof(int))。CMSG_SPACE会额外算上对齐填补的字节保证整个缓冲区的大小足够。CMSG_LEN是cmsghdr头部加数据部分“实际使用”的长度用在cmsg_len字段里是对的但如果拿它当缓冲区大小内核会认为你提供的缓冲区不够可能触发MSG_CTRUNC截断。这两个宏的区别我在第4.1节还会展开讲。3.3 接收端实现接收端把辅助数据读出来遍历所有cmsghdr找到SCM_RIGHTS类型后取出fd。static int recv_fd(int sockfd) { char buf[CMSG_SPACE(sizeof(int))]; struct iovec iov; struct msghdr msg; struct cmsghdr *cmsg; char dummy; int fd -1; iov.iov_base dummy; iov.iov_len sizeof(dummy); memset(msg, 0, sizeof(msg)); msg.msg_iov iov; msg.msg_iovlen 1; msg.msg_control buf; msg.msg_controllen sizeof(buf); if (recvmsg(sockfd, msg, 0) 0) return -1; if (msg.msg_controllen 0) 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) { memcpy(fd, CMSG_DATA(cmsg), sizeof(fd)); break; } } return fd; }遍历cmsghdr一定要用CMSG_FIRSTHDR和CMSG_NXTHDR宏不要自己按指针偏移跨过头部。因为cmsghdr的长度和对齐在不同体系结构上可能不同宏内部已经处理好了。3.4 主流程代码父进程打开文件传给子进程子进程接收到fd后往文件里写一行字符串。这个Demo足够验证整个链路。int main(void) { int sv[2], fd, received; pid_t pid; if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) 0) { perror(socketpair); exit(1); } pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { /* 子进程接收fd */ close(sv[1]); received recv_fd(sv[0]); if (received 0) { perror(recv_fd); exit(1); } printf(子进程接收到 fd%d\n, received); write(received, hello from child\n, 17); close(received); close(sv[0]); exit(0); } /* 父进程打开文件并发送 */ close(sv[0]); fd open(test.txt, O_RDWR | O_CREAT | O_TRUNC, 0644); if (fd 0) { perror(open); exit(1); } printf(父进程发送 fd%d\n, fd); if (send_fd(sv[1], fd) 0) { perror(send_fd); exit(1); } close(fd); close(sv[1]); wait(NULL); return 0; }编译运行gcc -o fdpass fdpass.c ./fdpass cat test.txt你会看到父进程打印发送fd子进程打印接收到fd而test.txt里出现了来自子进程写入的内容。特别说明父进程在send后立即close了fd子进程依然能往文件里写这就证明了底层struct file没有因为发送端关闭而失效。3.5 从Demo到真实项目的几个改造点上面这份代码只能做教学演示用在实际项目里还要做几处调整用循环加缓冲区管理来处理大块数据发送sendmsg一次发送超过缓冲区容量的数据时可能只发送部分需要循环resend。收发两端要校验recvmsg/sendmsg返回值并检查msg_flags中的MSG_CTRUNC和MSG_TRUNC标志。引入重试逻辑防止大量fd传递时阻塞在sendmsg上。把socketpair换成AF_UNIX路径监听版本时注意监听socket的权限避免被无关进程连接后白嫖你的fd。4. 实战中踩过的五个坑帮你提前排雷4.1 接收缓冲区太小fd被静默截断这大概是新手最容易遇到的问题。前面代码里接收端的控制缓冲区用了char buf[CMSG_SPACE(sizeof(int))]这已经是最小且安全的大小。如果你图省事用一个很小的固定数组比如char buf[16]那么recvmsg返回后msg_flags里会带上MSG_CTRUNC提示“辅助数据被截断”。更麻烦的是有些内核版本下即使截断了部分fd也可能已经被写入进程fd表造成“传过来但你不知道”的隐性句柄泄露。我给一个可以采纳的安全策略接收端的控制缓冲区统一用CMSG_SPACE(sizeof(int))起步如果要收多个fd就用CMSG_SPACE(n * sizeof(int))不要用猜的数字。收完之后检查msg_flags与MSG_CTRUNC一旦发现截断立刻终止处理宁可重传也不要盲用。顺带说下CMSG_LEN和CMSG_SPACE我当年写这段代码时也搞混过后来就记住了——CMSG_LEN是“消息头加数据”的最小长度用于设置cmsg_len字段CMSG_SPACE是“在缓冲区中占用的实际空间”包含了可能的填充字节用于申请缓冲区。两者不相等用错一个就会出问题。4.2 传完fd就close原fd接收方可能读到残留状态fd传递后发送方和接收方共享同一个struct file所以文件偏移、socket状态、O_NONBLOCK这些属性都是共享的。如果发送方在传完fd之后没有同步好“接下来谁使用”两边各写各的文件偏移就会互相干扰socket也可能出现两个进程同时操作的局面。实际的工程结论是传fd本质上是把使用权的交接而不是复制能力。交接之前发送方要保证自己不再操作这个fd至少要保证双方遵循同一套协议。很多人在做连接迁移时发完fd后忘记把当前epoll里注册的事件摘掉结果两边同时收到事件数据混乱。我踩过这个坑最终的解决办法是定义明确的交接协议发送方在自己的epoll里删除该fd后再发接收方确认收到并accept成功后再回一个ACK。4.3 MSG_CMSG_CLOEXEC与FD_CLOEXEC如果接收端进程在收到fd之后还会exec相当常见比如子进程收到fd后转去执行另一个二进制程序必须防止fd意外泄漏给exec后的进程。做法有两个在recvmsg后立刻调用fcntl(fd, F_SETFD, FD_CLOEXEC)。使用Linux提供的MSG_CMSG_CLOEXEC标志在recvmsg调用时把它加到msg_flags里内核会在生成新fd时自动带上FD_CLOEXEC。我实测更推荐第二种因为它不会产生“窗口期”不会出现另一个线程在fd生效到手动设置CLOEXEC之间把这个fd传出去的小概率问题。不过MSG_CMSG_CLOEXEC是Linux特定扩展移植性差一点。你的代码如果确定在Linux上跑直接用没问题如果要跨平台就用第一种。4.4 批量传253个fd的限制SCM_MAX_FD 253是Linux内核硬编码跟缓冲区大小无关。想传大量fd怎么办我的做法是分批传每253个一组多调用几次sendmsg配合接收端的循环读取。但注意每条消息还带有普通数据接收端要能够区分“这是第几批”建议在普通数据里编码序号或长度。如果你只是想“移交”已经打开的fd集合比如一批连接socket还有一个更简单粗暴的方案用sendmsg循环传直到全部传完接收端先recvmsg一批把所有收到的fd挂到一个队列里再继续收下一批。这个过程要注意接收端阻塞在recvmsg上时发送端也要显式发送一个“结束”标志否则接收端没法判断何时停止读取。4.5 与epoll协同时的惊群问题当你把监听socket传给多个worker后如果每个worker都在自己的epoll里监听这个fd那么新连接到达时内核在传统模式下会把多个进程同时唤醒出现著名的“惊群”问题。现在解决手段多了EPOLLEXCLUSIVE、SO_REUSEPORT、或者干脆由中心进程accept后把连接socket分发下去。fd传递恰好能配合最后一种一个进程持有listener并accept然后把每个连接的fd传给对应worker。我在自己写的连接分发Demo中就是这套模型稳定还简单不需要考虑listener共享时的事件仲裁。5. fd传递在真实工程里的玩法与边界5.1 平滑重启与热升级说到fd传递最有价值的应用我首推平滑重启。以nginx为例reload时老的master进程会fork新的master新master继承原来的监听socket但这里有个细节fd传递让nginx可以在不关闭监听socket的前提下把监听权交给新的worker进程组再慢慢关闭旧的worker实现零断连升级。自己动手实现时通常在程序里监听一个额外的Unix域套接字作为“控制通道”管理员通过它发送“重新加载配置并交接”的指令内部就是调用send_fd把listener传过去。读完这篇文章你应该能拼出一个迷你Demo了。顺带一提这个知识点在Linux系统管理、运维岗位的面试里也经常被问到懂原理的人直接加分。5.2 systemd socket activationsystemd的socket activation本质也是fd传递systemd在开机时就bind好TCP监听端口但服务的进程并不一定处于运行状态。当请求到达时systemd唤醒服务进程并通过Unix域套接字把监听fd传过去。服务进程拿到fd后连自己的bind/listen代码都省了直接accept就有请求。这里有个额外细节systemd传给服务的fd往往不止一个它会在环境变量LISTEN_FDS里写明个数服务进程从fd3开始按顺序领取。这样能省掉bind权限问题普通用户进程没法监听低于1024的端口但通过socket activation拿到systemd开好的fd后非root进程也能优雅处理80端口流量。5.3 安全与凭证不只是传一个数字fd传递很强大但也引出一个问题你怎么知道对端是谁如果让恶意进程传给你一个fd你拿到的资源可能是你原本无权访问的比如它偷摸打开了一个敏感文件然后传给你你就等于被拖下水了。好在Unix域套接字提供SO_PEERCRED接收方可以获取对端进程的真实uid、gid、pid在接收fd之前校验身份。或者更彻底使用SCM_CREDENTIALS让对端在消息里携带凭证接收方再用SO_PASSCRED选项接收。工程上我建议“先认证后传fd”把传fd的通道视为受控通道不要让普通业务数据走同一条路径。5.4 嵌入式与容器场景下的注意点嵌入式Linux和容器场景同样有fd传递的身影。嵌入式里常把整套系统打包成小而密的方案模块与模块之间通过Unix域套接字传递音频设备节点、buffer的fd避免多进程反复open硬件节点。容器运行时比如containerd用fd传递把容器stdio、控制socket传给容器内的init进程实现外部控制。在这些场景里特别要注意权限与uid映射。容器内进程最终的访问权限取决于它所在的namespace传递的fd如果由宿主进程打开并不代表容器进程就自动获得了对宿主文件系统的权限内核仍然会基于struct file上的权限模型做判断。实践时我们一般建议尽量由目标namespace内的高权限进程直接打开所需资源再在内部传递避免跨namespace的权限判断带来不可预期的行为。最后聊点我自己的体会。我最早学fd传递是在写一个必须平滑重启的网关时被迫去啃的当时对着man 7 unix和man 2 sendmsg看了很久一度被cmsg的对齐搞崩溃。后来有一天突然想明白SCM_RIGHTS的cmsghdr只是把fd当普通数据打包内核负责cmsg的规范用户只负责用宏操作别自己去算偏移。从那之后我写fd传递的代码就固定用一套模板就像上面这份send_fd/recv_fd都不用再动脑子了。如果你看完这篇想练手建议做一个“连接队列分发”的小项目一个listener负责accept几个worker通过fd传递接连接跑一跑压测你会发现这套机制在实践中非常可靠。fd传递不是什么黑魔法它是Linux内核专门给你准备的进程间“交接棒”。