深入理解 Linux 物理内存分配全链路实现

目录

内核物理内存分配接口

物理内存分配内核源码实现

 内存分配的心脏 __alloc_pages

prepare_alloc_pages

内存慢速分配入口 alloc_pages_slowpath

 总结


内核物理内存分配接口

在物理内存分配成功的情况下, alloc_pages,alloc_page 函数返回的都是指向其申请的物理内存块第一个物理内存页 struct page 指针。大家可以直接理解成返回的是一块物理内存,而 CPU 可以直接访问的却是虚拟内存,所以内核又提供了一个函数 __get_free_pages ,该函数直接返回物理内存页的虚拟内存地址。用户可以直接使用。

物理内存分配内核源码实现

在介绍 Linux 内核关于内存分配的源码实现之前,我们需要先找到内存分配的入口函数在哪里,在上小节中为大家介绍的众多内存分配接口的依赖层级关系如下图所示

深入理解 Linux 物理内存分配全链路实现_第1张图片

我们看到内存分配的任务最终会落在 alloc_pages 这个接口函数中,在 alloc_pages 中会调用 alloc_pages_node 进而调用 __alloc_pages_node 函数,最终通过 __alloc_pages 函数正式进入内核内存分配的世界~~

__alloc_pages 函数为 Linux 内核内存分配的核心入口函数      

 内存分配的心脏 __alloc_pages

/*
 * This is the 'heart' of the zoned buddy allocator.
 */
struct page *__alloc_pages(gfp_t gfp, unsigned int order, int preferred_nid,
                            nodemask_t *nodemask)
{
    // 用于指向分配成功的内存
    struct page *page;
    // 内存区域中的剩余内存需要在 WMARK_LOW 水位线之上才能进行内存分配,否则失败(初次尝试快速内存分配)
    unsigned int alloc_flags = ALLOC_WMARK_LOW;
    // 之前小节中介绍的内存分配掩码集合
    gfp_t alloc_gfp; 
    // 用于在不同内存分配辅助函数中传递参数
    struct alloc_context ac = { };

    // 检查用于向伙伴系统申请内存容量的分配阶 order 的合法性
    // 内核定义最大分配阶 MAX_ORDER -1 = 10,也就是说一次最多只能从伙伴系统中申请 1024 个内存页。
    if (WARN_ON_ONCE_GFP(order >= MAX_ORDER, gfp))
        return NULL;
    // 表示在内存分配期间进程可以休眠阻塞
    gfp &= gfp_allowed_mask;

    alloc_gfp = gfp;
    // 初始化 alloc_context,并为接下来的快速内存分配设置相关 gfp
    if (!prepare_alloc_pages(gfp, order, preferred_nid, nodemask, &ac,
            &alloc_gfp, &alloc_flags))
        // 提前判断本次内存分配是否能够成功,如果不能则尽早失败
        return NULL;

    // 避免内存碎片化的相关分配标识设置,可暂时忽略
    alloc_flags |= alloc_flags_nofragment(ac.preferred_zoneref->zone, gfp);

    // 内存分配快速路径:第一次尝试从底层伙伴系统分配内存,注意此时是在 WMARK_LOW 水位线之上分配内存
    page = get_page_from_freelist(alloc_gfp, order, alloc_flags, &ac);
    if (likely(page))
        // 如果内存分配成功则直接返回
        goto out;
    // 流程走到这里表示内存分配在快速路径下失败
    // 这里需要恢复最初的内存分配标识设置,后续会尝试更加激进的内存分配策略
    alloc_gfp = gfp;
    // 恢复最初的 node mask 因为它可能在第一次内存分配的过程中被改变
    // 本函数中 nodemask 起初被设置为 null
    ac.nodemask = nodemask;

    // 在第一次快速内存分配失败之后,说明内存已经不足了,内核需要做更多的工作
    // 比如通过 kswap 回收内存,或者直接内存回收等方式获取更多的空闲内存以满足内存分配的需求
    // 所以下面的过程称之为慢速分配路径
    page = __alloc_pages_slowpath(alloc_gfp, order, &ac);

out:
    // 内存分配成功,直接返回 page。否则返回 NULL
    return page;
}
  • 首先内核会尝试在内存水位线 WMARK_LOW 之上快速的进行一次内存分配。这一点我们从开始的 unsigned int alloc_flags = ALLOC_WMARK_LOW 语句中可以看得出来。

  • 校验本次内存分配指定伙伴系统的分配阶 order 的有效性,伙伴系统在内核中的最大分配阶定义在 /include/linux/mmzone.h 文件中,最大分配阶 MAX_ORDER -1  = 10,也就是说一次最多只能从伙伴系统中申请 1024 个内存页,对应 4M 大小的连续物理内存。

  • 调用 prepare_alloc_pages 初始化 alloc_context ,用于在不同内存分配辅助函数中传递内存分配参数。为接下来即将进行的快速内存分配做准备。

  • struct alloc_context {
        // 运行进程 CPU 所在 NUMA  节点以及其所有备用 NUMA 节点中允许内存分配的内存区域
        struct zonelist *zonelist;
        // NUMA  节点状态掩码
        nodemask_t *nodemask;
        // 内存分配优先级最高的内存区域 zone
        struct zoneref *preferred_zoneref;
        // 物理内存页的迁移类型分为:不可迁移,可回收,可迁移类型,防止内存碎片
        int migratetype;
    
        // 内存分配最高优先级的内存区域 zone
        enum zone_type highest_zoneidx;
        // 是否允许当前 NUMA 节点中的脏页均衡扩散迁移至其他 NUMA 节点
        bool spread_dirty_pages;
    };

  • 调用 get_page_from_freelist 方法首次尝试在伙伴系统中进行内存分配,这次内存分配比较快速,只是快速的扫描一下各个内存区域中是否有足够的空闲内存能够满足本次内存分配,如果有则立马从伙伴系统中申请,如果没有立即返回, page 设置为 null,进行后续慢速内存分配处理。

  • 这里需要注意的是:首次尝试的快速内存分配是在 WMARK_LOW 水位线之上进行的。

  • 当快速内存分配失败之后,情况就会变得非常复杂,内核将不得不做更多的工作,比如开启 kswapd 进程异步内存回收,更极端的情况则需要进行直接内存回收,或者直接内存整理以获取更多的空闲连续内存。这一切的复杂逻辑全部封装在 __alloc_pages_slowpath 函数中。

  • 深入理解 Linux 物理内存分配全链路实现_第2张图片

    总体流程介绍完之后,我们接着来看一下以上内存分配过程涉及到的三个重要内存分配辅助函数:prepare_alloc_pages,__alloc_pages_slowpath,get_page_from_freelist

prepare_alloc_pages

prepare_alloc_pages 初始化 alloc_context ,用于在不同内存分配辅助函数中传递内存分配参数,为接下来即将进行的快速内存分配做准备。

prepare_alloc_pages 主要的任务就是在快速内存分配开始之前,做一些准备初始化的工作,其中最核心的就是从指定 NUMA 节点中,根据  gfp_mask 掩码中的内存区域修饰符获取可以进行内存分配的所有内存区域 zone (包括其他备用 NUMA 节点中包含的内存区域)。

NUMA 节点的数据结构 struct pglist_data。struct pglist_data 结构中不仅包含了本 NUMA 节点中的所有内存区域,还包括了其他备用 NUMA 节点中的物理内存区域,当本节点中内存不足的情况下,内核会从备用 NUMA 节点中的内存区域进行跨节点内存分配。

我们可以根据 nid 和 gfp_mask 掩码中的物理内存区域描述符利用 node_zonelist 函数一次性获取允许进行内存分配的所有内存区域(所有 NUMA 节点)。

内存慢速分配入口 alloc_pages_slowpath

alloc_pages_slowpath 函数非常的复杂,其中包含了内存分配的各种异常情况的处理,并且会根据前边介绍的 GFP_,ALLOC_ 等各种内存分配策略掩码进行不同分支的处理,这样就变得非常的庞大而繁杂。

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
                        struct alloc_context *ac)
{
        ......... 初始化慢速内存分配路径下的相关参数 .......

retry_cpuset:

        ......... 调整内存分配策略 alloc_flags 采用更加激进方式获取内存 ......
        ......... 此时内存分配主要是在进程所允许运行的 CPU 相关联的 NUMA 节点上 ......
        ......... 内存水位线下调至 WMARK_MIN ...........
        ......... 唤醒所有 kswapd 进程进行异步内存回收  ...........
        ......... 触发直接内存整理 direct_compact 来获取更多的连续空闲内存 ......

retry:

        ......... 进一步调整内存分配策略 alloc_flags 使用更加激进的非常手段进行内存分配 ...........
        ......... 在内存分配时忽略内存水位线 ...........
        ......... 触发直接内存回收 direct_reclaim ...........
        ......... 再次触发直接内存整理 direct_compact ...........
        ......... 最后的杀手锏触发 OOM 机制  ...........

nopage:
        ......... 经过以上激进的内存分配手段仍然无法满足内存分配就会来到这里 ......
        ......... 如果设置了 __GFP_NOFAIL 不允许内存分配失败,则不停重试上述内存分配过程 ......

fail:
        ......... 内存分配失败,输出告警信息 ........

      warn_alloc(gfp_mask, ac->nodemask,
            "page allocation failure: order:%u", order);
got_pg:
        ......... 内存分配成功,返回新申请的内存块 ........

      return page;
}

内存分配在慢速路径下所需要的相关参数 :

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
                        struct alloc_context *ac)
{
    // 在慢速内存分配路径中可能会导致内核进行直接内存回收
    // 这里设置 __GFP_DIRECT_RECLAIM 表示允许内核进行直接内存回收
    bool can_direct_reclaim = gfp_mask & __GFP_DIRECT_RECLAIM;
    // 本次内存分配是否是针对大量内存页的分配,内核定义 PAGE_ALLOC_COSTLY_ORDER = 3
    // 也就是说内存请求内存页的数量大于 2 ^ 3 = 8 个内存页时,costly_order = true,后续会影响是否进行 OOM
    const bool costly_order = order > PAGE_ALLOC_COSTLY_ORDER;
    // 用于指向成功申请的内存
    struct page *page = NULL;
    // 内存分配标识,后续会根据不同标识进入到不同的内存分配逻辑处理分支
    unsigned int alloc_flags;
    // 后续用于记录直接内存回收了多少内存页
    unsigned long did_some_progress;
    // 关于内存整理相关参数
    enum compact_priority compact_priority;
    enum compact_result compact_result;
    int compaction_retries;
    // 记录重试的次数,超过一定的次数(16次)则内存分配失败
    int no_progress_loops;
    // 临时保存调整后的内存分配策略
    int reserve_flags;

    // 流程现在来到了慢速内存分配这里,说明快速分配路径已经失败了
    // 内核需要对 gfp_mask 分配行为掩码做一些修改,修改为一些更可能导致内存分配成功的标识
    // 因为接下来的直接内存回收非常耗时可能会导致进程阻塞睡眠,不适用原子 __GFP_ATOMIC 内存分配的上下文。
    if (WARN_ON_ONCE((gfp_mask & (__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)) ==
                (__GFP_ATOMIC|__GFP_DIRECT_RECLAIM)))
        gfp_mask &= ~__GFP_ATOMIC;

retry_cpuset:

retry:

nopage:

fail:

got_pg:

alloc_pages_slowpath 的内存分配逻辑:

static inline struct page *
__alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,
                        struct alloc_context *ac)
{
        ......... 初始化慢速内存分配路径下的相关参数 .......

retry_cpuset:

    // 在之前的快速内存分配路径下设置的相关分配策略比较保守,不是很激进,用于在 WMARK_LOW 水位线之上进行快速内存分配
    // 走到这里表示快速内存分配失败,此时空闲内存严重不足了
    // 所以在慢速内存分配路径下需要重新设置更加激进的内存分配策略,采用更大的代价来分配内存
    alloc_flags = gfp_to_alloc_flags(gfp_mask);

    // 重新按照新的设置按照内存区域优先级计算 zonelist 的迭代起点(最高优先级的 zone)
    // fast path 和 slow path 的设置不同所以这里需要重新计算
    ac->preferred_zoneref = first_zones_zonelist(ac->zonelist,
                    ac->highest_zoneidx, ac->nodemask);
    // 如果没有合适的内存分配区域,则跳转到 nopage , 内存分配失败
    if (!ac->preferred_zoneref->zone)
        goto nopage;
    // 唤醒所有的 kswapd 进程异步回收内存
    if (alloc_flags & ALLOC_KSWAPD)
        wake_all_kswapds(order, gfp_mask, ac);

    // 此时所有的 kswapd 进程已经被唤醒,正在异步进行内存回收
    // 之前我们已经在 gfp_to_alloc_flags 方法中重新调整了 alloc_flags
    // 换成了一套更加激进的内存分配策略,注意此时是在 WMARK_MIN 水位线之上进行内存分配
    // 调整后的 alloc_flags 很可能会立即成功,因此这里先尝试一下
    page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
    if (page)
        // 内存分配成功,跳转到 got_pg 直接返回 page
        goto got_pg;

    // 对于分配大内存来说 costly_order = true (超过 8 个内存页),需要首先进行内存整理,这样内核可以避免直接内存回收从而获取更多的连续空闲内存页
    // 对于需要分配不可移动的高阶内存的情况,也需要先进行内存整理,防止永久内存碎片
    if (can_direct_reclaim &&
            (costly_order ||
               (order > 0 && ac->migratetype != MIGRATE_MOVABLE))
            && !gfp_pfmemalloc_allowed(gfp_mask)) {
        // 进行直接内存整理,获取更多的连续空闲内存防止内存碎片
        page = __alloc_pages_direct_compact(gfp_mask, order,
                        alloc_flags, ac,
                        INIT_COMPACT_PRIORITY,
                        &compact_result);
        if (page)
            goto got_pg;

        if (costly_order && (gfp_mask & __GFP_NORETRY)) {
            // 流程走到这里表示经过内存整理之后依然没有足够的内存供分配
            // 但是设置了 NORETRY 标识不允许重试,那么就直接失败,跳转到 nopage
            if (compact_result == COMPACT_SKIPPED ||
                compact_result == COMPACT_DEFERRED)
                goto nopage;
            // 同步内存整理开销太大,后续开启异步内存整理
            compact_priority = INIT_COMPACT_PRIORITY;
        }
    }

retry:

nopage:

fail:

got_pg:
    return page;
}

之前我们介绍到快速分配路径是在  WMARK_LOW 水位线之上进行内存分配,与其相配套的内存分配策略比较保守,目的是快速的在各个内存区域 zone 之间搜索可供分配的空闲内存。快速分配路径下的失败意味着此时系统中的空闲内存已经不足了,所以在慢速分配路径下内核需要改变内存分配策略,采用更加激进的方式来进行内存分配,

  • 首先会把内存分配水位线降低到 WMARK_MIN 之上,然后将内存分配策略调整为更加容易促使内存分配成功的策略。
  • 当剩余内存处于 WMARK_MIN 与 WMARK_LOW 之间时,内核会唤醒所有 kswapd 进程来异步回收内存,直到剩余内存重新回到水位线 WMARK_HIGH 之上。
  • 到目前为止,内核已经在慢速分配路径下通过 gfp_to_alloc_flags 调整为更加激进的内存分配策略,并将水位线降低到 WMARK_MIN,同时也唤醒了 kswapd 进程来异步回收内存。
  • 此时在新的内存分配策略下进行内存分配很可能会一次性成功,所以内核会首先尝试进行一次内存分配。
  • 如果首次尝试分配内存失败之后,内核就需要进行直接内存整理 direct_compact (整理脆片)来获取更多的可供分配的连续内存页。
  • 如果经过 direct_compact 之后依然没有足够的内存可供分配,那么就会进入 retry 分支采用更加激进的方式来分配内存。如果内存分配策略设置了 __GFP_NORETRY 表示不允许重试,那么就会直接失败,流程跳转到 nopage 分支进行处理。
  • 内存分配流程来到 retry 分支这里说明情况已经变得非常危急了,在经过 retry_cpuset 分支的处理,内核将内存水位线下调至 WMARK_MIN,并开启了 kswapd 进程进行异步内存回收,触发直接内存整理 direct_compact,在采取了这些措施之后,依然无法满足内存分配的需求。

  • 内核会近一步采取更加激进的非常手段来获取连续的空闲内存,一开始需要调用 __gfp_pfmemalloc_flags 函数来重新调整内存分配策略,调整后的策略为:后续内存分配会忽略水位线的影响,并且允许内核从紧急预留内存中获取内存。

  • 在调整好更加激进的内存分配策略 alloc_flags 之后,内核会首先尝试从伙伴系统中进行一次内存分配,这时会有很大概率促使内存分配成功。

  • 如果在忽略内存水位线的情况下,内存依然分配失败,则进行直接内存回收 direct_reclaim 。

  • 经过 direct_reclaim 之后,仍然没有足够的内存可供分配的话,那么内核会再次进行直接内存整理  direct_compact 。

  • 如果 direct_compact 之后还是没有足够的内存,那么现在内核已经处于绝境了,是时候使用杀手锏:触发 OOM 机制杀死得分最高的进程以获取更多的空闲内存。

下面内核也不会直接开始 OOM,而是进入到重试流程,在重试流程开始之前内核需要调用 should_reclaim_retry 判断是否应该进行重试,重试标准:

  1. 如果内核已经重试了 MAX_RECLAIM_RETRIES (16) 次仍然失败,则放弃重试执行后续 OOM。

  2. 如果内核将所有可选内存区域中的所有可回收页面全部回收之后,仍然无法满足内存的分配,那么放弃重试执行后续 OOM。

到现在为止,内核已经尝试了包括 OOM 在内的所有回收内存的措施,但是仍然没有足够的内存来满足分配要求,看上去此次内存分配就要宣告失败了。但是这里还有一定的回旋余地,如果内存分配策略中配置了 __GFP_NOFAIL,则表示此次内存分配非常的重要,不允许失败。内核会在这里不停的重试直到分配成功为止。当 CPU 自己所在的本地 NUMA 节点内存不足时,CPU 就需要跨 NUMA 节点去访问其他内存节点,这种跨 NUMA 节点分配内存的行为就发生在这里,这种情况下 CPU 访问内存就会慢很多

这里笔者需要着重强调的一点就是,在 nopage 分支中决定开始重试之前,内核不能立即进行重试流程,因为之前已经经历过那么多严格激进的内存回收策略仍然没有足够的内存,内存现状非常紧急。

所以我们有理由相信,如果内核立即开始重试的话,依然没有什么效果,反而会浪费过多时间在搜索空闲内存上,导致其他进程处于饥饿状态。

所以在开始重试之前,内核会调用 cond_resched() 让 CPU 重新调度到其他进程上,让其他进程也运行一会,与此同时 kswapd 进程一直在后台异步回收着内存。

当 CPU 重新调度回当前进程时,说不定 kswapd 进程已经回收了足够多的内存,重试成功的概率会大大增加同时又避免了资源的无谓消耗。

 总结

结合 Linux 内核 5.19 版本源码详细讨论了物理内存分配在内核中的整个链路实现。在整个链路中,内存的分配整体分为了两个路径:

  1. 快速路径 fast path:该路径的下,内存分配的逻辑比较简单,主要是在 WMARK_LOW 水位线之上快速的扫描一下各个内存区域中是否有足够的空闲内存能够满足本次内存分配,如果有则立马从伙伴系统中申请,如果没有立即返回。

  2. 慢速路径 slow path:慢速路径下的内存分配逻辑就变的非常复杂了,其中包含了内存分配的各种异常情况的处理,并且会根据文中介绍的 GFP_,ALLOC_ 等各种内存分配策略掩码进行不同分支的处理,整个链路非常庞大且繁杂。

参考文献

深入理解 Linux 物理内存分配全链路实现

你可能感兴趣的:(liunx内核,linux,服务器,缓存,云计算)