skaiuijing

引言

中间件!中间件!还是tmd中间件!计算机任何问题都可以通过加一层中间件解决。

内容

笔者的专栏内容专注于Linux网络架构设计和性能优化:

1.关于架构设计的哲学始终围绕中间件展开。

2.性能优化的重点在于对内核行为的改变以及影响,聚焦于eBPF对网络数据包行为的改变,以及通过改写/proc等文件改变内核行为。

运行流程

在前面铺垫了这么久,总算来到了从内核到应用层这一次拷贝。

对应的流程图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
graph LR
A(NIC_DMA) --> B(NAPI)
B --> C(netif_receive_skb)
C --> D(ip_rcv)
D --> E(tcp_v4_rcv)
D --> F(udp_rcv)
G(recv_read) --> H(sock_recvmsg)
H --> I(tcp_recvmsg)
H --> J(udp_recvmsg)
I --> K(tcp_recvmsg_locked)
J --> L(__skb_recv_udp)
K --> M(skb_copy_datagram_msg)
L --> M
M --> N(skb_copy_datagram_iter)
N --> O(copy_to_iter)

问题

数据包从内核到应用层不仅仅是一次简单的拷贝,我们还需要处理用户空间提供的缓冲区。

如果用户层缓冲区可能不连续,那么每个子系统都得手写遍历逻辑:

1.网络要遍历 skb->data + frags[]

2.块设备要遍历 bio_vec[]

3.管道要遍历内核页列表,还要各自处理用户态 iovec 分段。

那么Linux内核是怎么解决这个问题的呢?答案当然是中间件了。

数据报的进入与输出

数据包通过ringbuf这个中间件完成输入数据与构造数据包的解耦合,那么又是通过哪个中间件完成数据包与输出到应用层数据的解耦合的呢?

是通过iov_iter。

iov_iter

iov_iter 是 Linux 内核中用于散/聚集 I/O的核心抽象,统一了内核缓冲区与用户缓冲区的多段遍历逻辑。它将多段 iovec、内核页、管道或块设备等多种数据源和目标封装成同一组状态字段,简化了各种子系统(网络、文件、块设备)中任意分段到任意分段的拷贝操作。

skb_copy_datagram_iter() 这类接口中,iov_iter 负责把任意内核缓冲区布局和任意用户缓冲区布局统一成一个可遍历的抽象:

统一多段 copy 逻辑

无论内核数据是线性区(skb->data)还是分片页(skb_shinfo(skb)->frags[]),也无论用户缓冲区是单一 char *、多段 iovec 还是管道/块设备,都用同一套 iov_iter 接口来迭代读写。

解耦

__skb_datagram_iter() 只管把 skb 数据按段交给 iov_iter

copy_to_iter() 则根据 iov_iter 的状态,把数据填到用户空间的每个 iovec 段中

skb 分片(frags)

内核在 __skb_datagram_iter() 中,会先拷贝线性区(skb->data),再遍历 skb_shinfo(skb)->frags[],将这两种“源缓冲区”抽象成同一套 iov_iter 操作

eBPF

围绕 iov_iterskb_copy_datagram_iter() 拷贝路径,Linux 已提供内核 tracepoint和ftrace/kprobe接口,eBPF 可以进行hook:

tracepoint点是: trace_skb_copy_datagram_iovec

1.内核在进入拷贝函数前调用,产生日志事件

2.eBPF 程序可 attach 到 skb:copy_datagram_iovec tracepoint,获取参数(skb ptr、len)

tcp_recvmsg

源码:

tcp_recvmsg_locked就是使用互斥锁保护的与数据拷贝相关的函数:

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


int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int flags,
int *addr_len)
{
int cmsg_flags = 0, ret;
struct scm_timestamping_internal tss;

if (unlikely(flags & MSG_ERRQUEUE))
return inet_recv_error(sk, msg, len, addr_len);

if (sk_can_busy_loop(sk) &&
skb_queue_empty_lockless(&sk->sk_receive_queue) &&
sk->sk_state == TCP_ESTABLISHED)
sk_busy_loop(sk, flags & MSG_DONTWAIT);

lock_sock(sk);
//数据的拷贝发生在这里面
ret = tcp_recvmsg_locked(sk, msg, len, flags, &tss, &cmsg_flags);
release_sock(sk);

if ((cmsg_flags || msg->msg_get_inq) && ret >= 0) {
if (cmsg_flags & TCP_CMSG_TS)
tcp_recv_timestamp(msg, sk, &tss);
if (msg->msg_get_inq) {
msg->msg_inq = tcp_inq_hint(sk);
if (cmsg_flags & TCP_CMSG_INQ)
put_cmsg(msg, SOL_TCP, TCP_CM_INQ,
sizeof(msg->msg_inq), &msg->msg_inq);
}
}
return ret;
}

tcp_recvmsg_locked

由于原函数比较复杂,这里我们使用伪代码进行描述,其中skb_copy_datagram_msg就是核心拷贝函数:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
function tcp_recvmsg_locked(sk, msg, len, flags):
// 如果 socket 处于监听状态,不能接收数据
if sk->state == TCP_LISTEN:
return -ENOTCONN

// 如果设置了 MSG_OOB,处理紧急数据
if flags 包含 MSG_OOB:
return tcp_recv_urg()

// 如果处于 TCP_REPAIR 模式,根据 repair_queue 处理
if tp->repair:
if repair_queue == TCP_SEND_QUEUE:
return tcp_peek_sndq()
else:
return 错误

// 设置读取起始序列号(peek 模式使用临时变量)
seq = tp->copied_seq 或 peek_seq

// 计算最小读取目标字节数
target = sock_rcvlowat(sk, flags, len)
//前面都是对TCP状态的检查

while len > 0:
// 遍历接收队列,查找合适的 skb
skb = 在 sk_receive_queue 中查找匹配 seq 的 skb
if 没找到:
// 如果已读够目标字节,退出
if copied >= target:
break
// 等待数据到达
wait_result = sk_wait_data(sk, timeo)
if wait_result < 0:
return copied 或 错误码
continue

// 计算当前 skb 中可用数据偏移和长度
offset = seq - skb->seq
used = min(skb->len - offset, len)

// 如果TCP有紧急数据,调整读取区域
if tp->urg_data 存在:
根据 urg_seq 调整 offset 和 used

// 执行数据拷贝(核心拷贝函数),在这里,数据将会被拷贝到上层
if 未设置 MSG_TRUNC:
err = skb_copy_datagram_msg(skb, offset, msg, used)
if err:
return copied 或 -EFAULT

// 更新序列号和计数器
seq += used
copied += used
len -= used

// 调整接收窗口大小
tcp_rcv_space_adjust(sk)

// 如果 skb 有时间戳,更新
if skb->has_rxtstamp:
tcp_update_recv_tstamps(skb, tss)

// 如果 skb 已完全读取,处理 FIN 或移除 skb
if skb 被完全消费:
if skb 有 FIN 标志:
seq += 1
tcp_eat_recv_skb(sk, skb)
break
else if 非 MSG_PEEK 模式:
tcp_eat_recv_skb(sk, skb)

// 清理接收缓冲区,触发 ACK
tcp_cleanup_rbuf(sk, copied)
return copied

skb_copy_datagram_msg

1
2
3
4
5
6

static inline int skb_copy_datagram_msg(const struct sk_buff *from, int offset,
struct msghdr *msg, int size)
{
return skb_copy_datagram_iter(from, offset, &msg->msg_iter, size);
}

skb_copy_datagram_iter

eBPF的hook点

在这里我们可以看见对应的eBPF的hook点,它位于的上下文就是这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

/**
* skb_copy_datagram_iter - Copy a datagram to an iovec iterator.
* @skb: buffer to copy
* @offset: offset in the buffer to start copying from
* @to: iovec iterator to copy to
* @len: amount of data to copy from buffer to iovec
*/
int skb_copy_datagram_iter(const struct sk_buff *skb, int offset,
struct iov_iter *to, int len)
{
trace_skb_copy_datagram_iovec(skb, len);
return __skb_datagram_iter(skb, offset, to, len, false,
simple_copy_to_iter, NULL);
}

__skb_datagram_iter

INDIRECT_CALL_1在运行时根据 cb 指针是否等于 simple_copy_to_iter,选择直接调用或间接调用,其实它只是一个宏,展开后类似这样:

1
2
3
4
5
if (cb == simple_copy_to_iter)
n = simple_copy_to_iter(skb->data + offset, copy, data, to);
else
n = cb(skb->data + offset, copy, data, to);

源码:

simple_copy_to_iter就是核心拷贝函数:

它的作用是:

vaddr + frag_offset 开始读取 copy_len 字节

将数据写入 iov_iter *to 所指向的用户空间缓冲区

数据来源

我们需要使用iov_iter处理三种数据:

1.线性头部:skb->data + skb_headlen(skb)

2.Paged frags:skb_shinfo(skb)->frags[i],这里可能分布在不同页,一些高性能网卡接收大包时,会直接写入多个页,从而避免 memcpy

3.链式 frags:通过 skb_walk_frags 递归调用自身处理链式的SKB

因此需要在三个地方调用simple_copy_to_iter。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91

static int __skb_datagram_iter(const struct sk_buff *skb, int offset,
struct iov_iter *to, int len, bool fault_short,
size_t (*cb)(const void *, size_t, void *,
struct iov_iter *), void *data)
{
int start = skb_headlen(skb);
int i, copy = start - offset, start_off = offset, n;
struct sk_buff *frag_iter;
//处理线性头部:`skb->data` + `skb_headlen(skb)`
/* Copy header. */
if (copy > 0) {
if (copy > len)
copy = len;

n = INDIRECT_CALL_1(cb, simple_copy_to_iter,
skb->data + offset, copy, data, to);
offset += n;
if (n != copy)
goto short_copy;
if ((len -= copy) == 0)
return 0;
}
//处理不同页
/* Copy paged appendix. Hmm... why does this look so complicated? */
for (i = 0; i < skb_shinfo(skb)->nr_frags; i++) {
int end;
const skb_frag_t *frag = &skb_shinfo(skb)->frags[i];

WARN_ON(start > offset + len);

end = start + skb_frag_size(frag);
if ((copy = end - offset) > 0) {
struct page *page = skb_frag_page(frag);
u8 *vaddr = kmap(page);

if (copy > len)
copy = len;
//这里还特地减了一个start,是为了处理头部问题,因为我们位于L4,但指针的移动很可能不是线性的
n = INDIRECT_CALL_1(cb, simple_copy_to_iter,
vaddr + skb_frag_off(frag) + offset - start,
copy, data, to);
kunmap(page);
offset += n;
if (n != copy)
goto short_copy;
if (!(len -= copy))
return 0;
}
start = end;
}

skb_walk_frags(skb, frag_iter) {
int end;

WARN_ON(start > offset + len);

end = start + frag_iter->len;
if ((copy = end - offset) > 0) {
if (copy > len)
copy = len;
//对于更复杂的分片链(如 IPv6 分片或 GSO 分段),递归入子 SKB
//这里的递归调用最深不会超过两次
if (__skb_datagram_iter(frag_iter, offset - start,
to, copy, fault_short, cb, data))
goto fault;
if ((len -= copy) == 0)
return 0;
offset += copy;
}
start = end;
}
if (!len)
return 0;

/* This is not really a user copy fault, but rather someone
* gave us a bogus length on the skb. We should probably
* print a warning here as it may indicate a kernel bug.
*/

fault:
iov_iter_revert(to, offset - start_off);
return -EFAULT;

short_copy:
if (fault_short || iov_iter_count(to))
goto fault;

return 0;
}

simple_copy_to_iter函数一路执行,让我们看看最后的拷贝函数,最终调用memcpy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

size_t _copy_to_iter(const void *addr, size_t bytes, struct iov_iter *i)
{
if (WARN_ON_ONCE(i->data_source))
return 0;
if (user_backed_iter(i))
might_fault();
iterate_and_advance(i, bytes, base, len, off,
copyout(base, addr + off, len),
memcpy(base, addr + off, len)
)

return bytes;
}

结语

以上就是数据包从内核离开的过程了,从ringbuf到iov_iter,我们可以看见,数据包的处理是相当复杂的。

数据包从内核走向用户空间的这一步,看起来只是个拷贝操作,涉及内存布局、页映射、权限校验、跨段复制……每个环节都不是随便拼起来的,而是经过精心设计的系统协作。

网络的东西太多了,笔者个人认为堪称Linux内核最复杂的子系统:从包过滤的 iptables、流控的 tc,到 Netfilter 的钩子机制、IP 层的路由与分片、TCP 的拥塞控制、重传逻辑、RTT估计器和 BBR 等算法,再到驱动层的 XDP 拦截、eBPF 的动态插桩、NAPI 的中断优化、RPS/RFS 的多核调度,还有 GRO/LRO、TFO、SO_REUSEPORT、Busy Poll、io_uring、AF_XDP、TLS offload、QUIC、连接跟踪、namespace 隔离、cgroup 网络策略……每个地方都是一个全新的世界。

而我们现在,连新手村都没进入。