cgroup 内存管理之 page cache 回收

page cache 的管理,是内核内存管理里最复杂的一块,也是容器混部场景下,问题最多的地方
我们这里只关注读 cache 的处理,脏页的控制单独讲。所以这篇文章里,无特殊说明 page cache 默认不包括脏页部分
当我们谈到 page cache 时, 我们会关注什么?
有以下几个关键的点
  1. 什么时机会触发 page cache 回收?
  2. 回收过程是什么样的
  3. 不可回收的页面有哪些?
  4. 不容易回收的页面有哪些?
  5. 回收力度如何控制
接下来,我们就这几点,来讲一讲 page cache 的一些内核实现内幕。以及混部场景下,可能会遇到的一些坑
实际上,不同的回收方式,其时机、回收的页面范围、力度、算法都稍有不同,所以下面我们将按照不同的回收方式来详细讲

1. 整机 drop_caches 回收

内核接口 /proc/sys/vm/drop_caches
内核的代码实现入口在 fs/drop_caches.c 里面
这个接口支持3种方式:
  1. echo 1,清理 page cache
  2. echo 2,清理 slab,比如 dentry cache 通常也很消耗内存
  3. echo 3,两种都清理
我们这里只讨论方式1

1.1. 回收时机、力度、算法

只有人为的 echo xx > /proc/sys/vm/drop_caches 时,才会触发 page cache 回收
每次触发 drop_caches,基本上都会把系统能回收的 clean page 一次性全部回收回来,注意,是全部能回收的
所以,这里其实也没有什么的特殊的回收算法了,简单全遍历就完了

1.2. 回收过程

内核代码 fs/drop_caches.c
简单来说,就是
  1. 遍历所有的超级块,super_block
  2. 遍历每个超级块上的所有 inode 对象
  3. 根据 inode->i_mapping 找到每个 inode 的 address_space 空间
  4. 遍历 address_space 下的所有 page
    1. 将 page 从 radix tree 上删除
    2. 调用文件系统的 releasepage 函数释放文件系统资源。这个可以忽略,我看 fs/* 几乎所有文件系统都不实现这个函数了
  5. 释放所有能释放的 page 内存(引用计数为0)
核心逻辑的调用栈如下:
  • drop_caches_sysctl_handler
    • iterate_supers(drop_pagecache_sb, NULL)
      • drop_pagecache_sb, list_for_each_entry(inode, &sb->s_inodes, i_sb_list)
        • invalidate_mapping_pages(inode->i_mapping, 0, -1) // 这个函数的实现在 mm/truncate.c 文件里,Invalidate all the unlocked pages of one inode
          • invalidate_inode_page(page) for page in pagevec_lookup_entries(&pvec)
            • invalidate_complete_page() 删除page的mapping,并从 page cache 的radix-tree 里面剔除,因为下一步就直接 free 内存了
        • pagevec_release(&pvec) // 释放所有的 page 内存空间

1.3. 回收范围

drop_caches 是一个非常轻量级的回收过程,只回收能够立即释放的 page
从 invalidate_inode_page() 我们可以看到,有3种页面,是不会被回收的:
  1. 脏页
  2. 正在回写的页
  3. mmap + MAP_SHARED 方式映射到 page table 的页
  4. PG_SyncReadahead 需要多次drop才能回收
int invalidate_inode_page(struct page *page)
{
        struct address_space *mapping = page_mapping(page);
        if (!mapping)
                return 0;
        if (PageDirty(page) || PageWriteback(page))
                return 0;
        if (page_mapped(page))
                return 0;
        return invalidate_complete_page(mapping, page);
}
注意,page_mapping() 和 page_mapped() 不是一个东西。另外,!mapping 这段代码我没看懂是过滤的啥?
page_mapping() 返回 page 的 address_space,读的是 page->mapping 信息
(1)返回 NULL,说明该页要么是 slab cache,要么是 anon
(2)返回非空,可能是 swap_address_space(),或者就是正常页所在的一个 address_space
struct address_space *page_mapping(struct page *page)
{
        struct address_space *mapping;
        page = compound_head(page);
        /* This happens if someone calls flush_dcache_page on slab page */
        if (unlikely(PageSlab(page)))
                return NULL;
        if (unlikely(PageSwapCache(page))) {
                swp_entry_t entry;

                entry.val = page_private(page);
                return swap_address_space(entry);
        }
        mapping = page->mapping;
        if ((unsigned long)mapping & PAGE_MAPPING_ANON)
                return NULL;
        return (void *)((unsigned long)mapping & ~PAGE_MAPPING_FLAGS);
}

而 page_mapped 是用来判断 page 是否在 page table 里面。这里用 page_mapped() 主要是用来判断当前 page 是否是一个 mmap + MAP_SHARED 产生的页(因为 MAP_PRIVATE 产生的页不会填充到 page table 里面,具体可以自己看下代码)

2. 整机 min 触发回收

当机器内存,或者 node 内存,free 小于 min 水线的时候,min_free_kbytes
机器上所有进程申请内存,都会直接进入 direct reclaim 慢路径模式

3. 容器 memory.drop_cache 触发回收

cgroup 的 memory.drop_cache 是一种容器级别的 page cache 回收机制
这个其实是我们自己研发的,主要是为了解决混部场景下,可控的回收一些低优先级的容器的 page cache
基本上搞混部都得遇到这个问题,每个公司的解决方案也类似,这里简单讲一下
方法很简单,和整机的 drop_caches 完全一样的处理流程,只不过在处理每个 page 的时候,判断一下当前 page 是不是属于当前正在处理的 mem_cgroup,即可
核心调用栈:
  • iterate_supers(drop_pagecache_sb, NULL)
    • drop_pagecache_sb, list_for_each_entry(inode, &sb->s_inodes, i_sb_list)
      • invalidate_memcg_mapping_pages
xx

4. 容器 memory.high 触发回收

memory.high 是 cgroup v2 提供的一种新的机制
memory.high 的作用很简单,一旦 cgroup 的内存使用达到 memory.high,进程会立即进入内存回收满路径,并根据超过的内存大小 sleep 一段时间,通常是2ms
memory.high 的核心逻辑在 try_charge() 函数里,也就是进程申请内存结束之后,开始 try_charge() 计数的时候
核心调用栈
  • try_charge
    • schedule_work(&memcg->high_work) -> high_work_func -> reclaim_high
      • try_to_free_mem_cgroup_pages(memcg, nr_pages, gfp_mask, true),true表示可以 swap,这个函数的主要作用其实就是选一个 node 开始回收
        • do_try_to_free_pages 没啥用
          • shrink_zones() 没啥用
            • shrink_node() 具体回收页面的逻辑
    • set_notify_resume -> 当前进程返回用户态的时候,会 sleep 一段时间
注意,shrink_zones 里有一个 global_reclaim(sc) 的函数,主要是用来判断是不是要从根上回收。由于我们从 cgroup 里面回收,所以 global_reclaim 一定是 false 的,if () {} 的代码忽略即可

4.1. reclaim_high 回收过程

函数首先通过 mem_cgroup_select_victim_node 随便选一个 node 来开始回收内存(函数内部的实现其实是用了 round robin 算法,所有 node 轮流来,公平)
不同于 alloc_pages() 时内存不足触发的 direct reclaim,因为分配内存要考虑亲缘性,所以分配的内存在哪个 node 上就在哪个 node 上开始回收
这里的目的只是回收一部分内存,不管在哪都可以
每次回收 #define MEMCG_CHARGE_BATCH 32 个 pages,由于 memory.high 只是限制当前容器的内存分配,不影响其他人,并且回收的 page 太少,所以其实也不用太讲究具体的回收算法,大概知道就行了
static void reclaim_high(struct mem_cgroup *memcg,
                         unsigned int nr_pages,
                         gfp_t gfp_mask)
{
        do {
                if (page_counter_read(&memcg->memory) <= memcg->high)
                        continue;
                memcg_memory_event(memcg, MEMCG_HIGH);
                try_to_free_mem_cgroup_pages(memcg, nr_pages, gfp_mask, true);
        } while ((memcg = parent_mem_cgroup(memcg)));
}

static void high_work_func(struct work_struct *work)
{
        struct mem_cgroup *memcg;

        memcg = container_of(work, struct mem_cgroup, high_work);
        reclaim_high(memcg, MEMCG_CHARGE_BATCH, GFP_KERNEL);
}

unsigned long try_to_free_mem_cgroup_pages(struct mem_cgroup *memcg,
                                           unsigned long nr_pages,
                                           gfp_t gfp_mask,
                                           bool may_swap)
{
        /* ... */
        struct scan_control sc = {
                /* 注意,这个宏默认也是 32U */
                .nr_to_reclaim = max(nr_pages, SWAP_CLUSTER_MAX),
                .reclaim_idx = MAX_NR_ZONES - 1,
                .target_mem_cgroup = memcg,
                .may_writepage = !laptop_mode,
                .may_unmap = 1,
                .may_swap = may_swap,
        };

        /*
         * Unlike direct reclaim via alloc_pages(), memcg's reclaim doesn't
         * take care of from where we get pages. So the node where we start the
         * scan does not need to be the current node.
         */
        nid = mem_cgroup_select_victim_node(memcg);

        zonelist = &NODE_DATA(nid)->node_zonelists[ZONELIST_FALLBACK];
        nr_reclaimed = do_try_to_free_pages(zonelist, &sc);
        
        return nr_reclaimed;
}

4.2. reclaim_high 回收范围

由于 memory.high 的机制是对进程做限速,目的是让进程进入内存较大的压力状态
所以 memory.high 的回收不管什么类型的 page cache,都会回收,包括 writeback,dirty,甚至 swap cache
上面 try_to_free_mem_cgroup_pages() 的 scan_control{} 结构可以看到

5. 容器 memory.max 触发回收

memory.max 是 cgroup v2 的接口,等价于 cgroup v1 的 memory.limit_in_bytes 接口
都是一个意思,设置容器的内存使用上限,俗称硬限
memory.max 触发回收分两个场景:
  1. 设置 memory.max 时触发
  2. 容器内进程分配内存超过 memory.max 时触发

5.1. 设置 memory.max 触发回收

核心函数是 memory_max_write,如果要设置的 max 值比当前容器的实际使用 memory.current 大,则触发回收,每次回收 memory.current – max 个页面,每次失败触发一次 OOM 过程,最多回收 MEM_CGROUP_RECLAIM_RETRIES = 5 次
static ssize_t memory_max_write(struct kernfs_open_file *of,
                                char *buf, size_t nbytes, loff_t off)
{
        /* ... */
        for (;;) {
                unsigned long nr_pages = page_counter_read(&memcg->memory);

                if (nr_pages <= max)
                        break;
                /* ... */
                if (nr_reclaims) {
                        if (!try_to_free_mem_cgroup_pages(memcg, nr_pages - max,
                                                          GFP_KERNEL, true))
                                nr_reclaims--;
                        continue;
                }

                memcg_memory_event(memcg, MEMCG_OOM);
                if (!mem_cgroup_out_of_memory(memcg, GFP_KERNEL, 0))
                        break;
        }
        /* ... */
        return nbytes;
}

5.2. 内存分配超过 memory.max 触发回收

通常我们说的内存,一般都是指 anon 和 page cache,这两种内存的分配方式是不一样的
anon 内存一般是通过缺页中断来分配的。没错,用户态进程每次 malloc 的时候,其实只不过是通过 mmap 分配了一个地址空间,如果这块地址空间没有任何的读写操作,其实是不产生任何物理内存开销的。
anon 的内存分配调用栈
  • handle_mm_fault -> __handle_mm_fault
    • handle_pte_fault
      • do_anonymous_page
      • do_fault
        • do_cow_fault
        • do_share_fault
不管是哪种 fault,还是直接 do_anonymous_page,最终都会进入到 mem_cgroup_try_charge() 函数,检查当前进程的内存使用是否超过了 memory.max
page cache 的内存分配也类似,主要是通过 __add_to_page_cache_locked() 时调用 mem_cgroup_try_charge(),代码都在 mm/filemap.c 里面

5.2.1. mem_cgroup_try_charge 过程

mem_cgroup_try_charge() 会找到当前进程的 mem_cgroup,然后调用
static int try_charge(struct mem_cgroup *memcg, gfp_t gfp_mask, unsigned int nr_pages)
由于 page 都是一个一个 charge 的,所以 nr_pages = 1
下面再来看看 try_charge 的核心逻辑
  1. 调用 page_counter_try_charge(),增加计数,成功 goto done_restock 直接返回,失败,往下走 oom 过程
  2. (这里开始往下都是 oom 过程了)找到哪个 cgroup 超了(往上找,把所有父节点全 check 一遍)
  3. 如果当前容器正在 oom,直接返回 ENOMEM,防止雪崩
  4. 回收一部分页面,不够继续回收,跳转到2
  5. 检查重试次数,跳转到2
  6. oom 处理
static int try_charge(struct mem_cgroup *memcg, gfp_t gfp_mask,
                      unsigned int nr_pages)
{
        /* ... */
retry:
        if (!do_memsw_account() ||
            page_counter_try_charge(&memcg->memsw, batch, &counter)) {
                /* 这里开始统计,成功直接 goto 返回了 */
                if (page_counter_try_charge(&memcg->memory, batch, &counter))
                        goto done_restock;
                /* 失败,找到哪个 cgroup 超了(往上找,把所有父节点全 check 一遍)*/
                mem_over_limit = mem_cgroup_from_counter(counter, memory);
        } else {
                mem_over_limit = mem_cgroup_from_counter(counter, memsw);
                may_swap = false;
        }

        if (batch > nr_pages) {
                batch = nr_pages;
                goto retry;
        }

        /* 如果当前 cgroup 正处于 oom 过程,那么 charge 立即失败,防止雪崩 */
        if (unlikely(task_in_memcg_oom(current)))
                goto nomem;

        /* 回收 nr_pages 个页面 */
        nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit, nr_pages,
                                                    gfp_mask, may_swap);
        /* 不够的话继续,重来一遍 */
        if (mem_cgroup_margin(mem_over_limit) >= nr_pages)
                goto retry;
        /* 只要能回收一点,就继续重拾一遍,因为有可能已经 free 了一些 page 了,这里
           如果不检查,效果就是既回收了 page 还会 kill 进程,不好 */
        if (nr_reclaimed && nr_pages <= (1 << PAGE_ALLOC_COSTLY_ORDER))
                goto retry;
        /* 重试次数限制 */
        if (nr_retries--)
                goto retry
        memcg_memory_event(mem_over_limit, MEMCG_OOM);

        mem_cgroup_oom(mem_over_limit, gfp_mask,
                       get_order(nr_pages * PAGE_SIZE));
        /* ... */
done_restock:
        /* ...
         这里其实还有一个 memory.high 的处理,和上面 memory.high 超限的处理
         一样,不讲了 */
        
        return 0;
}

6. 容器 page cache 后台回收机制

xx

7. 整机内存高低水位回收机制

xx

8. (重点)try_to_free_mem_cgroup_pages 回收算法

前面我们看到,其实很多地方的内存回收最终都会调用到 try_to_free_mem_cgroup_pages 这个函数,因为这个函数就是从 cgroup 里回收内存的入口
但是为什么在这里详细讲,是因为在容器超过 memory.max 回收时,这个逻辑是最需要讲究的。而前面讲的那些地方要么就象征性回收一点,要么全回收比如 drop_cache,逻辑简单粗暴
mem_cgroup 内存回收需要考虑几个关键的点:
  1. 回收力度,回收多少
  2. 从哪个容器回收?每个容器回收多少?
  3. 怎么保证回收的公平性?
调用栈:
  • try_to_free_mem_cgroup_pages 选一个 node 开始回收
    • do_try_to_free_pages 没啥用
      • shrink_zones
        • shrink_node node 级别回收
          • shrink_node_memcg node 上的容器回收

7.1. shrink_node 流程

shrink_node 函数的关键流程如下图:
注意:绿色的线表示在 direct reclaim 模式下可以提前结束回收过程
注意 direct reclaim(直接回收)和 kswapd reclaim(后台回收)的区别,前者回收够即可,后者会完整走一遍回收流程,回收固定量的 page cache
从回收流程来看:
cgroup 回收唯一比整机回收多了一个环节(黄色部分):先回收删除中的容器的page cache,以及回收优先级最小的子cgroup 的page cache
其他逻辑,都是一样的,都是遍历所有子 cgroup,调用 shrink_node_memcg。(整机其实也是一个cgroup,就是 cgroup 的根)

7.2. shrink_node_memcg 流程

shrink_node() 函数决定了整个回收的算法的大体逻辑,决定了什么时候,回收哪个容器
具体容器或者整机怎么回收,就取决于 shrink_node_memcg 函数了,这个函数决定了最关键的2个信息:
  1. 回收力度,回收多少 page cache
  2. 回收范围,哪些 page 会被回收
回收的过程基本上就是,遍历这个容器在 node 上的所有 lru 链表,对每个 lru 链表调用 shrink_active_list 或者 shrink_inactive_list 执行回收操作
/*
 * This is a basic per-node page freer.  Used by both kswapd and direct reclaim.
 */
static void shrink_node_memcg(struct pglist_data *pgdat, struct mem_cgroup *memcg,
                              struct scan_control *sc, unsigned long *lru_pages)
{
    /* ... */
    while (nr[LRU_INACTIVE_ANON] || nr[LRU_ACTIVE_FILE] ||
                                nr[LRU_INACTIVE_FILE]) {
        unsigned long nr_anon, nr_file, percentage;
        unsigned long nr_scanned;

        for_each_evictable_lru(lru) {
                if (nr[lru]) {
                        nr_to_scan = min(nr[lru], SWAP_CLUSTER_MAX);
                        nr[lru] -= nr_to_scan;

                        nr_reclaimed += shrink_list(lru, nr_to_scan,
                                                    lruvec, sc);
                }
        }
    }
    /* ... */
}

7.2.1. 回收力度 nr_to_reclaim

nr_to_reclaim 回收力度,初始值是通过 scan_control 传递进来的
我们回顾下前面的几个场景:
  1. page cache 分配超过 memory.max 上限,nr_to_reclaim 就是 1
  2. 匿名页分配超过 memory.max 上限,nr_to_reclaim 就是超过多少回收多少,所以 nr_to_reclaim = xx – max
除此之外,后台回收的时候,shrink_node_memcg 还有一个特殊的处理,遇到优先级低的 cgroup,回收力度会被放大
/* Try to reclaim more memory in the deleted and low priority cgroup */
if (current_is_kswapd() && !global_reclaim(sc)) {
        if (!mem_cgroup_online(memcg))
                nr_to_reclaim <<= 2;
        else if (enable_mem_cgroup_priority_reclaim(memcg) && get_mem_cgroup_reclaim_priority(memcg) < 0)
                nr_to_reclaim <<= 1;

        if (nr_to_reclaim < nr[LRU_INACTIVE_FILE]) nr_to_reclaim = max(nr_to_reclaim, nr[LRU_INACTIVE_FILE] >> ((DEF_PRIORITY - sc->priority) >> 1));
}

7.2.2. 回收范围

xx
发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注