fork创建进程过程(底层实现) 和 写实拷贝

时间:2024-04-07 13:21:01

.

现在我们来总结一下fork的整个处理流程。从C语言中的函数开始,它在glibc库中会被转换为int0x80加调用号的形式,触发中断。该中断在系统初始化过程中注册,它的处理函数是system_call,这个函数在system_call.s文件中,在这里面它首先压栈一些参数,然后会根据调用号调用sys_call_table的相应表项,sys_call_table定义在include/linux/sys.h中,它是一个函数指针数组。现在对应的就是sys_fork函数,它仍然是在system_call.s中定义的。我们来看它的处理过程,首先它会调用find_empty_process函数来从task数组中查找一个还没有使用的task,

找到之后把对应的索引返回(保存到eax中),随后它又保存了其他一些寄存器,并且把返回的任务索引压入栈中,依次作为参数来调用copy_process函数。下面我们来看copy_process的处理过程。首先,它会分配一个内存页,把task_struct结构体保存到这个内存页的最开始位置(同时需要注册进task数组),并把内核栈指针设为该页的顶端。紧接着为这个新的task_struct复制为之前的task_struct,然后需要修改一些域,这些域都是不能直接从父进程继承的。这些域包括进程id设置为我们在find_empty_process过程中找到的id,pid设置为调用fork的进程id,leader不能继承父类的,时钟和信号也不继承,设置eip,设置eax为0,设置内核栈指针等等。然后比较重要的现在需要为它设置页表了。我们再详细总结一下设置页表的过程。
为了设置页表,我们需要知道一个进程的代码段和数据段的起始地址以及占用了多大的空间。由于二者是重叠的,我们设置一个就可以了。获取的过程是通过查看进程ldt表项来获取基址和限长。由于linux0.11中进程的起始地址为64M×nr,所以前面的基址其实并没有太大作用,知道了起始地址,现在就可以知道它在页目录中的索引了,我们设置它的页目录项,现在可以设置它的页表了,首先获取父进程的页表,并对页表进行拷贝,拷贝的过程中,我们把子进程的页面属性设置为只读(父进程也被设为只读了,内核空间除外)。这样就完成了页表的设置,由于是只读的,当子进程执行写操作时会触发写保护,在写保护处理中会拷贝页面。现在页表就已经设置好了。接下来该为它设置gdt中的项目了。还需要提一点,之前我们根据进程号nr计算出代码段和数据段起始地址,并把它设置到了task_struct的ldt中。现在我们再次根据nr计算出ldt和tss在gdt中的位置,并向其中注册ldt和tss,通过这个过程我们就把task_struct中的tss和ldt的地址注册到gdt中对应的表项处。到此,所有的工作都已经完成了,设置其状态为可运行状态(刚开始是被设置为不可中断状态),返回新建进程号。


1. fork

linux系统中提供了三个系统调用可以创建新进程:clone()、fork()、vfork()。实际上,不管是我们比较熟悉的fork()还是剩下的两个在linux中都是通过clone()实现的。clone()是在c语言库中定义的一个封装函数,它负责建立进程堆栈并且调用对程序员隐藏的clone()系统调用。进一步观察发现,linux内核中又是用do_fork()来处理这三个系统调用的。

新的进程通过复制父进程而建立。为了创建新进程,首先在系统的物理内存中为新进程创建一个 task_struct 结构,将旧进程的 task_struct 结构内容复制到其中,再修改部分数据。接着,为新进程分配新的堆栈,分配新的进程标识符 pid。然后,将这个新 task_struct 结构的地址填到 task 数组中,并调整进程链关系,插入运行队列中。于是,这个新进程便可以在下次调度时被选择执行。此时,由于父进程的进程上下文 TSS 结构复制到了子进程的 TSS 结构中,通过改变其中的部分数据,便可以使子进程的执行效果与父进程一致,都是从系统调用中退出,而且子进程将得到与父进程不同的返回值(返回父进程的是子进程的 pid,而返回子进程的是 0)

fork()底层流程图如下:

fork创建进程过程(底层实现) 和 写实拷贝

然后来看看do_fork的具体过程:

  1.  p = copy_process(clone_flags, stack_start, regs, stack_size,
    child_tidptr, NULL, trace);
  2.   wake_up_new_task(p, clone_flags);
  

  第一步是调用copy_process函数来复制一个进程,并对相应的标志位等进行设置,接下来,如果copy_process调用成功的话,那么系统会有意让新开辟的进程运行,这是因为子进程一般都会马上调用exec()函数来执行其他的任务,这样就可以避免写是复制造成的开销,或者从另一个角度说,如果其首先执行父进程,而父进程在执行的过程中,可能会向地址空间中写入数据,那么这个时候,系统就会为子进程拷贝父进程原来的数据,而当子进程调用的时候,其紧接着执行拉exec()操作,那么此时,系统又会为子进程拷贝新的数据,这样的话,相比优先执行子程序,就进行了一次“多余”的拷贝。

   

  从上面的分析中可以看出,do_fork()的实现,主要是靠copy_process()完成的,这就是一环套一环,所以在看内核的时候,会觉得一下子跳到这,一下子又跳到那,一下子就看晕了的一个很大的原因。不过我觉得这也是linux的一大好处,因为其提高了函数的可重用行,比如本文一开始提到的几个函数的实现,归根到底,都是通过do_fork()实现的。

 

  接着再来看看copy_process()的实现:

  1. p = dup_task_struct(current); 为新进程创建一个内核栈、thread_iofo和task_struct,这里完全copy父进程的内容,所以到目前为止,父进程和子进程是没有任何区别的。
  2. 检查所有的进程数目是否已经超出了系统规定的最大进程数,如果没有的话,那么就开始设置进程描诉符中的初始值,从这开始,父进程和子进程就开始区别开了。
  3. 设置子进程的状态为不可被TASK_UNINTERRUPTIBLE,从而保证这个进程现在不能被投入运行,因为还有很多的标志位、数据等没有被设置。
  4. 复制标志位(falgs成员)以及权限位(PE_SUPERPRIV)和其他的一些标志。
  5. 调用get_pid()给子进程获取一个有效的并且是唯一的进程标识符PID。
  6. 根据传入的cloning flags(具体表示上面有)对相应的内容进行copy。比如说打开的文件符号、信号等。
  7. 父子进程平分父进程剩余的时间片。
  8. return p;返回一个指向子进程的指针。


  至此,do_fork的工作就基本结束了



写时拷贝技术:传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。Linuxfork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下举例来说,fork()后立即调用exec()—它们就无需复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。