进程地址

用户态内存范围由内核通过虚拟内存区域或类型为 struct vm_area_struct 的 ‘VMA’ 进行跟踪。

每个 VMA 描述一个具有相同属性的虚拟连续内存范围,每个范围都由一个 struct vm_area_struct 对象描述。除了相邻的堆栈 VMA 可以扩展以包含被访问的地址的情况外,用户态对 VMA 之外的访问是无效的。

所有 VMA 都包含在一个且仅一个虚拟地址空间内,该地址空间由一个 struct mm_struct 对象描述,该对象由共享虚拟地址空间的所有任务(即线程)引用。我们将其称为 mm

每个 mm 对象都包含一个 maple 树数据结构,该结构描述虚拟地址空间内的所有 VMA。

注意

一个例外是由使用 vsyscall 的架构提供的 ‘gate’ VMA,它是一个全局静态对象,不属于任何特定的 mm。

锁定

内核被设计为高度可扩展的,可以抵御对 VMA 元数据 的并发读取操作,因此需要一套复杂的锁来确保不会发生内存损坏。

注意

锁定 VMA 以获取其元数据不会对其描述的内存或映射它们的页表产生任何影响。

术语

  • mmap 锁 - 每个 MM 都有一个读/写信号量 mmap_lock,它以进程地址空间粒度锁定,可以通过 mmap_read_lock()mmap_write_lock() 和变体获取。

  • VMA 锁 - VMA 锁位于 VMA 粒度(当然),实际上表现为读/写信号量。VMA 读锁通过 lock_vma_under_rcu() 获取(并通过 vma_end_read() 解锁),而写锁通过 vma_start_write() 获取(所有 VMA 写锁在释放 mmap 写锁时自动解锁)。要获取 VMA 写锁,您必须已获取 mmap_write_lock()

  • rmap 锁 - 当尝试通过 struct address_spacestruct anon_vma 对象(可通过 folio->mapping 从 folio 访问)通过反向映射访问 VMA 时。对于匿名内存,必须通过 anon_vma_[try]lock_read()anon_vma_[try]lock_write() 来稳定 VMA,对于文件支持的内存,必须通过 i_mmap_[try]lock_read()i_mmap_[try]lock_write() 来稳定 VMA。我们将这些锁简称为反向映射锁或“rmap 锁”。

我们将在下面的专用部分中单独讨论页表锁。

这些锁所实现的第一个目标是稳定 MM 树中的 VMA。也就是说,保证 VMA 对象不会在您不知情的情况下被删除或修改(除了下面描述的某些特定字段)。

稳定 VMA 还会保持其描述的地址空间存在。

锁的使用

如果您想读取 VMA 元数据字段或只是保持 VMA 稳定,您必须执行以下操作之一

  • 通过 mmap_read_lock()(或合适的变体)获取 MM 粒度的 mmap 读锁,并在使用完 VMA 后使用匹配的 mmap_read_unlock() 解锁,

  • 尝试通过 lock_vma_under_rcu() 获取 VMA 读锁。这将尝试原子地获取锁,因此可能会失败,在这种情况下,如果此操作返回 NULL,则需要回退逻辑来获取 mmap 读锁,

  • 在遍历锁定的区间树(无论是匿名的还是文件支持的)以获取所需的 VMA 之前,获取 rmap 锁。

如果您想写入 VMA 元数据字段,则情况会有所不同,具体取决于字段(我们将在下面详细探讨每个 VMA 字段)。对于大多数情况,您必须

  • 通过 mmap_write_lock()(或合适的变体)获取 MM 粒度的 mmap 写锁,并在使用完 VMA 后使用匹配的 mmap_write_unlock() 解锁,并且

  • 通过 vma_start_write() 获取要修改的每个 VMA 的 VMA 写锁,该写锁将在调用 mmap_write_unlock() 时自动释放。

  • 如果要能够写入任何字段,还必须通过获取 rmap 写锁来隐藏反向映射中的 VMA。

VMA 锁很特殊,您必须首先获取 mmap 锁才能获取 VMA 锁。但是,可以在没有任何其他锁的情况下获取 VMA 锁(lock_vma_under_rcu() 将获取然后释放 RCU 锁来为您查找 VMA)。

这限制了写入器对读取器的影响,因为写入器可以与一个 VMA 交互,而读取器可以同时与另一个 VMA 交互。

注意

VMA 读锁的主要用户是页错误处理程序,这意味着如果没有 VMA 写锁,页错误将与您正在执行的任何操作并发运行。

检查所有有效的锁状态

mmap 锁

VMA 锁

rmap 锁

稳定?

读取?

写入大多数?

写入所有?

-

-

-

-

-

-

-

读/写

读/写

-/读

-/读/写

-/读

警告

虽然可以在持有 mmap 读锁的同时获取 VMA 锁,但尝试反向操作是无效的,因为它可能导致死锁 - 如果另一个任务已经持有 mmap 写锁并尝试获取 VMA 写锁,则会在 VMA 读锁上死锁。

实际上,所有这些锁都表现为读/写信号量,因此您可以为每个锁获取读锁或写锁。

注意

一般来说,读/写信号量是一类允许并发读取器的锁。但是,只有当所有读取器都离开临界区(并使待处理的读取器等待)时,才能获取写锁。

这使得读/写信号量上的读锁与其他读取器并发,而写锁与其他持有信号量的所有锁互斥。

VMA 字段

我们可以根据其用途细分 struct vm_area_struct 字段,这使得探索其锁定特性更容易

注意

为了避免混淆,我们在此处排除 VMA 锁特定的字段,因为这些实际上是内部实现细节。

虚拟布局字段

字段

描述

写锁

vm_start

VMA 描述的范围的包含起始虚拟地址。

mmap 写,VMA 写,rmap 写。

vm_end

VMA 描述的范围的独占结束虚拟地址。

mmap 写,VMA 写,rmap 写。

vm_pgoff

描述文件中的页面偏移量、虚拟地址空间内的原始页面偏移量(在任何 mremap() 之前),或者如果 PFN 映射且架构不支持 CONFIG_ARCH_HAS_PTE_SPECIAL,则描述 PFN。

mmap 写,VMA 写,rmap 写。

这些字段描述了 VMA 的大小、起始和结束位置,因此,在没有首先从反向映射中隐藏它们的情况下,无法修改这些字段,因为这些字段用于在反向映射间隔树中定位 VMA。

核心字段

字段

描述

写锁

vm_mm

包含 mm_struct。

无 - 在初始映射时写入一次。

vm_page_prot

从 VMA 标志确定的特定于架构的页表保护位。

mmap 写,VMA 写。

vm_flags

对描述 VMA 属性的 VMA 标志的只读访问,与私有可写 __vm_flags 联合。

不适用

__vm_flags

对 VMA 标志字段的私有、可写访问,由 vm_flags_*() 函数更新。

mmap 写,VMA 写。

vm_file

如果 VMA 是文件支持的,则指向描述底层文件的 struct file 对象;如果是匿名的,则为 NULL

无 - 在初始映射时写入一次。

vm_ops

如果 VMA 是文件支持的,那么驱动程序或文件系统会提供一个 struct vm_operations_struct 对象,描述 VMA 生命周期事件发生时要调用的回调函数。

无 - 由 f_ops->mmap() 在初始映射时写入一次。

vm_private_data

一个 void * 字段,用于存储特定于驱动程序的元数据。

由驱动程序处理。

这些是描述 VMA 所属 MM 及其属性的核心字段。

特定配置的字段

字段

配置选项

描述

写锁

anon_name

CONFIG_ANON_VMA_NAME

一个用于存储 struct anon_vma_name 对象的字段,为匿名映射提供名称,如果未设置或 VMA 是文件支持的,则为 NULL。 底层对象是引用计数的,并且可以在多个 VMA 之间共享以实现可扩展性。

mmap 写,VMA 写。

swap_readahead_info

CONFIG_SWAP

交换机制用于执行预读的元数据。此字段是原子访问的。

mmap 读取,特定于交换的锁。

vm_policy

CONFIG_NUMA

mempolicy 对象,描述 VMA 的 NUMA 行为。底层对象是引用计数的。

mmap 写,VMA 写。

numab_state

CONFIG_NUMA_BALANCING

vma_numab_state 对象,描述与此 VMA 相关的 NUMA 平衡的当前状态。在 mmap 读取锁下由 task_numa_work() 更新。

mmap 读取,特定于 numab 的锁。

vm_userfaultfd_ctx

CONFIG_USERFAULTFD

类型为 vm_userfaultfd_ctx 的 Userfaultfd 上下文包装器对象,如果 userfaultfd 被禁用则为零大小,或者包含指向底层 userfaultfd_ctx 对象的指针,该对象描述 userfaultfd 元数据。

mmap 写,VMA 写。

这些字段是否存在取决于是否设置了相关的内核配置选项。

反向映射字段

字段

描述

写锁

shared.rb

一个红黑树节点,如果映射是文件支持的,则用于将 VMA 放置在 struct address_space->i_mmap 红黑间隔树中。

mmap 写入,VMA 写入,i_mmap 写入。

shared.rb_subtree_last

如果 VMA 是文件支持的,则用于管理间隔树的元数据。

mmap 写入,VMA 写入,i_mmap 写入。

anon_vma_chain

指向分叉/CoW 的 anon_vma 对象和 vma->anon_vma 的指针列表,如果它不是 NULL

mmap 读取,anon_vma 写入。

anon_vma

由专门映射到此 VMA 的匿名页面使用的 anon_vma 对象。最初由 anon_vma_prepare() 设置,由 page_table_lock 串行化。只要有任何页面被故障处理,就会设置此项。

NULL 且设置为非 NULL 时:mmap 读取,page_table_lock。

当非 NULL 且设置为 NULL 时:mmap 写入,VMA 写入,anon_vma 写入。

这些字段用于将 VMA 放置在反向映射中,并且对于匿名映射,可以访问相关的 struct anon_vma 对象和 struct anon_vma,其中专门映射到此 VMA 的页面应驻留。

注意

如果使用 MAP_PRIVATE 设置映射文件支持的映射,那么它可以同时位于 anon_vmai_mmap 树中,因此所有这些字段可能会同时使用。

页表

我们不会详尽地讨论这个主题,但广义来说,页表通过一系列页表将虚拟地址映射到物理地址,每个页表都包含具有下一级页表物理地址(以及标志)的条目,并且在叶级,包含底层物理数据页的物理地址或诸如交换条目、迁移条目或其他特殊标记之类的特殊条目。这些页面中的偏移量由虚拟地址本身提供。

在 Linux 中,这些分为五个级别 - PGD、P4D、PUD、PMD 和 PTE。巨页可能会消除其中一个或两个级别,但是当这种情况发生时,我们通常将叶级称为 PTE 级别,无论如何。

注意

在架构支持的页表少于五个实例中,内核巧妙地“折叠”了页表级别,即取消与跳过的级别相关的函数。这允许我们在概念上表现得好像总是有五个级别,即使编译器在实践中可能会消除与缺失级别相关的任何代码。

通常在页表上执行四个关键操作

  1. 遍历页表 - 只是读取页表以遍历它们。这只需要保持 VMA 的稳定,因此建立这一点的锁足以进行遍历(也有无锁变体,可以消除此要求,例如 gup_fast())。

  2. 安装页表映射 - 无论是创建新映射还是修改现有映射以改变其标识。这要求通过 mmap 或 VMA 锁(明确不是 rmap 锁)保持 VMA 的稳定。

  3. Zapping/取消映射页表条目 - 这是内核调用在叶级清除页表映射的操作,同时保留所有页表不变。这是内核中非常常见的操作,在文件截断、通过 madvise() 进行的 MADV_DONTNEED 操作等情况下执行。这由许多函数执行,包括 unmap_mapping_range()unmap_mapping_pages()。VMA 只需要在此操作中保持稳定。

  4. 释放页表 - 当内核最终从用户态进程中删除页表时(通常通过 free_pgtables()),必须格外小心以确保安全地完成此操作,因为此逻辑最终会释放指定范围内的所有页表,忽略现有的叶条目(它假设调用者既已清除了该范围,又阻止了其中任何进一步的故障或修改)。

注意

在 rmap 锁下执行重新声明或迁移的映射修改,因为它与 zapping 一样,不会从根本上修改正在映射的内容的标识。

遍历zapping 范围可以持有上面术语部分中描述的任何一个锁来执行 - 即 mmap 锁、VMA 锁或任何一个反向映射锁。

也就是说 - 只要您保持相关的 VMA 稳定 - 您就可以继续对页表执行这些操作(尽管在内部,执行写入的内核操作也会获取内部页表锁以进行序列化 - 有关更多详细信息,请参阅页表实现详细信息部分)。

安装页表条目时,必须持有 mmap 或 VMA 锁以保持 VMA 的稳定。我们在下面的页表锁定详细信息部分中探讨了为什么会这样。

警告

页表通常只在 VMA 覆盖的区域中遍历。如果您想在可能不被 VMA 覆盖的区域中遍历页表,则需要更重的锁定。有关详细信息,请参阅 walk_page_range_novma()

释放页表是完全内部的内存管理操作,具有特殊的要求(有关更多详细信息,请参阅下面的页面释放部分)。

警告

释放页表时,必须不可能通过反向映射访问包含这些页表映射到的范围的 VMA。

free_pgtables() 函数会从反向映射中删除相关的 VMA,但不能允许任何其他 VMA 访问并跨越指定的范围。

锁排序

由于我们在内核中可能有多个锁可能在与显式 mm 或 VMA 锁同时获取,我们必须警惕锁反转,并且获取和释放锁的顺序变得非常重要。

注意

当两个线程需要获取多个锁时,会发生锁反转,但在这样做时会无意中导致相互死锁。

例如,考虑线程 1 持有锁 A 并尝试获取锁 B,而线程 2 持有锁 B 并尝试获取锁 A。

两个线程现在彼此死锁。然而,如果它们尝试以相同的顺序获取锁,那么其中一个线程将等待另一个线程完成其工作,而不会发生死锁。

mm/rmap.c 开头的注释详细描述了内存管理代码中锁的必要顺序。

inode->i_rwsem        (while writing or truncating, not reading or faulting)
  mm->mmap_lock
    mapping->invalidate_lock (in filemap_fault)
      folio_lock
        hugetlbfs_i_mmap_rwsem_key (in huge_pmd_share, see hugetlbfs below)
          vma_start_write
            mapping->i_mmap_rwsem
              anon_vma->rwsem
                mm->page_table_lock or pte_lock
                  swap_lock (in swap_duplicate, swap_info_get)
                    mmlist_lock (in mmput, drain_mmlist and others)
                    mapping->private_lock (in block_dirty_folio)
                        i_pages lock (widely used)
                          lruvec->lru_lock (in folio_lruvec_lock_irq)
                    inode->i_lock (in set_page_dirty's __mark_inode_dirty)
                    bdi.wb->list_lock (in set_page_dirty's __mark_inode_dirty)
                      sb_lock (within inode_lock in fs/fs-writeback.c)
                      i_pages lock (widely used, in set_page_dirty,
                                in arch-dependent flush_dcache_mmap_lock,
                                within bdi.wb->list_lock in __sync_single_inode)

还有一个文件系统特定的锁顺序注释,位于 mm/filemap.c 的顶部。

->i_mmap_rwsem                        (truncate_pagecache)
  ->private_lock                      (__free_pte->block_dirty_folio)
    ->swap_lock                       (exclusive_swap_page, others)
      ->i_pages lock

->i_rwsem
  ->invalidate_lock                   (acquired by fs in truncate path)
    ->i_mmap_rwsem                    (truncate->unmap_mapping_range)

->mmap_lock
  ->i_mmap_rwsem
    ->page_table_lock or pte_lock     (various, mainly in memory.c)
      ->i_pages lock                  (arch-dependent flush_dcache_mmap_lock)

->mmap_lock
  ->invalidate_lock                   (filemap_fault)
    ->lock_page                       (filemap_fault, access_process_vm)

->i_rwsem                             (generic_perform_write)
  ->mmap_lock                         (fault_in_readable->do_page_fault)

bdi->wb.list_lock
  sb_lock                             (fs/fs-writeback.c)
  ->i_pages lock                      (__sync_single_inode)

->i_mmap_rwsem
  ->anon_vma.lock                     (vma_merge)

->anon_vma.lock
  ->page_table_lock or pte_lock       (anon_vma_prepare and various)

->page_table_lock or pte_lock
  ->swap_lock                         (try_to_unmap_one)
  ->private_lock                      (try_to_unmap_one)
  ->i_pages lock                      (try_to_unmap_one)
  ->lruvec->lru_lock                  (follow_page_mask->mark_page_accessed)
  ->lruvec->lru_lock                  (check_pte_range->folio_isolate_lru)
  ->private_lock                      (folio_remove_rmap_pte->set_page_dirty)
  ->i_pages lock                      (folio_remove_rmap_pte->set_page_dirty)
  bdi.wb->list_lock                   (folio_remove_rmap_pte->set_page_dirty)
  ->inode->i_lock                     (folio_remove_rmap_pte->set_page_dirty)
  bdi.wb->list_lock                   (zap_pte_range->set_page_dirty)
  ->inode->i_lock                     (zap_pte_range->set_page_dirty)
  ->private_lock                      (zap_pte_range->block_dirty_folio)

请检查这些注释的当前状态,因为自从编写本文档以来,它们可能已经更改。

锁实现细节

警告

PTE 级别的页表锁定规则与其他级别的页表锁定规则非常不同。

页表锁定细节

除了上述术语部分中描述的锁之外,我们还有专用于页表的其他锁。

  • 高级页表锁 - 高级页表,即 PGD、P4D 和 PUD,在修改时都使用进程地址空间粒度 mm->page_table_lock 锁。

  • 细粒度页表锁 - PMD 和 PTE 都具有细粒度锁,这些锁要么保存在描述页表的页面中,要么在设置了 ALLOC_SPLIT_PTLOCKS 时单独分配并通过页面指向。PMD 自旋锁通过 pmd_lock() 获取,但是 PTE 被映射到更高的内存(如果是一个 32 位系统),并通过 pte_offset_map_lock() 小心地锁定。

这些锁表示与每个页表级别交互所需的最低限度,但还有其他要求。

重要的是,请注意,在页表的遍历中,有时不会获取任何此类锁。但是,在 PTE 级别,必须至少防止并发的页表删除(使用 RCU),并且必须将页表映射到高内存中,请参见下文。

是否在读取页表条目时采取措施取决于体系结构,请参见下面的原子性部分。

锁定规则

我们在与页表交互时建立基本的锁定规则。

  • 当更改页表条目时,必须持有该页表的页表锁,除非您可以安全地假设没有人可以同时访问页表(例如,在调用 free_pgtables() 时)。

  • 对页表条目的读取和写入必须是适当的原子操作。有关详细信息,请参见下面的原子性部分。

  • 填充以前为空的条目需要持有 mmap 或 VMA 锁(读取或写入),仅使用 rmap 锁这样做是危险的(请参见下面的警告)。

  • 如前所述,在保持 VMA 稳定的同时,可以执行 zapping,即持有 mmap、VMA 或 rmap 锁中的任何一个。

警告

填充以前为空的条目是危险的,因为在取消映射 VMA 时,vms_clear_ptes() 在 zapping(通过 unmap_vmas())和释放页表(通过 free_pgtables())之间有一个时间窗口,在此期间,VMA 在 rmap 树中仍然可见。free_pgtables() 假设 zap 已经执行,并无条件地删除 PTE(以及释放范围内的所有其他页表),因此安装新的 PTE 条目可能会泄漏内存,并导致其他意外和危险的行为。

移动页表时还有其他适用规则,我们将在下面关于此主题的部分中进行讨论。

PTE 级别的页表与其他级别的页表不同,并且访问它们有额外的要求。

  • 在 32 位体系结构上,它们可能位于高内存中(这意味着需要将其映射到内核内存中才能访问)。

  • 当为空时,可以在持有 mmap 锁或 rmap 锁进行读取以及 PTE 和 PMD 页表锁的情况下取消链接并 RCU 释放它们。特别是,这种情况发生在 retract_page_tables() 处理 MADV_COLLAPSE 时。因此,访问 PTE 级别的页表至少需要持有 RCU 读取锁;但这仅适用于可以容忍与并发页表更新竞争的读取器,以便观察到空的 PTE(在实际上已分离并标记为 RCU 释放的页表中),而另一个新的页表已安装在同一位置并填充了条目。写入器通常需要获取 PTE 锁并重新验证 PMD 条目是否仍然引用同一个 PTE 级别的页表。

要访问 PTE 级别的页表,可以使用像 pte_offset_map_lock()pte_offset_map() 这样的助手,具体取决于稳定性要求。如果需要,它们会将页表映射到内核内存中,获取 RCU 锁,并且根据变体,还可能查找或获取 PTE 锁。请参阅关于 __pte_offset_map_lock() 的注释。

原子性

无论页表锁如何,MMU 硬件都会同时更新访问位和脏位(可能更多,具体取决于体系结构)。此外,并行执行页表遍历操作(尽管保持 VMA 稳定)以及像 GUP-fast 这样的功能会无锁地遍历(即读取)页表,甚至根本不保持 VMA 稳定。

在执行页表遍历并保持 VMA 稳定时,是否必须执行一次且仅执行一次读取取决于体系结构(例如,x86-64 不需要任何特殊预防措施)。

如果要执行写入操作,或者如果读取操作告知是否会发生写入操作(例如,在安装页表条目时,例如在 __pud_install() 中),则必须始终格外小心。在这些情况下,我们永远不能假设页表锁会为我们提供完全独占的访问权限,并且必须仅检索一次且仅一次页表条目。

如果我们正在读取页表条目,那么我们只需要确保编译器不会重新排列我们的加载。这是通过 pXXp_get() 函数实现的 - pgdp_get()p4dp_get()pudp_get()pmdp_get()ptep_get()

它们中的每一个都使用 READ_ONCE() 来保证编译器仅读取一次页表条目。

但是,如果我们希望操纵现有的页表条目并关心先前存储的数据,则必须进一步使用硬件原子操作,例如,在 ptep_get_and_clear() 中。

同样,不依赖于保持 VMA 稳定的操作,例如 GUP-fast(请参阅 gup_fast() 及其各种页表级别处理程序,如 gup_fast_pte_range()),必须非常小心地与页表条目进行交互,使用诸如 ptep_get_lockless() 之类的函数,以及更高层页表级别的等效函数。

对页表条目的写入也必须是适当的原子操作,如 set_pXX() 函数所建立的 - set_pgd()set_p4d()set_pud()set_pmd()set_pte()

同样,清除页表条目的函数也必须是适当的原子操作,如 pXX_clear() 函数中所示 - pgd_clear()p4d_clear()pud_clear()pmd_clear()pte_clear()

页表安装

页表安装是在读取或写入模式下显式持有 mmap 或 VMA 锁的情况下执行的(有关原因的详细信息,请参见锁定规则部分中的警告)。

当分配 P4D、PUD 或 PMD 并在上述 PGD、P4D 或 PUD 中设置相关条目时,必须持有 mm->page_table_lock。这是在 __p4d_alloc()__pud_alloc()__pmd_alloc() 中分别获取的。

注意

__pmd_alloc() 实际上依次调用 pud_lock()pud_lockptr(),但在编写本文时,它最终引用了 mm->page_table_lock

分配 PTE 将使用 mm->page_table_lock,或者如果定义了 USE_SPLIT_PMD_PTLOCKS,则使用嵌入在 PMD 物理页元数据中的锁,该锁的形式为 struct ptdesc,通过 pmd_ptdesc() 获取,该函数由 pmd_lock() 调用,最终由 __pte_alloc() 调用。

最后,修改 PTE 的内容需要特殊处理,因为每当我们希望稳定且独占地访问 PTE 中包含的条目时,都必须获取 PTE 页表锁,尤其是在我们希望修改它们时。

这是通过 pte_offset_map_lock() 执行的,该函数仔细检查以确保 PTE 没有在我们不知情的情况下被更改,最终调用 pte_lockptr() 以获取在与物理 PTE 页关联的 struct ptdesc 中包含的 PTE 粒度的自旋锁。锁必须通过 pte_unmap_unlock() 释放。

注意

这里有一些变体,例如 pte_offset_map_rw_nolock(),当已知我们持有稳定的 PTE 时,为了简洁起见,我们不探讨它。有关更多详细信息,请参阅 __pte_offset_map_lock() 的注释。

当修改范围中的数据时,我们通常只希望在必要时分配更高层的页表,使用这些锁来避免竞争或覆盖任何内容,并根据需要在 PTE 级别设置/清除数据(例如在页面错误或清除时)。

当遍历页表条目以安装新的映射时,通常采用的模式是乐观地确定上方表中页表条目是否为空,如果是,则仅获取页表锁并再次检查它是否在我们不知情的情况下被分配。

这允许在仅在需要时才获取页表锁的情况下进行遍历。一个例子是 __pud_alloc()

在叶页表(即 PTE)中,我们不能完全依赖这种模式,因为我们有单独的 PMD 和 PTE 锁,例如,THP 折叠可能会在我们不知情的情况下消除 PMD 条目以及 PTE。

这就是为什么 __pte_offset_map_lock() 无锁地检索 PTE 的 PMD 条目,仔细检查它是否如预期的那样,然后再获取特定于 PTE 的锁,然后再次检查 PMD 条目是否如预期的那样。

如果发生 THP 折叠(或类似情况),则会获取两个页面的锁,因此我们可以确保在持有 PTE 锁时防止这种情况发生。

以这种方式安装条目可确保写入时的互斥。

页表释放

拆除页表本身是一件需要非常小心的事情。必须没有任何方式让并发任务遍历或引用指定要删除的页表。

仅仅持有 mmap 写锁和 VMA 锁(这将防止竞争错误和 rmap 操作)是不够的,因为文件支持的映射可以在 struct address_space->i_mmap_rwsem 下被截断。

因此,任何可以通过反向映射访问的 VMA(通过 struct anon_vma->rb_rootstruct address_space->i_mmap 区间树)都不能拆除其页表。

该操作通常通过 free_pgtables() 执行,该函数假设已获取 mmap 写锁(如其 mm_wr_locked 参数所指定),或者 VMA 已无法访问。

它会仔细地从所有反向映射中删除 VMA,但是重要的是,没有新的映射与这些映射重叠,或者保留任何允许访问正在拆除其页表的范围内的地址的路径。

此外,它假设已经执行了 zap 操作,并且已经采取了步骤来确保在 zap 操作和 free_pgtables() 的调用之间不会安装任何其他页表条目。

由于假定已采取所有这些步骤,因此在不使用页表锁的情况下清除页表条目(在 pgd_clear()p4d_clear()pud_clear()pmd_clear() 函数中)。

注意

叶页表可以独立于其上方的页表被拆除,就像 retract_page_tables() 所做的那样,该函数在 i_mmap 读锁、PMD 和 PTE 页表锁下执行,没有这种程度的谨慎。

页表移动

某些函数会操作 PMD 上方的页表级别(即 PUD、P4D 和 PGD 页表)。其中最著名的是 mremap(),它能够移动更高级别的页表。

在这些情况下,需要获取所有锁,即 mmap 锁、VMA 锁和相关的 rmap 锁。

您可以在 mremap() 实现中的 take_rmap_locks()drop_rmap_locks() 函数中观察到这一点,这两个函数执行锁获取的 rmap 侧,最终由 move_page_tables() 调用。

VMA 锁内部

概述

VMA 读锁定完全是乐观的 - 如果锁发生争用或竞争写入已开始,则我们不会获取读锁。

VMA 锁通过 lock_vma_under_rcu() 获取,该函数首先调用 rcu_read_lock() 以确保在 RCU 临界区中查找 VMA,然后尝试通过 vma_start_read() 对其进行 VMA 锁定,然后在通过 rcu_read_unlock() 释放 RCU 锁之前。

VMA 读锁在其持续时间内持有 vma->vm_lock 信号量的读锁,并且 lock_vma_under_rcu() 的调用者必须通过 vma_end_read() 释放它。

VMA 锁在 VMA 即将被修改的情况下通过 vma_start_write() 获取,与 vma_start_read() 不同,始终会获取锁。mmap 写锁必须在 VMA 写锁的持续时间内持有,释放或降级 mmap 写锁也会释放 VMA 写锁,因此没有 vma_end_write() 函数。

请注意,信号量写锁不会跨 VMA 锁持有。相反,序列号用于序列化,并且仅在写锁点才获取写信号量来更新此序列号。

这确保了我们需要的语义 - VMA 写锁提供对 VMA 的独占写访问。

实现细节

VMA 锁机制旨在成为一种避免使用竞争激烈的 mmap 锁的轻量级方法。它是使用读/写信号量和属于包含 struct mm_struct 和 VMA 的序列号的组合来实现的。

读锁通过 vma_start_read() 获取,这是一个乐观的操作,即它尝试获取读锁,但如果无法获取则返回 false。在读取操作结束时,调用 vma_end_read() 以释放 VMA 读锁。

调用 vma_start_read() 需要首先调用 rcu_read_lock(),以确定我们在 VMA 读锁获取时处于 RCU 临界区。一旦获取,就可以释放 RCU 锁,因为它仅用于查找。这由 lock_vma_under_rcu() 抽象化,这是用户应使用的接口。

写入操作需要对 mmap 进行写锁定,并通过 vma_start_write() 获取 VMA 锁。但是,写锁会在 mmap 写锁终止或降级时释放,因此不需要 vma_end_write()

所有这些都是通过使用每个 mm 和每个 VMA 的序列计数来实现的,这些计数用于降低复杂性,特别是对于一次写入锁定多个 VMA 的操作。

如果 mm 序列计数 mm->mm_lock_seq 等于 VMA 序列计数 vma->vm_lock_seq,则该 VMA 被写锁定。如果它们不同,则未被锁定。

每次在 mmap_write_unlock()mmap_write_downgrade() 中释放 mmap 写锁时,都会调用 vma_end_write_all(),它也会通过 mm_lock_seqcount_end() 递增 mm->mm_lock_seq

这样,我们确保,无论 VMA 的序列号如何,都不会错误地指示写锁定,并且当我们释放 mmap 写锁时,我们有效地同时释放 mmap 中包含的所有 VMA 写锁。

由于 mmap 写锁与其他持有者互斥,因此在其释放时自动释放任何 VMA 锁是有意义的,因为您永远不希望在完全独立的写入操作之间保持 VMA 锁定。它也保持了正确的锁顺序。

每次获取 VMA 读锁时,我们都会获取 vma->vm_lock 读/写信号量的读锁并持有它,同时检查 VMA 的序列计数是否与 mm 的序列计数不匹配。

如果匹配,则读锁失败。如果不匹配,我们持有该锁,排除写操作,但允许其他读取操作,这些读取操作也将在 RCU 下获取此锁。

重要的是,在 lock_vma_under_rcu() 中执行的 maple 树操作也是 RCU 安全的,因此整个读锁操作保证可以正常工作。

在写入端,我们在设置 VMA 的序列号之前,获取 vma->vm_lock 读/写信号量的写锁,同时持有 mmap 写锁。

这样,如果任何读锁生效,vma_start_write() 将休眠直到这些读锁完成并且实现互斥。

在设置 VMA 的序列号后,释放该锁,避免了长期持有写锁的复杂性。

这种读/写信号量和序列计数的巧妙组合允许快速基于 RCU 的每个 VMA 锁的获取(特别是在页面错误时,尽管在其他地方也使用),并且围绕锁顺序的复杂性最小。

mmap 写锁降级

当持有 mmap 写锁时,可以独占访问 mmap 中的资源(通常需要 VMA 写锁以避免与持有 VMA 读锁的任务发生竞争的情况)。

然后可以通过 mmap_write_downgrade() 从写锁降级到读锁,类似于 mmap_write_unlock(),它通过 vma_end_write_all() 隐式终止所有 VMA 写锁,但重要的是在降级时不会放弃 mmap 锁,因此保持锁定的虚拟地址空间稳定。

一个有趣的后果是,降级的锁与其他任何拥有降级锁的任务互斥(因为竞争的任务必须首先获取写锁才能降级它,而降级的锁会阻止获取新的写锁,直到原始锁被释放)。

为了清楚起见,我们将读(R)/降级写(D)/写(W)锁相互映射,显示哪些锁会排除其他锁

锁的互斥性

D

D

此处 Y 表示匹配的行/列中的锁是互斥的,而 N 表示它们不是。

堆栈扩展

堆栈扩展带来了额外的复杂性,因为我们不允许出现竞争的页面错误。因此,我们在 expand_downwards()expand_upwards() 中调用 vma_start_write() 来防止这种情况。