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 與事件接收並行執行。單執行緒實現應繼續使用當前的非同步事件傳遞模型。