PageCache与内存回收
章节概述
PageCache是Linux内核中至关重要的缓存机制,它显著提升了文件IO性能。本章将深入探讨PageCache的工作原理、脏页回写策略、内存回收机制,以及如何通过调优提升系统性能。
学习目标:
- 理解PageCache的工作机制和性能影响
- 掌握脏页回写的触发条件和策略
- 学会使用工具观测和调优PageCache
- 能够诊断和解决内存回收相关的性能问题
核心概念
1. PageCache架构
┌─────────────────────────────────────┐
│ 用户空间应用 │
│ read() / write() │
└──────────┬──────────────────────────┘
↓
┌─────────────────────────────────────┐
│ VFS层 │
└──────────┬──────────────────────────┘
↓
┌─────────────────────────────────────┐
│ PageCache │
│ ┌──────────────────────────────┐ │
│ │ Clean Pages (干净页) │ │
│ │ - 与磁盘内容一致 │ │
│ │ - 可以直接丢弃 │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ Dirty Pages (脏页) │ │
│ │ - 已修改未写入磁盘 │ │
│ │ - 需要回写后才能释放 │ │
│ └──────────────────────────────┘ │
└──────────┬──────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Block Layer / 磁盘 │
└─────────────────────────────────────┘
PageCache的作用:
- 读缓存:避免重复读取磁盘
- 写缓存:延迟写入,批量刷盘
- 预读优化:提前读取后续数据
- 减少IO次数:合并小IO为大IO
2. 脏页回写机制
触发条件:
1. 定时回写
- 每隔dirty_writeback_centisecs(默认5秒)
- 由pdflush/flush线程执行
2. 脏页比例超限
- dirty_ratio: 脏页占总内存比例上限
- dirty_background_ratio: 后台回写触发阈值
3. 手动同步
- sync系统调用
- fsync/fdatasync
4. 内存压力
- 内存不足时强制回写
回写策略:
优先级顺序:
1. 最老的脏页优先
2. 同一文件的脏页合并
3. 顺序IO优化
4. 避免写放大
3. 内存回收机制
回收层次:
┌─────────────────────────────────┐
│ 1. 回收PageCache (Clean Pages) │ ← 最容易
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ 2. 回写并回收Dirty Pages │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ 3. Swap匿名页 │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ 4. OOM Killer (杀进程) │ ← 最后手段
└─────────────────────────────────┘
kswapd守护进程:
- 后台回收进程,每个NUMA节点一个
- 当内存低于low水位时被唤醒
- 回收到high水位后休眠
4. 内存水位线
┌─────────────────────────────────┐
│ 可用内存区域 │ ← high水位
├─────────────────────────────────┤
│ │
│ │ ← low水位(唤醒kswapd)
├─────────────────────────────────┤
│ │ ← min水位(直接回收)
├─────────────────────────────────┤
│ 保留内存 │
└─────────────────────────────────┘
水位线说明:
- high:kswapd休眠的目标
- low:kswapd被唤醒的阈值
- min:触发直接回收,进程阻塞等待
源码解析
1. PageCache读取
关键文件: mm/filemap.c
// 从PageCache读取页
struct page *find_get_page(struct address_space *mapping, pgoff_t index)
{
struct page *page;
rcu_read_lock();
page = radix_tree_lookup(&mapping->page_tree, index);
if (page && !radix_tree_deref_retry(page)) {
if (!page_cache_get_speculative(page))
goto repeat;
// PageCache命中
if (unlikely(page != radix_tree_lookup(&mapping->page_tree, index))) {
put_page(page);
goto repeat;
}
}
rcu_read_unlock();
return page;
}
// 添加页到PageCache
int add_to_page_cache_lru(struct page *page, struct address_space *mapping,
pgoff_t index, gfp_t gfp_mask)
{
void *shadow = NULL;
int ret;
__SetPageLocked(page);
ret = __add_to_page_cache_locked(page, mapping, index, gfp_mask, &shadow);
if (unlikely(ret))
__ClearPageLocked(page);
else {
// 添加到LRU链表
lru_cache_add(page);
}
return ret;
}
2. 脏页标记和回写
关键文件: mm/page-writeback.c
// 标记页为脏
void __set_page_dirty(struct page *page, struct address_space *mapping)
{
unsigned long flags;
spin_lock_irqsave(&mapping->tree_lock, flags);
if (page->mapping) {
// 设置脏标志
radix_tree_tag_set(&mapping->page_tree, page->index,
PAGECACHE_TAG_DIRTY);
}
spin_unlock_irqrestore(&mapping->tree_lock, flags);
// 更新统计信息
__inc_zone_page_state(page, NR_FILE_DIRTY);
__inc_bdi_stat(mapping->backing_dev_info, BDI_RECLAIMABLE);
}
// 回写脏页
static int writeback_single_inode(struct inode *inode,
struct writeback_control *wbc)
{
struct address_space *mapping = inode->i_mapping;
long nr_to_write = wbc->nr_to_write;
int ret;
// 跳过没有脏页的inode
if (!mapping_tagged(mapping, PAGECACHE_TAG_DIRTY))
goto out;
// 回写脏页
ret = do_writepages(mapping, wbc);
// 同步元数据
if (wbc->sync_mode == WB_SYNC_ALL)
write_inode_now(inode, 1);
out:
return ret;
}
// 后台回写线程
static long wb_do_writeback(struct bdi_writeback *wb)
{
struct wb_writeback_work *work;
long wrote = 0;
while ((work = get_next_work_item(wb)) != NULL) {
// 执行回写工作
wrote += wb_writeback(wb, work);
// 完成工作项
wb_clear_pending(wb, work);
}
return wrote;
}
3. 内存回收
关键文件: mm/vmscan.c
// kswapd主循环
static int kswapd(void *p)
{
pg_data_t *pgdat = (pg_data_t*)p;
struct task_struct *tsk = current;
for (;;) {
bool was_frozen;
// 等待唤醒
wait_event_freezable(pgdat->kswapd_wait,
kswapd_should_run(pgdat));
// 执行内存回收
balance_pgdat(pgdat, order, classzone_idx);
}
return 0;
}
// 回收内存主函数
static unsigned long shrink_zone(struct zone *zone,
struct scan_control *sc)
{
unsigned long nr_reclaimed = 0;
// 扫描并回收不活跃页
nr_reclaimed += shrink_inactive_list(lruvec, sc);
// 扫描活跃页,将不活跃的移到不活跃链表
shrink_active_list(lruvec, sc);
return nr_reclaimed;
}
️ 实用命令
1. PageCache观测
查看系统内存使用:
# 总览
free -h
# 详细的PageCache信息
cat /proc/meminfo | grep -E "Cached|Dirty|Writeback"
# 输出示例:
# Cached: 8192000 kB # PageCache大小
# Dirty: 1024 kB # 脏页大小
# Writeback: 0 kB # 正在回写的页
实时监控脏页:
watch -n 1 'cat /proc/meminfo | grep -E "Dirty|Writeback"'
# 或使用自定义脚本
while true; do
date
cat /proc/meminfo | grep -E "Cached|Dirty|Writeback"
sleep 1
done
2. PageCache统计
使用vmstat:
vmstat 1
# 输出解释:
# bi: 从块设备读取的块数/秒
# bo: 写入块设备的块数/秒
# si: swap in
# so: swap out
# cache: PageCache大小
使用cachestat(eBPF工具):
# 安装bcc-tools
sudo apt install bpfcc-tools
# 实时监控PageCache命中率
sudo /usr/share/bcc/tools/cachestat 1
# 输出示例:
# HITS MISSES DIRTIES BUFFERS_MB CACHED_MB
# 1000 50 100 200 1000
3. 手动清理PageCache
清理前先同步:
# 同步所有脏页到磁盘
sync
清理PageCache:
# 清理PageCache
echo 1 | sudo tee /proc/sys/vm/drop_caches
# 清理dentries和inodes
echo 2 | sudo tee /proc/sys/vm/drop_caches
# 清理所有(PageCache + dentries + inodes)
echo 3 | sudo tee /proc/sys/vm/drop_caches
注意事项:
- 清理会导致短暂性能下降
- 通常不需要手动清理
- 主要用于性能测试时建立baseline
4. 脏页参数调整
查看当前参数:
sysctl -a | grep dirty
关键参数:
# 脏页占总内存的比例上限(默认20%)
vm.dirty_ratio = 20
# 后台回写触发阈值(默认10%)
vm.dirty_background_ratio = 10
# 回写间隔(默认5秒,单位:百分之一秒)
vm.dirty_writeback_centisecs = 500
# 脏页最长存活时间(默认30秒)
vm.dirty_expire_centisecs = 3000
调整参数:
# 降低脏页比例(适合数据库等场景)
sudo sysctl -w vm.dirty_ratio=10
sudo sysctl -w vm.dirty_background_ratio=5
# 更频繁的回写(提高数据安全性)
sudo sysctl -w vm.dirty_writeback_centisecs=100
# 永久生效(写入/etc/sysctl.conf)
echo "vm.dirty_ratio=10" | sudo tee -a /etc/sysctl.conf
代码示例
1. 观测PageCache效果
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/time.h>
#include <string.h>
#define FILE_SIZE (100 * 1024 * 1024) // 100MB
#define BUFFER_SIZE 4096
double get_time_ms() {
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0;
}
void create_test_file() {
int fd;
char buffer[BUFFER_SIZE];
printf("创建测试文件...\n");
memset(buffer, 'A', BUFFER_SIZE);
fd = open("/tmp/cache_test.dat", O_WRONLY | O_CREAT | O_TRUNC, 0644);
for (int i = 0; i < FILE_SIZE / BUFFER_SIZE; i++) {
write(fd, buffer, BUFFER_SIZE);
}
close(fd);
printf("测试文件创建完成\n\n");
}
void test_cold_read() {
int fd;
char buffer[BUFFER_SIZE];
double start, end;
printf("=== 冷读测试(PageCache未命中)===\n");
// 清理PageCache
system("sync && echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null");
start = get_time_ms();
fd = open("/tmp/cache_test.dat", O_RDONLY);
while (read(fd, buffer, BUFFER_SIZE) > 0);
close(fd);
end = get_time_ms();
printf("读取100MB耗时: %.2f ms\n", end - start);
printf("速度: %.2f MB/s\n\n", 100.0 / ((end - start) / 1000.0));
}
void test_hot_read() {
int fd;
char buffer[BUFFER_SIZE];
double start, end;
printf("=== 热读测试(PageCache命中)===\n");
start = get_time_ms();
fd = open("/tmp/cache_test.dat", O_RDONLY);
while (read(fd, buffer, BUFFER_SIZE) > 0);
close(fd);
end = get_time_ms();
printf("读取100MB耗时: %.2f ms\n", end - start);
printf("速度: %.2f MB/s\n\n", 100.0 / ((end - start) / 1000.0));
printf("提示: 热读比冷读快很多,这就是PageCache的效果!\n\n");
}
int main() {
printf("PageCache性能测试\n");
printf("==================\n\n");
create_test_file();
test_cold_read();
test_hot_read();
// 清理
unlink("/tmp/cache_test.dat");
return 0;
}
2. 监控脏页脚本
#!/bin/bash
# dirty_page_monitor.sh - 监控脏页变化
echo "脏页监控工具"
echo "按Ctrl+C停止"
echo ""
while true; do
# 获取当前时间
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
# 获取脏页信息
DIRTY=$(cat /proc/meminfo | grep "^Dirty:" | awk '{print $2}')
WRITEBACK=$(cat /proc/meminfo | grep "^Writeback:" | awk '{print $2}')
CACHED=$(cat /proc/meminfo | grep "^Cached:" | awk '{print $2}')
# 计算脏页比例
DIRTY_RATIO=$(awk "BEGIN {printf \"%.2f\", ($DIRTY/$CACHED)*100}")
# 输出
printf "%s | Dirty: %8d KB | Writeback: %8d KB | Cached: %10d KB | Ratio: %5.2f%%\n" \
"$TIMESTAMP" "$DIRTY" "$WRITEBACK" "$CACHED" "$DIRTY_RATIO"
sleep 1
done
3. Go语言PageCache测试
package main
import (
"fmt"
"io"
"os"
"os/exec"
"time"
)
const (
fileSize = 100 * 1024 * 1024 // 100MB
bufferSize = 4096
)
func createTestFile() {
fmt.Println("创建测试文件...")
file, _ := os.Create("/tmp/cache_test.dat")
defer file.Close()
buffer := make([]byte, bufferSize)
for i := range buffer {
buffer[i] = 'A'
}
for i := 0; i < fileSize/bufferSize; i++ {
file.Write(buffer)
}
fmt.Println("测试文件创建完成\n")
}
func dropPageCache() {
exec.Command("sync").Run()
exec.Command("sh", "-c", "echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null").Run()
}
func testColdRead() {
fmt.Println("=== 冷读测试(PageCache未命中)===")
dropPageCache()
start := time.Now()
file, _ := os.Open("/tmp/cache_test.dat")
defer file.Close()
buffer := make([]byte, bufferSize)
for {
_, err := file.Read(buffer)
if err == io.EOF {
break
}
}
elapsed := time.Since(start)
speed := float64(fileSize) / (1024 * 1024) / elapsed.Seconds()
fmt.Printf("读取100MB耗时: %v\n", elapsed)
fmt.Printf("速度: %.2f MB/s\n\n", speed)
}
func testHotRead() {
fmt.Println("=== 热读测试(PageCache命中)===")
start := time.Now()
file, _ := os.Open("/tmp/cache_test.dat")
defer file.Close()
buffer := make([]byte, bufferSize)
for {
_, err := file.Read(buffer)
if err == io.EOF {
break
}
}
elapsed := time.Since(start)
speed := float64(fileSize) / (1024 * 1024) / elapsed.Seconds()
fmt.Printf("读取100MB耗时: %v\n", elapsed)
fmt.Printf("速度: %.2f MB/s\n\n", speed)
}
func main() {
fmt.Println("PageCache性能测试")
fmt.Println("==================\n")
createTestFile()
testColdRead()
testHotRead()
os.Remove("/tmp/cache_test.dat")
}
动手实验
实验1:观测PageCache命中率
目标: 理解PageCache对性能的影响
步骤:
- 编译测试程序:
gcc -o cache_test cache_test.c
- 运行测试:
./cache_test
- 使用cachestat观测:
# 终端1:运行测试
./cache_test
# 终端2:观测命中率
sudo /usr/share/bcc/tools/cachestat 1
- 分析结果:
- 冷读:大量MISSES
- 热读:大量HITS
- 命中率 = HITS / (HITS + MISSES)
实验2:脏页回写实验
目标: 观测脏页的回写过程
步骤:
- 启动脏页监控:
chmod +x dirty_page_monitor.sh
./dirty_page_monitor.sh
- 执行大量写入:
dd if=/dev/zero of=/tmp/bigfile bs=1M count=1000
观察脏页变化:
- 脏页快速增长
- 达到阈值后开始回写
- Writeback列显示正在回写的页
手动触发同步:
sync
# 观察脏页迅速降为0
实验3:内存回收观测
目标: 观察kswapd的工作过程
步骤:
- 查看当前内存水位:
cat /proc/zoneinfo | grep -A 5 "pages free"
- 创建内存压力:
# 分配大量内存
stress-ng --vm 1 --vm-bytes 80% --vm-method all --timeout 60s
- 观察kswapd活动:
# 查看kswapd CPU使用
top -p $(pgrep kswapd)
# 查看内存回收统计
vmstat 1
# 观察si/so列
- 查看回收日志:
dmesg | grep -i "kswapd\|reclaim"
常见问题
Q1: PageCache会占用多少内存?
A: PageCache会使用所有可用的空闲内存。Linux的设计哲学是"用完所有内存",当应用需要内存时,内核会自动释放PageCache。通过free -h
命令可以看到:
buff/cache
: PageCache占用的内存available
: 实际可用内存(包括可释放的PageCache)
Q2: 什么时候需要手动清理PageCache?
A: 一般情况下不需要手动清理。只在以下场景考虑:
- 性能测试时建立baseline
- 调试内存相关问题
- 某些特殊的性能测试场景
正常运行时不应该清理,因为PageCache是性能优化的重要手段。
Q3: 如何优化脏页回写性能?
A: 优化策略:
- 降低dirty_ratio:减少突发写入的影响
- 降低dirty_background_ratio:更早开始后台回写
- 缩短dirty_writeback_centisecs:更频繁的回写
- 使用SSD:更快的写入速度
- 应用层优化:使用DirectIO或异步IO
Q4: swap和PageCache的关系是什么?
A: 内存回收的优先级:
- 先回收PageCache中的clean pages
- 再回收dirty pages(需要先回写)
- 最后才swap匿名页
可以通过vm.swappiness
参数调整:
- 0: 尽量不swap(推荐服务器)
- 60: 默认值
- 100: 积极swap
复习题
选择题
PageCache主要缓存什么内容?
- A. 进程代码
- B. 文件数据
- C. 网络数据包
- D. CPU指令
脏页(Dirty Page)是指:
- A. 从未使用过的页
- B. 已修改但未写入磁盘的页
- C. 已损坏的页
- D. 正在使用的页
kswapd是什么?
- A. 内核模块
- B. 系统调用
- C. 内存回收守护进程
- D. 文件系统
vm.dirty_ratio参数控制什么?
- A. 内存碎片比例
- B. 脏页占总内存的最大比例
- C. swap使用比例
- D. CPU使用率
清理PageCache使用的命令是?
- A. free -c
- B. echo 3 > /proc/sys/vm/drop_caches
- C. clear cache
- D. rm -rf /cache
简答题
解释PageCache的读缓存和写缓存机制。
什么情况下会触发脏页回写?
描述Linux内存回收的层次和优先级。
如何诊断系统是否存在PageCache相关的性能问题?
在什么场景下应该考虑使用Direct IO绕过PageCache?
实战题
性能分析题: 系统出现周期性的性能抖动,每5秒卡顿一次。请设计分析流程并给出可能的原因和解决方案。
参数调优题: 一个数据库服务器,内存32GB,经常出现IO写入延迟。请给出PageCache相关参数的调优方案。
故障排查题: 服务器内存使用率很高,但
free
显示还有大量buff/cache。应用抱怨内存不足。请分析可能的原因。
扩展阅读
推荐资源
深入方向
- 文件系统与PageCache的交互
- 内存回收算法演进
- NUMA与PageCache
- 透明大页(THP)对PageCache的影响
下一章预告: 我们将深入探讨零拷贝技术,包括sendfile、splice、mmap等高级IO技术,以及如何使用它们优化文件传输性能。