进程地址

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

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

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

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

注意

此规则的一个例外是 ‘gate’ VMA,它由使用 vsyscall 的体系结构提供,并且是一个不属于任何特定 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 通过 folio->mapping 访问)访问 VMA 时。 VMA 必须通过 anon_vma_[try]lock_read()anon_vma_[try]lock_write()(对于匿名内存)和 i_mmap_[try]lock_read()i_mmap_[try]lock_write()(对于文件支持的内存)进行稳定。 我们将这些锁称为反向映射锁,或简称 ‘rmap 锁’。

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

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

稳定 VMA 还会保留它描述的地址空间。

锁的使用

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

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

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

  • 在遍历锁定的间隔树(无论是匿名的还是文件支持的)以获取所需的 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 锁

稳定?

读取?

写入大多数?

写入全部?

-

-

-

-

R

-

-

-

R/W

R/W

-/R

-/R/W

W

W

-/R

W

W

W

警告

虽然可以在持有 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(如果是 PFN 映射且体系结构不支持 CONFIG_ARCH_HAS_PTE_SPECIAL)。

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 对象,描述 NUMA 平衡与此 VMA 相关的当前状态。 由 task_numa_work() 在 mmap 读锁下更新。

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

指向 fork/CoW 的 anon_vma 对象和 vma->anon_vma 的指针列表(如果它是非 NULL)。

mmap 读,anon_vma 写。

anon_vma

anon_vma 对象,由专门映射到此 VMA 的匿名 folio 使用。 最初由 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 的 folio 应驻留。

注意

如果使用 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()),必须格外小心以确保安全地完成此操作,因为此逻辑最终会释放指定范围内的所有页表,而忽略现有的叶条目(它假定调用者已 zap 了该范围并阻止了其中的任何进一步的故障或修改)。

注意

在 rmap 锁下执行用于回收或迁移的映射修改,因为它像 zapping 一样,不会从根本上修改正在映射的内容的标识。

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

也就是说 - 只要您保持相关 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 各自都有细粒度的锁,这些锁要么保存在描述页表的 folio 中,要么分配并由 folio 指向(如果设置了 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 释放它们。 特别是,当处理 MADV_COLLAPSE 时,这种情况发生在 retract_page_tables() 中。 因此,访问 PTE 级别的页表至少需要持有 RCU 读锁; 但这仅足以让可以容忍与并发页表更新竞争的读取器观察到空的 PTE(在实际上已经分离并标记为 RCU 释放的页表中),而另一个新的页表已安装在同一位置并填充了条目。 写入器通常需要获取 PTE 锁并重新验证 PMD 条目是否仍然引用同一 PTE 级别的页表。 如果写入器不在乎是否是同一 PTE 级别的页表,则它可以获取 PMD 锁并重新验证 pmd 条目的内容是否仍然满足要求。 特别是,当处理 MADV_COLLAPSE 时,这种情况也发生在 retract_page_tables() 中。

要访问 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 锁显式保持 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_lock() 调用的 pmd_ptdesc() 和最终的 __pte_alloc() 获取。

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

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

注意

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

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

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

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

在叶页表(即 PTE)上,我们不能完全依赖此模式,因为我们有单独的 PMD 和 PTE 锁,并且例如 THP 折叠可能已经从我们不知情的情况下消除了 PMD 条目以及 PTE。

这就是为什么 __pte_offset_map_lock() 在获取特定于 PTE 的锁之前,以无锁方式检索 PTE 的 PMD 条目,仔细检查它是否如预期,然后再次检查 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() 锁定它,然后在通过 rcu_read_unlock() 释放 RCU 锁之前。

在用户已经持有 mmap 读锁的情况下,可以使用 vma_start_read_locked()vma_start_read_locked_nested()。这些函数不会因锁争用而失败,但调用者仍应检查它们的返回值,以防它们因其他原因而失败。

VMA 读锁在其持续时间内递增 vma.vm_refcnt 引用计数器,并且 lock_vma_under_rcu() 的调用者必须通过 vma_end_read() 释放它。

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

请注意,当写锁定 VMA 锁时,vma.vm_refcnt 会被临时修改,以便读者可以检测到写作者的存在。一旦用于序列化的 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 锁通过 vma_start_write() 获得,但是写锁通过 mmap 写锁的终止或降级来释放,因此不需要 vma_end_write()

所有这些都是通过使用 per-mm 和 per-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_refcnt 引用计数器,并检查 VMA 的序列计数是否与 mm 的序列计数不匹配。

如果匹配,则读锁失败并删除 vma.vm_refcnt。如果不匹配,我们保持引用计数器升高,排除写作者,但允许其他读者,他们也可以在 RCU 下获得此锁。

重要的是,在 lock_vma_under_rcu() 中执行的 maple 树操作也是 RCU 安全的,因此保证整个读取锁定操作能够正常运行。

在写入方面,我们在 vma.vm_refcnt 中设置一个位,读者无法修改该位,并等待所有读者删除其引用计数。一旦没有读者,VMA 的序列号就会设置为与 mm 的序列号匹配。在整个操作过程中,mmap 写锁被持有。

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

在设置 VMA 的序列号后,清除 vma.vm_refcnt 中指示写作者的位。从此刻开始,VMA 的序列号将指示 VMA 的写锁定状态,直到 mmap 写锁被删除或降级。

引用计数器和序列计数的这种巧妙组合允许基于 RCU 的快速 per-VMA 锁获取(尤其是在页面错误时,但在其他地方也被利用),并且锁排序的复杂性最小。

mmap 写锁降级

当持有 mmap 写锁时,一个人对 mmap 中的资源具有独占访问权(通常需要 VMA 写锁才能避免与持有 VMA 读锁的任务发生竞争)。

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

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

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

锁互斥性

R

D

W

R

D

W

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

堆栈扩展

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