当前位置:   article > 正文

【写时复制(精华,从源码分析总结)】

写时复制

知道fork后,我们做一个小测试:

 #include <stdio.h>
  #include <unistd.h>
  
  int gval = 1;
  int main()
  {
      pid_t pid = 0;
  
      pid = fork();
      if (pid == 0)//子进程
      {
      	  gval= 100;
          printf("child gval:%d,&gavl-%p \n", gval,&gval);
      }
      else if (pid > 0)
      {
      	  sleep(3);
          printf("parent gval:%d,&gavl-%p \n", gval,&gval);
      }
      return 0;
  }

我们都知道,只要地址相同,数据一定也是相同的,一块内存数据不可能存在两个值。但是上面的结果却是地址相同,打印的数据不同。这是不是出错了呢?

实际上进程中访问的地址都是虚拟地址,而我们所说的程序地址空间实际上就是一个进程的虚拟地址空间。

虚拟地址空间是进程的虚拟地址空间,是操作系统为每个进程对于内存空间的虚拟描述,在linux下是一个结构体mm_struct。

而造成这种相同虚拟地址实际不同的物理地址的原因,就是fork的写时复制。

早期的内存分配方法:

某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。

问题1:进程地址空间不隔离。由于程序都是直接访问物理内存,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有bug的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。

  问题2:内存使用效率低。在A和B都运行的情况下,如果用户又运行了程序C,而程序C需要20M大小的内存才能运行,而此时系统只剩下8M的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上,释放出部分空间来供程序C使用,然后再将程序C的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。

  问题3:程序运行的地址不确定。当内存中的剩余空间可以满足程序C的要求后,操作系统会在剩余空间中随机分配一段连续的20M大小的空间给程序C使用,因为是随机分配的,所以程序运行的地址是不确定的。

Linux的解决:

物理地址是我们用户看不到的,我们所看到的地址0x601044就是一个虚拟地址,但是我们真正存放的那个数据是存放到物理内存当中的。那我们如何通过虚拟地址访问到对应的物理地址呢?–页表,通过虚拟地址映射到页表中,再通过映射到页表中的位置再映射到物理内存中,从而找到相对应的数据。

虽然父子进程的虚拟内存地址是一样的,但是他们在物理内存存放的数据的地址是不一样的。通过各自的虚拟地址和页表,映射到不同的物理内存中,从而找到各自的数据

作用:进程使用虚拟内存,通过页表映射物理内存,可以实现进程中数据在物理内存上的离散存储。这样子大大提高了内存的利用率。在页表中可以直接对某个地址设置访问权限标志位,让这个地址成为只读,从而实现对内存的访问控制,让进程更加安全,提高了进程的独立性,保证了各个进程的稳定性。从而避免了进程在物理内存上存在的问题。

Linux下的fork()函数利用了写时拷贝技术,子进程复制了父进程的页表,进程“读”操作的时候也能访问到同一个数据,那是因为他们映射到的物理内存上都是一块相同的空间。但是当有一个进程要进行“写”操作时,系统会在物理内存中复制出一块内存,然后将数据写进去,进行“写”操作的进程通过虚拟地址访问时就不是访问之前的那个数据,而是访问进行“写”操作后复制过来并写入数据的那块数据----写时拷贝技术:两个进程一开始指向同一块空间,等有进程进行“写”操作时候,再给进行“写”操作的进程重新开辟空间,目的就是提高子进程创建效率,保证进程的独立性

父子进程各种有一份虚拟空间地址,在子进程刚被创建时,父子进程代码和数据共享,所以此时虚拟地址空间的内容是基本一样的(当然有部分数据不同,比如各子的id等),且映射关系也是一样的,但是当子进程对数据进行修改时,子进程对那份数据进行写时拷贝,所以物理空间地址发生了变化,但是虚拟地址还是没有发生变化,只是改变了子进程的页表中那份虚拟地址的映射关系而已,所以两个相同的虚拟地址在父子进程分别看到了不同的物理地址空间。

父进程创建子进程,子进程复制父进程的信息都是可读的,父子进程访问的都是同一块物理内存,只有当有进程进行写操作时,才会给子进程开辟新的内存空间来保存数据这也保证了进程之间的独立性。

具体操作:

进程得到的这4G虚拟内存是一个连续的地址空间(这也只是进程认为),而实际上,它通常是被分隔成多个物理内存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换。

进程开始要访问一个地址,它可能会经历下面的过程:

  1. 每次我要访问地址空间上的某一个地址,都需要把地址翻译为实际物理内存地址
  2. 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上
  3. 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录
  4. 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)
  5. 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常
  6. 缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。

  1. 我们的cpu想访问虚拟地址所在的虚拟页(VP3),根据页表,找出页表中第三条的值.判断有效位。 如果有效位为1,DRMA缓存命中,根据物理页号,找到物理页当中的内容,返回。
  2. 若有效位为0,参数缺页异常,调用内核缺页异常处理程序。内核通过页面置换算法选择一个页面作为被覆盖的页面,将该页的内容刷新到磁盘空间当中。然后把VP3映射的磁盘文件缓存到该物理页上面。然后页表中第三条,有效位变成1,第二部分存储上了可以对应物理内存页的地址的内容。
  3. 缺页异常处理完毕后,返回中断前的指令,重新执行,此时缓存命中,执行1。
  4. 将找到的内容映射到告诉缓存当中,CPU从告诉缓存中获取该值,结束。

fork时对私有可写的页面做写保护的准备,在父子进程有一方发生写操作时触发了处理器的访问权限缺页异常,异常处理程序重新分配了新的页面给了发起写操作的进程,父子进程对应这个页面的引用就此分道扬镳。

因此,写时复制是以页为单位。

页表如何实现通过虚拟地址访问物理地址—MMU(内存管理单元)
内存管理方式:分页式内存管理 / 分段式内存管理 / 段页式内存管理

1、LRU算法–最近最久未使用的数据
2、LFU算法–最少使用的数据
3、Clock算法–与LRU算法近似

源码实现:

具体的实现,查看源码得知,Linux的进程都是task_struct结构体存储,其中内存块数据为mm_struct,mm_struct每个进程只有一个,如果fork的子进程没有新的写入操作,传递过来的clone_flags带有CLONE_VM标志,则不需要创建,直接和父进程共享地址空间即可,如内核线程和用户线程。

没有CLONE_VM标志时,如写时拷贝或者fork时带参,则需要为子进程创建新的地址空间,如创建子进程,就调用到dup_mm中。

这里需要注意的地方有三个地方:1.通过allocate_mm分配属于进程自己的mm_struct结构来管理自己的地址空间;2.通过mm_init来初始化mm_struct中相关成员;3.通过dup_mmap来复制父进程的地址空间(实际上后面我们会看到是复制父进程的vma以及页表)。

最终,我们看的在copy_present_pte函数中,对父子进程的写保护处理,也就是当发现父进程的vma的属性为私有可写的时候,就设置父进程和子进程的相关的页表项为只读。这点很重要,因为这样既保证了父子进程的地址空间的共享(读的时候),又保证了他们有独立的地址空间(写的时候)。

最后就是本文最开始问题中虚拟地址相同的原因,mm_struct源码。在地址空间中,mmap为地址空间的内存区域(用vm_area_struct结构来表示)链表,mm_rb用红黑树来存储,链表表示起来更加方便,红黑树表示起来更加方便查找。区别是,当虚拟区较少的时候,这个时候采用单链表,由mmap指向这个链表,当虚拟区多时此时采用红黑树的结构,由mm_rb指向这棵红黑树。这样就可以在大量数据的时候效率更高。所有的mm_struct结构体通过自身的mm_list域链接在一个双向链表上,该链表的首元素是init_mm内存描述符,代表init进程的地址空间。

因为写时复制需要在同一虚拟地址里查询不同物理地址,具体的源码实现使用mm_struct下的vm_area_struct结构对分块的不连续内存进行描述。

通常,进程所使用到的虚存空间不连续,且各部分虚存空间的访问属性也可能不同。所以一个进程的虚存空间需要多个vm_area_struct结构来描述。在vm_area_struct结构的数目较少的时候,各个vm_area_struct按照升序排序,以单链表的形式组织数据(通过vm_next指针指向下一个vm_area_struct结构)。但是当vm_area_struct结构的数据较多的时候,仍然采用链表组织的化,势必会影响到它的搜索速度。针对这个问题,vm_area_struct还添加了vm_avl_hight(树高)、vm_avl_left(左子节点)、vm_avl_right(右子节点)三个成员来实现AVL树,以提高vm_area_struct的搜索速度。

switch_mm
->__switch_mm
    ->check_and_switch_context(next)    //next为下一个进程的进程描述符  arch/arm64/mm/context.c
        -> ...    //ASID分配相关若干代码
        ->cpu_switch_mm(mm->pgd, mm)
            ->cpu_do_switch_mm(virt_to_phys(pgd),mm)   //virt_to_phys(pgd)将进程的mm->pgd转化为了物理地址
                ->  unsigned long ttbr1 = read_sysreg(ttbr1_el1);        //读取 ttbr1_el1寄存器                
                     unsigned long asid = ASID(mm);             //获得进程的ASID                            
                     unsigned long ttbr0 = phys_to_ttbr(pgd_phys);     //取pgd地址                    
 
                     /* Skip CNP for the reserved ASID */                                  
                     if (system_supports_cnp() && asid)                                    
                             ttbr0 |= TTBR_CNP_BIT;                                        
 
                     /* SW PAN needs a copy of the ASID in TTBR0 for entry */              
                     if (IS_ENABLED(CONFIG_ARM64_SW_TTBR0_PAN))                            
                             ttbr0 |= FIELD_PREP(TTBR_ASID_MASK, asid);                    
 
                     /* Set ASID in TTBR1 since TCR.A1 is set */                           
                     ttbr1 &= ~TTBR_ASID_MASK;                                             
                     ttbr1 |= FIELD_PREP(TTBR_ASID_MASK, asid);     //ASID设置到   ttbr1                    
 
                     write_sysreg(ttbr1, ttbr1_el1); //设置ttbr1_el1                           
                     isb();                                                                
                     write_sysreg(ttbr0, ttbr0_el1);//ttbr0_el1                                       
                     isb();                                                                
                     post_ttbr_update_workaround();                                        
 

可以看到每一次做va->pa的地址翻译的时候首先在tlb中查找,上面忘记说了一点,那就是对于用户空间虚拟地址tlb的查找需要根据va和ASID共同查找(内核空间虚拟地址所有进程共享不需要ASID)

注:TLB是快表存在于cache当中,ASID是ARM对查询快表优化的硬件解决方案

mm_user指的就是所有共享此mm_struct描述的进程地址空间的线程数量,即:一个(进程中)线程组中的线程个数。当本进程中的线程退出时,mm_user减1,但只有当所有共享此进程空间的线程退出时,会对mm_count减1,否则不减。

mm_count指的就是对mm_struct本身此结构体的引用次数。不管本进程中有多少线程,在没有其他进程或线程引用的情况下,此mm_count为1,因为本进程中的所有线程共享一个进程地址空间,就是创建线程,fork()时,直接将主线程task_struct中的mm域直接给了被fork出来的task_struct的mm域。当有其他进程或线程(除本进程中的线程)引用此mm_struct时,则mm_count加1。

当本进程中的线程退出时,mm_user会减1,如果mm_user减到0了,则会对mm_count减1,如果此时mm_count也为0了,说明该进程空间么有任何使用者了,则会归还此进程地址空间占的内存给系统。

总结:

当每个进程创建的时候,内核会为进程分配4G的虚拟内存,当进程还没有开始运行时,这只是一个内存布局。实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射)。这个时候数据和代码还是在磁盘上的。当运行到对应的程序时,进程去寻找页表,发现页表中地址没有存放在物理内存上,而是在磁盘上,于是发生缺页异常,于是将磁盘上的数据拷贝到物理内存中。

另外在进程运行过程中,要通过malloc来动态分配内存时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。

可以认为虚拟空间都被映射到了磁盘空间中(事实上也是按需要映射到磁盘空间上,通过mmap,mmap是用来建立虚拟空间和磁盘空间的映射关系的)

注:如果fork简单的vfork()的做法更加火爆,内核连子进程的虚拟地址空间结构也不创建了,直接共享了父进程的虚拟空间,当然了,这种做法就顺水推舟的共享了父进程的物理空间

优点:

  1. 既然每个进程的内存空间都是一致而且固定的(32位平台下都是4G),所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际内存地址,这交给内核来完成映射关系
  2. 当不同的进程使用同一段代码时,比如库文件的代码,在物理内存中可以只存储一份这样的代码,不同进程只要将自己的虚拟内存映射过去就好了,这样可以节省物理内存
  3. 在程序需要分配连续空间的时候,只需要在虚拟内存分配连续空间,而不需要物理内存时连续的,实际上,往往物理内存都是断断续续的内存碎片。这样就可以有效地利用我们的物理内存

参考文献:

  1. 【Linux篇】第七篇——进程地址空间(程序地址空间+虚拟地址空间)
  2. 通过fork来剖析Linux内核的内存管理和进程管理(上)
  3. 虚拟内存与物理内存的联系与区别
  4. 操作系统—物理内存与虚拟内存
  5. 虚拟地址
  6. 程序员必备知识——fork和exec函数详解
  7. 写时复制(Copy On Write)
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/秋刀鱼在做梦/article/detail/883044
推荐阅读
相关标签
  

闽ICP备14008679号