3.5 Linux 的内存分配和管理
下面我们来讨论内存管理的相关问题,通过前面的分析,我认为内存管理需要考虑以下几个问题:
内存经过频繁申请归还之后,会出现碎片,称为外部碎片,导致没有足够大的连续内存空间,如何解决该问题?
被申请占用的内存块没有有效利用,存在浪费称为内部碎片,如何解决?
每次申请完内存之后是否需要刷新进程页表,是否有性能问题?
内存申请是基于物理地址还是线性地址?
3.5.1 物理内存分配算法
我们已经知道,对于物理内存,经过频繁地申请和释放后会产生外部碎片,为了解决该问题,Linux 通过伙伴系统来解决外部碎片的问题。
假如仅仅解决外部碎片的问题,我们可以考虑内存分配的时候不提供连续的内存,但是在内核我们已经提前分配了 DMA 和 NORMAL 区域的页表,假如分配的物理地址不连续,但分配的线性地址总得连续吧?这就牵涉到页表刷新的问题了,在内核态频繁需要使用内存,这个方案对性能影响太大了,不能接受。为了解决这个问题,出现了伙伴系统。
下面我们来具体聊聊伙伴系统的实现:
从思路上来讲,伙伴系统在申请内存的时候让最小的块满足申请的需求,在归还的时候,尽量让连续的小块内存伙伴合并成大块,降低外部碎片出现的可能性。
在 Linux 中伙伴系统算法的实现方法为:
伙伴系统维护了11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续的物理页。对1024个页的最大请求对应着 4MB 大小的连续 RAM 块。每个块的第一个页框的物理地址是该块大小的整数倍。例如,大小为16个页框的块,其起始地址是16×212(212=4KB,这是一个常规页的大小)的倍数。
满足以下条件的两个块称为伙伴:
具有相同的大小。
物理地址是连续的。
申请的过程是从最接近大小的链表上找一个空闲块,释放的过程则会触发伙伴(假如找到空闲的伙伴)的合并。
下面我们来介绍一下 Linux 中和伙伴系统相关的数据结构(参见图3-15)。在前文中已经介绍了 pg_data 作为一个内存节点,在 node_mem_map 中维护了所有的物理内存页(page),另外在 pg_data 下维护了所有内存区域 node_zones,每个内存区域(zone)中维护了伙伴系统的空闲内存块 free_area。
图3-15 Linux 伙伴系统数据结构
free_area 维护了11个链表,链表中存储每个块的起始 page,块当中的 page 通过 page 的 lru 指针连接,page 当中的 private 代表了这个块属于哪个链表。
此外在 pg_data 的 node_mem_map 中保存了连续物理地址的 page 指针,所以用 page-node_mem_map 就能计算出该物理页的 pfn。
现在我们已经知道,在 Linux 中要使用物理内存(当然,这是在系统启动之后,启动前的事情就不在这里论述了)就得通过伙伴系统。那么在使用伙伴系统时,好歹得先初始化 free_area 链表吧?
前面我们已经知道系统在启动的时候通过 start_kernel()->paging_init()->free_area_init_node()来初始化节点、区域、page,在该过程中 free_area_init_node()->free_area_init_core()->init_currently_empty_zone()-->zone_init_free_lists()把 free_area 的链表初始化为空:
static void __meminit zone_init_free_lists(struct zone *zone)
{
unsigned int order, t;
for_each_migratetype_order(order, t) {
INIT_LIST_HEAD(&zone->free_area[order].free_list[t]);
zone->free_area[order].nr_free = 0;
}
}
然后通过 start_kernel()-->mm_init()-->mem_init(),负责统计所有可用的低端内存和高端内存,并释放到伙伴系统中,这个过程分为两个部分,详情可以从源码中去寻找:
1)通过 free_all_bootmem 释放低端内存(ZONE_DMA 和 ZONE_NORMAL)到伙伴系统中去。
2)通过 set_highmem_pages_init 将高端内存(ZONE_HIGHMEM)释放到伙伴系统中去。
这两个过程最终都会调用 _free_page()进行释放。
下面我们先来分析释放页的过程 _free_page()函数:
void __free_pages(struct page *page, unsigned int order)
{
if (put_page_testzero(page)) {
if (order == 0)
free_hot_cold_page(page, false);
else
__free_pages_ok(page, order);
}
}
释放流程分为两个部分:
假如 order==0,则说明是单页,直接存入 per-cpu 页高速缓存当中。
否则通过 _free_pages_ok 释放到伙伴系统中去,其最终调用 _free_one_page 函数:
static inline void __free_one_page(struct page *page,
unsigned long pfn,
struct zone *zone, unsigned int order,
int migratetype)
{
unsigned long page_idx;
unsigned long combined_idx;
unsigned long uninitialized_var(buddy_idx);
struct page *buddy;
unsigned int max_order;
max_order = min_t(unsigned int, MAX_ORDER, pageblock_order + 1);
…
// 先找到最大块链表中的偏移
page_idx = pfn & ((1 << MAX_ORDER) - 1);
…
continue_merging:
while (order < max_order - 1) { // 假如 order 小于 max_order-1 就有可能进行合并
(合并本来就是从小变大的过程,最大块当然没法合并了)
buddy_idx = __find_buddy_index(page_idx, order); // 寻找伙伴在链表中的偏移
buddy = page + (buddy_idx - page_idx); // 伙伴之间是连续的,所以通过该方式找到 page 页面
if (!page_is_buddy(page, buddy, order)) // 假如该伙伴非空闲,则没法合并,跳出
goto done_merging;
…
if (page_is_guard(buddy)) {
clear_page_guard(zone, buddy, order, migratetype);
} else {
list_del(&buddy->lru); // 合并之后就要去 order+1 那层了,把伙伴从现在这层链表中删掉
zone->free_area[order].nr_free--;
rmv_page_order(buddy);
}
combined_idx = buddy_idx & page_idx; // 因为 page_idx 和 buddy_idx 仅差异在 1<<order 位是否为1,那么可以理解为 combined_idx 取 page_idx 和 buddy_idx 的最小值
page = page + (combined_idx - page_idx);
page_idx = combined_idx;
order++;
}
…
done_merging:
set_page_order(page, order); // 合并完成,重新设置块的 order
…
list_add(&page->lru, &zone->free_area[order].free_list[migratetype]);
// 将新块添加到对应的 free_area 链表中
out:
zone->free_area[order].nr_free++;
}
其中寻找伙伴的函数 _find_buddy_index 实现为:
static inline unsigned long
__find_buddy_index(unsigned long page_idx, unsigned int order)
{
return page_idx ^ (1 << order); // page_idx 为2的 order 次的倍数,2的 order 以上的位和 1<<order 进行异或运算,伙伴在左还是在右取决于 1<<order 位是0还是1。假如是0则伙伴在后面;否则伙伴在前面,因为异或1变成0了。
}
这个内存释放的过程还是很好理解的,在把内存归还给伙伴系统的时候,首先检查这个内存区的伙伴是否是空闲的,如果是则进行合并,转移到更高阶的链表,直到无法合并为止。关键是理解如何寻找伙伴以及找到最后块 page 的过程,在以上代码中都已经做出了解释。
内存归还完毕之后,伙伴系统也就初始化完毕了,下面就可以进入内存的申请过程了,伙伴算法的分配通过 alloc_pages->alloc_pages_node->_alloc_pages_node->_alloc_pages->_alloc_pages_nodemask 来进行物理内存申请。我们这里仅关心从伙伴系统申请的主要路径,忽略细枝末节的干扰,_alloc_pages_nodemask的关键步骤为 get_page_from_freelist:
…
/* 从指定的 zone 开始寻找,直到找到一个拥有足够空间的管理区为止。例如,如果 high_zoneidx 对应的 ZONE_HIGHMEM,则遍历顺序为HIGHMEM-->NORMAL-->DMA,如果 high_zoneidx 对应 ZONE_NORMAL,则遍历顺序为 NORMAL-->DMA*/
for_each_zone_zonelist_nodemask(zone, z, zonelist, ac->high_zoneidx,
ac->nodemask) {
…
page = buffered_rmqueue(ac->preferred_zone, zone, order,
gfp_mask, alloc_flags, ac->migratetype);
…
return page;
}
}
buffered_rmqueue 函数假如申请的是单页则会从 per-cpu 缓存链表中找一个缓存的热页或者冷页。否则通过 _rmqueue 函数真正从伙伴系统中申请2的 order 次个页面,伙伴系统的申请在 _rmqueue_smallest 中实现:
static inline
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
int migratetype)
{
unsigned int current_order;
struct free_area *area;
struct page *page;
// 从指定的 order 一层一层往上寻找合适的块
for (current_order = order; current_order < MAX_ORDER; ++current_order) {
area = &(zone->free_area[current_order]);
page = list_first_entry_or_null(&area->free_list[migratetype],
struct page, lru);
if (!page)
continue;
list_del(&page->lru); // 找到后从该空闲链表中删除
rmv_page_order(page);
area->nr_free--;
expand(zone, page, order, current_order, area, migratetype);
// 假如 current_order 大于申请的 order,则进行 expand 拆分内存块
set_pcppage_migratetype(page, migratetype);
return page;
}
return NULL;
}
拆分函数 expand 的实现为:
static inline void expand(struct zone *zone, struct page *page,
int low, int high, struct free_area *area,
int migratetype)
{
unsigned long size = 1 << high;
while (high > low) {
area--;
high--;
size >>= 1; // 对半拆分,右移一次相当于除以2
…
list_add(&page[size].lru, &area->free_list[migratetype]);
// 一半拿来使用,另外一半拿来放入 area-- 的链表中
area->nr_free++;
set_page_order(&page[size], high); // 设置不使用的一半 order 为 high--
}
}
这个申请的过程和之前介绍的伙伴系统实现思路是一致的,尽量从满足大小的 area 链表中申请内存块,假如大小不够则往上层链表查找,由此每上升一次,内存块大小就会扩展两倍,所以假如 current_order 大于 order 的时候,必然存在一半的内存浪费,因此通过 expand 操作进行拆分,另一半存入 current_order-1 的链表中。
最后我们对伙伴系统进行一下总结(如图3-16所示),在系统初始化的时候,会把内存节点各区域(zone)都释放到伙伴系统中,每个区域还维护了 per-cpu 高速缓存来处理单页的分配,各个区域都通过伙伴算法进行物理内存的分配。
图3-16 Linux 伙伴系统实现
3.5.2 slab 分配器
我们已经了解到,上一节介绍的伙伴算法是用来解决外部碎片的问题,但是,如果不解决内部碎片的问题,还是会存在大量浪费内存的问题。所以 Linux 提供了 slab 分配器来解决内部碎片的问题。slab 分配器的概念如下:
slab 分配器其实也是一种内存预分配的机制,是一种空间换时间的做法,并且其假定从 slab 分配器中获取的内存都是比页还小的小块内存。
关于 slab 分配器的源码实现较为复杂,牵涉概念也较多,理解起来有些困难,但是只要抓住核心概念,化繁为简,其实也是可以理解的。
为了便于理解,我们首先来介绍一下 slab 分配器相关的概念(见图3-17)。在系统启动后,维护了一个全局的结构 struct kmem_cache,在 kmem_cache 中的 list 维护了所有的 slab 缓存列表,关联的全局结构为 LIST_HEAD(slab_caches),维护了所有的 kmem_cache 缓存列表,该列表中的缓存按照对象的大小,从小到大来构建的。
图3-17 Linux slab 分配器核心概念
kmem_cache 中的 kmem_cache_node 维护了三个 slab 链表,分别为:
slabs_full:该链表中的 slab 已经完全被分配出去了。
slabs_free:该链表中的 slab 都为空闲可分配状态。
slabs_partial:该链表中的 slab 部分被分配了出去。
其中,slab 代表物理地址连续的内存块,由 1~N 个物理内存页面(page)组成,在一个 slab 中,可以分配多个 object 对象。
在了解了关于 slab 分配器的基本概念后,下面我们来分析从缓存到 slab 的初始化过程。
1.创建缓存 kmem_cache_create
创建代码如下:
kmem_cache_create(const char *name, size_t size, size_t align,
unsigned long flags, void (*ctor)(void *))
其中参数如下:
name:缓存名称。
size:缓存中分配的对象大小。
align:对象对齐的大小。
flags:slab 相关标志位。
ctor:对象构造函数。
kmem_cache_create 主要通过 create_cache 来完成分配:
s = create_cache(cache_name, size, size,
calculate_alignment(flags, align, size),
flags, ctor, NULL, NULL);
下面是 create_cache 函数的实现:
static struct kmem_cache *create_cache(const char *name,
size_t object_size, size_t size, size_t align,
unsigned long flags, void (*ctor)(void *),
struct mem_cgroup *memcg, struct kmem_cache *root_cache)
{
struct kmem_cache *s;
int err;
err = -ENOMEM;
s = kmem_cache_zalloc(kmem_cache, GFP_KERNEL);
if (!s)
goto out;
s->name = name;
s->object_size = object_size;
s->size = size;
s->align = align;
s->ctor = ctor;
…
err = __kmem_cache_create(s, flags);
…
s->refcount = 1;
list_add(&s->list, &slab_caches);
…
return s;
…
}
create_cache 首先通过 kmem_cache_zalloc 从系统初始化之后确定的 kmem_cache 为新创建的 kmem_cache 结构体分配空间,然后进行属性的初始化,其中 s->object_size 和 size 传入的时候是同一个值,最后通过 _kmem_cache_create 进行后续的操作,分为几个部分进行:
1)进行对象对齐操作:
if (size & (BYTES_PER_WORD - 1)) {
size += (BYTES_PER_WORD - 1);
size &= ~(BYTES_PER_WORD - 1);
}
…
2)确定 slab 管理对象存储方式为内置还是外置:
if (size >= OFF_SLAB_MIN_SIZE && !slab_early_init &&
!(flags & SLAB_NOLEAKTRACE))
flags |= CFLGS_OFF_SLAB;
size = ALIGN(size, cachep->align);
3)通过 calculate_slab_order 函数计算 slab 由几个页面组成,每个 slab 有多少个对象:
static size_t calculate_slab_order(struct kmem_cache *cachep,
size_t size, size_t align, unsigned long flags)
{
unsigned long offslab_limit;
size_t left_over = 0;
int gfporder;
for (gfporder = 0; gfporder <= KMALLOC_MAX_ORDER; gfporder++) {
unsigned int num;
size_t remainder;
// 计算每个 slab 中的对象数量,浪费空间大小,参数如下:
gfporder: slab 大小为 2^gfporder 个页面
buffer_size: 对象大小
align: 对象的对齐方式
flags: 是外置 slab 还是内置 slab
remainder: slab 中浪费的空间(碎片)是多少
num: slab 中对象个数
cache_estimate(gfporder, size, align, flags, &remainder, &num);
if (!num) // 假如对象数量小于等于0,则进入下一个 order 继续尝试
continue;
…
cachep->num = num;
cachep->gfporder = gfporder;
left_over = remainder;
// 假如 slab 超过最大页面限制,也跳出循环
if (gfporder >= slab_max_order)
break;
// slab 占用页面大小是碎片的8倍以上,说明利用率较高,该 order 可以接受
if (left_over * 8 <= (PAGE_SIZE << gfporder))
break;
}
return left_over;// 返回的是 slab 中的浪费空间大小
}
其中 cache_estimate 的计算过程如下:
static void cache_estimate(unsigned long gfporder, size_t buffer_size,
size_t align, int flags, size_t *left_over,
unsigned int *num)
{
int nr_objs;
size_t mgmt_size;
size_t slab_size = PAGE_SIZE << gfporder; // slab 大小为 2^gfporder 个页面
…
if (flags & CFLGS_OFF_SLAB) { // 外置 slab 情况
mgmt_size = 0; // 没有管理对象
nr_objs = slab_size / buffer_size; // 对象个数为 slab 大小除以对象大小
} else { // 内置 slab 管理区的场景
nr_objs = calculate_nr_objs(slab_size, buffer_size,
sizeof(freelist_idx_t), align);
mgmt_size = calculate_freelist_size(nr_objs, align); // 管理区就是对齐后的空闲链表大小
}
*num = nr_objs;// 返回对象数量
*left_over = slab_size - nr_objs*buffer_size - mgmt_size; // slab大小-所有对象占用空间-管理区(空闲链表)
}
calculate_nr_objs 计算过程如下:
static int calculate_nr_objs(size_t slab_size, size_t buffer_size,
size_t idx_size, size_t align)
{
int nr_objs;
size_t remained_size;
size_t freelist_size;
int extra_space = 0;
...
nr_objs = slab_size / (buffer_size + idx_size + extra_space); // 算上空闲链表中占用的大小
remained_size = slab_size - nr_objs * buffer_size;
freelist_size = calculate_freelist_size(nr_objs, align); // 空闲链表对齐后的总长度
if (remained_size < freelist_size) // 剩余空间比空闲链表需要的小,则减去一个对象的空间
nr_objs--;
return nr_objs;
}
4)针对 slab 管理区是否外置,进行相应设置:
freelist_size = calculate_freelist_size(cachep->num, cachep->align); // 对齐后的空闲管理区大小
if (flags & CFLGS_OFF_SLAB && left_over >= freelist_size) {
// 剩余空间大于管理区,则把外置 slab 方式转换成内置的
flags &= ~CFLGS_OFF_SLAB;
left_over -= freelist_size;
}
if (flags & CFLGS_OFF_SLAB) {
freelist_size = calculate_freelist_size(cachep->num, 0); // 外部管理区不需要对齐
5)对前面计算完的结果,设置 kmem_cache 的相关值:
cachep->colour_off = cache_line_size(); // 着色块大小为 cache line 的大小
/* Offset must be a multiple of the alignment. */
if (cachep->colour_off < cachep->align)
cachep->colour_off = cachep->align; // 着色块大小必须是对齐大小的整数倍
cachep->colour = left_over / cachep->colour_off; // 计算着色区域包含着色块个数
cachep->freelist_size = freelist_size; // 空闲对象管理区大小
cachep->flags = flags;
cachep->allocflags = __GFP_COMP;
if (CONFIG_ZONE_DMA_FLAG && (flags & SLAB_CACHE_DMA))
cachep->allocflags |= GFP_DMA;
cachep->size = size; // slab 对象大小
cachep->reciprocal_buffer_size = reciprocal_value(size);
if (flags & CFLGS_OFF_SLAB) {
cachep->freelist_cache = kmalloc_slab(freelist_size, 0u);
// 外置管理区通过 kmalloc_slab 从 kmem_caches 中分配一块空间
}
6)setup_cpu_cache 进行 kmem_cache 中的三链节点初始化工作:
static int __init_refok setup_cpu_cache(struct kmem_cache *cachep, gfp_t gfp)
{
if (slab_state >= FULL)
return enable_cpucache(cachep, gfp); // cpucache 已经初始化完毕
cachep->cpu_cache = alloc_kmem_cache_cpus(cachep, 1, 1);
// 为 cachep->cpu_cache 分配空间,其为 per-cpu 变量
if (!cachep->cpu_cache)
return 1;
if (slab_state == DOWN) {
set_up_node(kmem_cache, CACHE_CACHE); // 给全局 kmem_cache 的三链节点分配空间
} else if (slab_state == PARTIAL) {
set_up_node(cachep, SIZE_NODE); // 为 cachep 的三链节点分配空间
} else {
int node;
for_each_online_node(node) { // 通过 kmalloc 给三链节点分配空间并且初始化
cachep->node[node] = kmalloc_node(
sizeof(struct kmem_cache_node), gfp, node);
BUG_ON(!cachep->node[node]);
kmem_cache_node_init(cachep->node[node]);
}
}
cachep->node[numa_mem_id()]->next_reap =
jiffies + REAPTIMEOUT_NODE +
((unsigned long)cachep) % REAPTIMEOUT_NODE;
// 进行初始化,目前还没有从伙伴系统中进行空间分配
cpu_cache_get(cachep)->avail = 0;
cpu_cache_get(cachep)->limit = BOOT_CPUCACHE_ENTRIES;
cpu_cache_get(cachep)->batchcount = 1;
cpu_cache_get(cachep)->touched = 0;
cachep->batchcount = 1;
cachep->limit = BOOT_CPUCACHE_ENTRIES;
return 0;
}
最开始 cachep->node 的空间没法从 slab 分配器中进行分配,因为系统还未初始化,所以通过 set_up_node 从全局的 init_kme_cache_node 中静态分配,然后再通过 kmalloc 分配后进行替换并且初始化。
在了解了 kmem_cache 的初始化完成之后,我们再来回顾一下 kmem_cache 的结构:
struct kmem_cache {
struct array_cache __percpu *cpu_cache;// slab 分配缓存
unsigned int batchcount; // 从 slab 中批量获取的对象数量,用于放入 cpu_cache 中
unsigned int limit; // 本地高速缓存中空闲对象的数量
unsigned int shared; // 是否存在共享 CPU 高速缓存
unsigned int size; // 缓存分配对象大小
struct reciprocal_value reciprocal_buffer_size;
unsigned int flags;
unsigned int num; // 每个 slab 中的对象数量
/* order of pgs per slab (2^n) */
unsigned int gfporder;
/* force GFP flags, e.g. GFP_DMA */
gfp_t allocflags;
size_t colour; // 着色区域数量
unsigned int colour_off; // 着色偏移大小
struct kmem_cache *freelist_cache;
unsigned int freelist_size;
void (*ctor)(void *obj); // 对象构造函数
const char *name; // 缓存名称
struct list_head list; // kmem_cache 缓存链表
int refcount;
int object_size; // 对象大小
int align; // 对齐大小
...
struct kmem_cache_node *node[MAX_NUMNODES];// slab 三链节点
};
2.slab 分配机制
在具体分析 slab 分配机制实现之前,先介绍一下 slab 分配的思路,便于后续理解:
在分配过程中,首先从 cpu_cache 中获取 slab,假如这个 array 成员里边没有 slab,则从 cache 的 slab 三链中把内存转给 array,如果 slab 三链也没有 slab,那么就让 slab 三链从伙伴系统中获取物理内存再转给 array。
从实现上来看,slab 分配的入口为 slab_alloc,通过 slab_alloc->_do_cache_alloc->___cache_alloc 来进行分配,下面分析 ___cache_alloc 实现,其主要流程分为以下几步。
1)假如缓存中有空闲的对象,则从缓存中获取:
if (likely(ac->avail)) {
ac->touched = 1;
objp = ac_get_obj(cachep, ac, flags, false); // 从缓存中寻找空闲对象空间
…
其中 ac_get_obj 的主要逻辑为:
objp = ac->entry[--ac->avail];
我们可以发现,一直是从最后一个对象开始分配的,这样 avail 对应的空闲对象是最热的,即最近释放出来的,更有可能驻留在 CPU 高速缓存中。
2)假如无法从缓存中分配对象,则为缓存增加空间:
objp = cache_alloc_refill(cachep, flags, force_refill):
static void *cache_alloc_refill(struct kmem_cache *cachep, gfp_t flags,
bool force_refill)
{
int batchcount;
struct kmem_cache_node *n;
struct array_cache *ac;
int node;
...
node = numa_mem_id(); // 取 numa 本地节点
if (unlikely(force_refill)) // 假如要强制增长 force_refill 为 true,就执行 force_grow
goto force_grow;
retry:
ac = cpu_cache_get(cachep); // 获取本地 CPU 的缓存
batchcount = ac->batchcount;
...
n = get_node(cachep, node); // 针对每个 node 的 slab 三链结构
...
// 如果有共享本地高速缓存,则从共享本地高速缓存填充仅用于多核,多个 CPU 共享的高速缓存(3链 node 中也有高速缓存)
if (n->shared && transfer_objects(ac, n->shared, batchcount)) {
n->shared->touched = 1;
goto alloc_done;
}
while (batchcount > 0) {
struct page *page;
page = get_first_slab(n);// 从 slabs_partial和slabs_free 中找可用的 slab 页面
if (!page) // 如果获取不到,那么必须增长了
goto must_grow;
...
BUG_ON(page->active >= cachep->num); // active 代表已经使用的对象数量,必然不能超过 num
// 节省了 slab 结构体的开销
while (page->active < cachep->num && batchcount--) {
// 将新找到的 slab 中的 batchcount 个对象放入 cpu cache 中
...
ac_put_obj(cachep, ac, slab_get_obj(cachep, page,
node));
}
list_del(&page->lru); // 将从中分配对象的 slab(此 slab 可能在 partial 或 free 的链表中)重新加入到合适的列表中
if (page->active == cachep->num)
list_add(&page->lru, &n->slabs_full); // 如果没有空闲对象了,那么就把该页面加入到 full 链表中
else
list_add(&page->lru, &n->slabs_partial); // 如果该 slab 中还有空闲对象,则加入 partial 链表中
}
must_grow:
n->free_objects -= ac->avail;
alloc_done:
…
if (unlikely(!ac->avail)) {
int x;
force_grow:
// partial 和 free 链表中都没有可用的 slab 了,则必须新分配内存对 kmem_cache 进行扩充
x = cache_grow(cachep, gfp_exact_node(flags), node, NULL);
ac = cpu_cache_get(cachep);
node = numa_mem_id();
...
// 第一次 grow 后,通常 ac->avail 为0,然后会跳转到 retry,重新从链表中选择 slab,
然后重新将其添加到 ac 中
if (!ac->avail)
goto retry;
}
ac->touched = 1;
// grow 的流程中,由于前面已经 retry,所以这里能保证 ac 中一定有需要的对象。另外没有 grow 的流程也会从这返回,此时 ac 中也一定是有对象可用的
return ac_get_obj(cachep, ac, flags, force_refill);
}
这个过程中 CPU cache 优先会从 slabs_partial 和 slabs_free 中找可用的 slab 页面,然后把可用对象放入 CPU cache 中。假如没有可用对象,那只能通过 cache_grow 从伙伴系统中进行分配了。
注意
page->active 代表该 slab 中已经被使用的对象数量,所以它不能大于 cachep->num 即一个 slab 中的最大可用对象数量。
此外,上述过程中有三个函数比较重要:slab_get_obj、ac_put_obj、cache_grow。
slab_get_obj 函数的作用是为了从 slab 中找到可用的对象:
static void *slab_get_obj(struct kmem_cache *cachep, struct page *page,
int nodeid)
{
void *objp;
objp = index_to_obj(cachep, page, get_free_obj(page, page->active));
page->active++;
...
return objp;
}
static inline void *index_to_obj(struct kmem_cache *cache, struct page *page,
unsigned int idx)
{
return page->s_mem + cache->size * idx;
}
static inline freelist_idx_t get_free_obj(struct page *page, unsigned int idx)
{
return ((freelist_idx_t *)page->freelist)[idx];
}
slab_get_obj 通过 freelist 找到可用的对象 idx,然后通过 index_to_obj 计算出相对 slab 内存开始地址(page->s_mem)的偏移。
ac_put_obj 是把找到的空闲对象放入到 per-CPU 缓存列表中:
ac->entry[ac->avail++] = objp;
cache_grow 是从伙伴系统中分配内存给 slab:
static int cache_grow(struct kmem_cache *cachep,
gfp_t flags, int nodeid, struct page *page)
{
void *freelist;
size_t offset;
gfp_t local_flags;
struct kmem_cache_node *n;
...
// 获取指定 node 对应的 slab 链表管理对象
n = get_node(cachep, nodeid);
...
// 着色计算
offset = n->colour_next;
n->colour_next++;
if (n->colour_next >= cachep->colour)
n->colour_next = 0;
...
offset *= cachep->colour_off;
...
if (!page)
// 从伙伴系统中分配物理内存页,用于 slab,根据 cachep->gfporder
page = kmem_getpages(cachep, local_flags, nodeid);
if (!page)
goto failed;
// 创建 slab 空闲对象列表,内置方式从 page 起始地址开始,外置则通过 kmallocl 来分配
freelist = alloc_slabmgmt(cachep, page, offset,
local_flags & ~GFP_CONSTRAINT_MASK, nodeid);
...
// 设置 slab 与 slab 缓冲区(kmem_cache)之间的关联
slab_map_pages(cachep, page, freelist);
// 调用各 slab 对象的构造函数(如果有的话),初始化新 slab 中的对象。通常都没有。
cache_init_objs(cachep, page);
...
// 将新分配的 slab 加入到 free 链表中
list_add_tail(&page->lru, &(n->slabs_free));
...
n->free_objects += cachep->num;
...
return 1;
...
}
通过上面分析,我们可以发现,在新版的内核中已经没有 slab 结构体,slab 的数据都是存在 page 结构中的,降低了 slab 结构数据的额外维护,图3-18总结了新版内核中的 slab 组织形式。
图3-18 slab 的组织形式
在 cache_grow 过程中,着色计算之后的 slab 倾向于把大小相同的对象放在同一个 cache line 中,这也是通过空间换时间提升访问速度的解决方案。
3.slab 通用缓存的初始化
这里有个悖论,在 slab 分配器还没初始化完成的时候,像 kmem_cache、kmem_cache_node(三链管理节点)这些结构又是如何初始化的呢?答案是系统一开始是静态分配的,然后再通过 kmalloc 向伙伴系统中申请内存后进行替换。在 start_kernel->mm_init 后调用 kmem_cache_init:
void __init kmem_cache_init(void)
{
int i;
...
kmem_cache = &kmem_cache_boot; // 全局静态分配
...
for (i = 0; i < NUM_INIT_LISTS; i++)
kmem_cache_node_init(&init_kmem_cache_node[i]);
// 初始化每个节点的三链结构(静态分配)
...
create_boot_cache(kmem_cache, "kmem_cache",
offsetof(struct kmem_cache, node) +
nr_node_ids * sizeof(struct kmem_cache_node *),
SLAB_HWCACHE_ALIGN);// 通过 kmem_cache_create 初始化全局的 kmem_cache
list_add(&kmem_cache->list, &slab_caches);
slab_state = PARTIAL;
...
{
int nid;
for_each_online_node(nid) {
init_list(kmem_cache, &init_kmem_cache_node[CACHE_CACHE + nid], nid); // 把三链管理节点用 kmalloc 申请后替换
...
}
}
// 通过 kmalloc 创建8到67108864的26个长度分档的通用 cache
create_kmalloc_caches(ARCH_KMALLOC_FLAGS);
}
3.5.3 内核态内存管理
现在我们已经知道了内核对于内存的管理方式,即伙伴系统和 slab 分配器,下面我们再来介绍内核中两种内存分配的函数。
1.连续物理地址的内存分配
Linux 在 slab 分配器上提供了 kmalloc 函数,可以为内核申请连续的内存空间,由于 kmalloc 是基于 slab 分配器的,所以比较适合小块内存的申请,图3-19描述了 kmalloc 与 slab 分配器以及伙伴系统的关系。
图3-19 kmalloc 与 slab 分配器和伙伴系统的关系
kmalloc 函数在实现的时候调用过程为 kmalloc->__kmalloc->__do_kmalloc,其中__do_kmalloc 的实现主要分为两步:
1)通过 kmalloc_slab 找到一个大小合适的 kmem_cache 缓存,该缓存在上一节中介绍过,在 kmem_cache_init 函数中进行初始化。
2)通过 slab_alloc 向 slab 分配器申请对象内存空间。
2.非连续物理地址的内存分配
很多时候连续的物理内存地址空间不一定能申请到,而且也不是必须的。Linux 提供 vmalloc 函数来获得连续的线性地址空间,但是其物理内存不一定是连续的。我们可以猜测一下,这样的实现必然是利用了 MMU 来修改页表搞定的。下面我们通过分析 vmalloc 的实现来验证一下。
vmalloc 函数的调用过程为:vmalloc->__vmalloc_node_flags->__vmalloc_node->__vmalloc_node_range,__vmalloc_node_range 分为两个步骤:
1)__get_vm_area_node 分配一个可用的线性地址空间,其核心调用为 alloc_vmap_area:
static struct vmap_area *alloc_vmap_area(unsigned long size,
unsigned long align,
unsigned long vstart, unsigned long vend,
int node, gfp_t gfp_mask)
{
struct vmap_area *va;
struct rb_node *n;
unsigned long addr;
int purged = 0;
struct vmap_area *first;
...
addr = ALIGN(vstart, align);
if (addr + size < addr)
goto overflow;
n = vmap_area_root.rb_node;
first = NULL;
while (n) {
struct vmap_area *tmp;
tmp = rb_entry(n, struct vmap_area, rb_node);
if (tmp->va_end >= addr) { // 该虚拟区域结束地址大于希望分配的开始地址
first = tmp;
if (tmp->va_start <= addr) // 虚拟区域起始地址小于希望分配的开始地址,证明找到了这么一块区域
break;
n = n->rb_left; // 左子树,往小地址空间寻找
} else
n = n->rb_right;// 右子树,往大地址空间寻找
}
if (!first) // 找到了一块需要寻找的起始地址落在该区域内
goto found;
}
// 检查该区域内是否有空洞存在
while (addr + size > first->va_start && addr + size <= vend) {
// 需要分配的结束地址(addr+size)落在该区域内
if (addr + cached_hole_size < first->va_start)
cached_hole_size = first->va_start - addr;
addr = ALIGN(first->va_end, align); // 结束地址+size 小于结束地址,表示溢出了
if (addr + size < addr)
goto overflow;
if (list_is_last(&first->list, &vmap_area_list)) // 最后一个区域也没有空洞,那就证明没问题了
goto found;
first = list_next_entry(first, list);
}
found:
if (addr + size > vend)
goto overflow;
// 下面开始赋值了
va->va_start = addr;
va->va_end = addr + size;
va->flags = 0;
__insert_vmap_area(va); // 插入红黑树和链表中
free_vmap_cache = &va->rb_node;
...
return va;
overflow: // 溢出
...
}
上述代码就是在 VMALLOC_START 和 VMACLLOC_END 的线性地址空间中,寻找一块连续并且“无空洞”的地址空间的过程。通过图3-20再结合之前内核对线性地址空间管理的知识,在32位系统中,进程的线性地址空间 3GB~4GB 之间是用来映射物理内存 0~1GB 之间的区域的。在这个区域中。3GB~3GB+896MB 直接和物理地址 0~896MB 映射。而在 8MB 空隙之后,就是我们用来给 VMALLOC 映射高端物理内存的 VMALLOC_START~VMALLOC_END 区域。
图3-20 vmalloc 线性地址空间映射原理
2)__vmalloc_area_node 为刚才申请的线性地址空间分配物理页面进行页表映射:
static void *__vmalloc_area_node(struct vm_struct *area, gfp_t gfp_mask,
pgprot_t prot, int node)
{
const int order = 0;
struct page **pages;
unsigned int nr_pages, array_size, i;
const gfp_t nested_gfp = (gfp_mask & GFP_RECLAIM_MASK) | __GFP_ZERO;
// 分配的内存页初始化为0
const gfp_t alloc_mask = gfp_mask | __GFP_NOWARN;
nr_pages = get_vm_area_size(area) >> PAGE_SHIFT; // 获取总共需要的页数
array_size = (nr_pages * sizeof(struct page *)); // page 结构数组所需要的空间大小
area->nr_pages = nr_pages;
if (array_size > PAGE_SIZE) { // page 结构数组大于一个页面,则用 vmalloc 来申请,这里会成为递归
pages = __vmalloc_node(array_size, 1, nested_gfp|__GFP_HIGHMEM,
PAGE_KERNEL, node, area->caller);
} else {
pages = kmalloc_node(array_size, nested_gfp, node); // 否则通过 kmalloc 来分配
}
area->pages = pages;
...
for (i = 0; i < area->nr_pages; i++) {
struct page *page;
// 通过 alloc_pages 来申请物理页面
if (node == NUMA_NO_NODE)
page = alloc_kmem_pages(alloc_mask, order);
else
page = alloc_kmem_pages_node(node, alloc_mask, order);
...
area->pages[i] = page;
...
}
if (map_vm_area(area, prot, pages)) // 利用页表项来建立映射
goto fail;
return area->addr;
...
}
这个过程较为简单,就是通过 alloc_pages 一页一页申请物理内存,然后和线性地址空间进行映射。
3.5.4 用户态内存申请
之前介绍的 kmalloc 和 vmalloc 都是针对内核态的内存分配,针对用户态的内核分配,有 libc 的 malloc 库,或是其他方法,限于篇幅交给读者自己去做探究。这里我仅介绍 malloc 实现原理。
以32位系统为例,进程线性地址空间的组织形式如图3-21所示。在线性地址空间的底部维护了 text、data、bss 段,在高端维护了栈地址空间。中间则有 heap 区域和 mmap 区域。其中 heap 区域的 start_brk 为 heap 区域的起始,brk 则是通过 sys_brk 系统调用对 heap 区域的伸缩进行控制,而 mmap 区域则通过 mmap 调用随机分配线性地址空间。
图3-21 进程地址空间组织形式
无论是 mmap 还是 sys_brk,最终分配的都是线性地址空间,物理内存都是没有分配的。当进程使用到该区域内存的时候,会发生缺页异常。这时候,异常中断响应程序会调用 __do_page_fault->handle_mm_fault->handle_pte_fault->do_fault,最终通过 alloc_pages 分配物理地址空间。
注意
malloc 有多种实现,有 libc 的 ptmalloc,有 jmalloc,有谷歌的 tcmalloc 等。
3.6 栈内存分配和管理
进程的切换、函数的调用通常都要使用到栈(stack),进程的栈一般有两个,分别是内核态栈和用户态栈。其中,用户态栈就是上一节中介绍的线性地址空间中的栈地址空间,用户可以通过 mmap 来分配。
图3-22 进程内核态栈
每个进程都有自己的内核态栈,用于进程在内核代码中执行期间进行内存分配控制,见图3-22。其所在线性地址中的位置由该进程 TSS 段中 ss0 和 esp0 两个字段指定。ss0 是进程内核态栈的段选择符,esp0 是栈底指针。因此每当进程从用户代码转移进入内核代码中执行时,进程的内核态栈总是空的。进程内核态栈被设置在位于其进程数据结构所在页面的末端,即与进程的任务数据结构(task_struct)放在同一页面内。这是在建立新进程时,fork()程序在任务 TSS 段的内核级栈字段(tss.esp0 和 tss.ss0)中设置的,在前面1.3.2节也已经介绍过。
3.7 内存管理案例分析
之前我们分析的是 Linux 内核对内存的管理方式,下面,我们来分析两个应用程序 Memcached 和 Redis 分别是如何来管理内存的。
虽然内核本身已经有对内存的分配和管理机制,但是假如动不动就通过内核去分配内存,对系统整体性能影响较大,而且不同的业务场景,对内存的需求也不一样,很容易因频繁申请产生大量碎片。所以,很多时候,我们需要在用户态去管理内存。
3.7.1 Memcached 内存管理机制分析
Memcached 对内存的管理有点类似内核的 slab 分配器,如图3-23所示。Memcached 根据 slab 内存块的大小,把内存分为不同的 slabclass,每个 slabclass 维护的 slab 是一样大的,slab 在 slab_list 链表中被管理。每个 slab 中被格式化成相同大小的 chunk,可以用来存储 item。
图3-23 Memcached 内存管理结构
其中 tails 和 heads 队列用来管理被分配出去的针对相应 slabclass 的 item,而被回收回来的 item 空间由 slots 来维护。
下面是 slabclass 和 item 的数据结构:
typedef struct {
unsigned int size; // 每个 item 大小
unsigned int perslab; // 每个 slab 中包含多少个 item
void **slots; // 空闲的 item 指针
unsigned int sl_total; // 已分配空闲的 item 个数
unsigned int sl_curr; // 当前空闲的 item 位置(也就是实际空闲 item 个数),从后往前数
void *end_page_ptr; // 指向最后一个页面中空闲的 item 开始位置
unsigned int end_page_free; // 最后一个页面,item 个数
unsigned int slabs; // 实际使用 slab 个数
void **slab_list; // slab 数组指针
unsigned int list_size; // 已经分配 slab 个数
…
size_t requested; // 所有使用内存的大小
} slabclass_t;
typedef struct _stritem {
struct _stritem *next;// item 在 slab 中存储时,是以双链表的形式存储的,next 是后向指针
struct _stritem *prev; // prev 为前向指针
struct _stritem *h_next; // hash 桶中元素的链接指针
rel_time_t time; // 最近访问时间
rel_time_t exptime; // 过期时间
int nbytes; // 数据大小
unsigned short refcount; // 引用次数
uint8_t nsuffix;
uint8_t it_flags;
uint8_t slabs_clsid; // 标记 item 属于哪个 slabclass 下
uint8_t nkey; // key 的长度
union {
uint64_t cas;
char end;
} data[]; // 真实的数据信息
} item;
在系统初始化的时候,会通过 slabs_init 函数为 slabclass 分配空间:
void slabs_init(const size_t limit, const double factor, const bool prealloc) {
int i = POWER_SMALLEST - 1;
unsigned int size = sizeof(item) + settings.chunk_size; // item 的大小应该是 item 结构 +chunk_size
mem_limit = limit;
if (prealloc) {
mem_base = malloc(mem_limit); // 预分配内存, 先通过 malloc 来申请
if (mem_base != NULL) {
mem_current = mem_base;
mem_avail = mem_limit;
...
}
memset(slabclass, 0, sizeof(slabclass));
// 下面计算每个 slabclass 中 slab 的大小,根据 factor 递增
while (++i < POWER_LARGEST && size <= settings.item_size_max / factor) {
// items 已经根据 n 字节对齐
if (size % CHUNK_ALIGN_BYTES)
size += CHUNK_ALIGN_BYTES - (size % CHUNK_ALIGN_BYTES);
slabclass[i].size = size;
slabclass[i].perslab = settings.item_size_max / slabclass[i].size;
size *= factor;
...
}
// 最后一个 slabclass 中的 slab 仅有1个 chunk,大小为 item\_size_max
power_largest = i;
slabclass[power_largest].size = settings.item_size_max;
slabclass[power_largest].perslab = 1;
...
if (prealloc) {
slabs_preallocate(power_largest);
}
}
slabs_init 主要用于初始化 slabclass 的规则(slab 中的 chunk 大小以及每个 slab 中 chunk 的个数)。
假如内存是预分配的,则会先初始化一下每个 slabclass 中的 slab,该实现较为简单,读者可以自己分析 slabs_preallocate 函数,其最终会调用 do_slabs_alloc->do_slabs_newslab 初始化一个 slab,然后把该 slab 挂载到 slabclass 的 slab_list 队列中。
假如需要对某个 item 申请内存,则通过 do_item_alloc 来实现:
item *do_item_alloc(char *key, const size_t nkey, const int flags,
const rel_time_t exptime, const int nbytes,
const uint32_t cur_hv) {
uint8_t nsuffix;
item *it = NULL;
char suffix[40];
size_t ntotal = item_make_header(nkey + 1, flags, nbytes, suffix,
&nsuffix);
...
unsigned int id = slabs_clsid(ntotal);
...
search = tails[id];
...
if (!tried_alloc && (tries == 0 || search == NULL))
it = slabs_alloc(ntotal, id);
// 下面代码对item进行初始化
...
it->refcount = 1;
mutex_unlock(&cache_lock);
it->next = it->prev = it->h_next = 0;
it->slabs_clsid = id;
DEBUG_REFCNT(it, '*');
it->it_flags = settings.use_cas ? ITEM_CAS : 0;
it->nkey = nkey;
it->nbytes = nbytes;
memcpy(ITEM_key(it), key, nkey);
it->exptime = exptime;
memcpy(ITEM_suffix(it), suffix, (size_t)nsuffix);
it->nsuffix = nsuffix;
return it;
}
在 do_item_alloc 中,先通过 item 的总大小找到合适的 slabclass,然后再到 tails 等队列中去找是否有可用的空间,最后,假如没有可用空间了,通过 slabs_alloc 再申请一块 slab 挂到 slabclass 上去(通过 do_slabs_alloc)。
通过分析可以发现,Memcached 的内存分配机制非常类似于内核的 slab 分配器,也是避免内部碎片的一种解决方案。
3.7.2 Redis 内存管理机制分析
在 Redis 中,zmalloc 对内存分配函数进行封装,允许按配置使用 tcmalloc、jemalloc 等库,它们速度快内存使用率高,并支持统计内存使用率。
在 Redis 的 zmalloc.c 源码中,我们可以看到如下代码:
#if defined(USE_TCMALLOC)
#define malloc(size) tc_malloc(size)
#define calloc(count,size) tc_calloc(count,size)
#define realloc(ptr,size) tc_realloc(ptr,size)
#define free(ptr) tc_free(ptr)
#elif defined(USE_JEMALLOC)
#define malloc(size) je_malloc(size)
#define calloc(count,size) je_calloc(count,size)
#define realloc(ptr,size) je_realloc(ptr,size)
#define free(ptr) je_free(ptr)
#endif
从上面的代码中我们可以看到,Redis 在编译时,会先判断是否使用 tcmalloc,如果是,会用 tcmalloc 对应的函数替换掉标准的 libc 中的函数实现。然后判断 jemalloc 是否使用,最后如果都没有使用才会用标准的 libc 中的内存管理函数。
在2.4以上的 Redis 版本中,jemalloc 已经作为源码包的一部分了,所以可以直接使用。而如果你要使用 tcmalloc 的话,是需要自己安装的。
Redis 通过 info 命令就能看到使用的内存分配器了。
对于 tcmalloc、jemalloc 和 libc 对应的三个内存分配器,它们的性能和碎片率如何呢?下面是一个简单测试结果,使用 Redis 自带的 redis-benchmark 写入等量数据进行测试,数据摘自采用不同分配器时 Redis info 信息。我们可以看到,采用 tcmalloc 时碎片率是最低的,为1.01,jemalloc 为1.02,而 libc 的分配器碎片率为1.31,如下所示:
used_memory:708391440
used_menory_human:675.57M
used_memory_rss:715169792
used_memory_peak:708814040
used_memory_peak_human:675.98M
mem_fragmentation_ratio:1.01
mem_allocator:tcmalloc-1.7
used_memory:708381168
used_menory_human:675.56M
used_memory_rss:723587072
used_memory_peak:708803768
used_memory_peak_human:675.97M
mem_fragmentation_ratio:1.02
mem_allocator:jemalloc-2.2.1
used_memory:869000400
used_menory_human:828.74M
used_memory_rss:1136689152
used_memory_peak:868992208
used_memory_peak_human:828.74M
mem_fragmentation_ratio:1.31
mem_allocator:libc
上面的测试数据都是小数据,也就是说单条数据量并不大,下面我们尝试设置 benchmark 的 -d 参数,将 value 值调整为 1k 大小,则测试结果发生了一些变化:
used_memory:830573680
used_memory_human:792.10M
used_memory_rss:849068032
used_memory_peak:831436048
used_memory_peak_human:792.92M
mem_fragmentation_ratio:1.02
mem_allocator:tcmalloc-1.7
used_memory:915911024
used_memory_human:873.48M
used_memory_rss:927047680
used_memory_peak:916773392
used_memory_peak_human:874.30M
mem_fragmentation_ratio:1.01
mem_allocator:jemalloc-2.2.1
used_memory:771963304
used_memory_human:736.20M
used_memory_rss:800583680
used_memory_peak:772784056
used_memory_peak_human:736.98M
mem_fragmentation_ratio:1.04
mem_allocator:libc
可以看出,在分配大块内存和小块内存上,几种分配器的碎片率差距还是比较大的。所以在使用 Redis 的时候,还是尽量用自己真实的数据去做测试,以选择最适合自己数据的分配器。
3.8 本章小结
要理解操作系统是如何管理内存的,首先需要了解 MMU 的内存管理机制,其核心还是分段和分页的机制,其中会涉及几个重要地址:线性地址、物理地址、虚拟地址。
本章以内存在体系结构中的作用为切入点,首先介绍了内存在使用中会遇到的问题,为什么需要管理等,然后介绍了 MMU 以及相关地址空间的核心概念。
有了底层硬件层面提供的概念和管理能力后,Linux 操作系统就是针对这些能力在上层进行了更加有针对性的建模和封装。开头我们已经提到,内存的管理主要围绕堆和栈来进行。Linux 内核对堆的管理核心还是围绕伙伴算法、slab 分配器来展开的,并且提供了相关的能力:kmalloc、vmalloc、malloc 等。对于栈的管理,我们需要区分内核栈和用户栈。
最后,为了便于更加深入理解内存管理,我们还介绍了 Memcached 和 Redis 是如何管理内存的。
内存管理的话题其实还有另一部分,比如系统启动时,内存分配器还没初始化时,系统是如何来分配管理内存的?malloc 有不同实现,细节是怎样的?内存和磁盘之间的交换又是如何实现的?这些问题就交给读者自己去解决了。