为了提高性能和扩展性,诞生了一种更高级的模式,非一致性内存访问NUMA(Non-uniform memory access)。这种模式下每个CPU有自己本地的内存,当本地内存不足时才会访问其他NUMA节点的内存。这样就提高了访问的效率。
值得注意的一点就是Mysql对NUMA支持不友好,NUMA在默认在本地CPU上分配内存,会导致CPU节点之间内存分配不均衡,当某个CPU节点的内存不足会使用Swap而不是直接从远程节点分配内存。经常内存还有耗尽,Mysql就已经使用Swap照成抖动,这就是"Swap Insanity”。所以单机部署Mysql的时候最好将NUMA关掉。
节点
接下来我们就看一下当前主流的模式NUMA,NUMA模式中内存分节点,每个CPU有本地内存,内核中用typedef struct pglist_data pg_data_t表示节点。我们来看一下这个结构体重点的变量。
typedef struct pglist_data {struct zone node_zones[MAX_NR_ZONES];struct zonelist node_zonelists[MAX_ZONELISTS];int nr_zones; /* number of populated zones in this node */#ifdef CONFIG_FLAT_NODE_MEM_MAP/* means !SPARSEMEM */struct page *node_mem_map;#ifdef CONFIG_PAGE_EXTENSION...unsigned long node_start_pfn;unsigned long node_present_pages; /* total number of physical pages */unsigned long node_spanned_pages; /* total size of physical page range, including holes */int node_id;...} pg_data_t;区域的类型如下:
enum zone_type {#ifdef CONFIG_ZONE_DMAZONE_DMA,#endif#ifdef CONFIG_ZONE_DMA32ZONE_DMA32,#endifZONE_NORMAL,#ifdef CONFIG_HIGHMEMZONE_HIGHMEM,#endifZONE_MOVABLE,#ifdef CONFIG_ZONE_DEVICEZONE_DEVICE,#endif__MAX_NR_ZONES};区
内核将内存分区的目的是形成不同内存池,从而根据用途进行分配。内核使用struct zone表示区。区就是本节点一个个页集合了。我们再来看一下这个结构体。
struct zone {...struct pglist_data*zone_pgdat;struct per_cpu_pageset __percpu *pageset;...unsigned longzone_start_pfn;atomic_long_tmanaged_pages;unsigned longspanned_pages;unsigned longpresent_pages;const char*name;...struct free_areafree_area[MAX_ORDER];unsigned longflags;spinlock_tlock;...} ____cacheline_internodealigned_in_smp;页
然后就到了最基本的内存单元——页,内核使用struct page表示物理页。结构体中有很多union,用于不同模式时的表示。
主要有两种模式:1. 整页分配使用伙伴系统;2. 小段内存分配使用slab allocator技术。
页的分配
页的分配有两种情况:
按页分配
使用伙伴系统分配,struct zone中的free_area数组每个元素都是一个链表首地址,每条链表有1、2、4、8、16、32、64、128、256、512 和 1024 个连续页。也就是说最多可以分配4MB的连续内存,每个页块的地址物理页地址是页块大小的整数倍。
分配使用函数alloc_pages,该函数返回连续物理页的第一页的struct page的地址。
释放页使用free_pages,参数addr和order分别为page地址和要是释放的页数,释放页数为 2^{order}。
Slab分配
内核以及用户空间几乎很少用到按页分配的情况,普遍使用都是像malloc那样小段内存申请,并且操作十分频繁。这种频繁的操作通常会使用空闲链表,空闲链表缓存被释放的结构,下次分配是直接从链表抓取而不是申请。
内核中,空闲链表面临的主要问题是不能全局控制,当可用内存紧缺时,内核无法通知每个空闲链表收缩从而释放一些内存。事实上内核根本不知道存在哪些空闲链表。为了弥补这一缺陷,Linux内核提供了Slab层。Slab分配器来充当通用数据结构缓存层的角色,以感知所有缓存链表状态。
Slab分配模式中:
- 每个结构体对应一个高速缓存,由kmem_cache_create函数创建,由kmem_cache_destroy函数销毁。例如进程线程的结构体struct task_struct对应高速缓存为task_struct_cachep,进程虚拟内存管理结构体struct mm_struct对应高速缓存为mm_cachep,每个高速缓存都使用struct kmem_cache表示。这里的struct kmem_cache是include/linux/slub_def.h下的,高速缓存中有多个slab。
内核最开始只有slab,后来开发者对slab逐渐完善,就出现了slob和slub。slob针对嵌入式等内存有限的机器,slub针对large NUMA系统的大型机。
- 每个slab里面存放了若干个连续物理页用于分配,物理页按照结构体大小分割。工程师通过kmem_cache_alloc申请结构体,通过kmem_cache_free释放结构体(放回)。
先分析一下高速缓存struct kmem_cache:
1.cpu_slab 是每个CPU本地缓存:
- void **freelist 空闲对象链表
- struct page *page 所有连续的物理页
- struct page *partial 部分分配的物理页,这是备用的。
2.list 是高速缓存所在链表。
3.node[MAX_NUMNODES] 是该高速缓存所有的slab的数组,每个slab都有一个状态(1.满的,2.空的,3.半满),本地缓存不够时根据这个状态去找其他的slab。另外还用链表维护着这三个状态的slab:
- struct list_head slabs_partial 存放半满的slab
- struct list_head slabs_full 存放已满的slab
- struct list_head slabs_free 存放空的slab
下面我们根据调用系统调用execve加载二进制文件的例子来看一下NUMA环境中Slab分配内存的完整过程。既然要加载二进制文件,那么进程结构体struct task_struct中内存管理变量struct mm_struct 当然要申请了。
调用链为execve -->do_execve -->do_execveat_common -->alloc_bprm -->bprm_mm_init -->mm_alloc -->allocate_mm -->kmem_cache_alloc
到这里可以看到高速缓存申请的接口kmem_cache_alloc,其中参数struct kmem_cache是struct mm_struct对应的高速缓存。再看一下这个函数做了哪些事情:
到现在已经说完slab分配对象的逻辑了,但是还有一个问题,就是空闲缓存的回收,由于有了slab层内核已经可以感知所有空闲链表的状态了,所以回收问题是可以解决的。初始化时内核就会注册回收任务,每隔两秒进行一次检查,检查是否需要收缩空闲链表。调用链是cpucache_init ->slab_online_cpu ->start_cpu_timer 将cache_reap注册为定时回调函数。
页换出
不管32位还是64位操作系统,不一定非得按照操作系统要求装内存条,例如32位最大4G虚拟地址空间,但是用户就买了2G怎么办?超过2G的虚拟地址空间不用了吗?不会的,现在几乎所有操作系统都是支持SWAP,就是将不活跃的物理页暂时缓存到磁盘上。
一般页换出有两种方式:
上边讲完了虚拟地址空间和物理地址空间是如何管理的,还剩下最后一个问题,这俩是怎么映射的?
其实虚拟地址不止可以和物理内存映射,还可以和文件等映射。物理内存只是一种特殊的情况。
4. 内存映射
用户态映射
首先来看一下用户态映射方式。
前边说堆的时候,malloc函数只讲了小内存brk的方式,当申请内存较大时会使用mmap(不是系统调用那个),对于堆来说就是将虚拟地址映射到物理地址。另外如果想将文件映射到内存也可以调用mmap。
我们先来分析一下mmap。
- 首先调用get_unmapped_area在进程地址空间里找到一个没映射的区域(那棵红黑树)。
- 然后调用mmap_region将文件映射到这个区域,并且调用call_mmap执行file->f_op->mmap接口把这个区域的struct vm_area_struct结构的内存操作接口换成那个文件的操作函数,也就是说对这段虚拟内存读写,就相当于执行该文件的读写函数。如果是ext4文件系统对应的mmap接口就是ext4_file_mmap。ext4_file_mmap中执行内存操作替换为文件操作vma->vm_ops = &ext4_file_vm_ops;
- 然后将struct vm_area_struct挂到进程的struct mm_struct上。
3. 现在文件已经和虚拟内存地址有映射了。还没有与物理内存产生关系,而物理内存的映射是用到的时候才映射。
缺页
访问某个虚拟地址时,如果没有对应的物理页就会触发缺页中断handle_page_fault这里会判断是内核态缺页还是用户态缺页,我们先来看用户态的,会调用do_user_addr_fault。这个函数中:
1.PTE表为空,说明是缺页(新的)
- 如果映射到物理内存就调用do_anonymous_page。
- 如果映射到文件就调用do_fault。
2.PTE表不为空,说明页表创建过了,是被换出到磁盘的就调用do_swap_page。
一个个分析,首先看映射到物理页的函数do_anonymous_page:
再来看下映射到文件的函数do_fault:
最后一种是交换空间类型的,函数do_swap_page,swap类型的和映射到文件的差不多,都是需要从把磁盘文件映射到内存。
处理完缺页之后,物理页有内容、进程空间有页表,接下来就可以通过虚拟地址找到物理地址了。
为了加快映射速度,我们引进了TLB专门来做地址映射的硬件,缓存了部分页表。查询时先查快表TLB查到了直接用物理内存,查不到再到内存访问页表。
内核态映射
首先内核页表和用户态页表不同,内核页表在初始化时就创建了。内核初始化时将swapper_pg_dir赋值给顶级目录pgd。swapper_pg_dir指向顶级目录init_top_pgt。
系统初始化函数setup_arch调用load_cr3(swapper_pg_dir)刷新TLB说明页表已经构建完了。
实际初始化在arch/x86/kernel/head_64.S中。
#if defined(CONFIG_XEN_PV) || defined(CONFIG_PVH)SYM_DATA_START_PTI_ALIGNED(init_top_pgt).quad level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE_NOENC.org init_top_pgt + L4_PAGE_OFFSET*8, 0.quad level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE_NOENC.org init_top_pgt + L4_START_KERNEL*8, 0/* (2^48-(2*1024*1024*1024))/(2^39) = 511 */.quad level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE_NOENC.fillPTI_USER_PGD_FILL,8,0SYM_DATA_END(init_top_pgt)SYM_DATA_START_PAGE_ALIGNED(level3_ident_pgt).quadlevel2_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE_NOENC.fill511, 8, 0SYM_DATA_END(level3_ident_pgt)SYM_DATA_START_PAGE_ALIGNED(level2_ident_pgt)/* * Since I easily can, map the first 1G. * Don't set NX because code runs from these pages. * * Note: This sets _PAGE_GLOBAL despite whether * the CPU supports it or it is enabled. But, * the CPU should ignore the bit. */PMDS(0, __PAGE_KERNEL_IDENT_LARGE_EXEC, PTRS_PER_PMD)SYM_DATA_END(level2_ident_pgt)#elseSYM_DATA_START_PTI_ALIGNED(init_top_pgt).fill512,8,0.fillPTI_USER_PGD_FILL,8,0SYM_DATA_END(init_top_pgt)#endif缺页
内核空间缺页同样会调用handle_page_fault。
用户评论
终于看到了这篇文章!上篇写的很棒,对内存管理基础很清晰,期待这篇更深入地讲解一下一些具体的算法和应用场景!
有18位网友表示赞同!
我正在学习c++的低级特性,看了很多关于内存分配的文章,感觉这个文章写的比较通俗易懂,能更容易理解其中的原理。希望后面还有更多内容讲解不同语言的内存管理机制。
有6位网友表示赞同!
我觉得这篇博客对提高内存利用率很重要,特别是对于嵌入式系统开发人员来说。希望能深入聊聊一些先进的内存管理技术,比如页面回收和自动内存管理,以及他们在实际应用中的优缺点比较?
有20位网友表示赞同!
内存管理的设计确实复杂啊,感觉这篇文章只是提了一点皮毛,希望后续的文章可以对常用的内存池、引用计数等实现机制进行更详细的分析,最好结合一些实际案例。
有9位网友表示赞同!
同意文章中关于调试内存泄漏的说法。这真是一个让人头疼的问题!个人觉得学习一些监测工具的使用非常重要,比如 Valgrind 或 AddressSanitizer,能有效帮助我们揪出隐藏在代码中的问题。
有9位网友表示赞同!
说实话,这篇文章的深度还不太够啊,对于一些比较高级的内存管理策略,比如线程私有堆和共用堆来说,只字未提。感觉更多的是面向初学者讲解基本概念。
有6位网友表示赞同!
文章写的确实不错,把复杂的知识讲得通俗易懂,很有帮助!我对内存管理机制一直不太了解,现在终于开始入门了。
有9位网友表示赞同!
很多项目文档里都简单地说一下内存管理,但很少深入介绍具体方法,这篇博客就比较详细了,让我对内存管理有了更深入的理解,真是太棒了!
有17位网友表示赞同!
这篇文章让我意识到内存管理的重要性。以前总是把这个当做辅助问题,但这篇文章确实让我明白,做好内存管理对于程序性能和稳定性至关重要。
有12位网友表示赞同!
内存池的设计思路很有意思,我会尝试在后续项目中使用它来优化内存分配效率,降低频繁的 malloc 和 free 操作带来的系统开销。文章给我的启发不少啊!
有10位网友表示赞同!
我之前对内存管理比较困惑,读了这篇文章终于豁然开朗!作者把概念阐释得非常清晰,并结合一些实用案例,更容易理解相关知识点。强烈推荐给正在学习C/C++的同学!
有7位网友表示赞同!
这篇博客文章内容很丰富,能够让我更加深入地了解内存管理设计的各个方面。建议后期可以添加更多具体的代码实现示例,更有助于我们深入学习和实践。
有8位网友表示赞同!
虽然文章说的不错,但我感觉还是比较理论化,缺乏一些更实用的场景分析和应用案例,对我来说有点不够吸引力。
有6位网友表示赞同!
我喜欢这种详细的讲解风格,把一个复杂的概念拆解成易于理解的小部分,并分别解释,真的很 hilfreich 。希望作者能够继续更新更多的相关内容!
有19位网友表示赞同!
这篇博客文章对于想要深入学习内存管理设计的人来说非常有价值,它不仅阐述了基础概念,还介绍了一些更高级的策略和技巧。不过,对于初学者来说,可能有一些部分比较晦涩难懂。
有15位网友表示赞同!
我认为文章标题写得不错,能很准确地概括文章内容。希望作者能够在后续的博文中继续深入探讨内存管理相关的精彩话题!
有8位网友表示赞同!
看了这篇文章,我对内存分配和回收机制有了更清晰的认识。以后开发过程中会更加注意这些细节,提高程序效率和稳定性。
有6位网友表示赞同!
我觉得对嵌入式系统设计、游戏开发等领域来说,理解内存管理尤为重要,这篇博客文章能够帮助我更好地掌握相关知识点,非常实用!
有12位网友表示赞同!