1. 为什么需要跨进程传递文件描述符:一个整数背后的两个世界
先聊一个很多人刚接触这个问题时都会踩的思维误区:文件描述符(fd)在代码里就是一个int,那我在进程 A 里打开一个文件,把这个整数通过管道或者共享内存传到进程 B 里,进程 B 拿这个数字直接 read,不就行了吗?
行不通。这个int只是进程 A 的文件描述符表里的一个下标,它指向进程 A 的打开文件表项,再指向内核的struct file对象。进程 B 有自己独立的一张文件描述符表,它那个表里下标 3 可能指向完全不同的东西,也可能根本还没有 3 号表项。你把数字扔过去,对方拿到的只是一个没有上下文的整数,内核不会因为你写了同一个数字就把它当成同一个文件。
真正要传递的,是那个struct file对象本身。跨进程传递文件描述符,本质上是请求内核对目标进程的文件描述符表做一次"新建表项"操作,让这个新表项指向同一个struct file,同时把它的引用计数加一。这跟dup的语义非常像,只不过dup是在同一个进程内复制表项,而这里是在两个进程之间复制表项。
什么场景下会用到这个能力?举几个我实际见过的例子:
- 服务进程启动时以 root 权限打开了一个配置文件或设备节点,但业务逻辑要降权到普通用户进程里跑。降权之后路径可能变得不可读,此时直接把已打开的 fd 递给业务进程,是最干净的做法。
- 守护进程负责建立监听 socket,然后通过 unix socket 连接把监听 fd 交给后续创建的工作进程,工作进程直接 accept 即可,不需要重新 bind 和 listen。
- 进程热升级场景:旧进程把已经 listen 的 fd 传给新进程,新进程无缝接管,TCP 连接一个都不掉。
- 某个模块打开了大文件、内存映射相关的 fd,自己不想暴露文件路径,通过"文件句柄分发"的方式给其他模块用。
这些场景都有一个共同点:文件的打开动作只在源头进程做一次,后续的消费方进程不关心路径、不关心权限,只要拿到一个"活的引用"就行。
而实现这个能力最标准的做法,就是通过 Unix 域套接字(AF_UNIX)的sendmsg/recvmsg,配合辅助数据(ancillary data)里的SCM_RIGHTS类型。标题里的sendfd和recvfd,就是对这个过程的封装。接下来我把原理、代码、坑一次讲透。
2. sendmsg/recvmsg 的辅助数据机制:Unix 域套接字的专属快递通道
2.1 为什么必须是 Unix 域套接字,TCP 不行
这是新手最容易忽略的一个点。send/sendto/write这些接口只能传普通的字节流,内核在 TCP 协议栈里根本不会去解析什么"把 fd 搬到另一个进程"的指令——TCP 的数据是给对端应用层看的,内核不会因为它里面包含了一个 fd 数字就帮你建立什么引用关系。
只有在内核自己实现的 Unix 域套接字里,sendmsg才会特判SCM_RIGHTS这种辅助数据类型,并调用unix_scm_to_skb这类内部逻辑,把发送进程 fd 表中的文件对象引用记到套接字缓冲区里。等对端recvmsg时,再把它安装到接收进程的 fd 表里。所以这个能力是 AF_UNIX 的专属福利,别指望 TCP 或 UDP 能做同样的事。
顺带说一句:这也意味着跨进程传递 fd 只能在同一台机器、同一个内核命名空间内进行。你不可能把一个 fd 通过网络连接传到另一台服务器上,因为另一台机器的内核不认你这个文件对象。
2.2 struct msghdr 和你必须理解的辅助数据布局
sendmsg/recvmsg的核心参数是struct msghdr,它的结构大致是:
struct msghdr { void *msg_name; // 目标地址(TCP/UDP 用,AF_UNIX 已连接场景可置空) socklen_t msg_namelen; struct iovec *msg_iov; // 普通数据缓冲区数组 size_t msg_iovlen; void *msg_control; // 辅助数据缓冲区 size_t msg_controllen; int msg_flags; // recv 时内核回填的标志 };普通数据那一块很好理解,就是你平时 send/recv 的那些字节。辅助数据这块,很多人第一次接触会觉得陌生,它本质上是一段由内核定义格式的"控制信息段",里面可以放多个 cmsghdr,每个 cmsghdr 由头部和负载数据组成。
struct cmsghdr { size_t cmsg_len; // 本辅助数据项的总长度(含头部) int cmsg_level; // 协议层级,传 fd 用 SOL_SOCKET int cmsg_type; // 类型,传 fd 用 SCM_RIGHTS // cmsg_data[] 紧随其后 };理解辅助数据,你可以把它想成寄快递。msg_iov里的字节是普通包裹,走的是普通物流;msg_control里的SCM_RIGHTS是挂号信,内核会单独检查、单独处理。"快递柜子"的大小由msg_controllen决定,而每个"格口"怎么分配,就需要用到CMSG_SPACE/CMSG_LEN这两个宏。
CMSG_LEN(sizeof(int))表示一个 cmsghdr 头部加上 fd 数据后,实际内容占据的字节数;CMSG_SPACE(sizeof(int))则是考虑对齐之后,在缓冲区里真正占用的格口大小。申请辅助数据缓冲区时用CMSG_SPACE,设置cmsg_len字段时用CMSG_LEN。这个区别踩过的人不少,后面我会专门说。
遍历辅助数据用CMSG_FIRSTHDR(&msg)和CMSG_NXTHDR(&msg, cmsg),这两个宏会按对齐规则在msg_control缓冲区里逐个找出 cmsghdr。重点是对端recvmsg后,内核已经帮我们解析好了,我们只需要循环判断cmsg_level == SOL_SOCKET && cmsg_type == SCM_RIGHTS,然后从CMSG_DATA(cmsg)里把 fd 数字 memcpy 出来。
2.3 为什么发送 fd 时还要附带一个普通字节
早期我看别人代码时有个疑惑:既然 fd 走的是辅助数据,为什么发送端还要在msg_iov里塞一个字节的普通数据?这不是多余吗?
不是多余的。sendmsg的返回值表示"普通数据发了多少个字节",辅助数据本身不参与这个计数。如果msg_iovlen为 0,或者所有 iov 的长度都是 0,那么这次 sendmsg 在接收端看来就是一个空消息,甚至在某些实现上会出现服务端recvmsg返回 0、被误判为对端关闭连接的尴尬情况。附带一个普通字节,是告诉接收端"我这里有一条完整的消息,快来取",它起到哨兵作用。接收端recvmsg返回的字节数如果是 1,就说明这条消息确实收到了,然后再从辅助数据里拿 fd。
3. sendfd 发送端实现:封装、逐行注释与常见误区
先给出完整的发送端封装函数,这是标题里 sendfd 的核心,代码可以放进项目里直接改改就用。
#include <sys/socket.h> #include <sys/types.h> #include <unistd.h> #include <string.h> #include <stdio.h> #include <errno.h> ssize_t sendfd(int sock_fd, int fd_to_send) { char byte = 'F'; struct iovec iov; iov.iov_base = &byte; iov.iov_len = sizeof(byte); char control[CMSG_SPACE(sizeof(int))]; memset(control, 0, sizeof(control)); struct msghdr msg; memset(&msg, 0, sizeof(msg)); 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_to_send, sizeof(int)); ssize_t n = sendmsg(sock_fd, &msg, 0); if (n < 0) { fprintf(stderr, "sendmsg failed: %s\n", strerror(errno)); return -1; } return n; }逐行拆几个关键点:
第一个是辅助数据缓冲区的声明。char control[CMSG_SPACE(sizeof(int))],括号里是一个 int,对应一个文件描述符。我们直接在栈上申请,不需要 malloc,因为固定只传一个 fd,大小是编译期常量。CMSG_SPACE已经按 8 字节(或 16 字节)对齐过了,所以这个数组长度比CMSG_LEN算出来的值要大一点点,这是正常的,给对齐留的余量。
第二个是CMSG_FIRSTHDR(&msg)的返回值。它返回的是msg_control缓冲区里第一个 cmsghdr 的地址。因为我们只需要一个辅助数据项,直接拿一个就够了。如果你需要同时传多个 fd 或者同时传凭据,那就要在设置完第一个 cmsg 之后,继续按CMSG_NXTHDR向后遍历填充。
第三个是memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int))。CMSG_DATA返回 cmsghdr 头部后面的负载区地址,这里就是把 fd 的二进制拷贝进去。注意,传进去的是一个整数,但到了内核那里,它会被解释成"发送进程 fd 表中的编号",然后由内核去查表、取引用。所以发送端传入的fd_to_send必须是当前进程确实持有的有效 fd,否则内核会报EBADF。
我在实际项目里一般还会给 sendfd 加一个对fd_to_send合法性的前置判断,比如fcntl(fd_to_send, F_GETFD)探一下,避免因为上游逻辑传进来一个已经关闭的 fd 而出现难排查的偶发失败。这个小习惯帮我省了不少事。
还有一个非常重要的细节:sendfd 成功返回后,发送端进程仍然持有这个 fd。这不像你把一张实体票据递给别人,自己手里就没了。内核只是给那个struct file对象增加了一次引用,所以发送端如果想"移交"这个 fd,必须在 sendmsg 之后主动 close 它。如果发送端不 close,这个文件对象就会一直有两个引用挂在两边。等到接收端也 close 了,引用计数归零,文件才会真正关闭。
发送端的 flags 参数,也就是 sendmsg 的第三个参数,我大多数时候传 0。如果你需要非阻塞发送,可以传MSG_DONTWAIT,但请注意:sendmsg 的返回值和普通 send 一样,表示发送的普通数据字节数,跟辅助数据是否成功传递没有直接关系——辅助数据的"失败"通常直接体现在返回值 -1 上。
4. recvfd 接收端实现:比发送端更需要对细节较劲
发送端代码十几行就能写完,接收端虽然也只有十几行,但细节明显更多,任何一个地方没注意,fd 就可能悄悄丢在黑洞里。
int recvfd(int sock_fd) { char byte; struct iovec iov; iov.iov_base = &byte; iov.iov_len = sizeof(byte); char control[CMSG_SPACE(sizeof(int))]; memset(control, 0, sizeof(control)); struct msghdr msg; memset(&msg, 0, sizeof(msg)); msg.msg_name = NULL; msg.msg_namelen = 0; msg.msg_iov = &iov; msg.msg_iovlen = 1; msg.msg_control = control; msg.msg_controllen = sizeof(control); ssize_t n = recvmsg(sock_fd, &msg, 0); if (n < 0) { perror("recvmsg"); return -1; } if (msg.msg_flags & MSG_CTRUNC) { fprintf(stderr, "control data truncated: fd lost\n"); return -1; } int received_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(&received_fd, CMSG_DATA(cmsg), sizeof(int)); break; } } if (received_fd < 0) { fprintf(stderr, "no SCM_RIGHTS in message\n"); return -1; } return received_fd; }4.1 不允许忽略 MSG_CTRUNC 标志
这是我用一句话总结过很多次的坑:接收端的辅助数据缓冲区太小,fd 就会被内核悄悄没收。当接收缓冲区msg_controllen放不下完整的辅助数据时,内核会截断它,同时把msg_flags里的MSG_CTRUNC置位。对于SCM_RIGHTS这种类型来说,被截断的部分里如果有 fd,这些 fd 不会出现在你的进程 fd 表里——它们在内核里被直接关闭了,你连 close 的机会都没有。
所以代码里一定要检查MSG_CTRUNC,一旦出现,应该当作严重错误处理。我在项目里会在这里打一条明显的错误日志,并配合监控告警,因为这种情况通常意味着某个协议升级后辅助数据变大了,而接收端的老代码还没更新缓冲区。你在排查这类问题时,日志里能看到"control data truncated",比什么都看不到好太多。
4.2 MSG_CMSG_CLOEXEC 标志:一个容易被忽略的安全细节
Linux 的recvmsg还支持一个MSG_CMSG_CLOEXEC标志。把这个标志加进去:
ssize_t n = recvmsg(sock_fd, &msg, MSG_CMSG_CLOEXEC);这样的话,内核在把 fd 安装进接收进程 fd 表时,会顺便给它打上FD_CLOEXEC。这个标志的意思是这个 fd 不会被继承给 exec 之后的新程序。如果你接收的进程后续还会 fork 并执行其他程序,而我们不希望这个 fd 意外泄漏进那些子进程,那MSG_CMSG_CLOEXEC就是一把非常趁手的锁。这也是我在所有新代码里默认开启的选项;只有在明确需要这个 fd 被 exec 后的进程继续使用的场景,我才会关掉它。
4.3 收到 fd 之后的收尾责任
接收端拿到 fd 之后,对它的使用跟自己打开一个 fd 没有任何区别。用完要 close,否则struct file的引用计数会一直挂在接收进程上。如果接收进程是长期运行的,每收一个 fd 都不释放,最终会把自己 fd 表耗尽,表现为EMFILE。
有一种常见做法是收到 fd 后立刻给这个 fd 设置非阻塞、调整缓冲区大小等属性,因为这时候你是唯一持有它的人(假设发送端已经 close),可以放心去改。如果你需要绕过文件的偏移量共享问题,可以在收到 fd 后做一个dup/dup2,把 fd 搬到一个预期的编号上再使用。
5. 端到端实测:跨进程把文件句柄搬运过去
光给函数不给完整可跑的例子,等于没写完。这里我给两个独立的程序,它们没有任何 fork 关系,完全靠 Unix 域套接字建立连接,模拟真实场景下两个互不相干的进程之间的 fd 传递。发送端打开一个普通文件(比如系统日志文件),把 fd 发给接收端,接收端读取内容并打印。
先看接收端,它做的是创建监听套接字、绑定路径、accept 然后用 recvfd 接收,最后往标准输出打印读到的内容:
// receiver.c #include <sys/socket.h> #include <sys/un.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int recvfd(int sock_fd); // 见上一节的完整实现 int main(void) { const char *path = "/tmp/fdtransfer.sock"; int listen_fd = socket(AF_UNIX, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(1); } struct sockaddr_un addr; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); unlink(path); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(listen_fd, 4) < 0) { perror("listen"); exit(1); } printf("receiver waiting for connection on %s\n", path); int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd < 0) { perror("accept"); exit(1); } int file_fd = recvfd(conn_fd); if (file_fd < 0) { exit(1); } printf("receiver got fd = %d\n", file_fd); char buf[512]; ssize_t n; while ((n = read(file_fd, buf, sizeof(buf)))) { if (n < 0) { perror("read"); break; } write(STDOUT_FILENO, buf, n); } close(file_fd); close(conn_fd); close(listen_fd); unlink(path); return 0; }再看发送端,它负责打开文件、连接 Unix socket、把 fd 发出去:
// sender.c #include <sys/socket.h> #include <sys/un.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <string.h> ssize_t sendfd(int sock_fd, int fd_to_send); // 见第三节完整实现 int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "usage: %s <file_path>\n", argv[0]); exit(1); } int file_fd = open(argv[1], O_RDONLY); if (file_fd < 0) { perror("open"); exit(1); } printf("sender opened fd = %d\n", file_fd); int sock_fd = socket(AF_UNIX, SOCK_STREAM, 0); if (sock_fd < 0) { perror("socket"); exit(1); } struct sockaddr_un addr; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; strncpy(addr.sun_path, "/tmp/fdtransfer.sock", sizeof(addr.sun_path) - 1); if (connect(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("connect"); exit(1); } if (sendfd(sock_fd, file_fd) < 0) { exit(1); } printf("sender sent fd, closing original...\n"); close(file_fd); close(sock_fd); return 0; }编译运行:
gcc -o receiver receiver.c gcc -o sender sender.c ./receiver & ./sender /var/log/syslog在我的测试环境里,输出大概是这样的:
receiver waiting for connection on /tmp/fdtransfer.sock sender opened fd = 4 sender sent fd, closing original... receiver got fd = 7 <文件内容从这里开始输出>注意看:发送端持有的 fd 是 4,接收端拿到的 fd 是 7。这正好印证了我最开始的判断——传的不是数字,传的是引用。如果只是把数字 4 传过去,接收端根本不可能用它读到任何内容,而现在接收端 7 号 fd 指向的是同一个打开文件描述。
发送端在 sendfd 之后立刻 close 了原始 fd,接收端依然能正常把文件读完。因为 sendmsg 在内核执行时就已经把文件对象引用计数加了 1,发送端的 close 只是把发送端自己的这一份引用释放掉,不影响接收端那份引用。
这个简单实验里其实已经覆盖了三个核心知识点:fd 表按进程隔离、sendmsg 传递的是引用、引用计数保证接收端不受发送端 close 影响。如果你有兴趣,还可以在接收端打印read出来的内容是否和发送端打开文件时的偏移量一致——如果发送端在打开文件后先读过一段,再把 fd 传过去,接收端会从同一个文件偏移位置继续读,这也说明它们共享的是同一个打开文件描述。
6. 实战中的高频坑:这五个问题我基本每次都会被问到
6.1 接收端 recvmsg 一直阻塞,fd 永远不来
常见原因有两个。第一个是发送端在 sendmsg 时没有带普通字节,或者 iov 长度是 0,导致接收端可能收到一个空消息甚至收不到;第二个更隐蔽——发送端只调用了 sendmsg,但辅助数据里的 fd 是无效的,内核在发送阶段直接报EBADF,接收端自然什么也等不到。
所以调试时我一般先在两个进程里分别打日志:发送端打出来"我要传的 fd 是几",接收端打出来"我收到的普通数据字节数是几"。这个字节数就是调节奏的钥匙。如果 recvmsg 返回 0,说明对端关闭了连接或者空消息到达;如果返回 1,再去看辅助数据里的内容,基本就能定位问题。
6.2 控制缓冲区大小算错,fd 被静默丢弃
很多人图省事,把msg_controllen设成sizeof(struct cmsghdr) + sizeof(int),结果在接收端反复遇到 fd 拿不到的情况。原因就是没有用CMSG_SPACE计算对齐后的真实占用。发送端用CMSG_SPACE(sizeof(int))申请缓冲区,接收端也必须用同样的大小去申请,两边的布局才一致。否则很可能会出现MSG_CTRUNC,而你又没检查这个标志,最后 fd 就是没了,程序还不报错,特别折磨人。
6.3 接收进程 fd 耗尽:EMFILE 的连锁反应
如果接收端进程已经把 fd 表用光了,recvmsg 会返回失败,errno 是EMFILE(进程 fd 数达上限)或者ENFILE(系统文件表满)。这时候一个容易被忽略的行为是:本来要发给你的 fd 会被内核关闭,因为你根本接不住。所以接收端应该是 fd 使用轻量化的进程,并且在 accept 之前就估好自己当前有没有足够的 fd 余量。
6.4 SOCK_STREAM 流式协议的消息边界问题
Unix 域套接字用SOCK_STREAM时,辅助数据是跟着普通数据一起在字节流里传输的,fd 的安装发生在 recvmsg 读到对应那条消息的第一个字节时。如果你一次 sendmsg 只发了一个字节加一个 fd,那对口很简单。但如果你的通信协议里一条 sendmsg 带了 100 字节业务数据和一个 fd,而接收端每次只读 10 字节,那么第一次 recvmsg 就可能拿到 fd,而后面几次 recvmsg 拿不到——这是正常的,不是 bug。为了减少这种困扰,我建议在专传 fd 的信道上用SOCK_SEQPACKET或SOCK_DGRAM,这类套接字天然保留消息边界,每个 recvmsg 恰好对应一次 sendmsg,fd 的归属不会有歧义。如果必须用 SOCK_STREAM,那就在业务协议层自己加消息边界,不要把"有 fd 的消息"和"没有 fd 的消息"混在一个流里。
6.5 传 socket fd 时的微妙语义
传 socket fd 也是可以的,但要知道它和传普通文件的区别。socket 这个对象本身有两端、有缓冲区状态,发送进程通过 sendmsg 把 socket fd 传过去后,发送端如果 close 了它,接收端依然可以正常收发数据,因为引用计数在维持这个 socket 的生命周期。但如果两端都 close 了这个 socket fd,那么这条连接才算真正关闭。在做热升级场景时,旧进程把监听 fd 传给新进程后,旧进程必须关闭自己的 fd,并确认新进程已经成功接受,否则新旧进程会对同一个监听 socket 上发生的 accept 事件产生竞争。
7. 扩展:一次传多个 fd、凭据传递以及 C++ 工程里的封装思路
7.1 一次传递多个文件描述符
某些场景下需要一次传一组 fd,比如一次性把读端和写端两个 pipe fd 都递过去。实现上只需要把辅助数据缓冲区扩大,然后在一个 cmsghdr 的负载区里连续存放多个 int:
#define MAX_FDS 4 char control[CMSG_SPACE(sizeof(int) * MAX_FDS)]; struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg->cmsg_level = SOL_SOCKET; cmsg->cmsg_type = SCM_RIGHTS; cmsg->cmsg_len = CMSG_LEN(sizeof(int) * fd_count); memcpy(CMSG_DATA(cmsg), fd_array, sizeof(int) * fd_count);接收端对应地要把received_fd改成数组,cmsg_len里算出实际有多少个 int,然后逐个拷贝出来:
int fd_count = (cmsg->cmsg_len - CMSG_LEN(0)) / sizeof(int); int fds[MAX_FDS]; memcpy(fds, CMSG_DATA(cmsg), sizeof(int) * fd_count);注意收发双方必须约定好一次最多传几个 fd,接收端的缓冲区大小要按这个上限来分配,否则会出现前面说的截断问题。
7.2 同时携带进程凭据
除了SCM_RIGHTS,Linux 还支持SCM_CREDENTIALS辅助数据,用于传递发送进程的 PID、UID、GID。这在做权限校验时很有用:接收端可以先拿到发送端真实的进程身份,再决定是否接受它发来的 fd,而不是只看 unix socket 路径的权限。实现上就是再填充一个 cmsghdr,类型换成SCM_CREDENTIALS,负载是一个struct ucred。有一点要注意:发送端进程必须已经调用了SCM_CREDENTIALS相关的 socket 选项(SO_PASSCRED),接收端才能收到凭据辅助数据。这块内容适合放到权限敏感的服务里,普通场景可以不用。
7.3 C++ 工程里的封装心得
标题里有 C/C++,这套机制在 C++ 项目里用起来也是完全一样的,毕竟底层就是 POSIX API。我在 C++ 里一般会写一个小的 RAII 包装类,把"持有 fd"和"传递 fd"分开:
FdHolder负责管理 fd 生命周期,析构时自动 close。sendfd/recvfd收发的对象不是裸 int,而是FdHolder的引用,发送方在 sendmsg 成功后有权利决定是否release()掉自己那份。
这样做最大的好处是避免在异常路径上 fd 泄漏。C 语言里你还可以靠 goto 集中处理错误,C++ 里如果到处裸 close,出现异常时几乎是防不住的。至于线程安全,sendfd和recvfd本身没有额外的共享状态,只要每个线程有自己的 socket 连接,就可以并行调用。
7.4 和 fd 缓存池 / RPC 结合的思路
如果你在做一个分布式或本地 RPC 系统,可以把"传 fd"当成一种特殊消息类型:控制消息里写一段自描述的结构体,说明这条消息附带几个 fd、分别是什么用途,然后真正的 fd 走辅助数据。接收端解析结构体后再按类型分发到各个处理函数。同理,如果你有 fd 缓存池复用(比如频繁 open/close 某个设备文件),也可以用传 fd 的机制把一部分空闲 fd 从管理进程分发到工作进程,省去每次重新 open 的开销。
我在实际项目里做这类封装时,有一个习惯:把"准备辅助数据"和"发送辅助数据"拆成两个函数,方便单测。因为辅助数据的填充逻辑计算量不大但容易出错,单独测它比整条链路一起测要快得多。每个 cmsghdr 填充完,我都顺手断言一次cmsg_len和缓冲区的边界,提前拦截越界写。
最后再说一个我在很多项目里验证过的经验:如果你发现自己正在为跨进程共享某个资源而反复 open、加锁、清理,不妨停下来想想这个资源能不能以 fd 的形式被"分发"出去。很多问题的解法不是让每个进程都摸一遍路径和权限体系,而是让一个进程把已打开的句柄交给别人——一条 Unix socket 通道加一对 sendfd / recvfd,就能把复杂问题切成干净的模块边界。