第十六章:Unix域套接字——本地进程间通信的利器

说实话,很多刚接触网络编程的朋友,一听到「套接字」三个字,脑子里蹦出来的就是 TCP/IP、端口号、远程连接这些概念。但你知道吗?套接字最初的设计目标,其实也包括了本地通信。

Unix 域套接字,就是专门为同一台机器上的进程间通信(IPC)准备的。它不走网络协议栈,不经过网卡,纯粹在内核层面完成数据交换。说白了,它就是一条「本地管道」,但比管道更灵活、更强大。

我在早期做嵌入式系统开发时,就吃过一次亏。当时需要让两个进程频繁交换数据,我图省事用了 TCP 回环地址(127.0.0.1)。结果性能测试时发现,CPU 占用率居高不下。后来换成 Unix 域套接字,性能直接翻了一倍。嗯,从那以后,只要是本地通信,我首选 Unix 域套接字。

核心要点:Unix 域套接字不经过网络协议栈,没有包头开销,没有路由查找,性能远高于 TCP 回环通信。它只适用于同一台主机上的进程间通信。

16.1 Unix 域套接字地址结构

Unix 域套接字的地址,不是 IP 加端口,而是一个文件路径。这个文件就是所谓的「套接字文件」。你想想看,两个进程要通信,总得有个「接头地点」吧?这个文件就是那个地点。

地址结构定义在 sys/un.h 中:

#include <sys/un.h>

struct sockaddr_un {
    sa_family_t sun_family;  // AF_UNIX 或 AF_LOCAL
    char        sun_path[];  // 路径名,长度限制通常为 108 字节
};

这里有个坑,我必须要提醒你:sun_path 的长度在不同系统上不一样。Linux 上是 108 字节,BSD 上是 104 字节。我曾经在跨平台项目中,因为没注意这个差异,导致地址结构被截断,连接一直失败。排查了半天才发现是路径长度问题。

注意:Unix 域套接字文件是真实存在于文件系统中的。进程退出后,这个文件不会自动删除。你需要手动 unlink()remove() 它。否则下次启动时,bind() 会失败。

16.2 Unix 域流式套接字(SOCK_STREAM)

流式套接字,对应的是 TCP 的行为——面向连接、可靠、有序、无边界。你发送 100 字节,对端可能分两次收到 50+50,但顺序不会乱,数据不会丢。

服务端代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>

#define SOCK_PATH "/tmp/unix_stream.sock"

int main() {
    int server_fd, client_fd;
    struct sockaddr_un addr;
    char buf[1024];

    // 1. 创建套接字
    server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(1);
    }

    // 2. 删除可能存在的旧文件
    unlink(SOCK_PATH);

    // 3. 绑定地址
    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1);

    if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("bind");
        exit(1);
    }

    // 4. 监听
    if (listen(server_fd, 5) == -1) {
        perror("listen");
        exit(1);
    }

    printf("Server listening on %s\n", SOCK_PATH);

    // 5. 接受连接
    client_fd = accept(server_fd, NULL, NULL);
    if (client_fd == -1) {
        perror("accept");
        exit(1);
    }

    // 6. 收发数据
    read(client_fd, buf, sizeof(buf));
    printf("Received: %s\n", buf);
    write(client_fd, "Hello from server", 17);

    close(client_fd);
    close(server_fd);
    unlink(SOCK_PATH);

    return 0;
}

客户端代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>

#define SOCK_PATH "/tmp/unix_stream.sock"

int main() {
    int sock_fd;
    struct sockaddr_un addr;
    char buf[1024];

    sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sock_fd == -1) {
        perror("socket");
        exit(1);
    }

    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1);

    if (connect(sock_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("connect");
        exit(1);
    }

    write(sock_fd, "Hello from client", 17);
    read(sock_fd, buf, sizeof(buf));
    printf("Server says: %s\n", buf);

    close(sock_fd);
    return 0;
}

你看,代码结构和 TCP 几乎一模一样。区别只在于:地址是文件路径,协议族是 AF_UNIX。我个人习惯把套接字文件放在 /tmp 目录下,因为系统重启后 /tmp 会被清空,省得我手动删文件。

16.3 Unix 域数据报套接字(SOCK_DGRAM)

数据报套接字对应的是 UDP 的行为——无连接、不可靠、有边界。每次 sendto() 发送的数据,对端一次 recvfrom() 就能完整收到。不会出现流式套接字那种「粘包」问题。

但注意,Unix 域数据报套接字和 UDP 有一个重要区别:它是可靠的。因为数据只在本地内核中传递,不会经过网络,所以不存在丢包问题。你发送的每个数据报,只要对端接收缓冲区有空间,就一定能收到。

服务端示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>

#define SOCK_PATH "/tmp/unix_dgram.sock"

int main() {
    int sock_fd;
    struct sockaddr_un addr, client_addr;
    socklen_t addr_len;
    char buf[1024];

    sock_fd = socket(AF_UNIX, SOCK_DGRAM, 0);
    if (sock_fd == -1) {
        perror("socket");
        exit(1);
    }

    unlink(SOCK_PATH);

    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path) - 1);

    if (bind(sock_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("bind");
        exit(1);
    }

    printf("Datagram server listening on %s\n", SOCK_PATH);

    addr_len = sizeof(client_addr);
    recvfrom(sock_fd, buf, sizeof(buf), 0,
             (struct sockaddr*)&client_addr, &addr_len);
    printf("Received: %s\n", buf);

    sendto(sock_fd, "Hello from server", 17, 0,
           (struct sockaddr*)&client_addr, addr_len);

    close(sock_fd);
    unlink(SOCK_PATH);

    return 0;
}

客户端:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>

#define SOCK_PATH "/tmp/unix_dgram.sock"
#define CLIENT_PATH "/tmp/unix_dgram_client.sock"

int main() {
    int sock_fd;
    struct sockaddr_un server_addr, client_addr;
    socklen_t addr_len;
    char buf[1024];

    sock_fd = socket(AF_UNIX, SOCK_DGRAM, 0);
    if (sock_fd == -1) {
        perror("socket");
        exit(1);
    }

    // 客户端也需要绑定一个地址,否则服务端无法回复
    unlink(CLIENT_PATH);
    memset(&client_addr, 0, sizeof(client_addr));
    client_addr.sun_family = AF_UNIX;
    strncpy(client_addr.sun_path, CLIENT_PATH,
            sizeof(client_addr.sun_path) - 1);
    bind(sock_fd, (struct sockaddr*)&client_addr, sizeof(client_addr));

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sun_family = AF_UNIX;
    strncpy(server_addr.sun_path, SOCK_PATH,
            sizeof(server_addr.sun_path) - 1);

    sendto(sock_fd, "Hello from client", 17, 0,
           (struct sockaddr*)&server_addr, sizeof(server_addr));

    addr_len = sizeof(server_addr);
    recvfrom(sock_fd, buf, sizeof(buf), 0,
             (struct sockaddr*)&server_addr, &addr_len);
    printf("Server says: %s\n", buf);

    close(sock_fd);
    unlink(CLIENT_PATH);

    return 0;
}

经验之谈:数据报模式下,客户端也必须 bind() 一个地址。否则服务端调用 sendto() 时,不知道往哪发。我见过不少新手在这里栽跟头——客户端只 sendto()bind(),结果服务端回复的数据报永远发不出去。

16.4 流式 vs 数据报:怎么选?

这个问题,我经常被问到。其实选择标准很简单:

场景 推荐类型 原因
需要保持连接状态 SOCK_STREAM 面向连接,适合长连接场景
消息有明确边界 SOCK_DGRAM 数据报天然保边界,无需自己分包
传输大量数据 SOCK_STREAM 流式套接字缓冲区大,吞吐量高
需要广播/多播 SOCK_DGRAM 数据报支持,流式不支持
追求极致性能 SOCK_DGRAM 无连接建立开销,延迟更低

我个人习惯是:如果两个进程之间需要频繁、双向、大量地交换数据,我用流式。如果只是偶尔发个消息、通知一下,我用数据报。你想想看,数据报每次都要指定对端地址,写起来也麻烦一点,对吧?

16.5 核心知识体系

下面这张图,是我梳理的 Unix 域套接字知识结构。你可以把它当作一张「地图」,学完本章后对照着检查自己有没有遗漏。

Unix 域套接字 SOCK_STREAM(流式) 面向连接 可靠有序 无消息边界 SOCK_DGRAM(数据报) 无连接 本地可靠 有消息边界 共同特性 地址为文件路径 不走网络协议栈 高性能低延迟 需手动清理文件

16.6 避坑指南与最佳实践

做 Unix 域套接字开发,有几个坑我踩过,今天一并告诉你:

  • 文件权限问题:套接字文件创建时,权限由当前进程的 umask 决定。如果两个进程以不同用户运行,可能无法访问。我曾经在调试一个服务时,发现客户端总是连接失败,最后发现是套接字文件权限是 700,而客户端以 www-data 用户运行,根本打不开。解决方案:在 bind() 之前调用 umask(0),然后手动 chmod() 设置权限。
  • 路径长度限制:前面说过,sun_path 长度有限。不要把套接字文件放在很深的目录里。我一般放在 /tmp/var/run 下。
  • 清理残留文件:进程崩溃退出时,套接字文件不会自动删除。下次启动时 bind() 会返回 EADDRINUSE。我的习惯是在 bind() 之前先 unlink() 一下,确保文件不存在。
  • 数据报的缓冲区大小:虽然 Unix 域数据报是可靠的,但接收缓冲区仍然有上限。如果发送方速度太快,接收方来不及读取,数据报可能会被丢弃。可以用 setsockopt() 调整 SO_RCVBUFSO_SNDBUF

一个小技巧:lsof -U 命令可以列出当前系统上所有的 Unix 域套接字。调试时非常有用,可以看到哪些套接字文件被哪些进程占用。

好了,关于 Unix 域套接字,核心内容就这些。流式还是数据报,取决于你的场景。记住一点:本地通信,优先考虑 Unix 域套接字,而不是 TCP 回环。性能差距,你一试便知。


公众号:蓝海资料掘金营,微信deep3321