资讯详情

Linux UNIX域套接字实战:AF_UNIX本地IPC、函数细节与性能优化

📅 2026/10/26 3:27:22 | 华诺云谱 👁 阅读
Linux UNIX域套接字实战:AF_UNIX本地IPC、函数细节与性能优化
第一次在项目评审上被问“两个进程都在同一台机器上为什么还要走 TCP 回环而不是 UNIX 域套接字”我才意识到这个看似基础的问题很多人是含糊过去的。那会儿我们的采集进程和计算进程在同一台机器上用127.0.0.1:9000通信链路本身没问题但在高并发小包场景下ss -s里的 TIME_WAIT 堆得很难看单核 CPU 有相当一部分被协议栈的校验和、分段、ACK 处理吃掉了。后来把这条链路换成AF_UNIX代码改动量不到 40 行压测下的 P99 延迟直接掉了一截端口冲突、防火墙策略这些运维层面的破事也一并消失了。这篇内容聊的就是 Linux 下UNIX 域套接字Unix Domain Socket简称 UDS这套函数家族socket、bind、listen、accept、connect、send/recv、sendmsg/recvmsg、socketpair以及围绕它们的sockaddr_un、抽象命名空间、凭据传递、描述符传递这些容易被文档一笔带过的细节。适合两类人看一类是写 C 服务端、正在给本地 IPC 做选型的同学另一类是已经在用 UDS但被EADDRINUSE、ECONNREFUSED、SIGPIPE、粘包这些问题反复折磨的同学。我会把函数语义、长度计算、错误码、封装代码、排查路径都摊开讲代码可以直接拿去改。1. 选型不是拍脑袋我为什么把本地 IPC 交给 AF_UNIX1.1 一次本地进程通信的取舍现场同一台机器上的进程通信可选方案其实不少匿名管道、命名管道FIFO、共享内存、消息队列、UNIX 域套接字、TCP/UDP 回环。每一种都有它舒服的场景选错了不会报错只会在某个深夜的压测报告里恶心你。匿名管道的问题在于只能单向、只能亲缘进程之间用pipe()返回的两个 fd 是半双工的做双向要开两条。命名管道解决了亲缘限制但它在文件系统里是个特殊文件读写语义相对粗糙没有连接的概念多个客户端同时写会交错做请求-响应模型很别扭。共享内存快是真快但同步得自己搞环形缓冲区、内存序、崩溃后的一致性恢复随便一个都能写出一堆 bug。消息队列POSIX mq有消息边界可惜内核实现路径和文件描述符模型不太一样接入epoll要额外包一层通知机制是mq_notify而不是 fd 可读。剩下的就是 TCP/UDP 回环和 UDS。TCP 回环的好处是“和线上一致”同一套代码改个地址就能跑远端调试方便。但它的成本也真实存在数据要走完整的 TCP/IP 协议栈分段、校验和、拥塞控制、ACK、滑动窗口全部要算一遍端口是全局资源多实例要么抢端口要么做端口分配TIME_WAIT在短连接场景下能堆到几万个虽然可调但总归是个运维负担还有一个隐性问题回环流量其实也会经过网络命名空间的规则某些环境里安全策略会误伤。UDS 走的是内核里的unix协议族数据从发送方的 socket 缓冲直接拷到接收方的 socket 缓冲不经过 IP 层没有校验和计算、没有分段重组、没有拥塞控制。它的寻址不是 IP端口而是文件系统路径或者一个抽象名字天然没有端口冲突问题。流式 UDS 的语义和 TCP 几乎一样SOCK_STREAM代码迁移成本很低数据报 UDSSOCK_DGRAM可靠且保序比 UDP 回环省心得多。我当时的选择逻辑很简单通信双方永远在同一台机器上就不该付协议栈的税。UDS 的定位就是“本地 IPC 的通用底座”凡是 pipe 能做的它都能做而且做得更完整凡是 TCP 回环在做的本地通信基本都可以考虑换过来。1.2 三种套接字类型对应三种语义socket()的第一个参数是协议族UDS 用AF_UNIX也可以用等价的PF_UNIX历史遗留的别名值相同。第二个参数决定语义这个必须选对选错了后面全是补丁。类型消息边界可靠性典型用途注意点SOCK_STREAM无字节流可靠、有序请求-响应、长连接 RPC、日志转发需要自己处理粘包和拆包SOCK_DGRAM有一次一报文可靠、有序内核保证状态上报、事件通知、小控制包超过缓冲区会截断或报错需要看MSG_TRUNCSOCK_SEQPACKET有可靠、有序、面向连接需要连接又要保边界的场景Linux 2.6.4 起支持跨平台性一般SOCK_STREAM是最常用的。它和 TCP 一样是字节流send一次 100 字节不代表对端recv一次就能拿到 100 字节可能拿到 60也可能两次的合并成 200。所以协议层必须自带长度前缀或者分隔符这一点后面会专门讲。SOCK_DGRAM容易被误解成“不可靠的 UDP”。不是。UNIX 域数据报在本地是可靠的、保序的内核直接把 skb 挂到对端接收队列上。它的问题是没有连接状态服务端必须bind一个地址客户端可以用sendto直接发也可以先connect一下锁定对端这样后续能用send/recv。如果对端接收缓冲区满了默认行为是阻塞或者返回EAGAIN不会静默丢包这一点比 UDP 好太多。SOCK_SEQPACKET是很多人不知道的存在。它像SOCK_STREAM一样要connect/accept建连接但每次send的内容在对端一次recv里完整取出边界天然保留。我在做进程间传结构化命令的时候用过它省掉了长度前缀那一层。代价是有些平台支持不完整如果你的代码要考虑可移植性老老实实用SOCK_STREAM加长度前缀更稳。提示SOCK_STREAM和SOCK_SEQPACKET在socket()时可以按位或上SOCK_CLOEXEC和SOCK_NONBLOCK一步到位省掉后续两次fcntl也避免了 fork 与 fcntl 之间的竞态窗口。这个技巧在写库的时候尤其值得用。1.3 抽象命名空间与文件路径先想清楚再写代码UDS 的地址结构是struct sockaddr_un { sa_family_t sun_family; // AF_UNIX char sun_path[108]; // Linux 上是 108 字节 };sockaddr_un的地址有两种形态这个必须先决定因为它影响unlink、权限、排查方式一整套东西。文件系统路径绑定sun_path里放一个路径比如/tmp/myapp.sock。bind成功后内核会在那个位置创建一个 socket 类型的文件权限受umask影响可以chmod/chown所以天然的访问控制——别的用户没权限就打不开。缺点是进程崩溃后文件留在那里下次bind直接EADDRINUSE必须处理而且路径长度受限于 108 字节长路径会被截断。抽象命名空间sun_path[0]设为\0从sun_path[1]开始放名字。它不在文件系统里落地进程退出自动消失没有清理负担也没有路径长度以外的权限位问题。名字的作用域是网络命名空间不同 netns 之间互相看不见。缺点是没有文件系统权限控制任何能访问该 netns 的进程都能连并且ls看不到排查得靠ss -x或者cat /proc/net/unix。我自己的习惯是同一套服务内、生命周期由父进程管理的组件之间用抽象命名空间省去清理逻辑需要给外部工具连、或者需要按用户做权限隔离的用文件路径把 socket 文件放在一个 0700 权限的目录里等于加了一道门禁。这里有个非常容易踩的坑bind的第三个参数addrlen必须算准。// 文件路径形态 socklen_t len offsetof(struct sockaddr_un, sun_path) strlen(path) 1; // 抽象命名空间形态sun_path[0] \0名字从 [1] 开始 socklen_t len offsetof(struct sockaddr_un, sun_path) 1 strlen(name);offsetof(struct sockaddr_un, sun_path)在 Linux 上是 2sa_family_t是 2 字节。抽象命名空间下长度就是名字的实际长度内核靠这个长度确定名字边界。如果你图省事直接传sizeof(sockaddr_un)抽象名字后面会带上一串\0内核会认为你的名字就是“name 一堆零字节”于是客户端用正确长度去connect就找不到报ECONNREFUSED或者ENOENT而且现象极其诡异——服务端明明起来了就是连不上。我在这个问题上浪费过整整一个下午。2. 函数家族的细节拆解那些文档一笔带过的地方2.1 socket() 与 sockaddr_un长度算错就 EINVALsocket(AF_UNIX, type, 0)的第三个参数 protocol 固定传 0。有人会传IPPROTO_TCP那是不对的AF_UNIX 不接受 IP 层的协议号会返回EPROTONOSUPPORT。返回的 fd 就是一个普通的文件描述符能进epoll、能dup、能设非阻塞。这点很重要——意味着你可以用同一套事件循环同时管理网络 fd、UDS fd、定时器 fd、信号 fd架构上很统一。关于结构体清零务必用sockaddr_un addr{};或者memset别偷懒。sun_path不初始化会带着栈上的垃圾字节路径形态下可能导致strlen越界读抽象形态下可能导致名字里混入脏数据。路径长度检查也要自己加static_assert(sizeof(sockaddr_un::sun_path) 108, unexpected sun_path size); if (path.size() 1 sizeof(sockaddr_un::sun_path)) { errno ENAMETOOLONG; return -1; }另外提一句sizeof(sun_path)在 Linux 上是 108在 macOS/BSD 上是 104抽象命名空间是 Linux 特有的。如果你的代码要跨平台抽象命名空间那段必须用宏隔开或者干脆全用文件路径。bind之前还有一步不能漏如果目标路径已存在要先处理掉。但不能无脑unlink因为可能有一个活着的服务正在监听那个文件你把它删了那个服务就变成了“孤儿”后续谁也连不上还查不出原因。正确的做法是先探一下// 探测是否有活着的监听者 int probe ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); if (::connect(probe, (sockaddr*)addr, len) 0) { ::close(probe); return -1; // 已经有服务在监听直接失败不要动别人的文件 } if (errno ECONNREFUSED) { ::unlink(path.c_str()); // 残留文件可以清掉 } ::close(probe);ECONNREFUSED的含义很明确文件在但没人监听。这就是上次崩溃留下的残骸放心删。2.2 bind / listen / accept 的服务端骨架服务端四步socket→bind→listen→accept循环。listen的 backlog 对 UDS 的意义和 TCP 类似控制已完成连接队列的长度。Linux 上SOMAXCONN的值一般是 4096具体受net.core.somaxconn影响。backlog 给小了高并发connect时会触发ECONNREFUSED或者客户端侧等待本地通信虽然快但短连接风暴下这个问题一样存在。accept返回新 fd代表这条连接。这里有个细节Linux 的accept不会继承监听 fd 的O_NONBLOCK状态但它能继承CLOEXEC吗也不能。所以如果你希望新连接也是非阻塞且 exec-safe用accept4int conn ::accept4(listen_fd, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC);accept4是 Linux 特有的比accept 两次fcntl少两次系统调用。在每秒建连几千次的场景下这点差异是看得见的。服务端骨架我会封装成 RAII 类fd 用unique_fd管起来避免异常路径漏 close。这个不是洁癖——UDS 的服务端经常是一个常驻守护进程fd 泄漏积累几天就会撞上EMFILE然后整个服务开始拒绝新连接日志里只有一堆Too many open files。还有一点值得说UDS 的accept拿到的连接 fd可以用getsockopt(fd, SOL_SOCKET, SO_PEERCRED, ...)拿到对端的pid、uid、gid三元组。这是 UDS 相对 TCP 的独门优势——内核帮你做了对端身份认证。我做过一个本地特权操作代理客户端把请求发过来服务端先看uid非授权用户直接close连协议解析都省了。这比在应用层搞 token 校验安全得多因为它不可伪造。struct ucred cred{}; socklen_t cred_len sizeof(cred); if (::getsockopt(conn, SOL_SOCKET, SO_PEERCRED, cred, cred_len) 0) { // cred.pid / cred.uid / cred.gid }注意SO_PEERCRED拿到的是建连那一刻的对端凭据如果对端进程后来被exec成了别的程序这个值不会更新。需要实时凭据的话用SO_PASSCRED配合SCM_CREDENTIALS在每次recvmsg时取。2.3 connect 与客户端重试策略客户端两步socket→connect。看起来简单实际坑不少。第一个坑是启动顺序。客户端比服务端先起来connect会失败错误码通常是ENOENT文件路径不存在或者ECONNREFUSED文件在但没监听抽象命名空间下也是这个。生产环境里守护进程的启动顺序不受你控制所以客户端必须有重试。我的做法是带退避的重试循环总时长控制在秒级超过就报错退出让上层 systemd 之类的重新拉起。int unix_connect(const char* name, int retries 100, int delay_ms 20) { for (int i 0; i retries; i) { int fd ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); if (fd 0) return -1; sockaddr_un addr{}; addr.sun_family AF_UNIX; std::strncpy(addr.sun_path 1, name, sizeof(addr.sun_path) - 2); socklen_t len static_castsocklen_t( offsetof(sockaddr_un, sun_path) 1 std::strlen(name)); if (::connect(fd, reinterpret_castsockaddr*(addr), len) 0) { return fd; } int e errno; ::close(fd); if (e ! ENOENT e ! ECONNREFUSED e ! EAGAIN) { return -1; // 其他错误没必要重试 } ::usleep(delay_ms * 1000); } return -1; }第二个坑是非阻塞 connect。UDS 的connect在 backlog 满的时候也会返回EAGAIN在非阻塞 fd 上则返回EINPROGRESS。处理方式和 TCP 一样等 fd 可写然后getsockopt(SO_ERROR)取真实结果。不过 UDS 的建连极快除非你在做几万 QPS 的短连接压力测试一般不会撞上。第三个坑容易被忽略connect到路径形态的 socket 文件时如果调用进程对该文件所在目录没有执行x权限也会失败报EACCES。这个和文件本身的权限是两回事排查的时候要一起看。2.4 收发函数send/recv 不等于消息边界UDS 的收发函数有四组read/write、send/recv、sendto/recvfrom、sendmsg/recvmsg。流式下前三组基本等价sendmsg是功能最全的能带控制消息传 fd、传凭据。这里必须强调一件事SOCK_STREAM下send成功只代表数据进了本端发送缓冲区不代表对端收到了。真正的送达确认只能靠应用层协议比如一个 ack 报文。这一点和 TCP 一样很多人会忘。第二个必须处理的问题是SIGPIPE。对端已经close了你还往里write内核会给进程发SIGPIPE默认行为是直接杀掉进程。服务端程序里这几乎是致命的一个客户端异常断开就能让整个服务没了。两种解决方式全局signal(SIGPIPE, SIG_IGN);简单粗暴但会影响整个进程。每次发送用send(fd, buf, len, MSG_NOSIGNAL)精准。我倾向前者在main最开始就设一次然后代码里到处写MSG_NOSIGNAL太啰嗦但如果这是个库不能让调用方背锅那就必须用MSG_NOSIGNAL。两种情况都写上更保险反正不冲突。第三个问题是返回值处理。send返回小于请求长度的值是完全合法的发送缓冲区快满了这时候剩下的部分必须自己循环发出去否则就是静默截断——对端收到的数据少了一截协议解析直接错位。这类 bug 在低负载下永远复现不了一到生产高峰就爆。ssize_t write_all(int fd, const void* buf, size_t len) { const char* p static_castconst char*(buf); size_t left len; while (left 0) { ssize_t n ::send(fd, p, left, MSG_NOSIGNAL); if (n 0) { p n; left - static_castsize_t(n); continue; } if (n 0 errno EINTR) continue; if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { // 非阻塞下需要挂到 epoll 等可写这里简单返回已写长度 return static_castssize_t(len - left); } return -1; } return static_castssize_t(len); }recv返回 0 表示对端有序关闭这是正常结束不是错误。返回 -1 且errno EINTR要重试EAGAIN表示当前没数据。只有其他负数才是真错误。这套判断逻辑我在几乎所有 IPC 代码里都写成宏或者 inline 函数因为它太容易被写漏。2.5 socketpair被低估的一对匿名连接socketpair(AF_UNIX, SOCK_STREAM, 0, sv)一次调用创建一对已经连通的 fdsv[0]和sv[1]互为对端。它没有地址不需要bind/connect进程内自测、父子进程通信、事件循环唤醒都用得上。最常见的用法是唤醒。事件循环阻塞在epoll_wait上另一个线程要投递任务需要把它叫醒。用eventfd也行但如果你需要传一点附带数据比如一个任务指针或者一个描述符socketpair更灵活——可以直接sendmsg把 fd 递过去。libevent、libuv 内部都用了类似手法。另一个高频场景是父子进程。fork之前socketpairfork之后父关一端、子关另一端就得到一条全双工通道。比pipe好在双向比共享内存好在有内核缓冲和流控。int sv[2]; if (::socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) 0) { return -1; } pid_t pid ::fork(); if (pid 0) { ::close(sv[0]); // 子进程用 sv[1] } else { ::close(sv[1]); // 父进程用 sv[0] }这里有个容易忽略的细节fork之后两端都关掉自己不用那一端否则对端永远收不到 EOF。很多“父进程怎么知道子进程退出了”的问题根子就在这——父进程没关自己那端的写方向recv就永远阻塞。3. 从零写一个能跑起来的 C 封装3.1 服务端抽象命名空间 epoll 的完整骨架下面这段是可以直接编译跑的。用抽象命名空间所以不需要unlink用epoll管理多连接每个连接读到一个长度前缀帧就回显一次。#include sys/socket.h #include sys/un.h #include sys/epoll.h #include unistd.h #include cstring #include cstdio #include cerrno #include cstddef #include string #include vector static int make_listener(const char* name) { int fd ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); if (fd 0) return -1; sockaddr_un addr{}; addr.sun_family AF_UNIX; size_t nlen std::strlen(name); if (nlen 1 sizeof(addr.sun_path)) { ::close(fd); errno ENAMETOOLONG; return -1; } std::memcpy(addr.sun_path 1, name, nlen); // [0] 保持 \0 socklen_t len static_castsocklen_t( offsetof(sockaddr_un, sun_path) 1 nlen); if (::bind(fd, reinterpret_castsockaddr*(addr), len) 0 || ::listen(fd, 128) 0) { ::close(fd); return -1; } return fd; }主循环里epoll_wait拿到事件监听 fd 可读就accept4循环收干连接 fd 可读就recv到应用层缓冲区缓冲区里凑够一个完整帧就处理。注意accept4和recv都要循环到EAGAIN为止尤其在边缘触发模式下漏读一个字节就再也收不到通知了。缓冲区这块我一般用std::vectorchar加读写下标避免每次erase头部的 O(n) 拷贝。简单做法是定长char buf[8192]加一个std::string pending够用了。3.2 客户端带重连的 connect 封装客户端的封装重点在重连和半关闭检测。recv返回 0 就要重连不是简单退出。我用一个小状态机CONNECTING→CONNECTED→CLOSED配合退避重试20ms 起翻倍到 500ms 封顶这样服务端重启的时候客户端能在 1 秒内自动恢复业务层几乎无感。有个细节值得单独说重连之后原来发出去但没收到响应的请求怎么办。我的做法是请求带序号重连后把未确认的请求重发一遍服务端按序号去重。这套东西如果协议设计初期不预留后面补起来非常痛苦。3.3 长度前缀协议粘包不是 TCP 的专利很多人以为粘包是 TCP 独有的其实所有字节流式接口都有这个问题包括SOCK_STREAM类型的 UDS。你send两次 100 字节对端一次recv拿到 200 字节完全合法。解决方案就那几种固定长度、分隔符、长度前缀。我一般选长度前缀因为二进制安全、解析效率高、能预先分配缓冲。格式定为 4 字节大端长度 payload长度字段本身不包括自己。// 组帧 std::string frame; uint32_t n static_castuint32_t(payload.size()); frame.resize(4 payload.size()); frame[0] static_castchar((n 24) 0xFF); frame[1] static_castchar((n 16) 0xFF); frame[2] static_castchar((n 8) 0xFF); frame[3] static_castchar(n 0xFF); std::memcpy(frame[4], payload.data(), payload.size());解析端要先检查长度字段的合理性别直接照着长度resize缓冲区。我曾经见过一个实现客户端发了个头部说是 4GB服务端直接malloc4GB然后被 OOM killer 干掉。加个上限比如 16MB超了直接断链。// 解析pending 是累积缓冲 if (pending.size() 4) return; // 头部都没收全 uint32_t n (uint8_t)pending[0] 24 | (uint8_t)pending[1] 16 | (uint8_t)pending[2] 8 | (uint8_t)pending[3]; if (n 16 * 1024 * 1024) { close_conn(); return; } // 防御性上限 if (pending.size() 4 n) return; // 体还没收全 std::string body pending.substr(4, n); pending.erase(0, 4 n);erase(0, k)在std::string上是 O(n) 的高频场景下用环形缓冲或者双指针 定期整理更好。普通业务量下这点开销无所谓先跑通再优化。3.4 传描述符与传凭据sendmsg 的正确打开方式UDS 最被低估的能力是传递文件描述符。SCM_RIGHTS控制消息可以让一个进程把打开的文件、socket、epoll fd 直接交给另一个进程内核完成 fd 的复制。这个能力让 UDS 成为很多架构的粘合剂——主进程负责监听和权限校验把连接 fd 交给工作进程处理工作进程不需要自己有监听权限或者把打开的日志文件 fd 分发下去所有进程写同一个文件不用加锁。发送端bool send_fd(int sock, int fd_to_send) { char dummy F; iovec iov{}; iov.iov_base dummy; iov.iov_len 1; char ctrl[CMSG_SPACE(sizeof(int))]; std::memset(ctrl, 0, sizeof(ctrl)); msghdr msg{}; msg.msg_iov iov; msg.msg_iovlen 1; msg.msg_control ctrl; msg.msg_controllen sizeof(ctrl); cmsghdr* cm CMSG_FIRSTHDR(msg); cm-cmsg_level SOL_SOCKET; cm-cmsg_type SCM_RIGHTS; cm-cmsg_len CMSG_LEN(sizeof(int)); std::memcpy(CMSG_DATA(cm), fd_to_send, sizeof(int)); return ::sendmsg(sock, msg, MSG_NOSIGNAL) 1; }接收端必须遍历控制消息别只看第一个int recv_fd(int sock) { char dummy; iovec iov{}; iov.iov_base dummy; iov.iov_len 1; char ctrl[CMSG_SPACE(sizeof(int))]; msghdr msg{}; msg.msg_iov iov; msg.msg_iovlen 1; msg.msg_control ctrl; msg.msg_controllen sizeof(ctrl); ssize_t n ::recvmsg(sock, msg, 0); if (n 0) return -1; if (msg.msg_flags MSG_CTRUNC) return -1; // 控制消息被截断fd 可能已经泄漏 for (cmsghdr* cm CMSG_FIRSTHDR(msg); cm; cm CMSG_NXTHDR(msg, cm)) { if (cm-cmsg_level SOL_SOCKET cm-cmsg_type SCM_RIGHTS) { int fd -1; std::memcpy(fd, CMSG_DATA(cm), sizeof(int)); return fd; } } return -1; }两个大坑。第一个是MSG_CTRUNC如果接收端的控制缓冲区开小了内核会截断而那些被截掉的 fd 在内核里已经被创建了接收端却拿不到于是永久泄漏。CMSG_SPACE(sizeof(int))是最小值一次要收多个 fd 就得乘上数量这个必须算准。第二个是cmsg_len用CMSG_LEN而不是sizeof(fd)这两个值不一样用错了对端解析会错位。至于凭据传递服务端要先开SO_PASSCREDint on 1; ::setsockopt(fd, SOL_SOCKET, SO_PASSCRED, on, sizeof(on));之后每一条通过recvmsg收到的消息都会附带SCM_CREDENTIALS里面是发送时刻的pid/uid/gid。注意这需要_GNU_SOURCE而且struct ucred是 Linux 专有。我在做本地审计日志的时候用过能精确记下是哪个进程发的比让客户端自报 pid 靠谱一万倍。4. 踩坑记录常见错误码与排查路径4.1 错误码速查表UDS 的错误码不算多但每个都有自己的“性格”认错了方向能查半天。错误码出现位置真实原因处理方式EADDRINUSEbind路径文件已存在或抽象名字已被占用先探测ECONNREFUSED才unlinkENOENTconnect路径不存在或抽象名字没人bind检查服务端是否启动路径是否拼错ECONNREFUSEDconnect文件在但没监听服务端没起来或已经挂了重试EACCESconnect/bind目录缺 x 权限或 socket 文件权限不足namei -l看整条路径的权限链ENAMETOOLONGbind/connect路径或名字超过 108 字节换短路径或用抽象命名空间EINVALbindaddrlen算错或sun_family不是AF_UNIX用offsetof重算长度EPIPEsend/write对端已关闭加MSG_NOSIGNAL并按断链处理EMSGSIZEsend/sendto数据报超过对端最大报文长度拆包或改用SOCK_STREAMEMFILE/ENFILEaccept/socketfd 耗尽查泄漏lsof -p看增长曲线EAGAINaccept/recv非阻塞下暂无数据正常不是错误回epoll_waitEMSGSIZE这个值得单说。SOCK_DGRAM类型的 UDS 有单个报文上限取决于发送缓冲和对端的接收缓冲设置。如果你要发大块数据要么拆分要么换流式。我一开始做状态快照分发的时候用了数据报单个快照到 200KB 左右开始报EMSGSIZE改成流式加长度前缀就没事了。4.2 残留的 socket 文件与权限这是使用文件路径形态时几乎一定会遇到的问题服务进程被kill -9之后socket 文件留在原地下次启动bind报EADDRINUSE运维第一反应是“端口被占用”然后开始查谁在监听查半天发现根本没人监听。根本原因在于 socket 文件的清理不归内核管进程正常退出时如果代码里没有unlink或者被强杀文件就会留下。我的处理套路是三层第一层服务端启动时先探测前面那段代码只有确认无人监听才unlink。第二层进程注册退出处理SIGTERM/SIGINT走正常的清理流程atexit里unlink路径。第三层即使这样也挡不住SIGKILL和掉电所以运维侧要有一个健康检查文件存在 connect返回ECONNREFUSED 残骸可以清。这个检查放进部署脚本里比每次人工排查省事。权限方面socket 文件的权限由umask决定bind之后再chmod/chown更可控::chmod(path.c_str(), 0660); ::chown(path.c_str(), target_uid, target_gid);不过更好的做法是把 socket 文件放进一个0700的专属目录。目录没权限里面的文件无论什么权限都进不去这个隔离更彻底也不用担心文件权限被误改。4.3 阻塞、非阻塞与边缘触发UDS 本身完全支持非阻塞和epoll的 ET 模式用法和网络 fd 一样没有特殊之处。但有两个坑要提醒。第一个是accept继承问题。前面说过accept出来的 fd 不继承O_NONBLOCK在 ET 模式下这会导致意外的阻塞。所以必须用accept4(..., SOCK_NONBLOCK)或者手动fcntl。第二个是SO_SNDBUF满时的行为。UDS 默认发送缓冲是net.core.wmem_default常见值 212992 字节。发送缓冲满了之后阻塞 fd 会阻塞非阻塞 fd 返回EAGAIN。在 ET 模式下你得在send返回EAGAIN时把剩余数据存到应用层写缓冲然后等EPOLLOUT再继续。如果只是简单地在EPOLLOUT里重试一次数据一样会丢这是 ET 模式下最常见的 bug。排查这类问题我一般用ss -x -m能看到每个 UDS 连接的skmem明细rb/tb是接收/发送队列字节数能直观看出是哪一头堵了。/proc/net/unix则是看有没有这个 socket 存在抽象命名空间的排查全靠它。5. 性能与边界它快在哪、什么时候别用它5.1 和 TCP 回环、pipe 的量级对比我在一台普通开发机上做过一组粗略对比SOCK_STREAM、64 字节小包、单连接、连续发 100 万次测吞吐和往返延迟。环境差异会让绝对值波动很大所以只看相对量级。方式相对吞吐相对 RTT说明UNIX 域SOCK_STREAM1.0基准1.0基准无协议栈开销TCP 回环约 0.5 ~ 0.7约 1.4 ~ 1.8走完整 TCP/IP 路径命名管道 FIFO约 0.8 ~ 1.0约 1.1单向双向要开两条UNIX 域SOCK_SEQPACKET约 0.95约 1.0少一层长度前缀处理差距主要来自两方面一是省掉了 IP 层的封装、校验和计算和路由查找二是 UDS 的数据传递在内核里是 skb 指针的直接挂接没有 TCP 的序列号维护、SACK、拥塞窗口那一堆状态机。小包高频场景差距最明显大块连续传输时差距会缩小因为此时瓶颈在内存带宽拷贝上两者都要拷。顺带说一句UDS仍然有两次拷贝用户态到内核发送缓冲内核接收缓冲到用户态。想再省掉这两次得上共享内存 环形队列但那样就得自己处理同步和崩溃恢复复杂度不是一个量级。绝大多数场景下 UDS 的性能已经足够不值得为了最后那点带宽上共享内存。5.2 缓冲区与背压UDS 的流控是内核做的。接收缓冲满了发送方的send会阻塞或者返回EAGAIN这就是背压。这个机制是好事——它让快的生产者自动慢下来不会把内存撑爆。但前提是你得正确处理EAGAIN否则背压就变成了丢数据。SO_SNDBUF/SO_RCVBUF可以调但要注意 Linux 对这两个值有上限net.core.wmem_max/rmem_max设超了会被静默截断到上限。而且setsockopt设的值内核会翻倍使用预留管理开销所以getsockopt读出来和设进去的不一样这不是 bug。我的经验是小包高频的场景把缓冲区调小比如 64KB这样背压反馈更及时延迟更可控大块传输的场景调大比如 1MB减少EAGAIN的次数。默认的 208KB 对多数场景是够的不要一上来就瞎调。5.3 不该用的场景UDS 不是万能的有几个场景我会明确避开。跨机器通信。UDS 的作用域是本机的网络命名空间跨机器根本连不上。如果你的架构未来可能拆成多机那从一开始就应该用 TCP 或者更上层的 RPC 框架别为了眼前的性能把路堵死。我见过一个项目本地用 UDS、远端用 TCP两套代码维护最后同步 bug 修到吐。需要和外部系统对接。外部工具、脚本、监控系统大概率只认 TCP 端口UDS 对它们来说是黑盒。这种情况下要么加一层代理要么老老实实用 TCP。需要细粒度流量控制的场景。TCP 有拥塞控制、有各种可调的拥塞算法UDS 没有——本地通信压根不存在网络拥塞。如果你要靠拥塞控制做限流那 UDS 给不了你这套东西得在应用层自己实现令牌桶。对端身份不可信的开放环境。抽象命名空间没有文件权限任何进程都能连文件路径形态虽然有权限但如果目录权限给松了等于没有。涉及特权操作的接口一定要用SO_PEERCRED做二次校验别指望文件权限兜底。最后分享一个我在实际项目里反复用到的组合监听用抽象命名空间权限校验用SO_PEERCRED连接建立后立刻把 fd 传给工作进程主进程只管 accept。这套结构跑了两三年服务端重启、客户端重连、工作进程热更新都没出过问题唯一需要维护的就是那个 4 字节长度前缀的上限值——每次有人想发大对象都得先来问我能不能调反倒成了一道隐形的设计约束。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑