寫時復制技術(一下簡稱COW)是linux內核比較重要的一種機制,我們都知道:父進程fork子進程的時候,子進程會和父進程會以只讀的方式共享所有私有的可寫頁,當有一方將要寫的時候會發生COW缺頁異常。那么究竟COW在linux內核中是如何觸發?又是如何處理的呢?我們將在本文中以源代碼情景分析的方式來解讀神秘的寫時COW,從源代碼級別的角度徹底理解它。
需要說明的是:本文中所分析的內核源碼時linux-5.0版本內核,使用arm64處理器架構,當然此文章發布時linux內核已經是linux-5.8.x,當你查看最新的內核源碼的時候會發現變化並不是很大。本文主要會從下面幾個方面去分析討論寫時復制:
- fork子進程時內核為COW做了哪些准備
- COW進程是如何觸發的
- 內核時怎樣處理COW這種缺頁異常的
- 匿名頁的reuse
一,從fork說起
我們都知道,進程是通過fork進行創建的,fork創建子進程的時候會和父進程共享資源,如fs,file,mm等等,其中內存資源的共享是一下路徑:
kernel/fork.c
_do_fork->copy_process->copy_mm
當然本文中討論的是COW,暫時不詳解其他資源共享以及內存資源共享的其他部分(后面的相關文章我們會討論),copy_mm總體來說所作的工作是:分配mm_struct結構實例mm,拷貝父進程的old_mm到mm,創建自己的pgd頁全局目錄,然后會遍歷父進程的vma鏈表為子進程建立vma鏈表(如代碼段,數據段等等),然后就是比較關鍵的頁的共享,linux內核為了效率考慮並不是拷貝父進程的所有物理頁內容,而是通過復制頁表來共享這些頁。而在復制頁表的時候,內核會判斷這個頁表條目是完全復制還是修改為只讀來為COW缺頁做准備。
共享父進程內存資源處理如下:
以下我們主要分析copy_one_pte 拷貝頁表條目的這一函數:
首先會處理一些頁表項不為空但物理頁不在內存中的情況(!pte_present(pte)分支)如被swap到交換分區中的頁,接下來處理物理頁在內存中的情況:
773 /*
774 |* If it's a COW mapping, write protect it both
775 |* in the parent and the child
776 |*/
777 if (is_cow_mapping(vm_flags) && pte_write(pte)) {//vma為私有可寫 而且pte有可寫屬性
778 ptep_set_wrprotect(src_mm, addr, src_pte);//設置父進程頁表項為只讀
779 pte = pte_wrprotect(pte); //為子進程設置只讀的頁表項值
780 }
781
上面的代碼塊是判斷當前頁所在的vma是否是私有可寫的屬性而且父進程頁表項是可寫:
247 static inline bool is_cow_mapping(vm_flags_t flags)
248 {
249 return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
250 }
如果判斷成立說明是COW的映射,則需要將父子進程頁表修改為只讀:
ptep_set_wrprotect(src_mm, addr, src_pte)將父進程的頁表項修改為只讀, pte = pte_wrprotect(pte)將子進程的即將寫入的頁表項值修改為只讀(注意:修改之前pte為父進程原來的pte值,修改之后子進程pte還沒有寫入到對應的頁表項條目中!)
修改頁表項為只讀的核心函數為:
152 static inline pte_t pte_wrprotect(pte_t pte)
153 {
154 pte = clear_pte_bit(pte, __pgprot(PTE_WRITE));//清可寫位
155 pte = set_pte_bit(pte, __pgprot(PTE_RDONLY));//置位只讀位
156 return pte;
157
再次回到copy_one_pte函數往下分析:
上面我們已經修改了父進程的頁表項,也獲得了子進程即將寫入的頁表項值pte(注意:現在還沒有寫入到子進程的頁表項中,因為此時子進程的頁表項值還沒有被完全拼接號好),接下來我們將要拼接子進程的頁表項的值:
782 /*
783 |* If it's a shared mapping, mark it clean in
784 |* the child
785 |*/
786 if (vm_flags & VM_SHARED) //vma的屬性為共享
787 pte = pte_mkclean(pte);//設置頁表項值為clean
788 pte = pte_mkold(pte); //設置頁表項值為未被訪問過即是清PTE_AF
789
790 page = vm_normal_page(vma, addr, pte); //獲得pte對應的page結構(即是和父進程共享的頁描述符)
791 if (page) {
792 get_page(page);//增進page結構的引用計數
793 page_dup_rmap(page, false);//注意:不是拷貝rmap 而是增加page->_mapcount計數(頁被映射計數)
794 rss[mm_counter(page)]++;
795 } else if (pte_devmap(pte)) {
796 page = pte_page(pte);
797
798 /*
799 |* Cache coherent device memory behave like regular page and
800 |* not like persistent memory page. For more informations see
801 |* MEMORY_DEVICE_CACHE_COHERENT in memory_hotplug.h
802 |*/
803 if (is_device_public_page(page)) {
804 get_page(page);
805 page_dup_rmap(page, false);
806 rss[mm_counter(page)]++;
807 }
808 }
809
810 out_set_pte:
811 set_pte_at(dst_mm, addr, dst_pte, pte);//將拼接的頁表項值寫入到子進程的頁表項中
812 return 0;
以上過程就完成了對於需要寫時復制的頁,將父子進程的頁表項改寫為只讀(這時候vma的屬性是可寫的),並共享相同的物理頁,這為下面的COW缺頁異常做好了頁表級別的准備工作。
二,COW缺頁異常觸發條件
當然如果父子進程僅僅是對COW共享的頁面做只讀訪問,則通過各自的頁表就能直接訪問到對應的數據,一切都正常,一旦有一方去寫,就會發生處理器異常,處理器會判斷出是COW缺頁異常:
arm64處理器處理過程:
我們從handle_pte_fault函數開始分析:
3800 if (vmf->flags & FAULT_FLAG_WRITE) {//vam可寫
3801 if (!pte_write(entry))//頁表項屬性只讀
3802 return do_wp_page(vmf);//處理cow
3803 entry = pte_mkdirty(entry);
3804 }
程序走到上面的判斷說明:頁表項存在,物理頁存在內存,但是vma是可寫,pte頁表項是只讀屬性(這就是fork的時候所作的准備),這些條件也是COW缺頁異常判斷的條件。
三,發生COW缺頁異常
當內核判斷了這次異常時COW缺頁異常,就會調用do_wp_page進行處理:
2480 static vm_fault_t do_wp_page(struct vm_fault *vmf)
2481 __releases(vmf->ptl)
2482 {
2483 struct vm_area_struct *vma = vmf->vma;
2484
2485 vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);//獲得異常地址對應的page實例
2486 if (!vmf->page) {
2487 /*
2488 |* VM_MIXEDMAP !pfn_valid() case, or VM_SOFTDIRTY clear on a
2489 |* VM_PFNMAP VMA.
2490 |*
2491 |* We should not cow pages in a shared writeable mapping.
2492 |* Just mark the pages writable and/or call ops->pfn_mkwrite.
2493 |*/
2494 if ((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
2495 | (VM_WRITE|VM_SHARED))
2496 return wp_pfn_shared(vmf);//處理共享可寫映射
2497
2498 pte_unmap_unlock(vmf->pte, vmf->ptl);
2499 return wp_page_copy(vmf);//處理私有可寫映射
2500 }
2485行,獲得發生異常時地址所在的page結構,如果沒有page結構是使用頁幀號的特殊映射,則通過wp_pfn_shared處理共享可寫映射,wp_page_copy處理私有可寫映射,當然這不是我們分析重點。
我們繼續往下分析:
我們主要關注2522行,判斷是否可以重新使用這個頁,這個稍后在分析。
2544 |* Ok, we need to copy. Oh, well..
2545 |*/
2546 get_page(vmf->page);
2547
2548 pte_unmap_unlock(vmf->pte, vmf->ptl);
2549 return wp_page_copy(vmf);
2546行 增加原來的頁的引用計數,防止被釋放。
2548行 釋放頁表鎖
2549行 這是COW處理的核心函數
我們下面將詳細分析wp_page_copy函數:
* - Allocate a page, copy the content of the old page to the new one.
2234 * - Handle book keeping and accounting - cgroups, mmu-notifiers, etc.
2235 * - Take the PTL. If the pte changed, bail out and release the allocated page
2236 * - If the pte is still the way we remember it, update the page table and all
2237 * relevant references. This includes dropping the reference the page-table
2238 * held to the old page, as well as updating the rmap.
2239 * - In any case, unlock the PTL and drop the reference we took to the old page.
2240 */
2241 static vm_fault_t wp_page_copy(struct vm_fault *vmf)
2242 {
2243 struct vm_area_struct *vma = vmf->vma;
2244 struct mm_struct *mm = vma->vm_mm;
2245 struct page *old_page = vmf->page;
2246 struct page *new_page = NULL;
2247 pte_t entry;
2248 int page_copied = 0;
2249 struct mem_cgroup *memcg;
2250 struct mmu_notifier_range range;
2251
2252 if (unlikely(anon_vma_prepare(vma)))
2253 goto oom;
2254
2255 if (is_zero_pfn(pte_pfn(vmf->orig_pte))) {
2256 new_page = alloc_zeroed_user_highpage_movable(vma,
2257 | vmf->address);
2258 if (!new_page)
2259 goto oom;
2260 } else {
2261 new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma,
2262 vmf->address);
2263 if (!new_page)
2264 goto oom;
2265 cow_user_page(new_page, old_page, vmf->address, vma);
2266 }
2252行 關聯一個anon_vma實例到vma
2255行 到 2259行 判斷原來的頁表項映射的頁是0頁,就分配高端可移動的頁並用0初始化
2261到2265行 如果不是0頁,分配高端可移動的頁,然后將原來的頁拷貝到新頁
2268 if (mem_cgroup_try_charge_delay(new_page, mm, GFP_KERNEL, &memcg, false))
2269 goto oom_free_new;
2270
2271 __SetPageUptodate(new_page);
2272
2273 mmu_notifier_range_init(&range, mm, vmf->address & PAGE_MASK,
2274 (vmf->address & PAGE_MASK) + PAGE_SIZE);
2275 mmu_notifier_invalidate_range_start(&range);
2276
2277 /*
2278 |* Re-check the pte - we dropped the lock
2279 |*/
2280 vmf->pte = pte_offset_map_lock(mm, vmf->pmd, vmf->address, &vmf->ptl);
2281 if (likely(pte_same(*vmf->pte, vmf->orig_pte))) {
2282 if (old_page) {
2283 if (!PageAnon(old_page)) {
2284 dec_mm_counter_fast(mm,
2285 mm_counter_file(old_page));
2286 inc_mm_counter_fast(mm, MM_ANONPAGES);
2287 }
2288 } else {
2289 inc_mm_counter_fast(mm, MM_ANONPAGES);
2290 }
2291 flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
2292 entry = mk_pte(new_page, vma->vm_page_prot);
2293 entry = maybe_mkwrite(pte_mkdirty(entry), vma);
2294 /*
2295 |* Clear the pte entry and flush it first, before updating the
2296 |* pte with the new entry. This will avoid a race condition
2297 |* seen in the presence of one thread doing SMC and another
2298 |* thread doing COW.
2299 |*/
2300 ptep_clear_flush_notify(vma, vmf->address, vmf->pte);
2301 page_add_new_anon_rmap(new_page, vma, vmf->address, false);
2302 mem_cgroup_commit_charge(new_page, memcg, false, false);
2303 lru_cache_add_active_or_unevictable(new_page, vma);
2304 /*
2305 |* We call the notify macro here because, when using secondary
2306 |* mmu page tables (such as kvm shadow page tables), we want the
2307 |* new page to be mapped directly into the secondary page table.
2308 |*/
2309 set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
2310 update_mmu_cache(vma, vmf->address, vmf->pte);
2311 if (old_page) {
2312 /*
2313 |* Only after switching the pte to the new page may
2314 |* we remove the mapcount here. Otherwise another
2315 |* process may come and find the rmap count decremented
2316 |* before the pte is switched to the new page, and
2317 |* "reuse" the old page writing into it while our pte
2318 |* here still points into it and can be read by other
2319 |* threads.
2320 |*
2321 |* The critical issue is to order this
2322 |* page_remove_rmap with the ptp_clear_flush above.
2323 |* Those stores are ordered by (if nothing else,)
2324 |* the barrier present in the atomic_add_negative
2325 |* in page_remove_rmap.
2326 |*
2327 |* Then the TLB flush in ptep_clear_flush ensures that
2328 |* no process can access the old page before the
2329 |* decremented mapcount is visible. And the old page
2330 |* cannot be reused until after the decremented
2331 |* mapcount is visible. So transitively, TLBs to
2332 |* old page will be flushed before it can be reused.
2333 |*/
2334 page_remove_rmap(old_page, false);
2335 }
2336
2337 /* Free the old page.. */
2338 new_page = old_page;
2339 page_copied = 1;
2340 } else {
2341 mem_cgroup_cancel_charge(new_page, memcg, false);
2342 }
2271行 設置新的頁標識位為PageUptodate,表示頁中包含有效數據。
2280行 鎖住頁表
2281到2339行是發生缺頁異常時獲得頁表項和現在鎖住之后獲得頁表項內容相同的情況
2341 時頁表項不同的情況
主要分析相同的情況:
2282到2290 主要時對頁計數的統計
2291 cache中刷新頁
2292行 由vma的訪問權限和新頁的頁描述符來構建頁表項的值
2293行 設置頁表項值屬性為臟和可寫(如果vma有可寫屬性,這個時候將頁表項修改為了可寫,fork的時候修改為只讀這個地方修改了回來)
2300行 將頁表項原有的值清除,然后刷新地址發生缺頁地址對應的tlb(這一行操作很重要)
2301行 將新的物理頁添加到vma對應的匿名頁的反向映射中
2303行 將新物理頁添加到活躍或不可回收LRU鏈表中
2309 行將構建好的頁表項值寫入到頁表項條目中,這個時候頁表項修改才會生效。
2334行 刪除原來的頁到虛擬頁的反向映射,然后做了比較重要的一個操作為atomic_add_negative(-1, &page->_mapcount)將頁的頁表映射計數減一。
2344到2347 遞減舊頁的引用計數 並釋放頁表鎖
2353到2364行 如果已經映射了新的物理頁,舊頁被鎖住在內存中,將舊頁解鎖。
到此就完成了寫時復制過程。總結下:分配新的物理頁,拷貝原來頁的內容到新頁,然后修改頁表項內容指向新頁並修改為可寫(vma具備可寫屬性)。
前面我們遺留了一個問題沒有討論,那就是do_wp_page函數中處理reuse_swap_page的處理,所謂的單身匿名頁面的處理。
四,匿名頁的reuse
假設有如下情形發生:父進程P通過fork創建了子進程A,其中有一私有可寫的匿名頁page1被共享,這個時候內核會此頁都映射到各自的虛擬內存頁,並修改雙方的頁表屬性為只讀,page1的映射計數_mapcount為2, 這個時候假設子進程寫page1,則發生COW異常,異常處理程序為子進程A分配了新頁page2並和虛擬頁建立映射關系,並改寫了子進程頁表項為可寫,這個時候子進程可以隨意的寫page2而不會影響父進程,當然上面分析我們知道page1的映射計數_mapcount會遞減1變為1,也就表面這個頁page1被父進程所唯一映射,那么這個時候父進程再去寫page1,會發生什么呢?還會發生COW去分配新的頁嗎?
下面我們在源代碼中尋找答案:
do_wp_page函數的2502到2541是我們分析重點:
2502 /*
2503 |* Take out anonymous pages first, anonymous shared vmas are
2504 |* not dirty accountable.
2505 |*/
2506 if (PageAnon(vmf->page) && !PageKsm(vmf->page)) {
2507 int total_map_swapcount;
2508 if (!trylock_page(vmf->page)) {
2509 get_page(vmf->page);
2510 pte_unmap_unlock(vmf->pte, vmf->ptl);
2511 lock_page(vmf->page);
2512 vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
2513 vmf->address, &vmf->ptl);
2514 if (!pte_same(*vmf->pte, vmf->orig_pte)) {
2515 unlock_page(vmf->page);
2516 pte_unmap_unlock(vmf->pte, vmf->ptl);
2517 put_page(vmf->page);
2518 return 0;
2519 }
2520 put_page(vmf->page);
2521 }
2522 if (reuse_swap_page(vmf->page, &total_map_swapcount)) {
2523 if (total_map_swapcount == 1) {
2524 /*
2525 |* The page is all ours. Move it to
2526 |* our anon_vma so the rmap code will
2527 |* not search our parent or siblings.
2528 |* Protected against the rmap code by
2529 |* the page lock.
2530 |*/
2524 /*
2525 |* The page is all ours. Move it to
2526 |* our anon_vma so the rmap code will
2527 |* not search our parent or siblings.
2528 |* Protected against the rmap code by
2529 |* the page lock.
2530 |*/
2531 page_move_anon_rmap(vmf->page, vma);
2532 }
2533 unlock_page(vmf->page);
2534 wp_page_reuse(vmf);
2535 return VM_FAULT_WRITE;
2536 }
2537 unlock_page(vmf->page);
2538 } else if (unlikely((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
2539 (VM_WRITE|VM_SHARED))) {
2540 return wp_page_shared(vmf);
2541 }
2506行 對於匿名頁面且非KSM頁
2522行 判斷是否這個頁面只被我所擁有(total_map_swapcount <= 0)
2534 調用wp_page_reuse處理(這是重點)
2195 /*
2196 * Handle write page faults for pages that can be reused in the current vma
2197 *
2198 * This can happen either due to the mapping being with the VM_SHARED flag,
2199 * or due to us being the last reference standing to the page. In either
2200 * case, all we need to do here is to mark the page as writable and update
2201 * any related book-keeping.
2202 */
2203 static inline void wp_page_reuse(struct vm_fault *vmf)
2204 __releases(vmf->ptl)
2205 {
2206 struct vm_area_struct *vma = vmf->vma;
2207 struct page *page = vmf->page;
2208 pte_t entry;
2209 /*
2210 |* Clear the pages cpupid information as the existing
2211 |* information potentially belongs to a now completely
2212 |* unrelated process.
2213 |*/
2214 if (page)
2215 page_cpupid_xchg_last(page, (1 << LAST_CPUPID_SHIFT) - 1);
2216
2217 flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
2218 entry = pte_mkyoung(vmf->orig_pte);
2219 entry = maybe_mkwrite(pte_mkdirty(entry), vma);
2220 if (ptep_set_access_flags(vma, vmf->address, vmf->pte, entry, 1))
2221 update_mmu_cache(vma, vmf->address, vmf->pte);
2222 pte_unmap_unlock(vmf->pte, vmf->ptl);
2223 }
代碼中可以清晰看到:
2218行 設置頁被訪問
2219行 設置頁表項為臟,如果頁所在的vma是可寫屬性則設置頁表項值為可寫
2220行 將設置好的頁表項值寫入到頁表項條目中(真正設置好了頁表項),注意arm64中在ptep_set_access_flags刷新了頁對應的tlb。
分析到這里,有關COW的機制已經全部分析完,當然這個過程涉及到了無數的技術細節,在此不再一一贅述,后面有機會會討論到相關的內容。
五,總結
我們總結一下寫時復制(COW)機制的整個過程:首先發生在父進程fork子進程的時候,父子進程會共享(此共享並不是我們通常所說的共享映射和私有映射,而是通過將頁映射到每個進程頁表形成共享)所有的私有可寫的物理頁,並將父子進程對應的頁表項修改為只讀,當有一方試圖寫共享的物理頁,由於頁表項屬性是只讀的會發生COW缺頁異常,缺頁異常處理程序會為寫操作的一方分配新的物理頁,並將原來共享的物理頁內容拷貝到新頁,然后建立新頁的頁表映射關系,這樣寫操作的進程就可以繼續執行,不會影響另一方,父子進程對共享的私有頁面訪問就分道揚鑣了,當共享的頁面最終只有一個擁有者(即是其他映射頁面到自己頁表的進程都發生寫時復制分配了新的物理頁),這個時候如果擁有者進程想要寫這個頁就會重新使用這個頁而不用分配新頁。
下面給出實驗代碼案例:
程序中有一全局變量num=10 打印num的值, 然后fork子進程,在子進程中修改全局變量num=100 然后打印num的值,父進程中睡眠1s故意等待子進程先執行完, 然后再次打印num的值
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <sys/types.h>
4
5
6 int num = 10;
7
8 int main(int argc,char **argv)
9 {
10
11 pid_t pid;
12
13 printf("###%s:%d pid=%d num=%d###\n", __func__, __LINE__, getpid(), num);
14
15
16 pid = fork();
17 if (pid < 0) {
18 printf("fail to fork\n");
19 return -1;
20 } else if (pid == 0) { //child process
21 num = 100;
22 printf("### This is child process pid=%d num=%d###\n", getpid(), num);
23 _exit(0);
24 } else { //parent process
25 sleep(1);
26 printf("### This is parent process pid=%d num=%d###\n", getpid(), num);
27 _exit(0);
28 }
29
30 return 0;
31 }
大家可以思考一下:第13,22, 27分別得出的num是多少?
我們編譯執行:
hanch@hanch-VirtualBox:~/test/COW$ gcc fork-cow-test.c -o fork-cow-test
hanch@hanch-VirtualBox:~/test/COW$ ./fork-cow-test
###main:13 pid=26844 num=10###
### This is child process pid=26845 num=100###
### This is parent process pid=26844 num=10###
可以發現父進程中的全局變量num =10, 當fork子進程后對這個全局變量進行了修改使得num =100,實際上fork的時候已經將父子進程的num這個全局變量所在的頁修改為了只讀,然后共享這個頁,當子進程寫這個全局變量的時候發生了COW缺頁異常,然而這對於應用程序來說是透明的,內核卻在缺頁異常處理中做了很多工作:主要是為子進程分配物理頁,將父進程的num所在的頁內容拷貝到子進程,然后將子進程的va所對應的的頁表條目修改為可寫和分配的物理頁建立了映射關系,然后缺頁異常就返回了(從內核空間返回到了用戶空間),這個時候處理器會重新執行賦值操作指令,這個時候屬於子進程的num才被改寫為100,但是要明白這個時候父進程的num變量所在的頁的讀寫屬性還是只讀,父進程再去寫的時候依然會發生COW缺頁異常。
最后我們用圖說話來理解COW的整個過程: