Userfaultfd

目标

Userfault 允许从用户空间实现按需分页,更一般地,它们允许用户空间控制各种内存页面错误,这是内核代码才能做的事情。

例如,userfault 允许对 PROT_NONE+SIGSEGV 技巧进行正确且更优化的实现。

设计

用户空间创建一个新的 userfaultfd,初始化它,并向其注册一个或多个虚拟内存区域。然后,在该区域内发生的任何页面错误都会导致消息传递到 userfaultfd,通知用户空间该错误。

userfaultfd(除了注册和取消注册虚拟内存范围)提供两个主要功能

  1. read/POLLIN 协议,用于通知用户空间线程正在发生的错误

  2. 各种 UFFDIO_* ioctl,可以管理在 userfaultfd 中注册的虚拟内存区域,这允许用户空间有效地解决它通过 1) 收到的 userfault,或者在后台管理虚拟内存

与 mremap/mprotect 的常规虚拟内存管理相比,userfault 的真正优势在于,它们在所有操作中从不涉及 vma 之类的重量级结构(实际上,userfaultfd 运行时负载从不获取 mmap_lock 进行写入)。在处理可能跨越 TB 的虚拟地址空间时,Vma 不适合页面(或大页)粒度的错误跟踪。为此需要太多的 vma。

userfaultfd 一旦创建,也可以使用 Unix 域套接字传递给管理进程,因此同一个管理进程可以处理多个不同进程的 userfault,而无需它们知道正在发生什么(当然,除非它们稍后尝试在管理器已在跟踪的同一区域上使用 userfaultfd,这是一个角落案例,当前将返回 -EBUSY)。

API

创建 userfaultfd

有两种方法可以创建新的 userfaultfd,每种方法都提供了限制对此功能访问的方式(因为从历史上看,处理内核页面错误的 userfaultfd 一直是利用内核的有用工具)。

第一种方法是自引入 userfaultfd 以来就支持的 userfaultfd(2) 系统调用。 对此的访问受到多种方式的控制

  • 任何用户都可以始终创建仅捕获用户空间页面错误的 userfaultfd。可以使用带有 UFFD_USER_MODE_ONLY 标志的 userfaultfd(2) 系统调用来创建此类 userfaultfd。

  • 为了捕获地址空间的内核页面错误,进程需要 CAP_SYS_PTRACE 功能,或者系统必须将 vm.unprivileged_userfaultfd 设置为 1。默认情况下,vm.unprivileged_userfaultfd 设置为 0。

最近添加到内核的第二种方法是通过打开 /dev/userfaultfd 并向其发出 USERFAULTFD_IOC_NEW ioctl。此方法产生与 userfaultfd(2) 系统调用等效的 userfaultfd。

与 userfaultfd(2) 不同,对 /dev/userfaultfd 的访问是通过普通文件系统权限(用户/组/模式)控制的,这可以对 userfaultfd 进行细粒度的访问,而不会同时授予其他不相关的权限(例如,授予 CAP_SYS_PTRACE 会这样做)。有权访问 /dev/userfaultfd 的用户始终可以创建捕获内核页面错误的 userfaultfd;不会考虑 vm.unprivileged_userfaultfd。

初始化 userfaultfd

首次打开时,必须通过调用 UFFDIO_API ioctl 并指定设置为 UFFD_API(或更高版本的 API)的 uffdio_api.api 值来启用 userfaultfd,该值将指定用户空间打算在 UFFD 上使用的 read/POLLIN 协议以及用户空间要求的 uffdio_api.features。 如果成功(即,如果运行的内核也使用请求的 uffdio_api.api 并且将启用请求的功能),则 UFFDIO_API ioctl 将分别在 uffdio_api.featuresuffdio_api.ioctls 中返回两个 64 位位掩码,其中包含 read(2) 协议的所有可用功能和可用的通用 ioctl。

UFFDIO_API ioctl 返回的 uffdio_api.features 位掩码定义了 userfaultfd 支持的内存类型以及可能生成的除页面错误通知之外的其他事件

  • UFFD_FEATURE_EVENT_* 标志指示支持除页面错误之外的各种其他事件。这些事件在下面的非协作 userfaultfd部分中进行了更详细的描述。

  • UFFD_FEATURE_MISSING_HUGETLBFSUFFD_FEATURE_MISSING_SHMEM 指示内核分别支持 hugetlbfs 和共享内存(涵盖所有 shmem API,即 tmpfs、IPCSHM/dev/zeroMAP_SHAREDmemfd_create 等)虚拟内存区域的 UFFDIO_REGISTER_MODE_MISSING 注册。

  • UFFD_FEATURE_MINOR_HUGETLBFS 指示内核支持 hugetlbfs 虚拟内存区域的 UFFDIO_REGISTER_MODE_MINOR 注册。UFFD_FEATURE_MINOR_SHMEM 是类似的功能,表示支持 shmem 虚拟内存区域。

  • UFFD_FEATURE_MOVE 指示内核支持从用户空间移动现有页面内容。

用户空间应用程序应在调用 UFFDIO_API ioctl 时设置它打算使用的功能标志,以请求在支持的情况下启用这些功能。

一旦启用了 userfaultfd API,就应该调用 UFFDIO_REGISTER ioctl(如果在返回的 uffdio_api.ioctls 位掩码中存在),通过相应地设置 uffdio_register 结构,在 userfaultfd 中注册一个内存范围。uffdio_register.mode 位掩码将指定内核要跟踪该范围的哪种类型的故障。UFFDIO_REGISTER ioctl 将返回 uffdio_register.ioctls 位掩码,其中包含适用于解析已注册范围上的用户错误的 ioctl。并非所有 ioctl 都必然支持所有内存类型(例如,匿名内存与 shmem 与 hugetlbfs)或所有类型的拦截故障。

用户态可以使用 uffdio_register.ioctls 在后台管理虚拟地址空间(从 userfaultfd 注册的范围添加或可能删除内存)。这意味着用户错误可能在用户态在后台映射用户错误页面之前触发。

解决用户错误

解决用户错误有三种基本方法

  • UFFDIO_COPY 原子地从用户空间复制一些现有的页面内容。

  • UFFDIO_ZEROPAGE 原子地将新页面置零。

  • UFFDIO_CONTINUE 映射现有的、之前已填充的页面。

这些操作是原子的,因为它们保证在操作完成之前,没有人能看到半填充的页面,因为读取器将一直发生用户错误,直到操作完成。

默认情况下,这些操作会唤醒在该范围内阻塞的用户错误。它们支持一个 UFFDIO_*_MODE_DONTWAKE mode 标志,该标志指示唤醒将在稍后的某个时间单独完成。

选择哪个 ioctl 取决于页面错误的类型,以及我们希望如何解决它

  • 对于 UFFDIO_REGISTER_MODE_MISSING 错误,需要通过提供新页面 (UFFDIO_COPY) 或映射零页面 (UFFDIO_ZEROPAGE) 来解决该错误。默认情况下,内核会为缺失的错误映射零页面。使用 userfaultfd,用户空间可以在错误线程继续之前决定提供什么内容。

  • 对于 UFFDIO_REGISTER_MODE_MINOR 错误,存在一个现有页面(在页面缓存中)。用户空间可以选择在解决错误之前修改页面的内容。一旦内容正确(已修改或未修改),用户空间就会请求内核映射该页面,并让错误线程通过 UFFDIO_CONTINUE 继续。

注释

  • 您可以通过检查 uffd_msg 中的 pagefault.flags 来判断发生了哪种类型的错误,检查 UFFD_PAGEFAULT_FLAG_* 标志。

  • 没有一个页面传递 ioctl 默认使用您注册的范围。您必须填写适当的 ioctl 结构的所有字段,包括范围。

  • 您可以从 uffd 中线程读取的 struct uffd_msg 中获取触发缺失页面事件的访问地址。您可以使用这些 IOCTL 提供任意数量的页面。请记住,除非您使用了 DONTWAKE,否则任何这些 IOCTL 中的第一个都会唤醒错误线程。

  • 请务必测试所有错误,包括 (pollfd[0].revents & POLLERR)。例如,当提供的范围不正确时,可能会发生这种情况。

写保护通知

这等效于(但比)使用 mprotect 和 SIGSEGV 信号处理程序更快。

首先,您需要使用 UFFDIO_REGISTER_MODE_WP 注册一个范围。您可以使用 ioctl(uffd, UFFDIO_WRITEPROTECT, struct *uffdio_writeprotect),而 mode = UFFDIO_WRITEPROTECT_MODE_WP 在传入的结构中,而不是使用 mprotect(2)。该范围不是默认值,并且不必与您注册的范围相同。您可以根据需要保护任意数量的范围(在注册范围内)。然后,在从 uffd 读取的线程中,结构将设置 msg.arg.pagefault.flags & UFFD_PAGEFAULT_FLAG_WP。现在,再次发送 ioctl(uffd, UFFDIO_WRITEPROTECT, struct *uffdio_writeprotect),同时 pagefault.mode 未设置 UFFDIO_WRITEPROTECT_MODE_WP。这将唤醒线程,该线程将继续运行并进行写入。这允许您在 ioctl 之前在 uffd 读取线程中进行关于写入的簿记。

如果您同时使用 UFFDIO_REGISTER_MODE_MISSINGUFFDIO_REGISTER_MODE_WP 注册,则需要考虑您提供页面和撤消写保护的顺序。请注意,写入 WP 区域和写入 !WP 区域之间存在差异。前者将设置 UFFD_PAGEFAULT_FLAG_WP,后者设置 UFFD_PAGEFAULT_FLAG_WRITE。后者并非因保护失败,但当使用 UFFDIO_REGISTER_MODE_MISSING 时,您仍然需要提供页面。

Userfaultfd 写保护模式目前在非 ptes(例如,当页面丢失时)在不同类型的内存上表现不同。

对于匿名内存,ioctl(UFFDIO_WRITEPROTECT) 将忽略非 ptes(例如,当页面丢失且未填充时)。对于文件支持的内存,如 shmem 和 hugetlbfs,非 ptes 将像 present pte 一样受到写保护。换句话说,只要页面范围之前受到写保护,当写入文件类型内存上的缺失页面时,就会生成 userfaultfd 写错误消息。默认情况下,不会在匿名内存上生成此类消息。

如果应用程序希望能够在匿名内存上保护非 ptes,可以使用例如 MADV_POPULATE_READ 预先填充内存。在较新的内核上,还可以检测 UFFD_FEATURE_WP_UNPOPULATED 功能,并预先设置功能位,以确保即使在匿名内存上,非 ptes 也会受到写保护。

当将 UFFDIO_REGISTER_MODE_WPUFFDIO_REGISTER_MODE_MISSINGUFFDIO_REGISTER_MODE_MINOR 结合使用时,当分别使用 UFFDIO_COPYUFFDIO_CONTINUE 解决缺失/次要错误时,可能希望对新页面/映射进行写保护(因此未来的写入也将导致 WP 错误)。这些 ioctl 支持模式标志(分别为 UFFDIO_COPY_MODE_WPUFFDIO_CONTINUE_MODE_WP)以按此方式配置映射。

如果 userfaultfd 上下文设置了 UFFD_FEATURE_WP_ASYNC 功能位,则任何注册了写保护的 vma 将在异步模式下工作,而不是默认的同步模式。

在异步模式下,发生写入操作时不会生成消息,同时内核将自动解决写保护。它可以被视为软脏跟踪的更准确版本,并且在几个方面可能有所不同

  • 脏结果不会受到 vma 更改(例如,vma 合并)的影响,因为脏结果仅由 pte 跟踪。

  • 它默认支持范围操作,因此只要页面对齐,就可以在任何内存范围内启用跟踪。

  • 如果由于各种原因(例如,在拆分 shmem 透明大页面期间)pte 被 zapped,脏信息不会丢失。

  • 由于软脏含义的颠倒(当设置 uffd-wp 位时页面干净;当清除 uffd-wp 位时页面脏),它在某些内存操作上具有不同的语义。例如:匿名内存上的 MADV_DONTNEED(或文件映射上的 MADV_REMOVE)将在该过程中通过删除 uffd-wp 位来处理为内存的脏化。

用户应用程序可以通过查找 /proc/pagemap 中感兴趣页面的 uffd-wp 位来收集“写入/脏”状态。

在该页面通过使用模式标志设置 UFFDIO_WRITEPROTECT_MODE_WPioctl(UFFDIO_WRITEPROTECT) 显式写保护之前,该页面将不属于 uffd-wp 异步模式的跟踪范围内。尝试解析由异步模式 userfaultfd-wp 跟踪的页面错误是无效的。

当单独使用 userfaultfd-wp 异步模式时,它可以应用于所有类型的内存。

内存中毒模拟

为了响应故障(无论是缺失还是轻微故障),用户空间可以采取的一种“解决”操作是发出 UFFDIO_POISON。这将导致任何未来的故障触发者收到 SIGBUS 信号,或者在 KVM 的情况下,客户机将收到 MCE,就像存在硬件内存中毒一样。

这用于模拟硬件内存中毒。想象一下,一个虚拟机运行在一台发生真正硬件内存错误的机器上。稍后,我们将虚拟机实时迁移到另一台物理机器。由于我们希望迁移对客户机是透明的,我们希望相同的地址范围表现得好像它仍然中毒一样,即使它位于一台新的物理主机上,该主机表面上在完全相同的位置没有内存错误。

QEMU/KVM

QEMU/KVM 正在使用 userfaultfd 系统调用来实现后复制实时迁移。后复制实时迁移是一种内存外部化的形式,它由一个虚拟机运行,其部分或全部内存驻留在云中的不同节点上。userfaultfd 抽象足够通用,以至于为了向 QEMU 添加后复制实时迁移,不需要修改一行 KVM 内核代码。

客户机异步页面错误、FOLL_NOWAIT 和所有其他 GUP* 功能都可以与用户错误一起正常工作。用户错误会在客户机调度程序中触发异步页面错误,因此那些不等待用户错误的客户机进程(即,网络绑定的进程)可以继续在客户机 vcpu 中运行。

通常,在开始后复制实时迁移之前运行一轮预复制实时迁移是有益的,以避免为只读客户机区域生成用户错误。

后复制实时迁移的实现目前使用一个单向双向套接字,但将来会使用两个不同的套接字(以最大限度地减少用户错误的延迟,而无需降低 /proc/sys/net/ipv4/tcp_wmem)。

源节点中的 QEMU 将它知道在目标节点中缺失的所有页面写入套接字,并且在目标节点中运行的 QEMU 的迁移线程在 userfaultfd 上运行 UFFDIO_COPY|ZEROPAGE ioctl,以便将接收到的页面映射到客户机中(如果源页面是零页面,则使用 UFFDIO_ZEROCOPY)。

目标节点中的另一个后复制线程使用 poll() 并行监听 userfaultfd。当用户错误触发后生成 POLLIN 事件时,后复制线程从 userfaultfd 中 read(),并接收故障地址(或者如果用户错误已解决并被并行 QEMU 迁移线程运行的 UFFDIO_COPY|ZEROPAGE 唤醒,则接收 -EAGAIN)。

在(运行在目标节点中的)QEMU 后复制线程获取用户错误地址后,它会将缺失页面的信息写入套接字。QEMU 源节点接收信息,并大致“查找”到该页面地址,并继续从新的页面偏移量发送所有剩余的缺失页面。此后不久(只是通过网络刷新 tcp_wmem 队列的时间),运行在目标节点中的 QEMU 的迁移线程将接收到触发用户错误的页面,并且它将像往常一样使用 UFFDIO_COPY|ZEROPAGE 映射它(实际上并不知道它是源自源节点的自发发送,还是通过用户错误请求的紧急页面)。

当用户错误开始时,目标节点中的 QEMU 不需要保留任何与实时迁移相关的每页状态位图,并且必须在源节点中运行的 QEMU 中维护单个每页位图,以了解哪些页面在目标节点中仍然缺失。检查源节点中的位图,以查找要在循环中发送的缺失页面,并在接收传入的用户错误时查找它。当然,在发送每个页面后,会相应地更新位图。避免两次发送同一页面也很有用(以防后复制线程在 UFFDIO_COPY|ZEROPAGE 在迁移线程中运行之前读取用户错误)。

非协作 userfaultfd

userfaultfd 由外部管理器监视时,管理器必须能够跟踪进程虚拟内存布局中的更改。Userfaultfd 可以使用与页面错误通知相同的 read(2) 协议来通知管理器有关此类更改。管理器必须通过在传递给 UFFDIO_API ioctl 的 uffdio_api.features 中设置适当的位来显式启用这些事件

UFFD_FEATURE_EVENT_FORK

为 fork() 启用 userfaultfd 钩子。启用此功能后,父进程的 userfaultfd 上下文将复制到新创建的进程中。管理器接收 UFFD_EVENT_FORK,其中新 userfaultfd 上下文的文件描述符位于 uffd_msg.fork 中。

UFFD_FEATURE_EVENT_REMAP

启用有关 mremap() 调用的通知。当非协作进程将虚拟内存区域移动到不同位置时,管理器将接收 UFFD_EVENT_REMAPuffd_msg.remap 将包含该区域的旧地址和新地址及其原始长度。

UFFD_FEATURE_EVENT_REMOVE

启用有关 madvise(MADV_REMOVE) 和 madvise(MADV_DONTNEED) 调用的通知。在这些对 madvise() 的调用时,将生成事件 UFFD_EVENT_REMOVEuffd_msg.remove 将包含已删除区域的起始地址和结束地址。

UFFD_FEATURE_EVENT_UNMAP

启用有关内存取消映射的通知。管理器将收到 UFFD_EVENT_UNMAP,其中 uffd_msg.remove 包含未映射区域的起始地址和结束地址。

虽然 UFFD_FEATURE_EVENT_REMOVEUFFD_FEATURE_EVENT_UNMAP 非常相似,但它们在 userfaultfd 管理器预期操作方面有所不同。在前一种情况下,虚拟内存被删除,但该区域没有,该区域仍然由 userfaultfd 监视,并且如果在该区域中发生页面错误,它将传递给管理器。此类页面错误的正确解决方案是零映射故障地址。但是,在后一种情况下,当一个区域被取消映射时,无论是显式地(使用 munmap() 系统调用),还是隐式地(例如在 mremap() 期间),该区域都会被删除,而该区域的 userfaultfd 上下文也会随之消失,并且管理器不会从已删除的区域中获得进一步的用户空间页面错误。尽管如此,仍需要通知,以防止管理器在未映射的区域上使用 UFFDIO_COPY

与必须是同步的并且需要显式或隐式唤醒的用户空间页面错误不同,所有事件都是异步传递的,并且非协作进程在管理器执行 read() 后立即恢复执行。userfaultfd 管理器应小心地将对 UFFDIO_COPY 的调用与事件处理同步。为了帮助同步,当受监控的进程在 UFFDIO_COPY 时退出时,UFFDIO_COPY ioctl 将返回 -ENOSPC;当非协作进程在未完成的 UFFDIO_COPY 操作的同时更改其虚拟内存布局时,将返回 -ENOENT

当前事件传递的异步模型对于单线程非协作 userfaultfd 管理器实现是最佳的。同步事件传递模型稍后可以作为新的 userfaultfd 功能添加,以方便非协作管理器的多线程增强,例如允许 UFFDIO_COPY ioctl 与事件接收并行运行。单线程实现应继续使用当前的异步事件传递模型。