庖丁解牛,讲究的是“目无全牛”,技术人读代码也一样。看到一行$sock = socket_create(AF_UNIX, SOCK_STREAM, 0);,不要只当它是抄来的魔法咒语,要能顺着参数看穿内核里的通信路径、数据结构,以及后续每个函数调用的底层意图。这篇文章我不讲高深理论,就用拆骨架的方式把这行代码剖开,顺便分享我在实际项目里用 Unix Socket 做 PHP 本地进程通信的踩坑记录和经验。
如果你写过 PHP 网络程序,或者好奇 Web 服务器和 PHP-FPM 之间是怎么悄悄说话的,那这篇文章适合你。读完之后,你不仅能明白AF_UNIX、SOCK_STREAM、0这三个值分别代表什么,还能直接抄我给的代码,在 Linux 上跑通一个完整的 Unix Socket 服务端和客户端。哪怕你基础一般,也可以跟着步骤一步步来。
1. 初见:一行调用背后藏着三个决定
socket_create是 PHP 的 socket 扩展提供的函数,作用是创建一个可以通信的套接字。它的签名是:
socket_create(int $domain, int $type, int $protocol): Socket|resource大多数情况下,我们会把这个返回值赋给变量,后面再拿去 bind、connect、listen。但你有没有想过:为什么偏偏是AF_UNIX?为什么不是AF_INET?SOCK_STREAM和SOCK_DGRAM有什么区别?最后一个0又代表什么?
这一行代码,实际上是三个连续的决定:决定通信范围,决定通信模式,决定通信协议。每一个都用它的方式影响后面所有行为。
1.1 参数逐个拆:AF_UNIX 不是 tcp/ip
AF_UNIX是地址族(Address Family),代表“Unix 域套接字”。它和AF_INET(IPv4)或者AF_INET6(IPv6)最本质的区别是:它只用于同一台机器上的进程间通信,不经过网络协议栈,也不通过网卡。
说得直白一点,AF_INET像是你给远方的朋友打电话,需要拨号、经过交换机、语音编码,就算对方坐在隔壁房间也一样要绕一大圈。而AF_UNIX就是直接递纸条:两个人面对面,写个字条塞过去,对方立刻就能看到。纸条不需要出房间,更不需要走邮局。
Unix 域套接字的地址不是 IP 加端口,而是一个文件系统路径,比如/tmp/myapp.sock。当你 bind 的时候,实际上会在文件系统上创建一个 socket 类型的文件。这个文件不是普通数据文件,而是内核用来识别通信端点的标记。如果你在命令行里执行ls -l /tmp/myapp.sock,会看到类型是s,而不是-或d。
那为什么 PHP 网络编程里,经常能看到有人用AF_INET加127.0.0.1来模拟本机通信?因为通用性好,且绕开了不少 Unix 权限概念;但代价是每次通信都要经过完整的 TCP/IP 协议栈,还要处理端口占用、防火墙规则。对于同一台机器上的高频交互,这个开销实属浪费。
1.2 SOCK_STREAM 是“流”而非“包子”
第二个参数SOCK_STREAM指定了套接字的类型。它表示我们要的是一个“流式套接字”,数据像水流一样,是有序、可靠、双向的字节流。
你可以把SOCK_STREAM想象成一根水管:你把水龙头打开,水就源源不断流过去,中间不会断,也不会因为哪次倒水而乱了顺序。对应到编程模型里,你写入的数据会被内核切分成数据段,对端收到后按顺序拼接,好像从来没有被切开过一样。TCP 用的就是这种模式,而著名的 HTTP、WebSocket,底层都是字节流。
与它相对的SOCK_DGRAM则是数据报模式,像寄快递或者发短信:一条消息是一个整体,有独立的边界,不需要维持连接,但也可能丢,也可能乱序。以生活类比,SOCK_STREAM是“连续剧”,SOCK_DGRAM是“单集短片”。
选择SOCK_STREAM的直接影响是:后续socket_read读到的数据长度不一定是对方一次socket_write写入的长度。你可能写了 100 个字节,对端第一次只读到 50 个字节,剩下的要在下一次 read 中才能读到。这是字节流的天然特性,所以实际开发时,我们通常要自己在应用层定义“消息边界”——常见办法是先用固定长度前缀表示消息体长度,或者用换行符分隔。
1.3 协议0到底选了什么
第三个参数0是协议(protocol)。很多人看到 0 就觉得“没协议”,其实不是。
当指定的地址族和套接字类型组合后,如果只有一种默认协议,那么传 0 就是让内核自动选择唯一匹配的协议。对于AF_UNIX和SOCK_STREAM这个组合,默认协议就是 Unix 域字节流协议,没有其他可选项,所以传 0 完全正确。
对比一下,如果你用AF_INET和SOCK_STREAM,理论上可以在IPPROTO_TCP(值为 6)和IPPROTO_SCTP(值为 132)之间选,这时候传 0 通常会给你一个默认的 TCP。但这在语义上不够明确,所以在网络编程里,遇到多协议组合时建议显式指定。
明白这一点之后,你就会知道:那三个参数不是孤立的,而是组合起来描述了一个“在本机内部、基于文件系统地址、采用可靠字节流进行双向通信”的端点。接下来我们看看,为什么这种端点非常适合做进程间通信。
2. 设计解构:为什么 Unix Socket 适合做进程间通信
2.1 不走网络栈的本地通道
Unix Socket 最核心的价值,就是把“通信端点”直接挂在文件系统上,让数据在内核空间内部流动,而不需要经过网络协议栈的层层封装。
具体来说,两个进程通过 Unix Socket 通信时,数据从发送进程的内核态缓冲区,直接拷贝到接收进程的内核态缓冲区,不需要走 IP 路由,不需要 ARP 解析,更不会被防火墙规则误伤。整个过程少了大量的开销和延迟,所以在同一台机器上的高频小数据交换场景里,Unix Socket 的性能明显优于回环地址(loopback)上的 TCP。
我自己的简单测压经验:在同样条件下,用AF_UNIX做本机短连接,每秒完成的请求数比AF_INET的127.0.0.1高不少,平均延迟也更低。尤其是连接频繁建立断开的时候,Unix Socket 的握手成本低得多,因为不需要走完整的 TCP 三次握手和四次挥手。
当然,它的缺点是只能本机用。如果你打算跨机器通信,那么AF_UNIX无能为力。所以,一个常见的程序架构是:内部的异步任务、进程间调度用 Unix Socket,对外提供业务时用 TCP 或 HTTP 暴露服务。
2.2 从 PHP-FPM 到 Redis,Unix Socket 都在哪
你其实在很多项目里早就用过 Unix Socket,只是没意识到。最经典的场景就是 Web 服务器与 PHP-FPM 的通信。
很多 Web 服务器的配置里,PHP-FPM 的listen可以被设置为一个 Unix Socket 文件路径,比如/run/php-fpm/www.sock。这样 Web 服务器接收 HTTP 请求后,解析出需要执行的 PHP 文件,然后通过这个 socket 文件把请求信息交给 PHP-FPM 进程;PHP-FPM 执行完脚本后,再通过同一个 socket 把响应返回。整个过程全部在本机完成,没有用到任何端口。
类似地,很多数据库和缓存服务也支持通过 Unix Socket 连接。连接时只需要指定 socket 路径,而不是 IP 和端口,既省掉了 TCP 开销,也避免把服务暴露在网络上,还天然受文件系统权限的保护。
这个概念对写 PHP 的人来说尤其重要。如果你在运维某台服务器时发现某个服务只监听在 socket 文件上,看不到端口,不用慌张,那是它在走“本机通道”而已。
3. 实操:用 PHP 搭一个 Unix Socket 服务端与客户端
理论听一百遍,不如自己动手跑一遍。这一节我给出可以直接运行的 PHP 代码,帮你完整体验从socket_create开始的完整生命周期。
我使用的环境是 Linux + PHP 8,启用了 socket 扩展。如果你用的是 PHP 7,代码也能跑,只是返回值类型从 resource 变成了对象,但面向过程函数名的用法完全一致。
3.1 服务端完整代码与要点
先创建一个服务端文件unix_server.php:
<?php $socketPath = '/tmp/unix_php_test.sock'; // 清理可能遗留的 socket 文件 if (file_exists($socketPath)) { unlink($socketPath); } // 这行就是我们解牛的起点 $server = socket_create(AF_UNIX, SOCK_STREAM, 0); if ($server === false) { echo "socket_create 失败: " . socket_strerror(socket_last_error()) . PHP_EOL; exit(1); } // 允许地址重用,但不是解决文件残留问题的万能药 socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1); if (!socket_bind($server, $socketPath)) { echo "socket_bind 失败: " . socket_strerror(socket_last_error()) . PHP_EOL; exit(1); } if (!socket_listen($server, 5)) { echo "socket_listen 失败: " . socket_strerror(socket_last_error()) . PHP_EOL; exit(1); } echo "服务端监听: $socketPath" . PHP_EOL; // 只演示一次连接处理,方便观察 $conn = socket_accept($server); if ($conn === false) { echo "socket_accept 失败: " . socket_strerror(socket_last_error()) . PHP_EOL; exit(1); } // 读取客户端数据。注意第二个参数 1024 只是缓冲区大小, // 不代表一次能读完整条消息。 $input = socket_read($conn, 1024); echo "收到客户端消息: " . trim($input) . PHP_EOL; $response = "hello from unix socket server"; socket_write($conn, $response, strlen($response)); // 关闭客户端连接和监听套接字 socket_close($conn); socket_close($server); // 清理 socket 文件 if (file_exists($socketPath)) { unlink($socketPath); }这份代码做了几件关键事情:
- 在 bind 前检查并删除可能残留的 socket 文件。如果上次程序没跑完就被 Ctrl+C 中止,socket 文件会留在
/tmp里,导致下一次 bind 失败。删除文件是一个常见但需要注意的操作,你不能在没有确认目标路径一定对的情况下随便删。 - 用
socket_set_option设置SO_REUSEADDR。对 Unix Socket 来说,这个选项不一定能完全解决地址占用问题,主要问题是文件存在而不是端口占用,所以还是靠unlink。设置它更多是为了保持习惯,避免某些系统把旧连接状态也纳入检查。 socket_listen($server, 5)中的第二个参数是连接队列长度。在同一个时刻,如果有很多客户端同时尝试连接,超过 5 个的请求会被暂时拒绝或排队,具体行为由内核队列容量决定。生产环境要根据负载调大一点。- 处理完业务后,务必关闭连接和监听套接字,并清理 socket 文件。否则下次启动服务端时,端口不占用了,但文件还在。
3.2 客户端连接与数据交换
再创建一个客户端文件unix_client.php:
<?php $socketPath = '/tmp/unix_php_test.sock'; // 同样的一行代码,角色变了:这是客户端端点 $client = socket_create(AF_UNIX, SOCK_STREAM, 0); if ($client === false) { echo "socket_create 失败: " . socket_strerror(socket_last_error()) . PHP_EOL; exit(1); } if (!socket_connect($client, $socketPath)) { echo "socket_connect 失败: " . socket_strerror(socket_last_error()) . PHP_EOL; exit(1); } $message = "hello unix server, i am client"; socket_write($client, $message, strlen($message)); $response = socket_read($client, 1024); echo "服务端响应: " . trim($response) . PHP_EOL; socket_close($client);注意,客户端和服务端用的是同一个地址族、类型和协议,因为通信双方的参数必须匹配。socket_connect客户端会去连 socket 文件,如果文件不存在、权限不够、或者服务端根本没 listen,都会在这里报错。
3.3 代码运行效果与细节验证
运行过程非常简单,开启两个终端:
先启动服务端:
php unix_server.php会让你看到”服务端监听: /tmp/unix_php_test.sock“。此时,服务端程序阻塞在socket_accept上,等待客户端连接。
然后在另一个终端启动客户端:
php unix_client.php客户端连接后,服务端收到消息并返回响应,终端会打印对应内容。最后服务端自己退出。
在服务端监听时,如果打开第三个终端执行:
ls -l /tmp/unix_php_test.sock ss -lx | grep unix_php_testls会显示一个类型为s的文件,ss则会看到这个 Unix Socket 的监听状态。前者说明它确实是个文件,后者说明内核里已经注册了一个套接字。这些验证手段在你以后排查问题时非常管用。
4. 常见问题与排查技巧实录
4.1 bind 失败:Address already in use
一次我在实际项目里重启服务,结果 PHP 直接报“Address already in use”。排查后发现,上次程序因为异常退出,没有清理/tmp/app.sock。文件还在,内核自然认为地址被占用了。
解决思路分两步:
- 确认没有其他进程仍然在监听这个 socket。用
ss -lx | grep app.sock查看。如果还有进程,那就需要找到对应 PID,确认是否可以停掉。 - 如果确实没有任何进程使用,那就手动删除这个 socket 文件:
rm -f /tmp/app.sock,再启动服务端。
为了让程序更健壮,我后来在 bind 前加了一段逻辑:先检查文件是否存在,再尝试连接。如果连接成功说明有服务正在跑,不应该覆盖;如果连接失败,说明只是残留文件,可以安全删除。这样避免误删正在通信的文件。
if (file_exists($socketPath)) { $probe = socket_create(AF_UNIX, SOCK_STREAM, 0); if (@socket_connect($probe, $socketPath)) { socket_close($probe); echo "socket 文件正在被使用,拒绝覆盖" . PHP_EOL; exit(1); } socket_close($probe); unlink($socketPath); }这段逻辑的意图是:只有确认没有进程在使用时才清理,避免误杀正在跑的服务。
4.2 权限不足与安全策略
Unix Socket 是通过文件系统来体现存在感的,所以文件权限直接决定你能不能连接。比如,socket 文件创建时的默认权限会受到进程umask的影响。如果服务端是 root 身份创建的,客户端如果以普通用户身份运行,很可能遇到 Permission denied。
一个常见的调整方式是,在服务端 bind 之后,显式修改 socket 文件的权限:
chmod($socketPath, 0777);简单粗暴,但适合开发环境。生产环境更合理的方式是把 socket 文件放在一个约定好的目录中,并让相关用户都加入同一个用户组,然后用chown设置好组权限。
还有一类容易被忽略的问题来自安全模块,比如 SELinux 或 AppArmor。如果权限看起来没问题,却还是无法连接,可以查看系统审计日志,比如grep avc /var/log/audit/audit.log,确认是否有安全策略拦截了对某个 socket 文件的操作。这种问题遇到一次就会有深刻印象。
4.3 阻塞与超时:进程卡死怎么办
默认情况下,socket_accept、socket_read都是阻塞调用。这意味着如果没有客户端连接,服务端会一直停在 accept;如果客户端不发送数据,服务端的 read 会一直等。很多新手第一次跑示例代码时,按下服务端启动指令后,终端就像死了一样,其实它在正常等待。
但生产环境不能放任这种无限等待。最简单的办法是给套接字设置接收超时:
socket_set_option($server, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 10, 'usec' => 0]);这样如果 10 秒内没有客户端连进来,socket_accept会返回 false,并产生超时错误,程序就能自己往下走,而不是挂死。
另一个更主动的做法是切换成非阻塞模式:
socket_set_nonblock($server);然后循环调用socket_accept,结合usleep或者stream_select实现事件驱动。PHP 的 socket 扩展也支持将套接字放入流数组,配合socket_select做多路复用。具体写法不在这里展开,但一定要记住:写网络程序,脑子里要时刻有阻塞、非阻塞、超时这些概念。
这里还要强调一个细节:socket_read读到的长度并不等于写入的长度。你如果期待一次 read 拿到完整消息,大概率会在业务层出 bug。解决的办法是让客户端在发送消息前,先发送一个固定长度的头部,表示消息体总长多少,服务端先读取这个头部,再循环读取剩余数据。这个“长度前缀法”在字节流式协议里非常常用。
4.4 服务端 accept 后忘记关闭旧连接
开发时我经常犯一个错:在一个长循环里 accept 新连接,却忘记 close 已经处理完的客户端连接。一段时间后,文件描述符(fd)耗尽,服务端无法再建立新连接,报错信息往往也不直观。
排查方法是用ls -l /proc/<pid>/fd | wc -l看进程打开的文件描述符数量。如果数量持续上涨,基本就是连接泄漏。处理方式很简单:每次socket_read和写入完成后,立即用socket_close($conn)释放连接。尤其是 i 长驻进程,这个习惯非常重要。
5. 扩展:从 socket 到 stream,再到 Swoole
5.1 stream_socket_server:更高层的替代
如果你觉得socket_*函数写起来繁琐,PHP 还提供了一套基于流(stream)的封装。创建 Unix Socket 服务端可以写成:
$server = stream_socket_server('unix:///tmp/stream_test.sock', $errno, $errstr); if (!$server) { echo "创建失败: $errstr" . PHP_EOL; exit(1); } $conn = stream_socket_accept($server, 10); if ($conn) { $data = fread($conn, 1024); fwrite($conn, "response from stream server"); fclose($conn); }stream_socket_server的好处是返回的是普通流资源,你可以用fread、fwrite这些大家熟悉的函数操作,还能配合stream_select做事件循环。很多老 PHP 框架的进程通信模块就是基于这套封装实现的。
那么底层socket_create是不是就不重要了?恰恰相反。封装层只是隐藏了细节,并没有改变本质。unix://协议最终在内核中创建的仍然是 AF_UNIX 的流式套接字。理解了底层,你才能解释为什么fread一次读不完一条消息,为什么 tail 到 EOF 会阻塞,为什么两个进程的数据顺序不会乱。
5.2 进阶框架如何复用这个理念
再往上看,Swoole、Workerman 这类常驻内存的网络框架,虽然没有直接让你写socket_create(AF_UNIX, SOCK_STREAM, 0),但它们的底层事件循环、回调驱动、连接管理,本质上都在处理同样的概念。
Swoole 中创建一个 Unix Socket 服务,可以这样写(用 PHP 语言描述思路):
$server = new Swoole\Server('/tmp/swoole.sock', 0, SWOOLE_PROCESS, SWOOLE_SOCK_UNIX_STREAM);这里的SWOOLE_SOCK_UNIX_STREAM翻译过来就是“Unix 域流式套接字”。一旦你看懂了AF_UNIX + SOCK_STREAM + 0,再看任何框架的 socket 常量,都会有似曾相识的感觉。框架的文档可能千变万化,但底层的内核对象不会变。
这也是我为什么坚持建议大家亲手写一次底层 socket 通信的原因。你确实可以在完全不懂原理的情况下用框架跑出服务,但出了问题,比如连接被重置、数据粘包、文件描述符耗尽、权限拦截,底层知识的价值就体现出来了。
结尾
把这行$sock = socket_create(AF_UNIX, SOCK_STREAM, 0);拆完以后,我自己最深的感触是:代码不是用来背的,而是用来认识的。你认识了一个系统调用,就认识了一类通信模式;你理解了地址族和套接字类型,以后在任何语言里看到类似的 API 都会觉得眼熟。
最后再分享一个小技巧:如果你在一个 Linux 服务器上看到一个.sock文件,又想知道它支持的协议,可以用file /path/to/xxx.sock查看,它能识别出这是不是“socket”。配合ss -lx可以查看监听状态,配合strace -p <pid>可以直接观察进程的 socket 调用序列。把这些工具用起来,你处理网络问题的速度和准确度都会上一个台阶。