第4章:高速缓存(Caches)
一、导读
4.1 本章学习目标
第4章讲解Linux内核的缓存机制。在计算机系统中,缓存无处不在——CPU有L1/L2/L3缓存,磁盘有读写缓存,而Linux内核本身也维护了多层缓存来加速文件系统操作。这些缓存通过保存频繁访问的数据副本,大幅减少了对慢速磁盘的访问次数,是系统性能的关键保障。
本章的核心学习目标包括:
- 理解页缓存(Page Cache)的工作原理:如何缓存文件数据,以页为单位管理
- 掌握缓冲区缓存(Buffer Cache)的作用:与页缓存的区别和联系
- 理解dentry缓存的设计:如何加速路径名到inode的查找
- 掌握inode缓存的机制:减少磁盘上inode的读取次数
- 理解缓存的淘汰策略:LRU链表如何决定哪些页面被回收
- 了解缓存一致性的维护:页缓存和块设备之间的数据同步
4.2 为什么本章如此重要
缓存是Linux文件系统性能的基石。没有缓存,每一次read()系统调用都需要访问磁盘——即使是刚刚读取过的数据。磁盘的访问延迟(毫秒级)比内存访问(纳秒级)高出百万倍,这意味着没有缓存的系统将慢得无法使用。
Linux的缓存机制设计精妙:页缓存以页(4KB)为单位缓存文件数据,dentry缓存加速目录项查找,inode缓存减少inode的磁盘读取。这些缓存协同工作,使得大多数文件操作都能在内存中完成。理解这些缓存的工作原理,对于系统性能调优、问题诊断和编写高效的I/O程序都至关重要。
4.3 核心问题引导
在阅读本章时,请带着以下问题思考:
为什么Linux需要同时维护页缓存和缓冲区缓存?它们各自缓存什么数据?
当你第二次读取同一个文件时,为什么速度会快很多?数据是从哪里读取的?
当系统内存不足时,内核如何决定哪些缓存页面应该被回收?
写操作是先写入磁盘还是先写入缓存?如果写入缓存后系统崩溃,数据会丢失吗?
dentry缓存为什么使用哈希表而不是其他数据结构?
什么是"脏页面"?脏页面如何被写回磁盘?
二、核心概念详解
2.1 页缓存(Page Cache)
页缓存是Linux中最重要的文件缓存机制。每当进程读取文件时,内核首先检查页缓存中是否已有该文件的数据页面。如果有(缓存命中),直接从内存返回数据;如果没有(缓存未命中),从磁盘读取数据到页缓存,再返回给用户。
/* 页缓存的核心数据结构 */
/* include/linux/fs.h */
struct address_space {
struct inode *host; /* 所属的inode */
struct radix_tree_root page_tree; /* 页缓存的基数树 */
spinlock_t tree_lock; /* 保护基数树 */
unsigned int i_mmap_writable; /* 可写映射计数 */
struct rb_root i_mmap; /* 私有映射的红黑树 */
struct list_head i_mmap_nonlinear; /* 非线性映射链表 */
unsigned long nrpages; /* 页面数量 */
unsigned long nrpages_writeback; /* 回写中的页面数 */
const struct address_space_operations *a_ops; /* 操作方法 */
/* ... */
};
/* address_space 操作方法 */
struct address_space_operations {
int (*writepage)(struct page *page, struct writeback_control *wbc);
int (*readpage)(struct file *filp, struct page *page);
int (*writepages)(struct address_space *mapping,
struct writeback_control *wbc);
int (*set_page_dirty)(struct page *page);
int (*readpages)(struct file *filp, struct address_space *mapping,
struct list_head *pages, unsigned nr_pages);
/* ... */
};2.1.1 读操作与页缓存
当进程调用read()读取文件时,内核的处理流程如下:
read() 系统调用
↓
检查页缓存(通过 address_space 的基数树查找)
↓
┌─── 命中 ───→ 直接从页缓存复制数据到用户缓冲区
│
└─── 未命中 ──→ 分配新的页面
↓
调用 readpage() 从磁盘读取数据
↓
数据到达后放入页缓存
↓
复制数据到用户缓冲区/* 文件读操作的核心流程(简化) */
/* mm/filemap.c */
ssize_t generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter) {
struct file *file = iocb->ki_filp;
struct address_space *mapping = file->f_mapping;
loff_t pos = iocb->ki_pos;
size_t count = iov_iter_count(iter);
/* 遍历请求的每个页面 */
while (count > 0) {
unsigned long index = pos >> PAGE_SHIFT; /* 页索引 */
unsigned int offset = pos & ~PAGE_MASK; /* 页内偏移 */
/* 在页缓存中查找页面 */
struct page *page = find_get_page(mapping, index);
if (page && PageUptodate(page)) {
/* 缓存命中:页面已在内存且数据是最新的 */
/* 等待页面解锁(如果正在I/O) */
wait_on_page_locked(page);
/* 复制数据到用户空间 */
size_t bytes = min_t(size_t, PAGE_SIZE - offset, count);
copy_to_iter(page_address(page) + offset, bytes, iter);
put_page(page);
} else {
/* 缓存未命中:需要从磁盘读取 */
if (!page) {
/* 分配新页面并加入页缓存 */
page = page_cache_alloc_readahead(mapping);
add_to_page_cache_lru(page, mapping, index, GFP_KERNEL);
}
/* 发起磁盘读取 */
mapping->a_ops->readpage(file, page);
/* 等待I/O完成 */
lock_page(page);
wait_on_page_locked(page);
/* 复制到用户空间 */
size_t bytes = min_t(size_t, PAGE_SIZE - offset, count);
copy_to_iter(page_address(page) + offset, bytes, iter);
put_page(page);
}
pos += bytes;
count -= bytes;
}
return iov_iter_count(iter);
}2.1.2 写操作与页缓存
写操作采用延迟写(Delayed Write)策略:数据先写入页缓存,标记为"脏"(Dirty),然后由内核在适当的时候异步写回磁盘。
write() 系统调用
↓
将数据从用户缓冲区复制到内核
↓
在页缓存中查找或分配页面
↓
将数据写入页缓存页面
↓
标记页面为"脏"(Dirty)
↓
返回成功(数据尚未写入磁盘!)
↓
... 稍后 ...
↓
内核的 pdflush/kworker 线程将脏页面写回磁盘/* 脏页面的写回机制 */
/* 内核线程定期将脏页面刷回磁盘 */
/* 脏页面的回写策略(可通过 /proc/sys/vm/ 配置) */
/*
* dirty_writeback_centisecs: 回写线程的唤醒间隔(默认500,即5秒)
* dirty_expire_centisecs: 脏页面的过期时间(默认3000,即30秒)
* dirty_ratio: 脏页面占总内存的比例阈值(默认20%)
* dirty_bytes: 脏页面的绝对字节数阈值
*
* 当脏页面超过阈值时,写入操作会被阻塞,直到脏页面被回写
*/
/* 手动触发脏页面回写 */
/* 用户空间:sync() 系统调用 */
/* 或命令行:sync */
/* 查看脏页面数量 */
/* cat /proc/meminfo | grep Dirty */
/* Dirty: 1234 kB */2.2 缓冲区缓存(Buffer Cache)
缓冲区缓存(Buffer Cache)是比页缓存更底层的缓存机制。它缓存的是块设备的数据块(Block),主要用于文件系统的元数据(如超级块、inode表、目录项等)。
/* 缓冲区头结构 */
/* include/linux/buffer_head.h */
struct buffer_head {
unsigned long b_state; /* 缓冲区状态标志 */
struct buffer_head *b_this_page;/* 同一页面的下一个缓冲区 */
struct page *b_page; /* 所属的页面 */
sector_t b_blocknr; /* 块设备上的块号 */
size_t b_size; /* 缓冲区大小 */
char *b_data; /* 数据指针 */
struct block_device *b_bdev; /* 关联的块设备 */
bh_end_io_t *b_end_io; /* I/O完成回调 */
void *b_private; /* 私有数据 */
struct list_head b_assoc_buffers;/* 关联的映射链表 */
};
/* 缓冲区状态标志 */
#define BH_Uptodate 0 /* 数据是最新的 */
#define BH_Dirty 1 /* 数据已被修改 */
#define BH_Lock 2 /* 缓冲区正在被I/O操作 */
#define BH_Req 3 /* I/O已提交 */
#define BH_Mapped 4 /* 已映射到块设备 */
#define BH_New 5 /* 新分配的缓冲区 */页缓存和缓冲区缓存的关系:
文件数据 → 页缓存(以页为单位,4KB)
↓
每个页可以包含多个缓冲区
↓
缓冲区缓存(以块为单位,512B/1KB/4KB)
↓
块设备在现代Linux内核中,页缓存和缓冲区缓存的界限逐渐模糊。对于普通文件数据,主要通过页缓存管理;对于文件系统的元数据,主要通过缓冲区缓存管理。
2.3 dentry缓存
dentry缓存(Directory Entry Cache)是加速路径名查找的关键机制。在Linux中,每次访问文件都需要将路径名(如/home/user/file.txt)解析为对应的inode。这个过程需要逐级查找目录项,涉及大量的磁盘读取。dentry缓存将最近查找过的目录项保存在内存中,极大地加速了路径解析。
/* dentry 结构(简化) */
/* include/linux/dcache.h */
struct dentry {
unsigned int d_flags; /* 标志 */
seqcount_t d_seq; /* 序列号(用于无锁查找) */
struct hlist_bl_node d_hash; /* 哈希链表节点 */
struct dentry *d_parent; /* 父目录的dentry */
struct qstr d_name; /* 目录项名称 */
struct inode *d_inode; /* 关联的inode */
unsigned char d_iname[DNAME_INLINE_LEN]; /* 短名称内联存储 */
unsigned int d_count; /* 引用计数 */
const struct dentry_operations *d_op; /* 操作方法 */
struct super_block *d_sb; /* 所属的超级块 */
struct hlist_node d_alias; /* inode的别名链表 */
unsigned long d_time; /* revalidate时间 */
struct rcu_head d_rcu; /* RCU释放 */
/* ... */
};
/* dentry 缓存的哈希表 */
/* fs/dcache.c */
static struct hlist_bl_head *dentry_hashtable;
/* 通过哈希表快速查找 dentry */
struct dentry *__d_lookup(const struct dentry *parent,
const struct qstr *name) {
unsigned int len = name->len;
unsigned int hash = name->hash;
const unsigned char *str = name->name;
/* 计算哈希桶 */
struct hlist_bl_head *b = dentry_hash(parent->d_inode, hash);
hlist_bl_lock(b);
hlist_bl_for_each_entry_rcu(dentry, node, b, d_hash) {
if (dentry->d_parent != parent)
continue;
if (dentry->d_name.hash != hash)
continue;
if (dentry->d_name.len != len)
continue;
if (memcmp(dentry->d_name.name, str, len) == 0) {
dentry->d_count++;
hlist_bl_unlock(b);
return dentry; /* 缓存命中 */
}
}
hlist_bl_unlock(b);
return NULL; /* 缓存未命中 */
}# 查看 dentry 缓存统计
cat /proc/sys/fs/dentry-state
# 输出:65432 45678 45 0 0 0
# 含义:nr_dentry nr_unused age_limit want_pages dummy dummy
# 清除 dentry 缓存(需要 root)
echo 2 > /proc/sys/vm/drop_caches2.4 inode缓存
inode缓存通过slab分配器实现,缓存从磁盘读取的inode结构。每个文件系统的inode格式不同,但VFS层的struct inode是统一的。inode缓存减少了从磁盘读取inode的次数。
/* VFS inode 结构(简化) */
/* include/linux/fs.h */
struct inode {
umode_t i_mode; /* 文件类型和权限 */
unsigned short i_opflags; /* 操作标志 */
kuid_t i_uid; /* 所有者UID */
kgid_t i_gid; /* 所有者GID */
unsigned int i_flags; /* 文件系统标志 */
const struct inode_operations *i_op; /* inode操作方法 */
unsigned long i_ino; /* inode号 */
dev_t i_rdev; /* 设备号(设备文件) */
loff_t i_size; /* 文件大小 */
struct timespec i_atime; /* 最后访问时间 */
struct timespec i_mtime; /* 最后修改时间 */
struct timespec i_ctime; /* 状态变更时间 */
unsigned short i_bytes; /* 最后一个块的字节数 */
unsigned int i_blkbits; /* 块大小(位) */
blkcnt_t i_blocks; /* 块数 */
struct address_space i_data; /* 页缓存的地址空间 */
struct hlist_head i_dentry; /* 引用此inode的dentry链表 */
struct block_device *i_bdev; /* 块设备(如果有) */
struct list_head i_wb_list; /* 回写链表 */
unsigned long i_state; /* 状态标志 */
/* ... */
};2.5 缓存淘汰与LRU
当系统内存不足时,内核需要回收一些缓存页面来释放内存。Linux使用LRU(Least Recently Used,最近最少使用) 链表来管理页面的回收。
/* LRU 链表管理 */
/* include/linux/mmzone.h */
struct lruvec {
struct list_head lists[NR_LRU_LISTS]; /* LRU链表 */
unsigned long nr_zone_pages[MAX_NR_ZONES];
struct zone_reclaim_stat reclaim_stat;
};
/* LRU 链表类型 */
enum lru_list {
LRU_INACTIVE_ANON, /* 不活跃匿名页面 */
LRU_ACTIVE_ANON, /* 活跃匿名页面 */
LRU_INACTIVE_FILE, /* 不活跃文件页面 */
LRU_ACTIVE_FILE, /* 活跃文件页面 */
LRU_UNEVICTABLE, /* 不可回收页面 */
NR_LRU_LISTS
};
/*
* LRU 的工作原理:
*
* 新分配的页面 → ACTIVE 链表
* ↓
* 当内存压力时,ACTIVE 链表尾部的页面 → INACTIVE 链表
* ↓
* INACTIVE 链表尾部的页面被回收
* ↓
* 如果页面被再次访问,移回 ACTIVE 链表头部
*
* 这种"二次机会"机制避免了频繁访问的页面被过早回收
*/# 查看各 LRU 链表的页面数
cat /proc/meminfo | grep -i "lru\|active\|inactive"
# Active(anon): 1234567 kB
# Active(file): 987654 kB
# Inactive(anon): 345678 kB
# Inactive(file): 234567 kB
# Unevictable: 12345 kB
# 查看虚拟内存统计
cat /proc/vmstat | grep -i "pgscan\|pgsteal"
# pgscan_kswapd_dma32 123456
# pgscan_direct 789
# pgsteal_kswapd 123000
# pgsteal_direct 780三、实战案例
3.1 观察页缓存的效果
# 准备测试文件
dd if=/dev/zero of=testfile bs=1M count=512
# 清除缓存
echo 3 > /proc/sys/vm/drop_caches
sync
# 第一次读取(缓存未命中)
time cat testfile > /dev/null
# real 0m2.345s ← 从磁盘读取
# 查看缓存使用
cat /proc/meminfo | grep -i "cached\|buffers"
# Cached: 524288 kB ← 文件已被缓存
# Buffers: 12345 kB
# 第二次读取(缓存命中)
time cat testfile > /dev/null
# real 0m0.123s ← 从缓存读取,快了近20倍!3.2 观察脏页面的回写
# 监控脏页面数量
watch -n 1 'cat /proc/meminfo | grep Dirty'
# 在另一个终端写入数据
dd if=/dev/zero of=bigfile bs=1M count=1024
# 观察 Dirty 值的变化:
# 写入时 Dirty 值增加
# 回写线程运行时 Dirty 值减少
# 写入完成后 Dirty 值回到接近0
# 手动触发回写
sync
# 或
echo 1 > /proc/sys/vm/drop_caches # 清除页缓存前先回写3.3 使用 vmtouch 分析缓存
# 安装 vmtouch(如果没有预装)
# 查看文件在内存中的缓存情况
vmtouch testfile
# testfile
# [OOOOOOOOOOOOOOOOOOOO] 128/128
# 128 pages all cached (524288 bytes)
# 清除缓存后
echo 3 > /proc/sys/vm/drop_caches
vmtouch testfile
# testfile
# [____________________] 0/128
# 0 pages cached
# 读取文件后再检查
cat testfile > /dev/null
vmtouch testfile
# testfile
# [OOOOOOOOOOOOOOOOOOOO] 128/128
# 128 pages all cached四、本章小结
本章深入讲解了Linux内核的三层缓存机制:页缓存、缓冲区缓存和dentry/inode缓存。页缓存以页为单位缓存文件数据,是文件系统性能的核心;缓冲区缓存缓存块设备的元数据块;dentry缓存加速路径名解析,inode缓存减少inode的磁盘读取。
缓存的淘汰策略基于LRU链表,通过"活跃-不活跃"两级链表实现二次机会机制,避免频繁访问的页面被过早回收。写操作采用延迟写策略,数据先写入页缓存标记为脏页面,再由内核线程异步回写磁盘,这种设计显著提升了写性能,但也引入了数据一致性的挑战。
理解这些缓存机制对于系统调优至关重要。通过调整/proc/sys/vm/下的参数,可以优化缓存行为以适应不同的工作负载。
五、思考与练习
思考题
为什么Linux同时维护页缓存和缓冲区缓存?在现代内核中,两者的关系发生了什么变化?
如果一个文件被频繁随机读取,页缓存的命中率会如何变化?如何优化这种情况?
延迟写策略有什么风险?O_SYNC标志如何解决这个问题?
为什么LRU使用"活跃-不活跃"两级链表?只用一级链表有什么问题?
编程练习
编写一个程序,使用mmap映射一个文件,然后使用mincore()检查哪些页面在物理内存中。对比读取文件前后的缓存状态。
编写一个脚本,测试不同大小的文件在清除缓存前后的读取性能差异,分析缓存命中率对性能的影响。
使用/proc/sys/vm/drop_caches和/proc/meminfo监控缓存的使用情况,验证页缓存的LRU淘汰行为。