第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域套接字使用文件系统中的路径名作为地址:
#include <sys/un.h>
struct sockaddr_un {
sa_family_t sun_family; // AF_UNIX
char sun_path[108]; // 路径名(以null结尾)
};// 创建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特有):
// 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(流模式):
// 类似TCP,提供可靠的字节流服务
int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);- 可靠的、有序的字节流
- 面向连接
- 无消息边界
SOCK_DGRAM(数据报模式):
// 类似UDP,提供消息边界的数据报服务
int sockfd = socket(AF_UNIX, SOCK_DGRAM, 0);- 保留消息边界
- 可靠的(UNIX域内不会丢包)
- 无连接(但需要bind地址)
// 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 函数原型
#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int sv[2]);
// 返回值:成功返回0,出错返回-1socketpair 创建一对相连的套接字,可以直接用于父子进程间的全双工通信。
11.2.2 使用示例
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
| 特性 | pipe | socketpair |
|---|---|---|
| 通信方向 | 半双工(单向) | 全双工(双向) |
| 描述符数量 | 2个(读端+写端) | 2个(每个都可读写) |
| 协议族 | 无 | AF_UNIX |
| 可移植性 | 所有POSIX | 所有POSIX |
| 描述符传递 | 不支持 | 支持 |
// 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域服务器
#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域客户端
#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 发送描述符
#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 接收描述符
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 完整示例
// 父进程打开文件,传递给子进程
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
#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 应用场景
// 安全的本地服务:只允许特定用户连接
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/sUNIX域套接字的性能通常是TCP回环的2-3倍。
三、实战案例
10.1 本地日志服务
// 简单的本地日志服务(类似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服务
// 模拟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)。