Linux Unix 域 Socket:连接建立、字节流与资源生命周期
1. 实验目标
通过两个独立启动的 C 程序,完成本机客户端与服务端通信,理解:
- Unix 域 socket 的地址与 FD;
socket()、bind()、listen()、accept()、connect()的职责;- 监听 socket 与已连接 socket 的区别;
- 字节流的读取边界;
- EOF、连接关闭与路径清理;
- 当前设计的适用范围与限制。
实验环境:Linux、C11、cc、strace、ss。
本例使用路径名 Unix 域流式 socket:
socket(AF_UNIX, SOCK_STREAM, 0);
服务端只接受一个客户端。客户端发送固定文本后关闭,服务端读取到 EOF 后清理并退出。
2. 先区分三个独立维度
| 维度 | 本例选择 | 含义 |
|---|---|---|
| 地址族 | AF_UNIX |
本机进程间通信 |
| socket 类型 | SOCK_STREAM |
面向连接的字节流 |
| I/O 模式 | 默认阻塞 | 条件不满足时,相关调用可以等待 |
需要避免两个混淆:
- Unix 域 socket 不等于 TCP;
- 流式通信不等于阻塞通信。
SOCK_STREAM 描述数据组织与连接语义,O_NONBLOCK 等设置影响 I/O 等待行为,两者不是同一维度。
2.1 为什么使用 Unix 域 socket?
之前的匿名管道实验通过 fork() 继承端点。这里客户端与服务端独立启动,需要通过约定地址找到对方。
本例地址为:
./demo.sock
它是文件系统中的 socket 节点,用于定位通信端点,不是存放业务文本的普通文件。数据通过内核中的 socket 通信机制传递。
路径与 FD 的职责不同:
- 路径:使另一个进程能够找到端点;
- FD:使当前进程能够操作自己持有的端点。
FD 编号只在所属进程中有意义,不能作为其他进程直接使用的全局地址。
3. 连接建立过程
3.1 服务端接口
| 调用 | 职责 |
|---|---|
socket() |
创建端点,返回 FD |
bind() |
为端点绑定本地地址 |
listen() |
将流式 socket 设置为监听状态 |
accept() |
接受一个连接,返回新的连接 FD |
read() |
从连接中读取数据 |
close() |
释放当前进程持有的 FD 引用 |
unlink() |
删除本例创建的路径节点 |
客户端则主要执行:
socket()创建自己的端点;connect()指定服务端地址并建立连接;write()发送数据;close()关闭连接端点。
本例客户端不需要绑定自己的路径,也不负责删除服务端路径。
3.2 监听 FD 与连接 FD
一次运行中可能出现:
| 所属进程 | FD | 角色 |
|---|---|---|
| 服务端 | 3 | 监听端点 |
| 服务端 | 4 | 当前客户端连接的服务端端点 |
| 客户端 | 3 | 客户端连接端点 |
服务端使用:
accept(listen_fd, NULL, NULL);
接受连接,再通过返回的 conn_fd 收发业务数据。
监听 FD 不会被替换成连接 FD。两者具有独立的职责和生命周期。
客户端 FD 3 与服务端 FD 4 是连接两侧的端点,不是 dup() 得到的描述符副本。
3.3 listen() 不负责等待客户端
listen(listen_fd, 4);
设置成功后即返回。参数 4 是待接受连接队列的相关长度参数,实际限制受内核和系统设置影响。
它不是:
- 工作线程数量;
- 自动创建的子进程数量;
- 服务端一生最多处理的客户端数量。
默认阻塞模式下,没有待接受连接时,accept() 才可能等待。
connect() 成功也不保证服务端应用已经执行完 accept(),更不保证已经处理业务数据。
4. 完整代码
将以下两段代码分别保存为编译命令中的服务端和客户端源文件。
4.1 服务端
接收缓冲区故意设置为 8 字节,每次最多读取 7 字节,最后一字节用于字符串结束符,便于观察分段读取。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
int main(void)
{
int listen_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
return EXIT_FAILURE;
}
printf("socket created: fd=%d\n", listen_fd);
struct sockaddr_un address = {0};
address.sun_family = AF_UNIX;
int length = snprintf(address.sun_path,
sizeof(address.sun_path),
"%s", "./demo.sock");
if (length < 0 ||
(size_t)length >= sizeof(address.sun_path)) {
fprintf(stderr, "failed to prepare socket path\n");
close(listen_fd);
return EXIT_FAILURE;
}
if (bind(listen_fd,
(const struct sockaddr *)&address,
sizeof(address)) == -1) {
perror("bind");
close(listen_fd);
return EXIT_FAILURE;
}
printf("socket bound: path=%s\n", address.sun_path);
if (listen(listen_fd, 4) == -1) {
perror("listen");
close(listen_fd);
unlink(address.sun_path);
return EXIT_FAILURE;
}
printf("server listening: path=%s, fd=%d\n",
address.sun_path, listen_fd);
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd == -1) {
perror("accept");
close(listen_fd);
unlink(address.sun_path);
return EXIT_FAILURE;
}
printf("connection accepted: listen_fd=%d, conn_fd=%d\n",
listen_fd, conn_fd);
int result = EXIT_SUCCESS;
char buffer[8];
for (;;) {
ssize_t nread = read(conn_fd, buffer,
sizeof(buffer) - 1);
if (nread > 0) {
buffer[nread] = '\0';
printf("server received: %s", buffer);
continue;
}
if (nread == 0) {
printf("server: client EOF\n");
break;
}
perror("server read");
result = EXIT_FAILURE;
break;
}
if (close(conn_fd) == -1) {
perror("close connection");
result = EXIT_FAILURE;
}
if (close(listen_fd) == -1) {
perror("close listener");
result = EXIT_FAILURE;
}
if (unlink(address.sun_path) == -1) {
perror("unlink socket path");
result = EXIT_FAILURE;
}
return result;
}
4.2 客户端
客户端发送固定的 18 字节文本,然后关闭连接。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
int main(void)
{
int client_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (client_fd == -1) {
perror("socket");
return EXIT_FAILURE;
}
struct sockaddr_un address = {0};
address.sun_family = AF_UNIX;
int length = snprintf(address.sun_path,
sizeof(address.sun_path),
"%s", "./demo.sock");
if (length < 0 ||
(size_t)length >= sizeof(address.sun_path)) {
fprintf(stderr, "failed to prepare server address\n");
close(client_fd);
return EXIT_FAILURE;
}
if (connect(client_fd,
(const struct sockaddr *)&address,
sizeof(address)) == -1) {
perror("connect");
close(client_fd);
return EXIT_FAILURE;
}
printf("client connected: fd=%d\n", client_fd);
const char message[] = "hello from client\n";
ssize_t written = write(client_fd, message,
sizeof(message) - 1);
if (written == -1) {
perror("client write");
close(client_fd);
return EXIT_FAILURE;
}
if ((size_t)written != sizeof(message) - 1) {
fprintf(stderr, "client short write\n");
close(client_fd);
return EXIT_FAILURE;
}
printf("client sent: %zd bytes\n", written);
if (close(client_fd) == -1) {
perror("close client");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
4.3 地址结构说明
struct sockaddr_un 中,本例使用:
sun_family:填写AF_UNIX;sun_path:保存地址路径。
snprintf() 只是在用户态填写结构体,不会创建 socket 节点。创建路径关联的是 bind()。
传入:
(const struct sockaddr *)&address
是为了匹配通用 socket 接口,实际对象仍然是 Unix 域地址结构。
本例针对 Linux,使用 sizeof(address) 传递地址长度。实验中显示的 110 是该环境下结构体大小,不是路径长度,更不是业务消息长度,不应在代码里写死。
5. 编译与运行
5.1 编译
cc -std=c11 -Wall -Wextra -Werror -g -O0 \
unix_server.c -o unix_server \
&& cc -std=c11 -Wall -Wextra -Werror -g -O0 \
unix_client.c -o unix_client
先保存源码,再编译。修改源码不会自动更新已有二进制文件。
5.2 运行
两个终端必须进入同一个实验目录,因为本例使用相对路径 ./demo.sock。
终端一启动服务端:
./unix_server
出现监听提示后,程序通常停在 accept(),等待客户端。
终端二启动客户端:
./unix_client
客户端发送后退出。服务端读取剩余数据,观察到 EOF,随后关闭资源并删除路径。
本例只接受一次连接,因此再次测试需要重新启动服务端。
5.3 观察监听状态
服务端等待连接时,可以执行:
ss -xlpn | grep -F 'demo.sock'
实验中出现过类似:
u_str LISTEN 0 4 ./demo.sock ... users:(("unix_server",pid=42913,fd=3))
其中:
u_str:Unix 域流式 socket;LISTEN:监听状态;- 队列信息:反映待接受连接,而不是业务数据字节数;
pid、fd:持有该端点的进程与描述符。
监听队列为空,不应被普遍解释成服务端没有任何已接受连接。
6. 用 strace 验证实际行为
6.1 跟踪服务端
strace -tt -T -s 128 \
-e trace=socket,bind,listen,accept,accept4,read,close,unlink \
./unix_server
6.2 跟踪客户端
在服务端启动后,另一个终端执行:
strace -tt -T -s 128 \
-e trace=socket,connect,write,close \
./unix_client
客户端 trace 中:
write(1, ...)通常是向终端输出日志;- 对
client_fd的write()才是本次业务发送。
不同进程中相同的 FD 编号,不代表同一个描述符。
6.3 accept() 的长耗时不等于连接建立很慢
一次实验中:
accept(3, NULL, NULL) = 4 <9.048402>
主要原因是服务端先启动,在等待稍后运行的客户端。
同次客户端的:
connect(3, ...) = 0 <0.000051>
耗时很短。
strace -T 显示调用经过的时间,包含等待,不是该调用消耗的 CPU 时间。
6.4 实测:一次写入,三次读取
客户端一次发送:
write(3, "hello from client\n", 18) = 18
服务端使用 8 字节数组,每次最多读取 7 字节,实际观察到:
read(4, "hello f", 7) = 7
read(4, "rom cli", 7) = 7
read(4, "ent\n", 7) = 4
read(4, "", 7) = 0
读取总量:
7 + 7 + 4 = 18
数据没有丢失,只是分批取出。尚未读取的数据继续保留在内核接收缓冲区中。
这个结果说明:
一次 write() 不保证对应一次 read()。
具体分段不保证每次都是 7、7、4。可以确定的是,本例每次读取不会超过请求的 7 字节。
也不能据此判断底层传输了“三个网络包”。这里观察的是应用程序的读取边界,不是底层传输分组。
6.5 为什么日志会连在一起?
服务端每次读取后执行:
printf("server received: %s", buffer);
前两个片段没有换行,最后一个片段才包含 \n,因此多个日志前缀可能连在一起,并在行缓冲刷新时一起显示。
这是日志格式和 stdio 缓冲行为,不代表 socket 打乱了数据。
日志中的每个 server received 对应一次读取片段,不天然代表一条完整消息。
7. 字节流与 EOF:本例的协议约定
7.1 字节流保持顺序,但不保存应用消息边界
SOCK_STREAM 不会自动标记:
- 哪些字节来自同一次
write(); - 一条业务消息在哪里结束;
- 一次
read()是否取得了完整消息。
即使双方在同一台主机上,也不能假设“一写一读”。
本例将一个连接上传输的全部内容视为一个整体,以 EOF 结束接收。
7.2 短读不等于 EOF
read(..., 7) = 4
只表示本次取得 4 字节,不能据此认定连接结束。
下一次:
read(..., 7) = 0
才明确报告 EOF。
在本次正常关闭路径中,客户端结束发送,服务端读完剩余数据后得到 EOF。异常断连也可能表现为错误,不能把所有连接结束都假设成返回 0。
7.3 EOF 不是换行符
代码没有按换行解析消息。
即使客户端发送的文本不包含换行,只要正常结束发送且数据被读完,服务端仍然可以得到 EOF。
相反,如果客户端发送了换行但继续保持连接,服务端不会仅因这个换行就自动退出读取循环。
7.4 这种结束方式的取舍
| 方案 | 优点 | 代价 |
|---|---|---|
| 以 EOF 结束一次传输 | 实现简单,不需要额外消息头 | 不适合直接复用连接进行多次请求 |
| 分隔符协议 | 适合简单文本交互 | 需要处理转义、长度上限和跨读取拼接 |
| 长度头+消息体 | 适合持久连接和二进制协议 | 需要长度校验和完整的接收状态管理 |
如果客户端等待回复而不关闭,服务端又一直等待 EOF 后才回复,双方可能互相等待。
若需要结束发送但继续接收,可以考虑:
shutdown(fd, SHUT_WR);
它关闭发送方向,不等于释放 FD。后续仍然需要 close()。本例暂不使用半关闭。
8. FD 与路径必须分别管理
| 操作 | 影响的对象 |
|---|---|
close(conn_fd) |
当前客户端连接的本端引用 |
close(listen_fd) |
本进程持有的监听引用 |
unlink(path) |
文件系统中的 socket 路径节点 |
关键结论:
- 关闭一个连接 FD,不会自动关闭监听 FD。
- 关闭监听 FD,不会自动销毁已经接受的连接。
- 关闭 socket FD,不会自动删除路径名 socket 节点。
- 删除路径节点,不等于关闭已有连接。
本例只有服务端负责删除自己创建的路径,客户端不调用 unlink()。
8.1 为什么不在 bind() 前无条件删除路径?
已有路径可能属于:
- 正在运行的另一个服务实例;
- 遗留的 socket 节点;
- 其他类型的文件。
本例在 bind() 失败时只报告错误,不直接删除已有路径。
遇到地址占用,应先核实路径和服务状态,而不是直接执行删除操作。
8.2 异常退出的边界
进程退出时,内核会回收剩余 FD,但程序未必执行到 unlink()。例如默认信号动作直接终止程序时,路径可能残留。
因此,“路径存在”不能单独证明服务仍然活着。
正式服务需要明确的路径所有权、权限和退出清理策略。
结论
Unix 域 socket 用地址解决独立进程之间的连接建立,用 FD 操作通信端点,用字节流传递有序数据。应用程序仍需自行定义消息边界、错误处理和资源生命周期。
理解这一层后,再阅读服务端框架或本地 RPC 代码,应重点关注:
连接在哪里建立、数据从哪个 FD 读取、何时可能阻塞、什么条件表示一条请求完整,以及谁负责最终清理。