linux内核写时复制机制源代码解读

转自Linux阅码场
写时复制技术(以下简称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缺页做准备 。
共享父进程内存资源处理如下:
linux内核写时复制机制源代码解读 247staticinlineboolis_cow_mapping(vm_flags_tflags)248{249return(flags&(VM_SHARED|VM_MAYWRITE))==VM_MAYWRITE;250}如果判断成立说明是COW的映射 , 则需要将父子进程页表修改为只读:
ptep_set_wrprotect( src_mm,addr, src_pte)将父进程的页表项修改为只读 , pte=pte_wrprotect(pte)将子进程的即将写入的页表项值修改为只读(注意:修改之前pte为父进程原来的pte值 , 修改之后子进程pte还没有写入到对应的页表项条目中!)
修改页表项为只读的核心函数为:
152staticinlinepte_tpte_wrprotect(pte_tpte)153{154pte=clear_pte_bit(pte,__pgprot(PTE_WRITE));//清可写位155pte=set_pte_bit(pte,__pgprot(PTE_RDONLY));//置位只读位156returnpte;157再次回到copy_one_pte函数往下分析:
上面我们已经修改了父进程的页表项 , 也获得了子进程即将写入的页表项值pte(注意:现在还没有写入到子进程的页表项中 , 因为此时子进程的页表项值还没有被完全拼接号好) , 接下来我们将要拼接子进程的页表项的值:
782/*783|*Ifit'sasharedmapping,markitcleanin784|*thechild785|*/786if(vm_flags&VM_SHARED)//vma的属性为共享787pte=pte_mkclean(pte);//设置页表项值为clean788pte=pte_mkold(pte);//设置页表项值为未被访问过即是清PTE_AF789790page=vm_normal_page(vma,addr,pte);//获得pte对应的page结构(即是和父进程共享的页描述符)791if(page){792get_page(page);//增进page结构的引用计数793page_dup_rmap(page,false);//注意:不是拷贝rmap而是增加page->_mapcount计数(页被映射计数)794rss[mm_counter(page)]++;795}elseif(pte_devmap(pte)){796page=pte_page(pte);797798/*799|*Cachecoherentdevicememorybehavelikeregularpageand800|*notlikepersistentmemorypage.Formoreinformationssee801|*MEMORY_DEVICE_CACHE_COHERENTinmemory_hotplug.h802|*/803if(is_device_public_page(page)){804get_page(page);805page_dup_rmap(page,false);806rss[mm_counter(page)]++;807}808}809810out_set_pte:811set_pte_at(dst_mm,addr,dst_pte,pte);//将拼接的页表项值写入到子进程的页表项中812return0;以上过程就完成了对于需要写时复制的页 , 将父子进程的页表项改写为只读(这时候vma的属性是可写的) , 并共享相同的物理页 , 这为下面的COW缺页异常做好了页表级别的准备工作 。
二 , COW缺页异常触发条件当然如果父子进程仅仅是对COW共享的页面做只读访问 , 则通过各自的页表就能直接访问到对应的数据 , 一切都正常 , 一旦有一方去写 , 就会发生处理器异常 , 处理器会判断出是COW缺页异常:
arm64处理器处理过程:
3800if(vmf->flags&FAULT_FLAG_WRITE){//vam可写3801if(!pte_write(entry))//页表项属性只读3802returndo_wp_page(vmf);//处理cow3803entry=pte_mkdirty(entry);3804}程序走到上面的判断说明:页表项存在 , 物理页存在内存 , 但是vma是可写 , pte页表项是只读属性(这就是fork的时候所作的准备) , 这些条件也是COW缺页异常判断的条件 。
三,发生COW缺页异常当内核判断了这次异常时COW缺页异常 , 就会调用do_wp_page进行处理:
2480staticvm_fault_tdo_wp_page(structvm_fault*vmf)2481__releases(vmf->ptl)2482{2483structvm_area_struct*vma=vmf->vma;24842485vmf->page=vm_normal_page(vma,vmf->address,vmf->orig_pte);//获得异常地址对应的page实例2486if(!vmf->page){2487/*2488|*VM_MIXEDMAP!pfn_valid()case,orVM_SOFTDIRTYclearona2489|*VM_PFNMAPVMA.2490|*2491|*Weshouldnotcowpagesinasharedwriteablemapping.2492|*Justmarkthepageswritableand/orcallops->pfn_mkwrite.2493|*/2494if((vma->vm_flags&(VM_WRITE|VM_SHARED))==2495|(VM_WRITE|VM_SHARED))2496returnwp_pfn_shared(vmf);//处理共享可写映射24972498pte_unmap_unlock(vmf->pte,vmf->ptl);2499returnwp_page_copy(vmf);//处理私有可写映射2500}2485行 , 获得发生异常时地址所在的page结构 , 如果没有page结构是使用页帧号的特殊映射 , 则通过wp_pfn_shared处理共享可写映射 , wp_page_copy处理私有可写映射 , 当然这不是我们分析重点 。
【linux内核写时复制机制源代码解读】我们继续往下分析:
我们主要关注2522行 , 判断是否可以重新使用这个页 , 这个稍后在分析 。
2544|*Ok,weneedtocopy.Oh,well..2545|*/2546get_page(vmf->page);25472548pte_unmap_unlock(vmf->pte,vmf->ptl);2549returnwp_page_copy(vmf);2546行增加原来的页的引用计数 , 防止被释放 。
2548行释放页表锁
2549行这是COW处理的核心函数
我们下面将详细分析wp_page_copy函数:
*-Allocateapage,copythecontentoftheoldpagetothenewone.2234*-Handlebookkeepingandaccounting-cgroups,mmu-notifiers,etc.2235*-TakethePTL.Iftheptechanged,bailoutandreleasetheallocatedpage2236*-Ifthepteisstillthewaywerememberit,updatethepagetableandall2237*relevantreferences.Thisincludesdroppingthereferencethepage-table2238*heldtotheoldpage,aswellasupdatingthermap.2239*-Inanycase,unlockthePTLanddropthereferencewetooktotheoldpage.2240*/2241staticvm_fault_twp_page_copy(structvm_fault*vmf)2242{2243structvm_area_struct*vma=vmf->vma;2244structmm_struct*mm=vma->vm_mm;2245structpage*old_page=vmf->page;2246structpage*new_page=NULL;2247pte_tentry;2248intpage_copied=0;2249structmem_cgroup*memcg;2250structmmu_notifier_rangerange;22512252if(unlikely(anon_vma_prepare(vma)))2253gotooom;22542255if(is_zero_pfn(pte_pfn(vmf->orig_pte))){2256new_page=alloc_zeroed_user_highpage_movable(vma,2257|vmf->address);2258if(!new_page)2259gotooom;2260}else{2261new_page=alloc_page_vma(GFP_HIGHUSER_MOVABLE,vma,2262vmf->address);2263if(!new_page)2264gotooom;2265cow_user_page(new_page,old_page,vmf->address,vma);2266}2252行关联一个anon_vma实例到vma
2255行到2259行判断原来的页表项映射的页是0页 , 就分配高端可移动的页并用0初始化
2261到2265行如果不是0页 , 分配高端可移动的页 , 然后将原来的页拷贝到新页
2268if(mem_cgroup_try_charge_delay(new_page,mm,GFP_KERNEL,&memcg,false))2269gotooom_free_new;22702271__SetPageUptodate(new_page);22722273mmu_notifier_range_init(&range,mm,vmf->address&PAGE_MASK,2274(vmf->address&PAGE_MASK)+PAGE_SIZE);2275mmu_notifier_invalidate_range_start(&range);22762277/*2278|*Re-checkthepte-wedroppedthelock2279|*/2280vmf->pte=pte_offset_map_lock(mm,vmf->pmd,vmf->address,&vmf->ptl);2281if(likely(pte_same(*vmf->pte,vmf->orig_pte))){2282if(old_page){2283if(!PageAnon(old_page)){2284dec_mm_counter_fast(mm,2285mm_counter_file(old_page));2286inc_mm_counter_fast(mm,MM_ANONPAGES);2287}2288}else{2289inc_mm_counter_fast(mm,MM_ANONPAGES);2290}2291flush_cache_page(vma,vmf->address,pte_pfn(vmf->orig_pte));2292entry=mk_pte(new_page,vma->vm_page_prot);2293entry=maybe_mkwrite(pte_mkdirty(entry),vma);2294/*2295|*Clearthepteentryandflushitfirst,beforeupdatingthe2296|*ptewiththenewentry.Thiswillavoidaracecondition2297|*seeninthepresenceofonethreaddoingSMCandanother2298|*threaddoingCOW.2299|*/2300ptep_clear_flush_notify(vma,vmf->address,vmf->pte);2301page_add_new_anon_rmap(new_page,vma,vmf->address,false);2302mem_cgroup_commit_charge(new_page,memcg,false,false);2303lru_cache_add_active_or_unevictable(new_page,vma);2304/*2305|*Wecallthenotifymacroherebecause,whenusingsecondary2306|*mmupagetables(suchaskvmshadowpagetables),wewantthe2307|*newpagetobemappeddirectlyintothesecondarypagetable.2308|*/2309set_pte_at_notify(mm,vmf->address,vmf->pte,entry);2310update_mmu_cache(vma,vmf->address,vmf->pte);2311if(old_page){2312/*2313|*Onlyafterswitchingtheptetothenewpagemay2314|*weremovethemapcounthere.Otherwiseanother2315|*processmaycomeandfindthermapcountdecremented2316|*beforethepteisswitchedtothenewpage,and2317|*"reuse"theoldpagewritingintoitwhileourpte2318|*herestillpointsintoitandcanbereadbyother2319|*threads.2320|*2321|*Thecriticalissueistoorderthis2322|*page_remove_rmapwiththeptp_clear_flushabove.2323|*Thosestoresareorderedby(ifnothingelse,)2324|*thebarrierpresentintheatomic_add_negative2325|*inpage_remove_rmap.2326|*2327|*ThentheTLBflushinptep_clear_flushensuresthat2328|*noprocesscanaccesstheoldpagebeforethe2329|*decrementedmapcountisvisible.Andtheoldpage2330|*cannotbereuseduntilafterthedecremented2331|*mapcountisvisible.Sotransitively,TLBsto2332|*oldpagewillbeflushedbeforeitcanbereused.2333|*/2334page_remove_rmap(old_page,false);2335}23362337/*Freetheoldpage..*/2338new_page=old_page;2339page_copied=1;2340}else{2341mem_cgroup_cancel_charge(new_page,memcg,false);2342}2271行设置新的页标识位为PageUptodate , 表示页中包含有效数据 。
2280行锁住页表
2281到2339行是发生缺页异常时获得页表项和现在锁住之后获得页表项内容相同的情况
2341时页表项不同的情况
主要分析相同的情况:
2282到2290主要时对页计数的统计
2291cache中刷新页
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|*Takeoutanonymouspagesfirst,anonymoussharedvmasare2504|*notdirtyaccountable.2505|*/2506if(PageAnon(vmf->page)&&!PageKsm(vmf->page)){2507inttotal_map_swapcount;2508if(!trylock_page(vmf->page)){2509get_page(vmf->page);2510pte_unmap_unlock(vmf->pte,vmf->ptl);2511lock_page(vmf->page);2512vmf->pte=pte_offset_map_lock(vma->vm_mm,vmf->pmd,2513vmf->address,&vmf->ptl);2514if(!pte_same(*vmf->pte,vmf->orig_pte)){2515unlock_page(vmf->page);2516pte_unmap_unlock(vmf->pte,vmf->ptl);2517put_page(vmf->page);2518return0;2519}2520put_page(vmf->page);2521}2522if(reuse_swap_page(vmf->page,&total_map_swapcount)){2523if(total_map_swapcount==1){2524/*2525|*Thepageisallours.Moveitto2526|*ouranon_vmasothermapcodewill2527|*notsearchourparentorsiblings.2528|*Protectedagainstthermapcodeby2529|*thepagelock.2530|*/2524/*2525|*Thepageisallours.Moveitto2526|*ouranon_vmasothermapcodewill2527|*notsearchourparentorsiblings.2528|*Protectedagainstthermapcodeby2529|*thepagelock.2530|*/2531page_move_anon_rmap(vmf->page,vma);2532}2533unlock_page(vmf->page);2534wp_page_reuse(vmf);2535returnVM_FAULT_WRITE;2536}2537unlock_page(vmf->page);2538}elseif(unlikely((vma->vm_flags&(VM_WRITE|VM_SHARED))==2539(VM_WRITE|VM_SHARED))){2540returnwp_page_shared(vmf);2541}2506行对于匿名页面且非KSM页
2522行判断是否这个页面只被我所拥有(total_map_swapcount<=0)
2534调用wp_page_reuse处理(这是重点)
2195/*2196*Handlewritepagefaultsforpagesthatcanbereusedinthecurrentvma2197*2198*ThiscanhappeneitherduetothemappingbeingwiththeVM_SHAREDflag,2199*orduetousbeingthelastreferencestandingtothepage.Ineither2200*case,allweneedtodohereistomarkthepageaswritableandupdate2201*anyrelatedbook-keeping.2202*/2203staticinlinevoidwp_page_reuse(structvm_fault*vmf)2204__releases(vmf->ptl)2205{2206structvm_area_struct*vma=vmf->vma;2207structpage*page=vmf->page;2208pte_tentry;2209/*2210|*Clearthepagescpupidinformationastheexisting2211|*informationpotentiallybelongstoanowcompletely2212|*unrelatedprocess.2213|*/2214if(page)2215page_cpupid_xchg_last(page,(1<<LAST_CPUPID_SHIFT)-1);22162217flush_cache_page(vma,vmf->address,pte_pfn(vmf->orig_pte));2218entry=pte_mkyoung(vmf->orig_pte);2219entry=maybe_mkwrite(pte_mkdirty(entry),vma);2220if(ptep_set_access_flags(vma,vmf->address,vmf->pte,entry,1))2221update_mmu_cache(vma,vmf->address,vmf->pte);2222pte_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#include2#include3#include456intnum=10;78intmain(intargc,char**argv)9{1011pid_tpid;1213printf("###%s:%dpid=%dnum=%d###n",__func__,__LINE__,getpid(),num);141516pid=fork();17if(pid<0){18printf("failtoforkn");19return-1;20}elseif(pid==0){//childprocess21num=100;22printf("###Thisischildprocesspid=%dnum=%d###n",getpid(),num);23_exit(0);24}else{//parentprocess25sleep(1);26printf("###Thisisparentprocesspid=%dnum=%d###n",getpid(),num);27_exit(0);28}2930return0;31}大家可以思考一下:第13 , 22,27分别得出的num是多少?
我们编译执行:
hanch@hanch-VirtualBox:~/test/COW$gccfork-cow-test.c-ofork-cow-testhanch@hanch-VirtualBox:~/test/COW$./fork-cow-test###main:13pid=26844num=10######Thisischildprocesspid=26845num=100######Thisisparentprocesspid=26844num=10###可以发现父进程中的全局变量num=10,当fork子进程后对这个全局变量进行了修改使得num=100,实际上fork的时候已经将父子进程的num这个全局变量所在的页修改为了只读 , 然后共享这个页 , 当子进程写这个全局变量的时候发生了COW缺页异常 , 然而这对于应用程序来说是透明的 , 内核却在缺页异常处理中做了很多工作:主要是为子进程分配物理页 , 将父进程的num所在的页内容拷贝到子进程 , 然后将子进程的va所对应的的页表条目修改为可写和分配的物理页建立了映射关系 , 然后缺页异常就返回了(从内核空间返回到了用户空间) , 这个时候处理器会重新执行赋值操作指令 , 这个时候属于子进程的num才被改写为100 , 但是要明白这个时候父进程的num变量所在的页的读写属性还是只读 , 父进程再去写的时候依然会发生COW缺页异常 。
最后我们用图说话来理解COW的整个过程:


    推荐阅读