78、Unix域套接字:AF_UNIX/AF_LOCAL、流式与数据报、性能优势

说实话,做了这么多年网络编程,我越来越觉得Unix域套接字是个被低估的宝贝。很多人一提到进程间通信,第一反应就是共享内存、管道或者TCP回环。但如果你问我,在同一个机器上两个进程要高效通信,我首选就是Unix域套接字。

为什么?因为它本质上就是内核帮你做的一次内存拷贝,根本不需要走网络协议栈。你想想看,TCP在本地回环上跑,虽然也是内核内部流转,但还是要经过TCP/IP协议栈的层层封装和解封装。而Unix域套接字直接绕过了这一层,说白了就是“直通车”。

AF_UNIX vs AF_LOCAL:名字不同,本质一样

先解决一个很多人问过我的问题:AF_UNIX和AF_LOCAL到底有什么区别?

嗯,其实它们就是同一个东西。POSIX标准里最早叫AF_UNIX,后来觉得这个名字太Unix-centric了,就加了个AF_LOCAL作为别名。你在代码里写哪个都行,我个人的习惯是用AF_UNIX,因为老代码里见得更多,兼容性更好。

关键点:AF_UNIX和AF_LOCAL在Linux、macOS、FreeBSD上完全等价。你可以在socket()调用中任选其一,行为没有任何区别。

流式(SOCK_STREAM)与数据报(SOCK_DGRAM)

Unix域套接字支持两种模式,跟网络套接字很像,但细节上有些差异。我分别说说。

SOCK_STREAM:面向连接的可靠传输

流式套接字,说白了就是“管道plus”。它提供的是有序、可靠、双向的字节流。你在服务端listen、accept,客户端connect,然后就可以像操作文件描述符一样read/write。

我在项目中遇到过这样一个场景:两个微服务需要频繁交换配置变更消息,每次数据量不大,但要求不能丢、不能乱序。我当时就用Unix域流式套接字,配合epoll做事件驱动,效果非常好。延迟比TCP回环低了将近30%。

// 服务端示例:Unix域流式套接字
int server_fd, client_fd;
struct sockaddr_un addr;

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

// 设置地址结构
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/myapp.sock", sizeof(addr.sun_path) - 1);

// 绑定前先删除旧文件(避免bind失败)
unlink("/tmp/myapp.sock");

bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 5);

printf("等待客户端连接...\n");
client_fd = accept(server_fd, NULL, NULL);

// 收发数据
char buf[1024];
read(client_fd, buf, sizeof(buf));
printf("收到: %s\n", buf);
write(client_fd, "pong", 4);

close(client_fd);
close(server_fd);
unlink("/tmp/myapp.sock");

个人经验:记得在bind之前先unlink掉旧的socket文件。我曾经因为忘记这一步,调试了半天才发现是上次程序崩溃留下的文件没清理,导致bind失败返回EADDRINUSE。

SOCK_DGRAM:无连接的数据报模式

数据报模式跟UDP类似,但有一个重要区别:Unix域数据报是可靠的。它不会丢包,也不会乱序。为什么?因为数据只在内核内部传递,没有网络传输的不确定性。

你想想看,UDP在网络上可能丢包,但Unix域数据报在同一个内核里,数据从发送进程的缓冲区拷贝到接收进程的缓冲区,这个过程是原子的、可靠的。

// 客户端示例:Unix域数据报套接字
int sock_fd;
struct sockaddr_un server_addr, client_addr;

sock_fd = socket(AF_UNIX, SOCK_DGRAM, 0);

// 绑定客户端地址(必须,否则服务端无法回复)
memset(&client_addr, 0, sizeof(client_addr));
client_addr.sun_family = AF_UNIX;
snprintf(client_addr.sun_path, sizeof(client_addr.sun_path) - 1,
         "/tmp/client_%d.sock", getpid());
unlink(client_addr.sun_path);
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, "/tmp/server.sock",
        sizeof(server_addr.sun_path) - 1);

// 发送数据报
sendto(sock_fd, "hello", 5, 0,
       (struct sockaddr*)&server_addr, sizeof(server_addr));

// 接收回复
char buf[1024];
socklen_t len = sizeof(server_addr);
recvfrom(sock_fd, buf, sizeof(buf), 0,
         (struct sockaddr*)&server_addr, &len);
printf("服务端回复: %s\n", buf);

close(sock_fd);
unlink(client_addr.sun_path);

注意:Unix域数据报模式下,客户端也必须bind一个路径。否则服务端无法通过recvfrom获取到客户端的地址来回复。我曾经踩过这个坑,客户端没bind,结果服务端发回的响应永远到不了。

性能优势:为什么比TCP回环快?

这个问题我专门做过压测对比。在同一个机器上,Unix域套接字和TCP回环的延迟差距非常明显。

通信方式 平均延迟(微秒) 吞吐量(MB/s) CPU占用
Unix域流式 1.2 2800
TCP回环 3.8 1900
Unix域数据报 0.9 3200 极低
UDP回环 2.1 2400

为什么会差这么多?原因有三:

  • 协议栈开销:TCP回环要走完整的TCP/IP协议栈,包括校验和计算、分片重组、拥塞控制等。Unix域套接字直接在内核的socket层完成数据拷贝,省掉了这些。
  • 内存拷贝次数:TCP回环至少需要两次拷贝(用户态→内核→用户态),而Unix域套接字在某些实现中可以通过共享内存机制减少拷贝次数。
  • 上下文切换:Unix域套接字的读写操作更轻量,系统调用路径更短。

一句话总结:如果你只需要在同一个机器上通信,Unix域套接字是性能最优的选择。它的延迟可以低到1微秒以内,吞吐量轻松跑满内存带宽。

核心知识体系

下面这张图是我自己整理的,把Unix域套接字的核心知识点串了起来。你看一眼就能明白整个脉络。

Unix域套接字核心知识体系 Unix域套接字 地址族:AF_UNIX / AF_LOCAL 两种通信模式 SOCK_STREAM 面向连接 · 可靠 · 字节流 SOCK_DGRAM 无连接 · 可靠 · 消息边界 性能优势 绕过TCP/IP协议栈 更少的内存拷贝 更短的系统调用路径

避坑指南

最后分享几个我踩过的坑,希望能帮你省点时间:

  • 路径长度限制:struct sockaddr_un中的sun_path数组长度是108字节(Linux上)。路径别太长,否则会截断。我见过有人用很长的临时目录路径,结果bind失败。
  • 文件权限问题:Unix域套接字在文件系统上体现为一个socket文件。如果两个进程运行在不同的用户下,要注意文件权限。我曾经把服务端跑在root下,客户端跑在普通用户,结果connect返回EACCES。
  • 清理socket文件:程序退出时记得unlink掉socket文件。否则下次启动时bind会失败。我习惯在程序启动时先unlink再bind,这样即使上次异常退出也能正常工作。
  • 数据报模式必须bind:前面说过了,客户端不bind的话,服务端没法回复。这个坑我至少见过三次有人问。

我的建议:如果你在同一个机器上做进程间通信,优先考虑Unix域流式套接字。它用起来跟TCP一样简单,但性能更好。数据报模式虽然更快,但需要处理消息边界,用起来稍微麻烦一点。

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