18、Unix域套接字:本地进程间通信的“高速公路”
说到进程间通信(IPC),大家可能首先想到管道、共享内存、消息队列这些经典手段。但今天我要聊的,是另一种我特别偏爱的方案——Unix域套接字。
说白了,它就是为同一台机器上的进程之间,提供的一种类似网络通信的机制。你想想看,网络通信要经过协议栈、路由、网卡,绕一大圈。而Unix域套接字呢?它直接在操作系统内核里就把数据传过去了,效率高得多。
我个人习惯在需要高性能本地通信的场景下,优先考虑它。比如数据库客户端和服务器之间的通信,很多就是用Unix域套接字实现的。
18.1 什么是Unix域套接字?
Unix域套接字,也叫本地套接字。它和网络套接字(比如TCP/UDP)最大的区别在于:它不依赖网络协议栈。
网络套接字需要IP地址和端口号。Unix域套接字呢?它只需要一个文件路径作为标识。这个文件是特殊的,你用ls -l看,它的类型是s,代表socket。
我记得刚入行时,有个同事把Unix域套接字文件当成普通文件删了,结果服务挂了半天才找到原因。嗯,这里要注意:这个文件只是入口,删了不影响已有连接,但新连接就进不来了。
18.2 两种类型:流式 vs 数据报
Unix域套接字也分两种,和网络套接字对应:
| 类型 | 对应网络协议 | 特点 |
|---|---|---|
| SOCK_STREAM | TCP | 面向连接、可靠、有序、无边界 |
| SOCK_DGRAM | UDP | 无连接、不可靠但有边界 |
你可能会问:既然都是本机通信,数据报还会丢包吗?
答案是:不会。Unix域数据报套接字在本机环境下是可靠的,不会丢包,也不会乱序。但它保留了消息边界,这点和UDP一样。我在项目中遇到过需要传递结构化消息的场景,用数据报就特别合适,每条消息就是一个完整的数据包,接收方不用自己拼包。
18.3 流式套接字实战
先看一个最简单的例子:服务端监听,客户端连接,然后发一条消息。
服务端代码(server.c)
#include <stdio.h>
#include <sys/un.h>
#include <sys/socket.h>
#include <unistd.h>
#define SOCK_PATH "/tmp/unix_domain_socket"
int main() {
int server_fd, client_fd;
struct sockaddr_un addr;
char buf[1024];
// 1. 创建套接字
server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
// 2. 绑定地址
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, SOCK_PATH);
unlink(SOCK_PATH); // 删除可能存在的旧文件
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
// 3. 监听
listen(server_fd, 5);
// 4. 接受连接
client_fd = accept(server_fd, NULL, NULL);
// 5. 读取数据
read(client_fd, buf, sizeof(buf));
printf("收到消息: %s\n", buf);
close(client_fd);
close(server_fd);
unlink(SOCK_PATH);
return 0;
}
客户端代码(client.c)
#include <stdio.h>
#include <sys/un.h>
#include <sys/socket.h>
#include <unistd.h>
#define SOCK_PATH "/tmp/unix_domain_socket"
int main() {
int client_fd;
struct sockaddr_un addr;
client_fd = socket(AF_UNIX, SOCK_STREAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, SOCK_PATH);
connect(client_fd, (struct sockaddr*)&addr, sizeof(addr));
write(client_fd, "Hello, Unix Domain Socket!", 27);
close(client_fd);
return 0;
}
这段代码和TCP编程几乎一模一样。唯一的区别就是地址结构从sockaddr_in换成了sockaddr_un,IP+端口换成了文件路径。
unlink一下socket文件。否则如果上次程序异常退出,文件还在,bind就会失败。我曾经因为这个bug排查了半天,后来就养成了这个习惯。
18.4 数据报套接字实战
数据报模式不需要连接,直接发。服务端先bind,然后recvfrom;客户端直接sendto。
服务端(datagram_server.c)
int fd = socket(AF_UNIX, SOCK_DGRAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/dgram_socket");
unlink("/tmp/dgram_socket");
bind(fd, (struct sockaddr*)&addr, sizeof(addr));
char buf[1024];
struct sockaddr_un client_addr;
socklen_t len = sizeof(client_addr);
recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr*)&client_addr, &len);
printf("收到: %s\n", buf);
close(fd);
unlink("/tmp/dgram_socket");
客户端(datagram_client.c)
int fd = socket(AF_UNIX, SOCK_DGRAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/dgram_socket");
sendto(fd, "Hello Dgram", 12, 0, (struct sockaddr*)&addr, sizeof(addr));
close(fd);
注意:数据报模式下,客户端不需要bind。但如果你想接收服务端的回复,客户端也得bind一个自己的路径。
18.5 socketpair:成对创建的“捷径”
有时候你只需要两个进程之间通信,比如父进程和子进程。这时候用socketpair就特别方便。
socketpair会创建一对相互连接的套接字。就像一根管子,两端各拿一头,直接就能通信。
#include <sys/socket.h>
#include <unistd.h>
int main() {
int sv[2]; // sv[0] 和 sv[1] 是一对
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
pid_t pid = fork();
if (pid == 0) {
// 子进程
close(sv[0]); // 关闭一端
write(sv[1], "来自子进程", 20);
close(sv[1]);
} else {
// 父进程
close(sv[1]); // 关闭另一端
char buf[1024];
read(sv[0], buf, sizeof(buf));
printf("父进程收到: %s\n", buf);
close(sv[0]);
}
return 0;
}
你看,不需要bind,不需要listen,不需要accept。一个函数调用,一对套接字就到手了。我个人觉得这是父子进程通信最优雅的方式。
18.6 知识体系总览
下面这张图,把Unix域套接字的全貌展示出来了。我建议你保存下来,写代码时对照着看。
18.7 避坑指南与经验总结
做了这么多年C开发,我在Unix域套接字上踩过的坑,可以列个小清单:
- 路径长度限制:
sun_path数组长度是108字节(Linux上)。路径别太长,否则bind会失败。我曾经用了一个很深的目录路径,结果死活绑不上。 - 权限问题: socket文件受文件权限控制。如果两个进程用户不同,记得调整目录或文件的权限。我一般把socket文件放在
/tmp下,或者专门建一个/var/run/myapp目录。 - 清理残留文件: 程序退出时一定要
unlink掉socket文件。否则下次启动会bind失败。建议在atexit里注册清理函数。 - 数据报的“假连接”: 数据报模式下,
connect也可以调用,但只是设置默认目标地址,不是真的建立连接。别搞混了。
最后说一句:Unix域套接字是本地IPC的利器。它比管道灵活(支持双向通信、数据报模式),比共享内存安全(内核管理缓冲区),比消息队列简单(API和网络编程一致)。如果你还没用过,我建议你从socketpair开始,写一个父子进程通信的小程序试试。相信我,你会爱上它的。