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域套接字有一个独门绝技:通过sendmsgrecvmsg配合辅助数据(ancillary data),可以把一个文件描述符从进程A传到进程B。

为什么要传文件描述符?我给你举个实际场景:

  • 一个高权限的守护进程打开了某个设备文件或日志文件
  • 它想把这个"打开的文件"交给一个低权限的工作进程去读写
  • 直接传路径不行——低权限进程可能没有权限打开
  • 传文件描述符——完美解决!

说白了,传递文件描述符就是传递一个"访问权"。接收方拿到这个fd后,可以像自己打开的一样去read/write。

传递文件描述符的核心API

关键就两个函数:sendmsgrecvmsg。它们都使用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时,内核做的是:

  1. 从发送进程的fd表中找到对应的内核文件结构
  2. 在接收进程的fd表中分配一个新的fd(数值可能不同)
  3. 让这个新fd指向同一个内核文件结构
  4. 增加该文件结构的引用计数

说白了,传递的不是"数字3",而是一个指向内核对象的引用

进程A fd表: fd=3 → 文件A fd=4 → 文件B 内核文件结构 引用计数 = 2 进程B fd表: fd=7 → 文件A fd=8 → 文件C 传递文件描述符原理 1. 进程A调用 sendmsg,告诉内核:把 fd=3 传过去 2. 内核找到 fd=3 指向的文件结构,增加引用计数 3. 进程B调用 recvmsg,内核在B的fd表中分配新fd(如7) 4. 新fd指向同一个文件结构,B可以正常读写

避坑指南

我曾经踩过的坑:

  • 忘记传普通数据——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