10UNIX网络编程(第3版)

UNIX域协议

本地通信

UNIX域套接字流套接字数据报套接字

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第10章 UNIX域协议

本地通信

一、导读

10.1 本章学习目标

第10章介绍UNIX域协议——一种在同一台主机上的进程间通信(IPC)机制。与网络套接字不同,UNIX域套接字不经过网络协议栈,直接在内核中传递数据,因此性能更高、延迟更低。本章将系统讲解UNIX域套接字的类型、使用方法、高级特性(如描述符传递和凭证传递),以及它在现代软件架构中的应用。

本章的核心学习目标包括:

  • 理解UNIX域套接字的基本概念:与网络套接字有什么区别?为什么性能更高?
  • 掌握两种UNIX域套接字类型:流模式(SOCK_STREAM)和数据报模式(SOCK_DGRAM)的区别和使用场景。
  • 学会使用socketpair创建全双工管道:与pipe有什么区别?
  • 掌握描述符传递机制:如何通过UNIX域套接字在进程间传递文件描述符?
  • 理解凭证传递:如何获取对端进程的用户身份信息?
  • 了解UNIX域套接字在现代软件中的应用:Docker、数据库、消息队列等如何使用它?

10.2 为什么本章如此重要

UNIX域套接字是高性能本地通信的首选方案。在现代软件架构中,它被广泛使用:

  • Docker:Docker daemon通过 /var/run/docker.sock 提供API
  • 数据库:PostgreSQL、MySQL默认使用UNIX域套接字进行本地连接
  • 消息队列:Redis的本地通信
  • 系统服务:systemd的日志接口(/dev/log)
  • 浏览器:Chrome的扩展通信

为什么不用TCP回环(127.0.0.1)?

虽然TCP回环也在本机通信,但它需要经过完整的网络协议栈(TCP/IP处理、拥塞控制、校验和等)。UNIX域套接字直接在进程间复制数据,不经过网络层,因此:

  • 延迟更低:没有网络协议栈的开销
  • 吞吐量更高:数据直接在内核缓冲区间复制
  • 更安全:不暴露在网络中,只有本机进程可以访问
  • 支持高级特性:描述符传递、凭证传递

10.3 核心问题引导

在阅读本章时,请带着以下问题思考:

UNIX域套接字的地址是什么格式?与网络套接字的IP地址+端口有什么不同?

socketpair 和 pipe 有什么区别?为什么需要 socketpair?

描述符传递的底层机制是什么?为什么只能通过UNIX域套接字传递?

为什么PostgreSQL默认使用UNIX域套接字而不是TCP回环?

UNIX域套接字的SOCK_STREAM和SOCK_DGRAM在语义上有什么区别?

如何验证连接到UNIX域套接字的进程身份?


二、核心概念详解

11.1 UNIX域套接字基础

11.1.1 地址格式

UNIX域套接字使用文件系统中的路径名作为地址:

c
#include <sys/un.h>

struct sockaddr_un {
    sa_family_t sun_family;    // AF_UNIX
    char        sun_path[108]; // 路径名(以null结尾)
};
c
// 创建UNIX域套接字
int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);

// 绑定到路径
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/my_socket.sock", sizeof(addr.sun_path) - 1);

// 删除已存在的socket文件
unlink("/tmp/my_socket.sock");

bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));

路径名的限制:

  • 最大长度通常为108字节(sun_path 的大小)
  • 路径必须在文件系统中存在(bind时创建socket文件)
  • 需要适当的文件系统权限

抽象命名空间(Linux特有):

c
// Linux支持抽象命名空间(不以文件系统路径为基础)
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
addr.sun_path[0] = '\0';  // 第一个字节为null表示抽象命名空间
strncpy(addr.sun_path + 1, "my_abstract_socket", sizeof(addr.sun_path) - 2);

// 抽象命名空间的优点:
// 1. 不创建文件系统对象,不需要清理
// 2. 不受文件系统权限限制
// 3. 进程退出后自动消失

11.1.2 两种套接字类型

SOCK_STREAM(流模式):

c
// 类似TCP,提供可靠的字节流服务
int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
  • 可靠的、有序的字节流
  • 面向连接
  • 无消息边界

SOCK_DGRAM(数据报模式):

c
// 类似UDP,提供消息边界的数据报服务
int sockfd = socket(AF_UNIX, SOCK_DGRAM, 0);
  • 保留消息边界
  • 可靠的(UNIX域内不会丢包)
  • 无连接(但需要bind地址)
c
// SOCK_STREAM示例
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
// sv[0]和sv[1]可以双向通信

write(sv[0], "Hello", 5);
char buf[10];
read(sv[1], buf, 5);  // 读到"Hello"

// SOCK_DGRAM示例
int sv[2];
socketpair(AF_UNIX, SOCK_DGRAM, 0, sv);

send(sv[0], "Hello", 5, 0);
send(sv[0], "World", 5, 0);

// SOCK_DGRAM保留消息边界
char buf1[10], buf2[10];
recv(sv[1], buf1, 5, 0);  // 读到"Hello"
recv(sv[1], buf2, 5, 0);  // 读到"World"
// 不会合并为"HelloWorld"

11.2 socketpair

11.2.1 函数原型

c
#include <sys/socket.h>

int socketpair(int domain, int type, int protocol, int sv[2]);
// 返回值:成功返回0,出错返回-1

socketpair 创建一对相连的套接字,可以直接用于父子进程间的全双工通信。

11.2.2 使用示例

c
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);

if (fork() == 0) {
    // 子进程
    close(sv[0]);
    
    char buf[100];
    read(sv[1], buf, sizeof(buf));
    printf("Child received: %s\n", buf);
    
    write(sv[1], "Hello from child", 16);
    close(sv[1]);
} else {
    // 父进程
    close(sv[1]);
    
    write(sv[0], "Hello from parent", 17);
    
    char buf[100];
    read(sv[0], buf, sizeof(buf));
    printf("Parent received: %s\n", buf);
    
    close(sv[0]);
}

11.2.3 socketpair vs pipe

特性pipesocketpair
通信方向半双工(单向)全双工(双向)
描述符数量2个(读端+写端)2个(每个都可读写)
协议族无AF_UNIX
可移植性所有POSIX所有POSIX
描述符传递不支持支持
c
// pipe:半双工
int pipefd[2];
pipe(pipefd);
// pipefd[0]只能读,pipefd[1]只能写
// 要双向通信需要两个pipe

// socketpair:全双工
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
// sv[0]和sv[1]都可以读写
// 一个socketpair就够双向通信

11.3 服务器和客户端示例

11.3.1 UNIX域服务器

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

#define SOCK_PATH "/tmp/echo_server.sock"

int main(void) {
    int listenfd, connfd;
    struct sockaddr_un addr;

    listenfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("socket");
        return 1;
    }

    unlink(SOCK_PATH);  // 删除已存在的socket文件

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

    if (bind(listenfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind");
        return 1;
    }

    if (listen(listenfd, 10) < 0) {
        perror("listen");
        return 1;
    }

    printf("UNIX domain echo server listening on %s\n", SOCK_PATH);

    for (;;) {
        connfd = accept(listenfd, NULL, NULL);
        if (connfd < 0) {
            perror("accept");
            continue;
        }

        char buf[1024];
        ssize_t n;
        while ((n = read(connfd, buf, sizeof(buf))) > 0) {
            write(connfd, buf, n);
        }

        close(connfd);
    }

    close(listenfd);
    unlink(SOCK_PATH);
    return 0;
}

11.3.2 UNIX域客户端

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

#define SOCK_PATH "/tmp/echo_server.sock"

int main(void) {
    int sockfd;
    struct sockaddr_un addr;

    sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return 1;
    }

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

    if (connect(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("connect");
        return 1;
    }

    char sendline[] = "Hello UNIX domain socket!";
    write(sockfd, sendline, strlen(sendline));

    char recvline[1024];
    ssize_t n = read(sockfd, recvline, sizeof(recvline));
    recvline[n] = '\0';
    printf("Received: %s\n", recvline);

    close(sockfd);
    return 0;
}

11.4 描述符传递

11.4.1 原理

UNIX域套接字支持通过辅助数据(ancillary data)传递文件描述符。接收进程获得描述符后,可以像自己打开的文件一样使用它。

底层机制:内核在两个进程的文件描述符表中创建指向同一个 struct file 对象的条目。两个进程共享同一个文件偏移量和文件状态标志。

11.4.2 发送描述符

c
#include <sys/socket.h>

int send_fd(int sock, int fd_to_send) {
    struct msghdr msg = {0};
    struct iovec iov[1];
    char buf[1] = {0};
    
    iov[0].iov_base = buf;
    iov[0].iov_len = 1;
    msg.msg_iov = iov;
    msg.msg_iovlen = 1;
    
    // 控制消息(辅助数据)
    union {
        struct cmsghdr cm;
        char control[CMSG_SPACE(sizeof(int))];
    } control_un;
    memset(&control_un, 0, sizeof(control_un));
    
    msg.msg_control = control_un.control;
    msg.msg_controllen = sizeof(control_un.control);
    
    struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
    cmsg->cmsg_len = CMSG_LEN(sizeof(int));
    cmsg->cmsg_level = SOL_SOCKET;
    cmsg->cmsg_type = SCM_RIGHTS;
    *((int *)CMSG_DATA(cmsg)) = fd_to_send;
    
    return sendmsg(sock, &msg, 0);
}

11.4.3 接收描述符

c
int recv_fd(int sock) {
    struct msghdr msg = {0};
    struct iovec iov[1];
    char buf[1];
    
    iov[0].iov_base = buf;
    iov[0].iov_len = 1;
    msg.msg_iov = iov;
    msg.msg_iovlen = 1;
    
    union {
        struct cmsghdr cm;
        char control[CMSG_SPACE(sizeof(int))];
    } control_un;
    memset(&control_un, 0, sizeof(control_un));
    
    msg.msg_control = control_un.control;
    msg.msg_controllen = sizeof(control_un.control);
    
    if (recvmsg(sock, &msg, 0) <= 0) {
        return -1;
    }
    
    struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
    if (cmsg == NULL || cmsg->cmsg_level != SOL_SOCKET ||
        cmsg->cmsg_type != SCM_RIGHTS) {
        return -1;
    }
    
    return *((int *)CMSG_DATA(cmsg));
}

11.4.4 完整示例

c
// 父进程打开文件,传递给子进程
int main(void) {
    int sv[2];
    socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
    
    int fd = open("test.txt", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:接收描述符
        close(sv[0]);
        close(fd);  // 子进程不需要原始描述符
        
        int received_fd = recv_fd(sv[1]);
        if (received_fd < 0) {
            fprintf(stderr, "Failed to receive fd\n");
            exit(1);
        }
        
        char buf[256];
        ssize_t n = read(received_fd, buf, sizeof(buf) - 1);
        if (n > 0) {
            buf[n] = '\0';
            printf("Child read: %s\n", buf);
        }
        
        close(received_fd);
        close(sv[1]);
    } else {
        // 父进程:发送描述符
        close(sv[1]);
        
        send_fd(sv[0], fd);
        printf("Parent sent fd %d\n", fd);
        
        close(fd);
        close(sv[0]);
        wait(NULL);
    }
    
    return 0;
}

11.5 凭证传递

11.5.1 SO_PEERCRED

c
#include <sys/socket.h>

// 获取对端进程的凭证
struct ucred cred;
socklen_t len = sizeof(cred);

if (getsockopt(connfd, SOL_SOCKET, SO_PEERCRED, &cred, &len) == 0) {
    printf("Peer PID: %d\n", cred.pid);
    printf("Peer UID: %d\n", cred.uid);
    printf("Peer GID: %d\n", cred.gid);
}

11.5.2 应用场景

c
// 安全的本地服务:只允许特定用户连接
int connfd = accept(listenfd, NULL, NULL);

struct ucred cred;
socklen_t len = sizeof(cred);
getsockopt(connfd, SOL_SOCKET, SO_PEERCRED, &cred, &len);

// 只允许root或特定用户
if (cred.uid != 0 && cred.uid != 1000) {
    fprintf(stderr, "Unauthorized user: uid=%d\n", cred.uid);
    close(connfd);
    continue;
}

// 提供服务...

11.6 性能比较

11.6.1 UNIX域 vs TCP回环

测试场景:在本地发送1MB数据

TCP回环(127.0.0.1):
  - 经过TCP协议栈(序列号、确认、拥塞控制)
  - 经过IP协议栈(IP头部处理)
  - 经过回环设备驱动
  - 延迟:~50μs
  - 吞吐量:~5GB/s

UNIX域套接字:
  - 直接在内核缓冲区间复制
  - 无协议栈开销
  - 延迟:~10μs
  - 吞吐量:~10GB/s

UNIX域套接字的性能通常是TCP回环的2-3倍。


三、实战案例

10.1 本地日志服务

c
// 简单的本地日志服务(类似syslog)
#include <sys/socket.h>
#include <sys/un.h>
#include <stdio.h>
#include <time.h>

#define LOG_SOCK "/tmp/log_service.sock"

int main(void) {
    int sockfd = socket(AF_UNIX, SOCK_DGRAM, 0);
    
    struct sockaddr_un addr = {0};
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, LOG_SOCK, sizeof(addr.sun_path) - 1);
    
    unlink(LOG_SOCK);
    bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
    
    printf("Log service listening on %s\n", LOG_SOCK);
    
    char buf[4096];
    for (;;) {
        ssize_t n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0, NULL, NULL);
        if (n > 0) {
            buf[n] = '\0';
            
            time_t now = time(NULL);
            char timestamp[64];
            strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", localtime(&now));
            
            printf("[%s] %s\n", timestamp, buf);
        }
    }
    
    return 0;
}

10.2 Docker风格的API服务

c
// 模拟Docker的API服务(通过UNIX域套接字)
#define API_SOCK "/tmp/api.sock"

void handle_api_request(int connfd) {
    char buf[4096];
    ssize_t n = read(connfd, buf, sizeof(buf) - 1);
    if (n <= 0) return;
    buf[n] = '\0';
    
    // 简单的JSON响应
    char response[] = "{\"status\": \"ok\", \"message\": \"Hello from API\"}";
    write(connfd, response, strlen(response));
}

int main(void) {
    int listenfd = socket(AF_UNIX, SOCK_STREAM, 0);
    
    struct sockaddr_un addr = {0};
    addr.sun_family = AF_UNIX;
    strncpy(addr.sun_path, API_SOCK, sizeof(addr.sun_path) - 1);
    
    unlink(API_SOCK);
    bind(listenfd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listenfd, 10);
    
    printf("API service on %s\n", API_SOCK);
    
    for (;;) {
        int connfd = accept(listenfd, NULL, NULL);
        handle_api_request(connfd);
        close(connfd);
    }
    
    return 0;
}

四、本章小结

本章系统讲解了UNIX域协议,核心要点回顾:

UNIX域套接字:用于同一主机上的进程间通信。地址是文件路径(或Linux的抽象命名空间)。性能优于TCP回环,因为没有网络协议栈开销。

两种类型:SOCK_STREAM提供可靠的字节流(类似TCP),SOCK_DGRAM提供有消息边界的数据报(类似UDP,但在UNIX域内可靠)。

socketpair:创建一对相连的套接字,用于全双工通信。比pipe更灵活(双向通信、支持描述符传递)。

描述符传递:通过辅助数据(SCM_RIGHTS)在进程间传递文件描述符。底层是共享同一个 struct file 对象。

凭证传递:使用SO_PEERCRED获取对端进程的PID/UID/GID,用于身份验证和权限控制。

应用场景:Docker API、数据库本地连接、日志服务、系统服务等。是高性能本地通信的标准方案。


五、思考与练习

思考题

UNIX域套接字的socket文件与普通文件有什么区别?为什么不能用cat查看它的内容?

描述符传递后,两个进程的read/write是否会相互影响文件偏移量?为什么?

为什么SOCK_DGRAM在UNIX域内是可靠的,而在网络上(UDP)是不可靠的?

抽象命名空间(Linux特有)相比文件系统路径有什么优势和劣势?

为什么Docker选择UNIX域套接字而不是TCP暴露API?安全性考虑是什么?

编程练习

本地RPC服务:实现一个基于UNIX域套接字的简单RPC框架。客户端发送函数名和参数,服务器执行函数并返回结果。使用SOCK_STREAM和自定义消息格式。

描述符传递的文件服务器:实现一个文件服务器,客户端通过UNIX域套接字发送文件路径,服务器打开文件并将描述符传递给客户端,客户端直接读取文件内容。

多客户日志服务:扩展日志服务,支持多个客户同时连接(使用epoll)。每个客户的日志消息加上客户标识(PID/UID)。

10/10UNIX
0%