游戏迷提供最新游戏下载和手游攻略!

深入解析内存管理策略(第二部分)

发布时间:2024-10-18浏览:71

为了提高性能和扩展性,诞生了一种更高级的模式,非一致性内存访问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;
  • 节点ID,node_id。
  • node_mem_map 就是这个节点的 struct page 数组,用于描述这个节点里面的所有的页。
  • node_start_pfn 是这个节点的起始页号。
  • node_spanned_pages 是这个节点中包含不连续的物理内存地址的页面数。
  • node_present_pages 是真正可用的物理页面的数目。
  • 节点内再将页分成区,存放在node_zones数组中。大小是MAX_NR_ZONES。
  • nr_zones表示节点的区域数量。
  • node_zonelists是备用节点和它的内存区域的情况。当本地内存不足时会使用到。
  • 区域的类型如下:

    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};
  • ZONE_DMA直接内存读取区域,DMA是一种机制,要把外设的数据读入内存或把内存的数据传送到外设,原来都要通过 CPU 控制完成,但是这会占用 CPU,影响 CPU 处理其他事情,所以有了 DMA 模式。CPU 只需向 DMA 控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,这样就可以解放 CPU。对于64位系统有两个DMA区域ZONE_DMA、ZONE_DMA32,后者只能被32位设备访问。
  • ZONE_NORMAL直接映射区,内核虚拟地址空间讲过,就是地址加上一个常量与虚拟地址空间映射。
  • ZONE_HIGHMEM高端内存区,64位系统是不需要的。
  • ZONE_MOVABLE可移动区,通过将内存划分为可移动区和不可移动区来避免碎片。
  • ZONE_DEVICE为支持热插拔设备而分配的Non Volatile Memory非易失性内存。
  • 内核将内存分区的目的是形成不同内存池,从而根据用途进行分配。内核使用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;
  • zone_start_pfn表示这个区中第一页。
  • spanned_pages表示和节点中的node_spanned_pages变量类似,都是不连续物理页数,就是终止页减去起始页(中间可能有空洞,但是不管)。
  • present_pages实际物理页数量。
  • managed_pages被伙伴系统管理的所有的 page 数目。
  • pageset用于区分冷热页,前面将分段机制时说过80386架构CS、DS等段寄存器由单纯表示段地址升级为段选择子和段描述符缓存器。就是说有些经常被访问的页会被缓存在寄存器中,被缓存的就是热页,这个变量就是用于区分冷热页。
  • free_area空闲页。
  • 然后就到了最基本的内存单元——页,内核使用struct page表示物理页。结构体中有很多union,用于不同模式时的表示。

    主要有两种模式:1. 整页分配使用伙伴系统;2. 小段内存分配使用slab allocator技术。

    页的分配

    页的分配有两种情况:

  • 按页分配
  • Slab分配(通常分配大小小于一页)
  • 按页分配

    使用伙伴系统分配,struct zone中的free_area数组每个元素都是一个链表首地址,每条链表有1、2、4、8、16、32、64、128、256、512 和 1024 个连续页。也就是说最多可以分配4MB的连续内存,每个页块的地址物理页地址是页块大小的整数倍。

    分配使用函数alloc_pages,该函数返回连续物理页的第一页的struct page的地址。

  • alloc_pages调用alloc_pages_current。
  • alloc_pages_current中根据参数gfp判断分配什么类型的页,GFP_USER用户空间页,GFP_KERNEL内核空间页,GFP_HIGHMEM高端内存页。参数order表示分配2order2order个页。之后调用__alloc_pages_nodemask。
  • __alloc_pages_nodemask是伙伴系统的核心方法,大概逻辑就是先看当前区空闲页是否足够,不够就看备用区,遍历每个区时,比如要分配128个页,就会从128个页的块往上找,例如128没有,256有,就将256分割称128和128,一个用于分配,另一个放入128页为一块的链表中。
  • 释放页使用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_alloc,紧接着调用slab_alloc_node。
  • slab_alloc_node中首先在CPU本地缓存cpu_slab中分配,这就是注释中说的快速通道,分配到了直接返回,否则就调用__slab_alloc去其他slab中分配,这就是普通通道。
  • __slab_alloc中首先再尝试从本地缓存cpu_slab中分配,没有的话就跳到new_slab先考虑从本地缓存cpu_slab备用物理页partial中分配,再没有的话就调用new_slab_objects去其他slab中分配了。如果再没有就只能再申请物理页了。
  • 到现在已经说完slab分配对象的逻辑了,但是还有一个问题,就是空闲缓存的回收,由于有了slab层内核已经可以感知所有空闲链表的状态了,所以回收问题是可以解决的。初始化时内核就会注册回收任务,每隔两秒进行一次检查,检查是否需要收缩空闲链表。调用链是cpucache_init ->slab_online_cpu ->start_cpu_timer 将cache_reap注册为定时回调函数。

    页换出

    不管32位还是64位操作系统,不一定非得按照操作系统要求装内存条,例如32位最大4G虚拟地址空间,但是用户就买了2G怎么办?超过2G的虚拟地址空间不用了吗?不会的,现在几乎所有操作系统都是支持SWAP,就是将不活跃的物理页暂时缓存到磁盘上。

    一般页换出有两种方式:

  • 主动(当申请内存时,内存紧张就考虑将部分页缓存到磁盘)
  • 被动(Linux 内核线程kswapd定时检查是否需要换出部分页)
  • 调用链为balance_pgdat ->kswapd_shrink_node ->shrink_node ->shrink_node_memcgs
  • shrink_node_memcgs就是处理页换出的函数了,里面有个LRU表,根据最近最少未使用的原则换出。
  • 上边讲完了虚拟地址空间和物理地址空间是如何管理的,还剩下最后一个问题,这俩是怎么映射的?

    其实虚拟地址不止可以和物理内存映射,还可以和文件等映射。物理内存只是一种特殊的情况。

    4. 内存映射

    用户态映射

    首先来看一下用户态映射方式。

    前边说堆的时候,malloc函数只讲了小内存brk的方式,当申请内存较大时会使用mmap(不是系统调用那个),对于堆来说就是将虚拟地址映射到物理地址。另外如果想将文件映射到内存也可以调用mmap。

    我们先来分析一下mmap。

  • 调用ksys_mmap_pgoff参数有fd,通过fd找到对应struct file。接下来调用vm_mmap_pgoff ->do_mmap。
  • do_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。这个函数中:

  • 找到缺页区域对应的struct vm_area_struct。
  • 然后调用handle_mm_fault->__handle_mm_fault。
  • __handle_mm_fault中首先会创建前面一直提的页表,然后调用handle_pte_fault。
  • handle_pte_fault中有三种情况:
  • 1.PTE表为空,说明是缺页(新的)

      • 如果映射到物理内存就调用do_anonymous_page。
      • 如果映射到文件就调用do_fault。

    2.PTE表不为空,说明页表创建过了,是被换出到磁盘的就调用do_swap_page。

    一个个分析,首先看映射到物理页的函数do_anonymous_page:

  • 调用pte_alloc分配一个页表。
  • 页表有了,就要申请一个物理页放到页表项里了,调用链是alloc_zeroed_user_highpage_movable ->__alloc_zeroed_user_highpage ->alloc_page_vma ->alloc_pages_vma ->__alloc_pages_nodemask。又看到熟悉的函数了__alloc_pages_nodemask就是前边说过的伙伴系统核心函数。
  • 调用mk_pte创建一个页表项并把物理页放进去,最后调用set_pte_at将页表项放入页表。至此页表里面有对应物理页了,虚拟地址就有映射了。
  • 再来看下映射到文件的函数do_fault:

  • do_fault也有几种不同情况但最终都会调到__do_fault。
  • __do_fault中会调用vma->vm_ops->fault接口,之前文件映射是说过在缺页之前已经将内存操作接口换成文件操作接口了,所以如果是ext4文件系统,这里的vm_ops就应该是ext4_file_vm_ops,也就是调用了ext4_filemap_fault。紧接着调用filemap_fault。
  • filemap_fault首先调用find_get_page查找一下物理内存里事先有没有缓存好的,如果找到了就调用do_async_mmap_readahead从文件中预读一些数据到内存。没有的话就调用pagecache_get_page分配一个物理页并且把物理页加到LRU表里,然后调用struct address_space *mapping->a_ops->readpage接口将文件内容缓存到物理页中。ext4文件系统readpage接口对应ext4_readpage,这个函数又调用到ext4_readpage_inline ->ext4_read_inline_page。
  • ext4_read_inline_page中首先调用kmap_atomic映射到内核的虚拟地址空间得到虚拟地址kaddr,本来的目的是将物理内存映射到用户虚拟地址空间,但是从文件读取内容缓存到物理内存又不能用物理地址(除了内存管理模块其他操作都得是虚拟地址),所以这里kaddr只是临时虚拟地址,读取完再把kaddr取消就行。
  • 最后一种是交换空间类型的,函数do_swap_page,swap类型的和映射到文件的差不多,都是需要从把磁盘文件映射到内存。

  • 首先调用lookup_swap_cache查看swap文件在内存有没有缓存页,有就直接用,没有就调用swapin_readahead将swap文件读到内存页中缓存,再调用mk_pte创建页表项,调用set_pte_at将页表项放入页表。
  • 读swap文件过程和上一步映射到文件的差不多。
  • 调用swap_free释放掉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位网友表示赞同!

    爱到伤肺i

    内存池的设计思路很有意思,我会尝试在后续项目中使用它来优化内存分配效率,降低频繁的 malloc 和 free 操作带来的系统开销。文章给我的启发不少啊!

        有10位网友表示赞同!

    殃樾晨

    我之前对内存管理比较困惑,读了这篇文章终于豁然开朗!作者把概念阐释得非常清晰,并结合一些实用案例,更容易理解相关知识点。强烈推荐给正在学习C/C++的同学!

        有7位网友表示赞同!

    像从了良

    这篇博客文章内容很丰富,能够让我更加深入地了解内存管理设计的各个方面。建议后期可以添加更多具体的代码实现示例,更有助于我们深入学习和实践。

        有8位网友表示赞同!

    一笑傾城゛

    虽然文章说的不错,但我感觉还是比较理论化,缺乏一些更实用的场景分析和应用案例,对我来说有点不够吸引力。

        有6位网友表示赞同!

    淡抹丶悲伤

    我喜欢这种详细的讲解风格,把一个复杂的概念拆解成易于理解的小部分,并分别解释,真的很 hilfreich 。希望作者能够继续更新更多的相关内容!

        有19位网友表示赞同!

    珠穆郎马疯@

    这篇博客文章对于想要深入学习内存管理设计的人来说非常有价值,它不仅阐述了基础概念,还介绍了一些更高级的策略和技巧。不过,对于初学者来说,可能有一些部分比较晦涩难懂。

        有15位网友表示赞同!

    呆萌

    我认为文章标题写得不错,能很准确地概括文章内容。希望作者能够在后续的博文中继续深入探讨内存管理相关的精彩话题!

        有8位网友表示赞同!

    巴黎盛开的樱花

    看了这篇文章,我对内存分配和回收机制有了更清晰的认识。以后开发过程中会更加注意这些细节,提高程序效率和稳定性。

        有6位网友表示赞同!

    在哪跌倒こ就在哪躺下

    我觉得对嵌入式系统设计、游戏开发等领域来说,理解内存管理尤为重要,这篇博客文章能够帮助我更好地掌握相关知识点,非常实用!

        有12位网友表示赞同!

    热点资讯