4深入理解Linux内核(第3版)

高速缓存

缓存管理

页缓存缓冲区缓存dentry缓存

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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中最重要的文件缓存机制。每当进程读取文件时,内核首先检查页缓存中是否已有该文件的数据页面。如果有(缓存命中),直接从内存返回数据;如果没有(缓存未命中),从磁盘读取数据到页缓存,再返回给用户。

c
/* 页缓存的核心数据结构 */
/* 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() 从磁盘读取数据
                   ↓
               数据到达后放入页缓存
                   ↓
               复制数据到用户缓冲区
c
/* 文件读操作的核心流程(简化) */
/* 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 线程将脏页面写回磁盘
c
/* 脏页面的写回机制 */
/* 内核线程定期将脏页面刷回磁盘 */

/* 脏页面的回写策略(可通过 /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表、目录项等)。

c
/* 缓冲区头结构 */
/* 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缓存将最近查找过的目录项保存在内存中,极大地加速了路径解析。

c
/* 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;  /* 缓存未命中 */
}
bash
# 查看 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_caches

2.4 inode缓存

inode缓存通过slab分配器实现,缓存从磁盘读取的inode结构。每个文件系统的inode格式不同,但VFS层的struct inode是统一的。inode缓存减少了从磁盘读取inode的次数。

c
/* 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,最近最少使用) 链表来管理页面的回收。

c
/* 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 链表头部
 *
 * 这种"二次机会"机制避免了频繁访问的页面被过早回收
 */
bash
# 查看各 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 观察页缓存的效果

bash
# 准备测试文件
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 观察脏页面的回写

bash
# 监控脏页面数量
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 分析缓存

bash
# 安装 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淘汰行为。


4/10高速缓存
0%