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
感谢大佬的分享!
很想知道大佬是干什么的,感觉涉猎广且深。