page cache 的管理,是内核内存管理里最复杂的一块,也是容器混部场景下,问题最多的地方
我们这里只关注读 cache 的处理,脏页的控制单独讲。所以这篇文章里,无特殊说明 page cache 默认不包括脏页部分
当我们谈到 page cache 时, 我们会关注什么?
有以下几个关键的点
- 什么时机会触发 page cache 回收?
- 回收过程是什么样的
- 不可回收的页面有哪些?
- 不容易回收的页面有哪些?
- 回收力度如何控制
接下来,我们就这几点,来讲一讲 page cache 的一些内核实现内幕。以及混部场景下,可能会遇到的一些坑
实际上,不同的回收方式,其时机、回收的页面范围、力度、算法都稍有不同,所以下面我们将按照不同的回收方式来详细讲
1. 整机 drop_caches 回收
内核接口 /proc/sys/vm/drop_caches
内核的代码实现入口在 fs/drop_caches.c 里面
这个接口支持3种方式:
- echo 1,清理 page cache
- echo 2,清理 slab,比如 dentry cache 通常也很消耗内存
- echo 3,两种都清理
我们这里只讨论方式1
1.1. 回收时机、力度、算法
只有人为的 echo xx > /proc/sys/vm/drop_caches 时,才会触发 page cache 回收
每次触发 drop_caches,基本上都会把系统能回收的 clean page 一次性全部回收回来,注意,是全部能回收的
所以,这里其实也没有什么的特殊的回收算法了,简单全遍历就完了
1.2. 回收过程
内核代码 fs/drop_caches.c
简单来说,就是
- 遍历所有的超级块,super_block
- 遍历每个超级块上的所有 inode 对象
- 根据 inode->i_mapping 找到每个 inode 的 address_space 空间
- 遍历 address_space 下的所有 page
- 将 page 从 radix tree 上删除
- 调用文件系统的 releasepage 函数释放文件系统资源。这个可以忽略,我看 fs/* 几乎所有文件系统都不实现这个函数了
 
- 释放所有能释放的 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 内存了
 
 
- invalidate_inode_page(page) for page in pagevec_lookup_entries(&pvec)
- pagevec_release(&pvec) // 释放所有的 page 内存空间
 
- invalidate_mapping_pages(inode->i_mapping, 0, -1) // 这个函数的实现在 mm/truncate.c 文件里,Invalidate all the unlocked pages of one inode
 
- drop_pagecache_sb, list_for_each_entry(inode, &sb->s_inodes, i_sb_list)
 
- iterate_supers(drop_pagecache_sb, NULL)
1.3. 回收范围
drop_caches 是一个非常轻量级的回收过程,只回收能够立即释放的 page
从 invalidate_inode_page() 我们可以看到,有3种页面,是不会被回收的:
- 脏页
- 正在回写的页
- mmap + MAP_SHARED 方式映射到 page table 的页
- 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
 
 
- drop_pagecache_sb, list_for_each_entry(inode, &sb->s_inodes, i_sb_list)
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() 具体回收页面的逻辑
 
 
- shrink_zones() 没啥用
 
- do_try_to_free_pages 没啥用
 
- try_to_free_mem_cgroup_pages(memcg, nr_pages, gfp_mask, true),true表示可以 swap,这个函数的主要作用其实就是选一个 node 开始回收
- set_notify_resume -> 当前进程返回用户态的时候,会 sleep 一段时间
 
- schedule_work(&memcg->high_work) -> high_work_func -> reclaim_high
注意,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 触发回收分两个场景:
- 设置 memory.max 时触发
- 容器内进程分配内存超过 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
 
 
 
- handle_pte_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 的核心逻辑
- 调用 page_counter_try_charge(),增加计数,成功 goto done_restock 直接返回,失败,往下走 oom 过程
- (这里开始往下都是 oom 过程了)找到哪个 cgroup 超了(往上找,把所有父节点全 check 一遍)
- 如果当前容器正在 oom,直接返回 ENOMEM,防止雪崩
- 回收一部分页面,不够继续回收,跳转到2
- 检查重试次数,跳转到2
- 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 内存回收需要考虑几个关键的点:
- 回收力度,回收多少
- 从哪个容器回收?每个容器回收多少?
- 怎么保证回收的公平性?
调用栈:
- try_to_free_mem_cgroup_pages 选一个 node 开始回收
- do_try_to_free_pages 没啥用
- shrink_zones
- shrink_node node 级别回收
- shrink_node_memcg node 上的容器回收
 
 
- shrink_node node 级别回收
 
- shrink_zones
 
- do_try_to_free_pages 没啥用
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个信息:
- 回收力度,回收多少 page cache
- 回收范围,哪些 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 传递进来的
我们回顾下前面的几个场景:
- page cache 分配超过 memory.max 上限,nr_to_reclaim 就是 1
- 匿名页分配超过 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
	
感谢大佬的分享!
很想知道大佬是干什么的,感觉涉猎广且深。