☰
跨进程传递文件描述符:Unix域套接字与SCM_RIGHTS实现指南
2026/10/19 2:32:45 网站建设 项目流程

1. 为什么跨进程传 FD 这么麻烦:先搞懂 descriptor 本质

1.1 fd 并不是一个“全局句柄”

在 C/C++ 里,int fd = open(...)拿到的那个整数,看起来只是一个编号,实际上它是“进程文件描述符表”的下标,不是内核全局对象的手柄。每个进程都有一张独立的文件描述符表,表中每一项指向内核里的一个打开文件描述对象,也就是struct file。两个进程即使都拿到数字 7,它们在各自表里看到的可能是完全不同的文件对象;反过来,同一个文件对象也可以被两个进程用不同的数字编号引用。

这个区别很关键。如果你想着“我把7通过管道写到另一个进程,那边直接read到 7,再用 7 去操作同一个文件”,大概率会翻车。接收方确实读到了一个整数 7,但它的 fd 表里第 7 项指向的是它自己的文件,不是你想传的那个。两个进程之间的write/read只能搬运字节,不能搬运“内核对象引用”,所以跨进程传 fd 必须借助内核提供的专门机制。

在 Linux/Unix 上最常用的机制就是 Unix 域套接字的SCM_RIGHTS控制消息,配合sendmsg()/recvmsg()使用。你不需要自己发明协议,也不需要给 fd 编号做映射,内核会把sendmsg时指定的文件描述符复制到接收进程的 fd 表里,并返回一个真正可用的新 fd。这个过程在语义上很像“跨进程的 dup”,共享的是同一个打开文件描述,所以偏移量、文件状态标志、锁都是共享的。

1.2 通过 Unix 域套接字传 fd 的核心原理

Unix 域套接字是同一台机器上进程间通信的老牌手段,它和 TCP/UDP 最大的区别是:它不经过网络协议栈,完全在内核内部完成。除了传输普通字节流,Unix 域套接字还支持“辅助数据”,也就是msghdr结构里的msg_control字段。SCM_RIGHTS就是辅助数据的一种类型,专门用于传递文件描述符。

当你调用sendmsg()并附带一个SCM_RIGHTS控制消息时,内核会做三件事:

  • 发送端进程的 ioctl/read/write 权限不再重要,内核直接从当前进程的 fd 表里找到你传入的那个 fd,取出它指向的struct file *。
  • 把这份引用计数加一,然后把它连同普通数据一起放进对端套接字的接收队列。
  • 当对端进程调用recvmsg()时,内核在接收进程的 fd 表里分配一个空闲编号,并让这个编号指向刚才那份struct file *,再把编号放进控制消息返回给用户。

整个过程不复制文件的内容,只复制“内核对象引用”。收发两端看到的是同一份打开文件描述,所以在任意一端调用read/write/lseek,另一端正使用的 fd 也会感知到 offset 变化。这和 TCP 那种把文件内容读出来再原样写过去完全是两码事。

1.3 典型使用场景与选型判断

我实际接触到的场景里,用得最多的是下面几类:

  • 多进程网络服务:主进程统一accept(),拿到已连接的 socket fd,然后通过 Unix 域套接字把 fd 分发给 worker 进程。这样 worker 可以直接收发数据,不需要经过主进程转发,既减少拷贝也降低主进程瓶颈。
  • 权限分离:一个初始化进程先以较高权限打开文件或设备,接着 fork 出无权限的 worker,把已打开的 fd 传过去。worker 虽然没有重新打开的权限,但可以通过收到 fd 正常读写。
  • 平滑重启:老进程把监听的 socket fd 传给新启动的进程,新进程立刻继续监听同一个端口,不用经历端口释放再重绑的窗口。

当然它也不是万能的。如果你的需求只是父子进程在fork()后共享一组启动时就打开的 fd,那直接用继承就行了,没必要走sendmsg。如果通信双方不在同一台机器上,SCM_RIGHTS也帮不上忙。另外 Linux 上还有pidfd_getfd这种基于 pidfd 的获取方式,但它需要进程有对应权限,限制比 Unix 域套接字多得多,日常实现还是socketpair+SCM_RIGHTS最顺手。

2. sendmsg/recvmsg 与辅助数据:必须啃透的底层细节

2.1 msghdr + cmsghdr 的结构到底长什么样

sendmsg()和recvmsg()的核心参数是struct msghdr,看着字段多,但用起来要分清哪些是这次传递必须关心的:

struct msghdr { void *msg_name; // Socket 地址,已连接通道可传 NULL socklen_t msg_namelen; struct iovec *msg_iov; // 普通数据,必须填 size_t msg_iovlen; void *msg_control; // 辅助数据缓冲区 size_t msg_controllen; int msg_flags; // recvmsg 时返回的标志 };

辅助数据不是一股脑的字节流,而是由一个或多个struct cmsghdr头组成。每个头后面跟着载荷数据,头与头之间可能有对齐填充。struct cmsghdr在 Linux 上大约是:

struct cmsghdr { socklen_t cmsg_len; // 本控制消息的长度,包含头和载荷 int cmsg_level; // 协议层,SCM_RIGHTS 时是 SOL_SOCKET int cmsg_type; // 控制类型,SCM_RIGHTS };

用CMSG_FIRSTHDR(&msg)拿到第一段控制头,用CMSG_NXTHDR(&msg, cmsg)拿到下一段,用CMSG_DATA(cmsg)拿到载荷地址。不要手动去算偏移,因为不同架构下cmsghdr的对齐规则不一样,宏已经帮你处理好了。SCM_RIGHTS 的载荷就是int数组,每个元素表示一个文件描述符。

2.2 CMSG_LEN、CMSG_SPACE 到底选哪一个

这是初学者最容易搞混的点。CMSG_LEN和CMSG_SPACE都用来计算控制消息的长度,但用途完全不同:

  • CMSG_LEN(sizeof(int))是“填进cmsg_len字段的值”,表示这个 cmsghdr 从头到数据结束的实际长度。
  • CMSG_SPACE(sizeof(int))是整个控制消息在msg_control缓冲区里占据的字节数,包含末尾对齐填充,用于分配缓冲区和设置msg_controllen。

举一个具体的例子:如果你要传 3 个 fd,载荷是3 * sizeof(int)字节,那么cmsg_len应该填CMSG_LEN(3 * sizeof(int)),而msg_controllen和缓冲区大小应该用CMSG_SPACE(3 * sizeof(int))。如果分配内存时只按CMSG_LEN去算,后面再用CMSG_NXTHDR迭代多个控制消息就会越界;如果发送时cmsg_len填成了CMSG_SPACE,多出来的填充也会被一些严格实现看作错误或者让对端解析出错误长度。

常规写法里我习惯用 union 来保证对齐:

union { struct cmsghdr align; unsigned char buf[CMSG_SPACE(sizeof(int))]; } control;

char数组本身没有对齐保证,但 union 里带着一个struct cmsghdr,就保证了buf的首地址对齐到 cmsghdr 需要的边界。把这个 union 的buf交给msg_control最稳妥。

2.3 普通数据字节不能省:一条必须守住的协议约定

很多示例代码只发了辅助数据,没有发任何普通字节。在 Linux 上,给AF_UNIX套接字发送sendmsg()并只带控制数据,有时也能成功,但这并不值得依赖。更普遍的做法是:每次传递 fd 时,同时在msg_iov里带一个普通字节。

为什么要有这一字节?

  • 接收端可以用recvmsg()的返回值判断对方是否关闭,如果返回 0 表示 EOF,正常情况下不会读到 0。
  • 控制数据是跟随普通数据一起进入套接字队列的,带一个字节能明确表达“这个数据包里伴随一个 fd”,也让调试时能区分普通业务消息和 fd 传递消息。
  • 有些早期实现的 Unix 系统对纯辅助数据支持不友好,带普通数据最兼容。

你完全可以把这 1 个普通字节设计成自己的协议字段,比如1表示“后面有 fd”,0表示“下一条是普通数据”。在接收端,如果先调用read()去读普通数据,控制消息很可能被丢弃,fd 不会保留,所以收发双方都始终走sendmsg/recvmsg,不要中途换成read/write。

3. 完整实现:sendfd 与 recvfd 代码逐段拆解

3.1 发送端 send_fd:从分配 control buffer 到 sendmsg

发送端的核心工作是把fd_to_send填进一个SCM_RIGHTS控制消息,然后用sendmsg()发到一个 Unix 域套接字上。下面这份实现可以直接抄:

#include <sys/socket.h> #include <sys/types.h> #include <unistd.h> #include <errno.h> #include <string.h> #include <fcntl.h> int send_fd(int sock_fd, int fd_to_send) { if (sock_fd < 0 || fd_to_send < 0) { errno = EBADF; return -1; } union { struct cmsghdr align; unsigned char buf[CMSG_SPACE(sizeof(int))]; } control; memset(control.buf, 0, sizeof(control.buf)); char byte = 'F'; struct iovec iov = { .iov_base = &byte, .iov_len = sizeof(byte) }; struct msghdr msg = { 0 }; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = control.buf; msg.msg_controllen = sizeof(control.buf); struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); if (cmsg == NULL) { errno = EINVAL; return -1; } 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)); int flags = 0; #ifdef MSG_NOSIGNAL flags |= MSG_NOSIGNAL; #endif return sendmsg(sock_fd, &msg, flags) == -1 ? -1 : 0; }

这段代码里有几个细节需要注意:

  • 传入fd_to_send必须是发送进程自己已经打开的 fd,否则内核在sendmsg()阶段就会返回EBADF。想象一下:某个 fd 刚被 close,再去传,内核根本找不到对应的struct file *。
  • msg_controllen用CMSG_SPACE(sizeof(int)),分配缓冲也用它。cmsg_len则用CMSG_LEN(sizeof(int)),两者不要混。
  • 普通数据byte没有业务含义,纯粹是伴生字节。我用'F'表示fd,接收端可以按需校验。
  • 我加了MSG_NOSIGNAL,避免对端已经关闭时sendmsg()触发SIGPIPE把整个进程干掉。如果没有这个宏,就在全局忽略SIGPIPE或者自行处理。

3.2 接收端 recv_fd:解析 cmsghdr 并取得新 fd

接收端要比发送端多做一些防御。使用输出参数fd_out,而不是直接返回 fd,否则对端传了一个合法值为 0 的 fd 时,返回值和错误码会混淆。下面是配套实现:

int recv_fd(int sock_fd, int *fd_out) { if (sock_fd < 0 || fd_out == NULL) { errno = EINVAL; return -1; } union { struct cmsghdr align; unsigned char buf[CMSG_SPACE(sizeof(int))]; } control; memset(control.buf, 0, sizeof(control.buf)); char byte = '\0'; struct iovec iov = { .iov_base = &byte, .iov_len = sizeof(byte) }; struct msghdr msg = { 0 }; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = control.buf; msg.msg_controllen = sizeof(control.buf); int recv_flags = 0; #ifdef MSG_CMSG_CLOEXEC recv_flags |= MSG_CMSG_CLOEXEC; #endif ssize_t n = recvmsg(sock_fd, &msg, recv_flags); if (n <= 0) { return -1; } if (msg.msg_flags & MSG_CTRUNC) { errno = EINVAL; return -1; } struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); for (; cmsg != NULL; cmsg = CMSG_NXTHDR(&msg, cmsg)) { if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) { if (cmsg->cmsg_len < CMSG_LEN(sizeof(int))) { errno = EINVAL; return -1; } int fd; memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd)); #ifndef MSG_CMSG_CLOEXEC fcntl(fd, F_SETFD, FD_CLOEXEC); #endif *fd_out = fd; return 0; } } errno = ENOENT; return -1; }

接收端的关键点:

  • recvmsg()返回 0 表示对端关闭,此时不可能拿到有效 fd,直接返回 -1。
  • MSG_CTRUNC表示控制数据被截断。如果发生,说明缓冲区不够大或对端发来的控制消息太复杂,不能当作成功处理。
  • 我用MSG_CMSG_CLOEXEC给新 fd 自动设置FD_CLOEXEC,这样在后续调用exec时不会意外泄漏。如果平台不支持,再用fcntl补一次设置。

提示:收到 fd 后,它和你发送端的 fd 不一定同号。内核会在接收进程的 fd 表里选一个最小的空闲编号,所以永远不要假定“发过去是 7,收回来还是 7”。

3.3 用一个 fork 示例把流程串起来

下面用socketpair()和fork()搭一个最小可运行示例。子进程打开一个文件,把 fd 发回给父进程,父进程用它直接写入内容:

#include <stdio.h> #include <stdlib.h> #include <sys/socket.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> int main(void) { int sv[2]; if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) == -1) { perror("socketpair"); return 1; } pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { close(sv[0]); int fd = open("/tmp/fd_pass_demo.txt", O_CREAT | O_RDWR | O_TRUNC, 0644); if (fd < 0) { perror("open"); exit(1); } if (send_fd(sv[1], fd) != 0) { perror("send_fd"); exit(1); } close(fd); close(sv[1]); _exit(0); } close(sv[1]); int fd = -1; if (recv_fd(sv[0], &fd) != 0) { perror("recv_fd"); return 1; } dprintf(fd, "hello from parent, via passed fd\n"); close(fd); waitpid(pid, NULL, 0); close(sv[0]); return 0; }

运行后,/tmp/fd_pass_demo.txt里会出现父进程写下的字符串。子进程虽然先退出了,但它在send_fd()成功后才close(fd),父进程收到的 fd 依然有效。这就是引用计数带来的效果:sendmsg()成功的那一刻,内核已经给struct file增加了一次引用,之后发送端关不关闭原 fd,都不影响接收端的 fd。

3.4 更稳妥的封装:支持多 fd、超时与批量接收

日常项目里,单个 fd 传递只是基础功能。有时你需要一次传多个 fd,或者给接收端加超时,免得线程卡在recvmsg()上。多 fd 的发送端改动很小:

int send_fds(int sock_fd, int *fds, size_t count) { if (sock_fd < 0 || fds == NULL || count == 0) { errno = EINVAL; return -1; } union { struct cmsghdr align; unsigned char buf[CMSG_SPACE(sizeof(int) * count)]; } control; memset(control.buf, 0, sizeof(control.buf)); char byte = 'M'; struct iovec iov = { .iov_base = &byte, .iov_len = 1 }; struct msghdr msg = { 0 }; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = control.buf; msg.msg_controllen = sizeof(control.buf); struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg->cmsg_len = CMSG_LEN(sizeof(int) * count); cmsg->cmsg_level = SOL_SOCKET; cmsg->cmsg_type = SCM_RIGHTS; memcpy(CMSG_DATA(cmsg), fds, sizeof(int) * count); return sendmsg(sock_fd, &msg, 0) == -1 ? -1 : 0; }

接收端只要把控制缓冲区开成CMSG_SPACE(sizeof(int) * max_fds),然后用cmsg_len算出实际收到的 fd 个数。注意poll()可以在recvmsg()前加超时:

struct pollfd pfd = { .fd = sock_fd, .events = POLLIN }; int ret = poll(&pfd, 1, timeout_ms); if (ret <= 0) { return -1; }

这样封装之后,上层可以像调用普通函数一样使用,不用每次都手工构造msghdr。但在正式代码里,别忘了处理poll()因为被信号中断而返回EINTR的情况,要判断errno并继续等,否则服务会被无谓打断。

4. 实战中的经典坑与排查技巧

4.1 接收端 fd 数量暴涨:谁把 fd 泄漏了

我在实际调试时见过不少“进程 fd 数量一直涨,最后 close 失败”的问题,原因往往不是文件没关闭,而是SCM_RIGHTS产生的隐式 fd 被漏掉了。每次recv_fd()成功,接收进程的 fd 表就会多一个条目。如果你只是读取普通数据,却没有用一个专门接收控制消息的recvmsg(),内核不会帮你清理这些 fd;如果你调用了recvmsg(),却没有 close 从CMSG_DATA里拿到的 fd,同样会泄漏。

更隐蔽的是另一种情况:接收端虽然没有收到业务数据,但只要内核在 socket 的接收队列里暂存了某个 fd,而这个 socket 一直不关闭,这份文件引用也会一直占着。解决办法只有一条:明确每个 fd 的“拥有者”和“关闭点”。发送端在sendmsg()成功之后,如果不再需要原 fd 就立即close();接收端在处理完业务之后也要close()接收到的 fd。如果你把 fd 继续转手给下一个进程,记得转发成功后同样处理掉自己手里的副本。

4.2 recvmsg 拿到了 fd 却打不开/不能用

比较常见的一个困惑是:recv_fd()返回了 0,但拿着这个 fd 去read/write立刻得到EBADF。这通常不是 fd 传递本身失败,而是接收进程 fd 表已经满了,或者收到的 fd 被后续的exec关掉了。

检查方法很简单:拿到 fd 后立刻用fcntl(fd, F_GETFD)验证一下,返回值不是 -1 就说明 fd 合法。如果是EBADF,再去查RLIMIT_NOFILE。如果 fd 本来有效,后来执行了exec才失效,那八成就是没有设置FD_CLOEXEC。所以我在代码里用MSG_CMSG_CLOEXEC或者补一个fcntl(F_SETFD, FD_CLOEXEC),这不仅仅是“锦上添花”,而是避免生产事故的必要手段。

还有一个容易被忽略的语义:通过SCM_RIGHTS传递之后,收发两端共享的是同一个“打开文件描述”,不是两个独立的打开动作。比如发送端用O_TRUNC打开文件后,接收端接着write,写入位置就是当前共享的 offset,而不是从文件头开始。如果你错误地认为接收端会得到一个“新的、位置为 0 的 fd”,看到的结果就会很奇怪。这不是 bug,而是机制本身的设计。

4.3 非阻塞模式下的 EOF 与 MSG_CMSG_CLOEXEC

当套接字本身是O_NONBLOCK时,recvmsg()在没有数据时会返回-1,errno是EAGAIN或EWOULDBLOCK。此时不能当作对端关闭处理,要用poll()或事件循环继续等待。普通数据和控制消息是绑在一次sendmsg()调用里的,所以只要poll()返回可读,就大胆用recv_fd()收;除非连续多次读到 EOF 或断连,才判定 fd 传递失败。

MSG_CMSG_CLOEXEC还有一个隐藏优势:防止竞态。如果接收端是多线程程序,某个线程收到 fd 后,另一个线程马上调用exec,而 fd 还没被设置FD_CLOEXEC,这个 fd 就会逃逸到新的程序镜像里。用MSG_CMSG_CLOEXEC让内核在分配 fd 的同时设置 close-on-exec 标志,比先recvmsg再fcntl要安全得多。如果你的代码需要跨平台,记得先用#ifdef判断这个宏是否存在,不存在再走fcntl兜底。

4.4 常见 errno 速查表

这里给一张我调试时经常翻的表格,对照errno能快速定位问题:

errno出现时机常见原因与处理
EBADF发送端或接收端传入的sock_fd或fd_to_send无效,检查是否提前 close
EINVAL发送端或接收端msg_controllen、cmsg_len或 socket 类型不符合要求
EAGAIN非阻塞接收端暂时没有数据可收,等待下次可读事件再重试
EMSGSIZE发送端数据或控制消息体积超过内核限制,考虑分批或减少数据量
EPIPE/ECONNRESET发送端对端套接字已关闭,使用MSG_NOSIGNAL或做好对端生命周期管理
EPERM发送端SELinux/seccomp 等安全策略阻止SCM_RIGHTS,检查进程沙箱配置
EMFILE接收端接收进程 fd 表已满,recvmsg无法分配新 fd,需要提高RLIMIT_NOFILE或关闭无用 fd

遇到EPERM时最容易懵:代码逻辑完全正确,但内核就是不让你传 fd。这种多半不是代码问题,而是运行环境的安全策略。先脱离沙箱试一次,如果正常,再回去排查策略配置。

5. 进阶玩法与其他平台补遗

5.1 一次传递多个 fd 和批量接收

前面已经给了send_fds()的核心代码。一个SCM_RIGHTS控制消息可以同时携带多个 fd,接收端用一个cmsg就能取回整个数组。这样做比“fd 逐个发”高效得多,也方便保证一组 fd 的到达顺序。

接收端批量接收时,要注意cmsg_len里的长度可能包含多个 int。用(cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int)计算 fd 个数是比较通用的写法,其中CMSG_LEN(0)是控制头本身的长度。如果对方传的 fd 个数超过了本地这次申请的缓冲区容量,会触发MSG_CTRUNC,所以缓冲区大小要按最大预期 fd 数来分配。Linux 上单个SCM_RIGHTS消息的 fd 数量有上限,具体上限由SCM_MAX_FD常量表达,需要传特别多 fd 时就分批。

5.2 传递 socket fd、epoll fd 需要注意什么

socket fd 是SCM_RIGHTS最常见的乘客。多进程网络服务里,父进程把accept()返回的已连接 socket 传给 worker,worker 就可以直接read/write/send/recv,连接上的 TCP 状态、缓冲区、内核 socket 对象都保持不变。这比在父进程里做转发能省掉一次用户态与内核态之间的红娘式拷贝。

epoll fd 同样可以走SCM_RIGHTS,因为它本身也是一个内核文件对象。不过要理解 epoll 实例是“全局的”还是“进程私有的”这个问题。一个 epoll fd 代表一个内核事件表,不是 fd 表的快照。你把 epoll fd 传给另一个进程后,两个进程都能对同一个 epoll 实例做epoll_ctl和epoll_wait,但要注意事件驱动循环通常只在一个进程里跑,多进程同时epoll_wait会带来重复唤醒和负载分配问题。传递这种特殊 fd 前,先确认你到底要传递“对象所有权”还是“事件订阅状态”,后者并不是一张 fd 就能概括的。

5.3 Linux 之外:其他平台补遗

SCM_RIGHTS这个思路不是 Linux 独有。大部分 BSD 系系统也支持sendmsg/recvmsg传递 fd,宏和结构体大同小异。跨平台代码应该用CMSG_*宏而不是手算字节偏移,这样才能在 32 位和 64 位架构之间安全切换。

也有一些平台没有等价机制,比如某些非 Unix 系统并不支持AF_UNIX套接字扩展控制消息。如果你的项目需要在一个不具备SCM_RIGHTS的平台上实现 fd 传递,就要单独做一层抽象,把底层能力藏起来,上层仍然暴露send_fd/recv_fd两个接口。这样换平台时只需要替换这一层实现,调用方代码完全不用动。

我在实际项目中体会最深的一点是:sendfd/recvfd的代码量并不大,真正的难度永远在生命周期管理。谁 open、谁 close、fd 会不会被 exec 吞掉、socket 什么时候关闭、接收队列里还压着多少个未取走的 fd,这些问题比单纯调用几个系统调用要难得多。写实现的时候多想想异常路径,少一点“反正正常流程能跑通”的侥幸,线上事故能少一半。如果一开始就把MSG_CMSG_CLOEXEC、输出参数、MSG_CTRUNC这些细节都处理干净,后续基本不用回头修 fd 泄漏。

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

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

立即咨询