引子:TCP loopback 有什么问题?
两台机器通信要经过复杂的网络栈:路由、拥塞控制、校验和、分片重组。但同一台机器上两个进程通信,走 TCP loopback 也能通,只是杀鸡用牛刀了——数据包绕了一大圈网卡环路才回来,CPU 白白做了大量不需要的协议处理。
Unix Domain Socket(UDS)就是为这个场景设计的:不走网络层,直接在进程间共享内存缓冲。它比 TCP loopback 快 2-3 倍,CPU 消耗低一个数量级,而且在同一台机器上,UDS 的安全性天然可控(文件权限就能限制访问)。
UDS 最重要的设计决策:地址用路径名
TCP 用 IP:Port 标识端点,UDS 则用文件系统路径(或抽象命名空间的名字)。这是理解 UDS 一切设计的关键:
1
2
3
4
5
6
|
// include/uapi/linux/un.h
#define UNIX_PATH_MAX 108
struct sockaddr_un {
__kernel_sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* 路径名 */
};
|
bind 到这个路径上时,内核会在文件系统创建一个特殊的 socket 文件(type=S_IFSOCK),ls 能看到:
1
2
3
|
$ ls -l /run/containerd/containerd.sock
srw-rw---- 1 root root 0 Aug 28 10:00 /run/containerd/containerd.sock
# ↑ 开头的 s 就是 socket 文件类型
|
路径名地址带来了两个好处:
- 权限控制天然继承自文件系统:chmod 就能控制谁能连接
- 生命周期可见:socket 文件存在就是有人监听,删了就是停了
但路径名还有个缺点:需要创建文件,用完还要清理(unlink)。所以 Linux 后来加了抽象命名空间(abstract namespace),把 sun_path 第一个字节设为 \0,socket 名字只存在于内核内存中,不落盘:
1
2
3
4
5
|
// 抽象命名空间示例:地址为 "@containerd"
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
addr.sun_path[0] = '\0'; // 首字节为 \0 表示抽象命名空间
memcpy(addr.sun_path + 1, "containerd", 10);
|
创建 socket:比 TCP 少一层
UDS 的 socket 创建入口和 TCP 一样,都是 socket(AF_UNIX, SOCK_STREAM, 0),但内核路径完全不同。
socket() 系统调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// net/socket.c
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
return __sys_socket(family, type, protocol);
}
int __sys_socket(int family, int type, int protocol)
{
struct socket *sock;
sock = __sys_socket_create(family, type, protocol);
if (IS_ERR(sock))
return PTR_ERR(sock);
return sock_map_fd(sock, type & (O_CLOEXEC | O_NONBLOCK));
}
|
核心是两步:创建 socket 对象 → 映射到文件描述符。
创建 socket 对象:__sys_socket_create → sock_create → __sock_create
__sock_create 会调用 net_families[AF_UNIX]->create,也就是 unix_create:
1
2
3
4
5
6
|
// net/unix/af_unix.c
static const struct net_proto_family unix_family_ops = {
.family = AF_UNIX,
.create = unix_create,
.owner = THIS_MODULE,
};
|
unix_create 做的事情很简单:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
static int unix_create(struct net *net, struct socket *sock, int protocol, int kern)
{
struct sock *sk;
// 校验协议类型:只支持 SOCK_STREAM / SOCK_DGRAM / SOCK_SEQPACKET
if (protocol && protocol != PF_UNIX)
return -EPROTONOSUPPORT;
// 分配 sock 结构(这才是真正的内核 socket 对象)
sk = unix_sk_alloc(net);
if (!sk)
return -ENOMEM;
// 根据类型初始化不同的 ops 表
switch (sock->type) {
case SOCK_STREAM:
sock->ops = &unix_stream_ops; // TCP 风格
break;
case SOCK_DGRAM:
sock->ops = &unix_dgram_ops; // UDP 风格
break;
case SOCK_SEQPACKET:
sock->ops = &unix_seqpacket_ops; // 流式 + 保边界
break;
default:
return -ESOCKTNOSUPPORT;
}
// 初始化 sock 结构(锁、等待队列等)
unix_insert_socket(unix_sk(sk));
return 0;
}
|
关键区别:UDS 不需要像 TCP 那样分配 port、初始化路由表、设置拥塞控制。unix_sk_alloc 只是分配一个 struct unix_sock(比 struct inet_sock 小得多),加上基本的锁和队列初始化。
映射到文件描述符
1
2
3
4
5
6
7
8
|
static int sock_map_fd(struct socket *sock, int flags)
{
struct file *newfile;
int fd = get_unused_fd_flags(flags); // 取一个空闲 fd 号
newfile = sock_alloc_file(sock, flags, NULL);
fd_install(fd, newfile); // 关联 fd 和 file
return fd;
}
|
sock_alloc_file 分配一个伪文件(pseudo file),它的 file_operations 是 socket_file_ops——这是 UDS 能以 read/write 方式操作的关键。
bind:把 socket 和路径绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
// net/unix/af_unix.c
static int unix_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)
{
struct sock *sk = sock->sk;
struct unix_sock *u = unix_sk(sk);
struct sockaddr_un *sunaddr = (struct sockaddr_un *)uaddr;
char *path = sunaddr->sun_path;
int err;
// 已经是 bound 状态 → 拒绝
if (u->addr)
return -EINVAL;
// 校验地址格式
struct unix_address *addr = kmalloc(sizeof(*addr) + addr_len, GFP_KERNEL);
addr->len = addr_len;
memcpy(addr->name, sunaddr, addr_len);
if (path[0] == '\0') {
// ★ 抽象命名空间:名字存在内核内存,不落盘
addr->hash = unix_abstract_hash(addr->name, addr_len, sk->sk_type);
} else {
// ★ 路径名命名空间:在文件系统创建一个 socket 文件
struct path parent;
err = kern_path(sunaddr->sun_path, LOOKUP_PARENT, &parent);
// ... 创建 S_IFSOCK 文件
addr->hash = unix_autobind_hash(sk);
}
// 获取 hash 锁,插入到全局 hash 表
spin_lock(&unix_table_lock);
__unix_insert_socket(unix_sk(sk));
spin_unlock(&unix_table_lock);
u->addr = addr;
return 0;
}
|
两种绑定路径的区别:
|
路径名 |
抽象命名空间 |
| 标识方式 |
sun_path[0] != '\0' |
sun_path[0] == '\0' |
| 文件系统 |
创建 socket 文件 |
无文件 |
| 清理 |
需要 unlink |
进程退出自动释放 |
| 权限 |
文件权限控制 |
无(只有命名空间隔离) |
| 典型场景 |
生产服务(/run/*.sock) |
临时通信 |
listen/accept:连接管理
UDS 的 listen/accept 和 TCP 在语义上完全一致,但实现简单得多:
1
2
3
4
5
6
7
8
|
static int unix_listen(struct socket *sock, int backlog)
{
struct sock *sk = sock->sk;
// 只是设置两个字段:listen 状态 + 最大挂起数
sk->sk_max_ack_backlog = backlog;
sk->sk_state = TCP_LISTEN;
return 0;
}
|
没有 TCP 的三次握手、没有 SYN 队列、没有定时器——就是设个状态。
accept 的核心是 unix_stream_accept:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
static int unix_stream_accept(struct socket *sock, struct socket *newsock,
struct proto_accept_arg *arg)
{
struct sock *sk = sock->sk, *newsk;
struct unix_sock *newu;
// 从等待队列取一个已连接的客户端(如果没有就阻塞)
newsk = skb_dequeue(&sk->sk_receive_queue);
// 新 socket 继承监听 socket 的 credentials
newu = unix_sk(newsk);
// 返回新 socket 的文件描述符
newsock->sk = newsk;
return 0;
}
|
注意:当客户端 connect 时,它的 socket 已经被放入服务端的 sk_receive_queue 了。accept 只是在排队。
connect:UDS 最关键的链路
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
static int unix_stream_connect(struct socket *sock, struct sockaddr *uaddr,
int addr_len, int flags)
{
struct sock *sk = sock->sk;
struct sockaddr_un *sunaddr = (struct sockaddr_un *)uaddr;
struct sock *other;
// 1. 根据路径名找到服务端 socket
other = unix_find_other(sock->sk->sk_net, sunaddr, addr_len,
sk->sk_type, hash, &err);
if (!other)
return err;
// 2. 分配一个新 sock(用于这条连接)
newsk = unix_create1(sock->sk->sk_net, NULL);
// 新 sock 绑定一个临时地址(自动生成)
// 3. 把新 sock 放到服务端的接收队列尾部
skb = sock_alloc_send_skb(sk, 0, flags & O_NONBLOCK, &err);
skb->sk = newsk;
skb_queue_tail(&other->sk_receive_queue, skb);
// 4. 唤醒服务端(如果它在 accept 上阻塞)
other->sk_data_ready(other);
// 5. 修改状态
other->sk_state = TCP_ESTABLISHED;
newsk->sk_state = TCP_ESTABLISHED;
return 0;
}
|
关键:unix_find_other 根据路径名在全局 hash 表里找到服务端 socket。这一步不需要像 TCP 那样经过路由表查找、ARP 解析、网卡中断——纯内存操作。
数据收发:不走网络栈
send:直接把数据放到接收队列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
static int unix_stream_sendmsg(struct socket *sock, struct msghdr *msg,
size_t len)
{
struct sock *sk = sock->sk;
struct sock *other = unix_peer(sk); // 直接拿到对端
// 分配 skb
skb = sock_alloc_send_skb(sk, len, ...);
// 从用户态拷贝数据到 skb
err = skb_store_bits(skb, 0, msg->msg_iov->iov_base, len);
// 直接把 skb 放到对端的接收队列
skb_queue_tail(&other->sk_receive_queue, skb);
// 通知对端有数据来了
other->sk_data_ready(other);
return len;
}
|
对比 TCP 的路径:
| 步骤 |
TCP |
UDS |
| 数据拷贝 |
用户→内核skb |
用户→内核skb |
| 路由查找 |
查路由表 |
无 |
| 拥塞控制 |
窗口管理、重传定时器 |
无 |
| 校验和 |
计算 IP/TCP 校验和 |
无 |
| 分片 |
可能分片、重组 |
无 |
| 中断 |
软中断、硬中断 |
无 |
| 投递 |
通过网卡发送 |
直接放到对端队列 |
UDS 的数据路径只有 2 次拷贝(用户→内核→对端),而 TCP loopback 要经过完整的协议栈,即使没出网卡,仍要经过路由、拥塞控制、校验和、softirq 等环节。
recv:从接收队列取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
static int unix_stream_recvmsg(struct socket *sock, struct msghdr *msg,
size_t size, int flags)
{
struct sock *sk = sock->sk;
// 从接收队列取 skb
skb = skb_dequeue(&sk->sk_receive_queue);
if (!skb)
return -EAGAIN; // 或阻塞等待
// 把数据拷贝到用户态
skb_copy_datagram_msg(skb, 0, msg, size);
return size;
}
|
进阶:UDS 独有的能力
1. 文件描述符传递(SCM_RIGHTS)
这是 UDS 最有价值的能力之一——把一个进程的文件描述符传给另一个进程,对端拿到的是同一个内核文件对象:
1
2
3
4
5
6
7
8
9
10
11
12
|
// 发送端
struct msghdr msg;
struct cmsghdr *cmsg;
char buf[CMSG_SPACE(sizeof(int))];
cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
*(int *)CMSG_DATA(cmsg) = fd_to_send; // 要传递的 fd
sendmsg(sock, &msg, 0);
|
内核实现:unix_scm_to_skb 把文件指针从当前进程的 fd 表里取出,插入到 skb 的辅助数据中,对端接收时从 skb 取出文件指针,在它的 fd 表中分配一个新 fd。整个过程不涉及文件内容拷贝。
这在 containerd 中非常关键:containerd 通过 UDS 把容器标准输入/输出/错误的 fd 传递给 shim 进程。
2. 证书传递(SCM_CREDENTIALS)
UDS 可以自动携带发送进程的 PID、UID、GID:
1
2
3
4
5
|
// 接收端可以获取对端进程的证书
struct ucred peercred;
socklen_t len = sizeof(peercred);
getsockopt(sock, SOL_SOCKET, SO_PEERCRED, &peercred, &len);
// peercred.pid, peercred.uid, peercred.gid
|
这个能力是内核自动提供的,不需要发送端配合。这意味着服务端可以基于 PID 做细粒度权限控制——比如只允许某个进程连接。
3. SOCK_SEQPACKET:流式 + 保边界
TCP 是流式(SOCK_STREAM),数据可能粘包;UDP 是报文(SOCK_DGRAM),有大小限制。UDS 的 SOCK_SEQPACKET 则兼具两者优点:
- 面向连接(像 TCP)
- 保消息边界(像 UDP)
- 数据可靠、有序
总结
UDS 是 Linux 本地 IPC 的最佳选择——比 TCP loopback 快 2-3 倍,CPU 消耗低一个数量级,而且支持 fd 传递和证书传递这些独特能力。它的核心设计思想是:既然两个进程在同一台机器上,那就绕过整个网络栈,直接在内存中传递数据。
回到 containerd 的通信架构,它的所有 gRPC 和 TTRPC 接口都基于 UDS 实现——这不是巧合,而是对安全性和性能的共同追求。
参考资料
- Linux kernel 6.6 源码:
net/unix/af_unix.c
man 7 unix — Unix sockets 手册
man 7 cmsg — 辅助数据(fd 传递、证书传递)
- Stevens, UNIX Network Programming, Vol 1, Chapter 15 (Unix Domain Protocols)