skaiuijing

引言

世上没有免费的午餐,凡事皆有代价。

笔者很喜欢微观经济学中的一条公理:得到一个东西的代价是为了得到它而失去的东西。

思考

在前几章,笔者讲过,实现网络协议栈高性能的技巧就是零拷贝。

所以,Linux内核网络子系统到底发生了几次拷贝呢?

一般是两次或者三次。

两次的来源:DMA拷贝到skb的内存区算一次,内核提交给应用层又算一次。

三次的来源:DMA拷贝到ringbuf指向的内存地址算一次,拷贝到skb的内存又算一次,内核到应用层又算一次。

内存分配

为什么第二种不能直接像第一种那样直接通过DMA拷贝到skb呢?

先让笔者解释内存分配的过程。

前文我们以e1000e网卡为例,我们知道ringbuf中的entry指向的就是skb,而skb的分配取决于slab系统或者是kmalloc这些,

像数据结构这些直接使用slab分配(kmem_cache_alloc_node):

但是对于数据data这一块:

如果数据较小,使用 SLAB/SLUB 分配器:

1
kmalloc(size, gfp_mask) 

如果数据较大:

直接使用伙伴分配器:

1
page_frag_alloc(&cache, size, gfp_mask)

源码如下:

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

struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
int flags, int node)
{
struct kmem_cache *cache;
struct sk_buff *skb;
bool pfmemalloc;
u8 *data;

cache = (flags & SKB_ALLOC_FCLONE)
? skbuff_fclone_cache : skbuff_cache;

if (sk_memalloc_socks() && (flags & SKB_ALLOC_RX))
gfp_mask |= __GFP_MEMALLOC;

/* Get the HEAD */
if ((flags & (SKB_ALLOC_FCLONE | SKB_ALLOC_NAPI)) == SKB_ALLOC_NAPI &&
likely(node == NUMA_NO_NODE || node == numa_mem_id()))
skb = napi_skb_cache_get();
else
skb = kmem_cache_alloc_node(cache, gfp_mask & ~GFP_DMA, node);
if (unlikely(!skb))
return NULL;
prefetchw(skb);

/* We do our best to align skb_shared_info on a separate cache
* line. It usually works because kmalloc(X > SMP_CACHE_BYTES) gives
* aligned memory blocks, unless SLUB/SLAB debug is enabled.
* Both skb->head and skb_shared_info are cache line aligned.
*/
data = kmalloc_reserve(&size, gfp_mask, node, &pfmemalloc);
if (unlikely(!data))
goto nodata;
/* kmalloc_size_roundup() might give us more room than requested.
* Put skb_shared_info exactly at the end of allocated zone,
* to allow max possible filling before reallocation.
*/
prefetchw(data + SKB_WITH_OVERHEAD(size));

/*
* Only clear those fields we need to clear, not those that we will
* actually initialise below. Hence, don't put any more fields after
* the tail pointer in struct sk_buff!
*/
memset(skb, 0, offsetof(struct sk_buff, tail));
__build_skb_around(skb, data, size);
skb->pfmemalloc = pfmemalloc;

if (flags & SKB_ALLOC_FCLONE) {
struct sk_buff_fclones *fclones;

fclones = container_of(skb, struct sk_buff_fclones, skb1);

skb->fclone = SKB_FCLONE_ORIG;
refcount_set(&fclones->fclone_ref, 1);
}

return skb;

nodata:
kmem_cache_free(cache, skb);
return NULL;
}

问题

对于e1000e这类网卡,数据包内存的分配过程如下:

1
2
graph LR
Aa(数据包到来)-->Ab(ksoftirq进程处理)-->Ac(处理完,重新调用alloc_skb分配内存)-->Ad(内存分配受到slab等系统的限制)-->Ae(分配速度赶不上数据包到来的速度)

这是一种简单的一对一模型,也就是:一个数据包到,就分配一块内存。当高并发场景发生时,频繁的内存分配一定会成为性能瓶颈。

于是,我们开始思考,面对高并发情景,当大量的数据包拷贝发生时,能不能在内存子系统与网络子系统之间建立一块内存缓冲区呢?

这块内存缓存区足够大,所以能直接存储到来的数据包内容,然后再从这块缓冲区中拷贝到skb中。

这样做,就实现了内存子系统与网络协议栈的解耦。

更方便的是,我们可以在这块内存根据网络协议栈特性自定义内存管理算法

对比

mlx5就是使用了第二种策略的网卡,现在进行对比:

特性 e1000e mlx5
链路速率 1 Gb(主流) 40 Gb、100 Gb(高端)
缓冲区布局 每包一整块 skb->data Page-pool + Striding RQ:一个大页上按 stride 存多包
DMA 映射 针对每个 skb->data 调用 dma_map_single 初始化时一次性对整个 page-pool 做映射,收包不再 map/unmap
数据写入 网卡直接写入 skb->data,无需 memcpy 网卡写到 page-pool,驱动再线性化 memcpy 到 skb->data
CPU 开销 每包一次 dma_map/unmap + 协议栈处理 一次 memcpy;page-pool 大批复用,省掉频繁 alloc/free
内存管理 skb/kmalloc 高并发下碎片和压力较大 page-pool 预分配大页,复用率高,内存分配/回收压力小
典型吞吐 1 Gb/s 之内已足够 40/100 Gb/s 级别才体现优势

所以说,凡事皆有代价。

得到更高吞吐量的代价就是要多进行一次拷贝,但也意味着内存管理的压力更小。

至于第一种,当频繁的内存分配发生时,数据包的内存不仅小,而且不连续,换而言之碎片化显然会更高。

当内存回收时,也是一个问题,我们的数据包也要一一回收。

ringbuf

在第一种情况中,ringbuf中的entry指向skb的data区域,而在第二种情况中,entry指向的是一块内存缓冲区,然后skb的data会拷贝这块区域。

读者可能会好奇,为什么不按照第一种方法那样把DMA地址映射过去呢?

因为第二种是按页的不同 offset 写入数据的,也就是说,数据是不连续的,一个 page 里可能同时有 N 个包的部分或全部内容。

这样做的好处就是内存利用率得到了提高。

而且,协议栈的处理是非常复杂的,就算能进行映射,缓冲区的内存要等到协议栈处理完数据包才能被释放,这显然不符合高并发要求,所以说,还是直接拷贝更方便,拷贝完这块内存就可以回收了。

结语

本节介绍了两种不同的的数据包与内存的交互行为,下一节再介绍数据包从内核到应用层的这一次拷贝。