79、Unix域套接字实战:本地进程间通信、传递文件描述符
说实话,网络编程搞久了,你会发现一个有意思的现象——很多人一提到进程间通信,脑子里蹦出来的就是管道、共享内存、消息队列这些老面孔。但在我个人的项目经验里,Unix域套接字才是真正的"瑞士军刀"。它既能做普通的本地通信,还能干一件绝活:传递文件描述符。
今天我们就来聊聊这个。我会把我在实际项目中踩过的坑、总结的经验,都揉进这章里。
什么是Unix域套接字?
Unix域套接字,说白了就是同一台机器上两个进程之间的通信通道。它和网络套接字(比如TCP/UDP)最大的区别在于:
- 不需要网络协议栈——不走IP、不走端口,直接在内核层面完成数据交换
- 性能更高——省去了协议封装、路由查找等开销
- 更安全——只能被本地进程访问,外部无法连接
你想想看,如果两个进程都在同一台机器上,你非要用TCP回环地址(127.0.0.1)去通信,那其实是在"杀鸡用牛刀"。Unix域套接字才是正解。
两种类型:流式 vs 数据报
| 类型 | 对应网络协议 | 特点 |
|---|---|---|
AF_LOCAL / SOCK_STREAM |
TCP | 面向连接、可靠、有序 |
AF_LOCAL / SOCK_DGRAM |
UDP | 无连接、不可靠、保留消息边界 |
我个人习惯用SOCK_STREAM,因为大多数场景下我们需要可靠传输。但如果你对消息边界有严格要求(比如每个send对应一个recv),那SOCK_DGRAM会更合适。
基础通信示例
先来个最简单的——服务端和客户端通过Unix域套接字交换数据。
服务端代码
#include <sys/socket.h>
#include <sys/un.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#define SOCK_PATH "/tmp/my_unix_socket"
int main() {
int sfd, cfd;
struct sockaddr_un addr;
char buf[1024];
// 1. 创建套接字
sfd = socket(AF_LOCAL, SOCK_STREAM, 0);
if (sfd == -1) {
perror("socket");
return 1;
}
// 2. 绑定地址
unlink(SOCK_PATH); // 删除可能存在的旧文件
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_LOCAL;
strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1);
if (bind(sfd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
perror("bind");
return 1;
}
// 3. 监听
if (listen(sfd, 5) == -1) {
perror("listen");
return 1;
}
printf("Server listening on %s\n", SOCK_PATH);
// 4. 接受连接
cfd = accept(sfd, NULL, NULL);
if (cfd == -1) {
perror("accept");
return 1;
}
// 5. 读取数据
int n = read(cfd, buf, sizeof(buf));
if (n > 0) {
buf[n] = '\0';
printf("Received: %s\n", buf);
}
close(cfd);
close(sfd);
unlink(SOCK_PATH);
return 0;
}
客户端代码
#include <sys/socket.h>
#include <sys/un.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#define SOCK_PATH "/tmp/my_unix_socket"
int main() {
int sfd;
struct sockaddr_un addr;
char *msg = "Hello from client!";
// 1. 创建套接字
sfd = socket(AF_LOCAL, SOCK_STREAM, 0);
if (sfd == -1) {
perror("socket");
return 1;
}
// 2. 连接服务端
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_LOCAL;
strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1);
if (connect(sfd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
perror("connect");
return 1;
}
// 3. 发送数据
write(sfd, msg, strlen(msg));
close(sfd);
return 0;
}
嗯,这里要注意一点:bind之前一定要先unlink,否则如果上次程序异常退出,socket文件还留在那里,第二次运行就会bind失败。我曾经因为这个bug排查了半天……
传递文件描述符——这才是重头戏
普通的数据交换,管道也能做。但Unix域套接字有一个独门绝技:通过sendmsg和recvmsg配合辅助数据(ancillary data),可以把一个文件描述符从进程A传到进程B。
为什么要传文件描述符?我给你举个实际场景:
- 一个高权限的守护进程打开了某个设备文件或日志文件
- 它想把这个"打开的文件"交给一个低权限的工作进程去读写
- 直接传路径不行——低权限进程可能没有权限打开
- 传文件描述符——完美解决!
说白了,传递文件描述符就是传递一个"访问权"。接收方拿到这个fd后,可以像自己打开的一样去read/write。
传递文件描述符的核心API
关键就两个函数:sendmsg和recvmsg。它们都使用struct msghdr结构体,其中msg_control字段用来携带辅助数据。
辅助数据的格式由struct cmsghdr定义:
struct cmsghdr {
socklen_t cmsg_len; // 辅助数据长度
int cmsg_level; // 协议层级,必须为 SOL_SOCKET
int cmsg_type; // 类型,传递fd时用 SCM_RIGHTS
// 后面跟着实际数据
};
实战:传递文件描述符
下面这个例子,服务端打开一个文件,把fd传给客户端,客户端直接读取文件内容。
服务端(发送fd)
#include <sys/socket.h>
#include <sys/un.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/uio.h>
#define SOCK_PATH "/tmp/fd_pass_socket"
void send_fd(int socket, int fd_to_send) {
struct msghdr msg = {0};
struct iovec iov;
char buf[1] = {0}; // 至少传一个字节的数据
// 准备辅助数据
char cmsg_buf[CMSG_SPACE(sizeof(int))];
struct cmsghdr *cmsg = (struct cmsghdr *)cmsg_buf;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
*(int *)CMSG_DATA(cmsg) = fd_to_send;
// 组装消息
iov.iov_base = buf;
iov.iov_len = 1;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);
if (sendmsg(socket, &msg, 0) == -1) {
perror("sendmsg");
}
}
int main() {
int sfd, cfd;
struct sockaddr_un addr;
// 打开一个文件用于演示
int fd = open("/tmp/test_fd_pass.txt", O_RDONLY | O_CREAT, 0644);
if (fd == -1) {
perror("open");
return 1;
}
// 创建Unix域套接字
sfd = socket(AF_LOCAL, SOCK_STREAM, 0);
unlink(SOCK_PATH);
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_LOCAL;
strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1);
bind(sfd, (struct sockaddr*)&addr, sizeof(addr));
listen(sfd, 5);
printf("Server waiting for connection...\n");
cfd = accept(sfd, NULL, NULL);
// 发送文件描述符
send_fd(cfd, fd);
printf("Sent fd %d to client\n", fd);
close(fd);
close(cfd);
close(sfd);
unlink(SOCK_PATH);
return 0;
}
客户端(接收fd)
#include <sys/socket.h>
#include <sys/un.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/uio.h>
#define SOCK_PATH "/tmp/fd_pass_socket"
int recv_fd(int socket) {
struct msghdr msg = {0};
struct iovec iov;
char buf[1];
char cmsg_buf[CMSG_SPACE(sizeof(int))];
struct cmsghdr *cmsg = (struct cmsghdr *)cmsg_buf;
iov.iov_base = buf;
iov.iov_len = 1;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);
if (recvmsg(socket, &msg, 0) == -1) {
perror("recvmsg");
return -1;
}
// 提取辅助数据
cmsg = CMSG_FIRSTHDR(&msg);
if (cmsg && cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
return *(int *)CMSG_DATA(cmsg);
}
return -1;
}
int main() {
int sfd;
struct sockaddr_un addr;
sfd = socket(AF_LOCAL, SOCK_STREAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_LOCAL;
strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1);
connect(sfd, (struct sockaddr*)&addr, sizeof(addr));
int received_fd = recv_fd(sfd);
if (received_fd == -1) {
printf("Failed to receive fd\n");
return 1;
}
printf("Received fd: %d\n", received_fd);
// 读取文件内容
char buf[1024];
int n = read(received_fd, buf, sizeof(buf));
if (n > 0) {
buf[n] = '\0';
printf("File content: %s\n", buf);
}
close(received_fd);
close(sfd);
return 0;
}
核心要点:传递文件描述符时,sendmsg必须至少携带1字节的普通数据。这是POSIX规范的要求——辅助数据不能单独发送,必须附着在普通数据上。
传递文件描述符的底层原理
为什么会这样?你想想看,文件描述符本质上是进程文件描述符表的一个索引。进程A的fd=3和进程B的fd=3,指向的可能是完全不同的内核文件结构。
所以传递fd时,内核做的是:
- 从发送进程的fd表中找到对应的内核文件结构
- 在接收进程的fd表中分配一个新的fd(数值可能不同)
- 让这个新fd指向同一个内核文件结构
- 增加该文件结构的引用计数
说白了,传递的不是"数字3",而是一个指向内核对象的引用。
避坑指南
我曾经踩过的坑:
- 忘记传普通数据——sendmsg只传辅助数据不传普通数据,在某些系统上会直接返回错误。我的习惯是至少传1个字节的"占位数据"。
- 接收方忘记关闭收到的fd——传递fd后,接收方用完一定要close,否则会造成文件描述符泄漏。我在一个长期运行的服务里就因为这个把fd耗尽过……
- 跨用户传递fd——Unix域套接字传递fd时,两个进程必须属于同一个用户,或者发送方有足够权限。否则内核会拒绝。
- 多线程同时sendmsg——如果多个线程同时往同一个Unix域套接字发送fd,辅助数据可能会错乱。建议加锁或使用专用通道。
小技巧:如果你只是想传fd,不想传任何业务数据,可以定义一个全局的"空数据"标志。比如发送方传一个字节的'\0',接收方收到后只提取fd,忽略数据内容。这样代码更清晰。
总结
Unix域套接字是本地进程间通信的利器。普通的数据交换用read/write就够了,但如果你需要传递文件描述符,那就必须用sendmsg/recvmsg配合辅助数据。
我个人觉得,传递文件描述符这个能力,让Unix域套接字在"进程间资源转移"的场景下无可替代。比如:
- Web服务器把已接受的连接fd传给工作进程
- 日志守护进程把日志文件fd传给压缩进程
- 特权进程打开设备后把fd传给普通进程
嗯,这些场景在实际项目中都很常见。掌握了这个技术,你的进程间通信工具箱里就多了一把利器。
公众号:蓝海资料掘金营,微信deep3321