Userfaultfd

目标

Userfaults 允许从用户空间实现按需分页,更普遍地,它们允许用户空间控制各种内存页错误,而这原本只有内核代码才能做到。

例如,userfaults 允许更好地和更优地实现 PROT_NONE+SIGSEGV 技巧。

设计

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

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

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

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

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

创建后,userfaultfd 也可以使用 unix 域套接字传递给管理器进程,因此同一个管理器进程可以处理多个不同进程的 userfaults,而无需它们知道发生了什么(当然,除非它们稍后尝试在管理器已经跟踪的同一区域上使用 userfaultfd 本身,这是一个目前会返回 -EBUSY 的边界情况)。

API

创建 userfaultfd

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

第一种方法,自引入 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) 系统调用等效的 userfaultfds。

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

初始化 userfaultfd

首次打开时,必须通过调用 UFFDIO_API ioctl 并指定设置为 UFFD_API(或更高版本的 API)的 uffdio_api.api 值来启用 userfaultfd,这将指定用户空间打算在 UFFD 上使用的 read/POLLIN 协议以及用户空间要求的 uffdio_api.features。 如果 UFFDIO_API ioctl 成功(即,如果运行中的内核也使用请求的 uffdio_api.api 并且将启用请求的功能),则将在 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 和共享内存的 UFFDIO_REGISTER_MODE_MISSING 注册(涵盖所有 shmem API,即 tmpfs,IPCSHM/dev/zeroMAP_SHAREDmemfd_create 等)虚拟内存区域。

  • 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 位掩码,该位掩码表示适合于解决已注册范围内的 userfaults 的 ioctl。 并非所有 ioctl 都必然支持所有内存类型(例如,匿名内存与 shmem 与 hugetlbfs),或者所有类型的拦截错误。

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

解决Userfaults

有三种基本方法可以解决 userfaults

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

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

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

这些操作是原子的,因为它们保证没有人可以看到半填充的页面,因为读者将继续 userfaulting,直到操作完成。

默认情况下,这些会唤醒阻塞在相关范围上的 userfaults。 它们支持 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),而不是使用 mprotect(2),而 mode = UFFDIO_WRITEPROTECT_MODE_WP 在传入的结构中。 该范围不会默认为且不必与您注册的范围相同。 您可以根据需要写保护尽可能多的范围(在注册范围内)。 然后,在从 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 写保护模式目前在不同类型的内存上的 none ptes(例如,当页面丢失时)上的行为不同。

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

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

当将 UFFDIO_REGISTER_MODE_WPUFFDIO_REGISTER_MODE_MISSINGUFFDIO_REGISTER_MODE_MINOR 结合使用时,在使用 UFFDIO_COPYUFFDIO_CONTINUE 分别解决 missing/minor 错误时,可能希望新的页面/映射受到写保护(因此将来的写入也会导致 WP 错误)。 这些 ioctl 支持模式标志(分别为 UFFDIO_COPY_MODE_WPUFFDIO_CONTINUE_MODE_WP)以这种方式配置映射。

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

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

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

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

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

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

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

在该页面被 ioctl(UFFDIO_WRITEPROTECT) 显式写保护,并设置了模式标志 UFFDIO_WRITEPROTECT_MODE_WP 之前,该页面将不会受到 uffd-wp 异步模式的跟踪。 尝试解决由异步模式 userfaultfd-wp 跟踪的页面错误是无效的。

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

内存损坏模拟

作为对错误(丢失或 minor)的响应,用户空间可以采取的“解决”它的一个操作是发出 UFFDIO_POISON。 这将导致任何将来的错误者获得 SIGBUS,或者在 KVM 的情况下,来宾将收到 MCE,就像存在硬件内存损坏一样。

这用于模拟硬件内存损坏。 想象一下,VM 在一台遇到真实硬件内存错误的机器上运行。 稍后,我们将 VM 实时迁移到另一台物理机器。 由于我们希望迁移对来宾透明,因此我们希望该地址范围的行为就像它仍然已损坏一样,即使它位于一台表面上在完全相同的位置没有内存错误的新物理主机上。

QEMU/KVM

QEMU/KVM 正在使用 userfaultfd 系统调用来实现 postcopy 实时迁移。 Postcopy 实时迁移是内存外部化的一种形式,包括在云中不同的节点上运行部分或全部内存的虚拟机。 userfaultfd 抽象足够通用,无需修改任何 KVM 内核代码即可将 postcopy 实时迁移添加到 QEMU。

来宾异步页面错误、FOLL_NOWAIT 和所有其他 GUP* 功能与 userfaults 结合使用效果很好。 Userfaults 在来宾调度程序中触发异步页面错误,因此那些不等待 userfaults 的来宾进程(即网络绑定)可以继续在来宾 vcpu 中运行。

通常在启动 postcopy 实时迁移之前运行一次 precopy 实时迁移是有益的,以避免为只读来宾区域生成 userfaults。

postcopy 实时迁移的实现当前使用单个双向套接字,但将来将使用两个不同的套接字(以尽可能减少 userfaults 的延迟,而无需减少 /proc/sys/net/ipv4/tcp_wmem)。

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

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

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

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

非协作式userfaultfd

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

UFFD_FEATURE_EVENT_FORK

为 fork() 启用 userfaultfd 钩子。 启用此功能后,父进程的 userfaultfd 上下文将复制到新创建的进程中。 管理器在 uffd_msg.fork 中接收带有新 userfaultfd 上下文的文件描述符的 UFFD_EVENT_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 与事件接收并行运行。单线程实现应继续使用当前的异步事件传递模型。