为了适应自己的应用场景,Linux 内核采用了一种名为 slab/slub 的内存管理机制。该机制通过以下四个步骤对物理内存条进行管理,以供内核申请和分配内核对象。具体过程如下:
![Linux 内存管理过程 Linux 内存管理过程](/blog/posts/core-principles-of-linux-kernel-memory-management/images/linux-memory-manager.webp)
NUMA 架构
现代服务器通常会配置多个 CPU,因此无法像多核单 CPU 那样通过共享总线访问内存。常见的解决方案是采用 NUMA 架构。在 NUMA 架构中,每个 CPU 有都自己的本地内存和内存控制器,在访问本地节点时,可以直接访问本地内存,从而提升性能。
通过执行 dmidecode
命令,可以查看主板上插着的 CPU 的详细信息:
1
2
3
4
5
6
7
8
9
10
11
12
| Processor Information #第一颗 CPU
Socket Designation: CPU 0
Version: Intel(R) Xeon(R) CPU E5-2680 v3 @ 2.50GHz
Core Count: 12
Thread Count: 24
# 省略 ...
Processor Information #第二颗 CPU
Socket Designation: CPU 1
Version: Intel(R) Xeon(R) CPU E5-2680 v3 @ 2.50GHz
Core Count: 12
Thread Count: 24
# 省略 ...
|
并且可以显示内存条的相关信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # CPU0 上总共插着两条内存
Memory Device
Size: 16384 MB
Locator: CPU0 DIMM A1
# 省略 ...
Memory Device
Size: 16384 MB
Locator: CPU0 DIMM A2
# 省略 ...
# CPU1 上总共插着两条内存
Memory Device
Size: 16384 MB
Locator: CPU1 DIMM A1
# 省略 ...
Memory Device
Size: 16384 MB
Locator: CPU1 DIMM A2
# 省略 ...
|
Node 划分
每个 CPU 和它直接连接的内存条组成一个节点 (node):
![Node 划分](/blog/posts/core-principles-of-linux-kernel-memory-management/images/numa.webp)
我们可以在终端使用 numactl --hardware
命令看到每个 node 的情况:
1
2
3
4
5
6
7
| available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 24 25 26 27 28 29 30 31 32 33 34 35
node 0 size: 65419 MB
# 省略 ...
node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23 36 37 38 39 40 41 42 43 44 45 46 47
node 1 size: 65419 MB
# 省略 ...
|
Zone 划分
每个 node 又会划分为若干个 zone (区域),zone 表示内存中的一块范围,如下图所示:
![ZONE 划分](/blog/posts/core-principles-of-linux-kernel-memory-management/images/zone.webp)
- ZONE_DMA:地址段最低的一块内存区域,供 I/O 设备 DMA 访问。
- ZONE_DMA32:该 zone 用于仅支持 32 位地址总线的 DMA 设备,只在 64 位系统里才有效。
- ZONE_NORMAL:在 x86-64 架构下,DMA 和 DMA32 之外的内存全部在 NORMAL 的 zone 里管理。
扩展:ZONE_HIGHMEM 高端内存
ZONE_HIGHMEM(高端内存)是指 32 位 Linux 内核管理的高地址内存区域。在 32 位系统中,由于地址空间的限制,内核只能映射一部分物理内存,通常为 1GB 左右。当系统中的物理内存超过这个限制时,就需要使用高端内存来管理。高端内存的地址空间不直接映射到内核空间,而是通过页表和临时映射的方式访问,这样可以解决内核地址空间不足的问题。在 Linux 中,高端内存通常被用于存储大型数据结构,例如缓存和文件系统中的数据。
不过,在现今普遍使用 64 位服务器的时代,ZONE_HIGHMEM 已经不再使用了,因为 64 位系统可以直接映射更大的内存,不再受到 32 位系统的限制。
在 Linux 内核中,每个内存管理区域 (zone) 下都包含了许多个页面 (Page),一般每个页面的大小为 4KB。我们可以通过 Linux 中的 proc 文件系统中的 zoneinfo
文件来查看系统中内存区域的划分,以及每个区域所管理的页面数量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # cat /proc/zoneinfo
Node 0, zone DMA
# 省略 ...
pages free 3808
managed 3840
# 省略 ...
Node 0, zone DMA32
# 省略 ...
pages free 380380
managed 427659
# 省略 ...
Node 0, zone Normal
pages free 15031616
managed 16345717
# 省略 ...
Node 1, zone Normal
pages free 15912823
managed 16777216
# 省略 ...
|
每个页面的大小为 4KB,因此我们可以很容易地计算出每个内存管理区域(zone)的大小。例如,对于上文提到的 node1 中的 Normal 区域,其大小为 16777216 x 4KB = 64GB。
![ZONE 划分](/blog/posts/core-principles-of-linux-kernel-memory-management/images/zone1.webp)
伙伴算法管理空闲页面
每个内存管理区域 (zone) 下都包含大量的页面,Linux 使用伙伴系统对这些页面进行高效的管理。在内核中,表示内存区域的数据结构是 struct zone
。该数据结构下的一个数组,即 free_area
,管理了绝大部分可用的空闲页面。这个数组是伙伴系统实现的重要数据结构:
1
2
3
4
5
6
| //file: include/linux/mmzone.h
#define MAX_ORDER 11
struct zone {
free_area free_area[MAX_ORDER];
...
}
|
free_area
是一个包含 11 个元素的数组,每个数组元素分别代表可分配连续 4KB、8KB、16KB、…、4MB 内存空间的空闲链表,如下图所示:
![伙伴算法管理空闲页面](/blog/posts/core-principles-of-linux-kernel-memory-management/images/buddy.webp)
通过 cat /proc/pagetypeinfo
命令可以看到当前系统中伙伴系统各个尺寸的可用连续内存块数量:
1
2
3
4
5
6
| Free pages count per migrate type at order 0 1 2 3 4 5 6 7 8 9 10
# 省略 ...
Node 0, zone DMA32, type Unmovable 91 124 85 1 57 23 22 10 4 7 7
Node 0, zone DMA32, type Movable 543 225 122 43 17 12 11 11 10 12 7
Node 0, zone DMA32, type Reclaimable 3 10 13 8 3 1 0 0 0 1 0
# 省略 ...
|
内核通过分配器函数 alloc_pages
到上面的多个链表中寻找可用连续页面:
1
| struct page * alloc_pages(gfp_t gfp_mask, unsigned int order)
|
假如要申请 8KB —— 连续两个页框的内存,工作流程如下所示。为了描述方便,先暂时忽略UNMOVEABLE
、RELCLAIMABLE
等不同类型:
![Buddy 工作流程](/blog/posts/core-principles-of-linux-kernel-memory-management/images/buddy1.webp)
注释
伙伴系统中的伙伴指的是两个内存块,大小相同,地址连续,同属于一个大块区域。
在基于伙伴系统的内存分配中,有时需要将大块内存拆分成两个小伙伴。在释放内存时,可能会将两个小伙伴合并,以组成更大块的连续内存。
SLAB 分配器
上文中介绍的内存分配器都是以页 (Page) 为单位的,但实际上内核运行过程中使用的各个对象大小不尽相同,有的对象只有几十上百字节,直接使用伙伴系统来分配内存就会导致内存浪费。
为了解决这个问题,内核在伙伴系统的基础上,开发了一种专门用于内核的内存分配器,称为 slab 或 slub(两者都指同一种分配器)。
这个内存分配器最显著的特点是:它在一个 slab 中只分配特定大小、或者特定类型的对象。如下图所示:
![SLAB 分配器](/blog/posts/core-principles-of-linux-kernel-memory-management/images/slab.webp)
当一个对象释放内存后,同类的另一个对象可以直接使用这块内存。这种方法可以大大降低内存碎片发生的概率。
Slab 相关的内核对象定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
| //file: include/linux/slab_def.h
struct kmem_cache {
struct kmem_cache_node **node;
...
}
//file: mm/slab.h
struct kmem_cache_node {
struct list_head slabs_partial;
struct list_head slabs_full;
struct list_head slabs_free;
...
}
|
每个 cache 都有满 (slabs_full)、半满 (slabs_partial)、空 (slabs_free) 三个链表。每个链表节点都对应一个 slab,一个 slab 由个或者多个内存页组成。
每一个 slab 内都保存的是同等大小的对象。一个 cache 的组成如下图示:
![SLAB CACHE](/blog/posts/core-principles-of-linux-kernel-memory-management/images/slab-cache.webp)
当缓存中的内存不足时,会调用基于伙伴系统的分配器(alloc_pages
函数)请求分配整页连续内存:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| //file: mm/slab.c
static void *kmem_getpages(struct kmem cache *cachep,
gfp_t flags, int nodeid)
{
...
flags |= cachep->alocflags;
if (cachep->flags & SLAB_RECLAIM_ACCOUNT)
flags |= __GFP_RECLAIMABLE;
page = alloc_pages_exact_node(nodeid, ...);
...
}
//file: include/linux/gfp.h
static inline struct page *alloc_pages_exact_node(int nid,
gfp_t gfp_mask, unsigned int order)
{
return __alloc_pages(gfp_mask, order, node_zonelist(nid, gfp_mask));
}
|
内核中会存在很多个 kmem_cache
,它们在 Linux 初始化或者运行过程中被动态分配。这些中 cache 有些是专用的,有些是通用的。如下图所示:
![kmem_cache](/blog/posts/core-principles-of-linux-kernel-memory-management/images/slab-cache-chain.webp)
我们可以通过查看 /proc/slabinfo
来查看系统中所有的 kmem_cache
:
1
2
3
4
5
6
7
8
9
10
| slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
...
kmalloc-512 2063 2064 512 16 2 : tunables 0 0 0 : slabdata 129 129 0
kmalloc-256 1512 1552 256 16 1 : tunables 0 0 0 : slabdata 97 97 0
kmalloc-192 2436 2436 192 21 1 : tunables 0 0 0 : slabdata 116 116 0
kmalloc-128 1372 1376 128 32 1 : tunables 0 0 0 : slabdata 43 43 0
kmalloc-96 1523 1932 96 42 1 : tunables 0 0 0 : slabdata 46 46 0
...
|
另外,Linux 还提供了一个特别方便的命令 slabtop
来按照占用内存从大到小进行排列,这个命令用来分析 slab 内存开销非常方便:
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
| # slabtop
Active / Total Objects (% used) : 221648 / 228379 (97.1%)
Active / Total Slabs (% used) : 7790 / 7790 (100.0%)
Active / Total Caches (% used) : 117 / 162 (72.2%)
Active / Total Size (% used) : 50400.97K / 51659.00K (97.6%)
Minimum / Average / Maximum Object : 0.01K / 0.23K / 8.00K
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
36708 36613 99% 0.19K 1748 21 6992K dentry
35061 35054 99% 0.10K 899 39 3596K buffer_head
25152 25086 99% 0.12K 786 32 3144K kernfs_node_cache
21760 21657 99% 0.03K 170 128 680K lsm_inode_cache
15684 15684 100% 0.62K 1307 12 10456K inode_cache
7168 6751 94% 0.02K 28 256 112K kmalloc-16
5952 2985 50% 0.06K 93 64 372K vmap_area
4992 4898 98% 0.06K 78 64 312K kmalloc-64
4784 4737 99% 0.15K 184 26 736K vm_area_struct
4675 4675 100% 0.05K 55 85 220K ftrace_event_field
4608 4589 99% 0.01K 9 512 36K kmalloc-8
4608 4516 98% 0.03K 36 128 144K kmalloc-32
4186 4156 99% 0.57K 299 14 2392K radix_tree_node
3978 3956 99% 0.04K 39 102 156K ext4_extent_status
3776 3387 89% 0.06K 59 64 236K anon_vma_chain
...
|
无论是 /proc/slabinfo
还是 slabtop
命令的输出,都包含了每个 cache 中 slab 的两个关键信息:
- objsize:每个对象的大小;
- objperslab:一个 slab 里存放的对象数量。
/proc/slabinfo
还输出了一个 pagesperslab
,表示一个 slab 占用的页面数量,每个页面大小为 4KB。这样就可以计算出每个 slab 占用的内存大小了。
最后,slab 管理器组件提供了若干接口函数,方便自己使用。以下是三个例子:
- kmem_cache_create:创建一个基于 slab 的内核管理对象管理器;
- kmem_cache_alloc:为某个对象申请内存;
- kmem_cache_free:将对象占用的内存归还给 slab 分配器。
总结
前文介绍的四个步骤中,前三个步骤属于基础模块,在用户态程序中也能使用,但第四步 slab 管理器只有内核支持。
注释
用户态程序无法直接使用内核的 slab 管理器来进行内存分配,但是可以使用一些第三方库,例如 jemalloc 等来实现类似的内存分配和管理机制。
虽然采用了 slab 分配机制极大地减少了内存碎片的发生,但是仍然不能完全避免内存碎片的产生。举个例子:
1
2
| # cat /proc/slabinfo | grep TCP
TCP 101 140 2304 14 8 ...
|
可以看出,对于 TCP cache 来说,每个 slab 占用了 8 个页面,也就是 8 x 4096 字节 = 32,768 字节。而每个 TCP cache 对象的大小为 2,304 字节,每个 slab 最多能存放 14 个该类对象,因此会有 512 字节的剩余空间。
虽然存在一些空间碎片,但是比起 slab 机制带来的性能提升,这点牺牲还是可以接受的。