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() 删除本例创建的路径节点

客户端则主要执行:

  1. socket() 创建自己的端点;
  2. connect() 指定服务端地址并建立连接;
  3. write() 发送数据;
  4. 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 路径节点

关键结论:

  1. 关闭一个连接 FD,不会自动关闭监听 FD。
  2. 关闭监听 FD,不会自动销毁已经接受的连接。
  3. 关闭 socket FD,不会自动删除路径名 socket 节点。
  4. 删除路径节点,不等于关闭已有连接。

本例只有服务端负责删除自己创建的路径,客户端不调用 unlink()。

8.1 为什么不在 bind() 前无条件删除路径?

已有路径可能属于:

  • 正在运行的另一个服务实例;
  • 遗留的 socket 节点;
  • 其他类型的文件。

本例在 bind() 失败时只报告错误,不直接删除已有路径。

遇到地址占用,应先核实路径和服务状态,而不是直接执行删除操作。

8.2 异常退出的边界

进程退出时,内核会回收剩余 FD,但程序未必执行到 unlink()。例如默认信号动作直接终止程序时,路径可能残留。

因此,“路径存在”不能单独证明服务仍然活着。

正式服务需要明确的路径所有权、权限和退出清理策略。


结论

Unix 域 socket 用地址解决独立进程之间的连接建立,用 FD 操作通信端点,用字节流传递有序数据。应用程序仍需自行定义消息边界、错误处理和资源生命周期。

理解这一层后,再阅读服务端框架或本地 RPC 代码,应重点关注:

连接在哪里建立、数据从哪个 FD 读取、何时可能阻塞、什么条件表示一条请求完整,以及谁负责最终清理。