Userfaultfd¶
目标¶
Userfault 允许从用户空间实现按需分页,更一般地,它们允许用户空间控制各种内存页面错误,这是内核代码才能做的事情。
例如,userfault 允许对 PROT_NONE+SIGSEGV
技巧进行正确且更优化的实现。
设计¶
用户空间创建一个新的 userfaultfd,初始化它,并向其注册一个或多个虚拟内存区域。然后,在该区域内发生的任何页面错误都会导致消息传递到 userfaultfd,通知用户空间该错误。
userfaultfd
(除了注册和取消注册虚拟内存范围)提供两个主要功能
read/POLLIN
协议,用于通知用户空间线程正在发生的错误各种
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.features
和 uffdio_api.ioctls
中返回两个 64 位位掩码,其中包含 read(2) 协议的所有可用功能和可用的通用 ioctl。
UFFDIO_API
ioctl 返回的 uffdio_api.features
位掩码定义了 userfaultfd
支持的内存类型以及可能生成的除页面错误通知之外的其他事件
UFFD_FEATURE_EVENT_*
标志指示支持除页面错误之外的各种其他事件。这些事件在下面的非协作 userfaultfd部分中进行了更详细的描述。UFFD_FEATURE_MISSING_HUGETLBFS
和UFFD_FEATURE_MISSING_SHMEM
指示内核分别支持 hugetlbfs 和共享内存(涵盖所有 shmem API,即 tmpfs、IPCSHM
、/dev/zero
、MAP_SHARED
、memfd_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_MISSING
和 UFFDIO_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_WP
与 UFFDIO_REGISTER_MODE_MISSING
或 UFFDIO_REGISTER_MODE_MINOR
结合使用时,当分别使用 UFFDIO_COPY
或 UFFDIO_CONTINUE
解决缺失/次要错误时,可能希望对新页面/映射进行写保护(因此未来的写入也将导致 WP 错误)。这些 ioctl 支持模式标志(分别为 UFFDIO_COPY_MODE_WP
或 UFFDIO_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_WP
的 ioctl(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_REMAP
。uffd_msg.remap
将包含该区域的旧地址和新地址及其原始长度。UFFD_FEATURE_EVENT_REMOVE
启用有关 madvise(MADV_REMOVE) 和 madvise(MADV_DONTNEED) 调用的通知。在这些对 madvise() 的调用时,将生成事件
UFFD_EVENT_REMOVE
。uffd_msg.remove
将包含已删除区域的起始地址和结束地址。UFFD_FEATURE_EVENT_UNMAP
启用有关内存取消映射的通知。管理器将收到
UFFD_EVENT_UNMAP
,其中uffd_msg.remove
包含未映射区域的起始地址和结束地址。
虽然 UFFD_FEATURE_EVENT_REMOVE
和 UFFD_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 与事件接收并行运行。单线程实现应继续使用当前的异步事件传递模型。