fork

Fork创建总结(一)

一:在fork总结之前,我们先详细认识一下进程,进程的基本概念在前面博客已经写过了,这里就不冗余了。

我们知道进程是处于执行期的程序以及相关资源的总称。像打开的文件,挂起的信号,一个或多个具有内存映射的内存地址空间及一个或多个执行线程,当然还有用来存放全局变量的数据段等。

而内核在为每一个进程分配PCB的时候,实际上是分配了两个连续的物理页面(共8K),这两个页面的底部1K空间用作进程的PCB结构,剩余7K就是这个进程的系统空间堆栈了,如图:

 

当进程在系统空间内运行时,常常需要访问当前进程自身的task_struct结构,为此内核定义了一个宏操作current,提供指向当前进程的task_struct结构体指针。

 

二:我们接下来就要看看task_struct这个结构体到底定义了什么:

我们先把最重要的几个部分介绍一下,这个部分大体可以分为状态,性质,资源和组织等几类。

①:task_struct存储的状态信息

 

这里85行的状态TASK_INTERRUPTIBLE和86行的状态TASK_UNINTERRUPTIBLE均表示进程进入睡眠状态,但是状态TASK_UNINTERRUPTIBLE表示进入了深度睡眠,不受信号的影响,而状态TASK_INTERRUPTIBLE则可以因为信号的到来而被唤醒。内核中提供了多个函数来让一个进程进入到不同程度的睡眠中或者将进程从睡眠中唤醒:

  • 具体来说函数sleep_on()和函数wake_up()用于让进程进入深度睡眠和唤醒
  • 而函数interruptible_sleep_on()和函数wake_up_interruptible()则用于浅度睡眠

而状态TASK_RUNNING并不是表示一个进程正在执行中,而是表示这个进程可以被调度成为当前进程。所以进程不论处于执行状态还是就绪状态,内核中都只会显示TASK_RUNNING状态,内核会将处于这个状态的进程的task_struct结构通过其list_head结构体类型的run_list挂入到一个“运行队列”。

 

状态TASK_ZOMBIE表示当前进程已经“去世”(exit)了,但是户口还没有注销。(进程主体已经释放,但是其task_struct结构依旧保留,用于保存进程的退出信息,只有当父进程获取了子进程的退出信息后,子进程的PCB结构才可以释放)---父进程调用wait函数

状态TASK_STOPPED主要用于调试,进程收到一个SIGSTOP信号后就会将状态修改为TASK_STOPPED而进入“挂起状态”,然后再接收到一个SIGCONT信号后又恢复运行。

 

 

②:如何根据一个具体的进程PID找到一个进程

1.树形组织:我们知道每一个进程之间都是存在一定的联系的,不会无缘无故的出现,这种关系是一种树形组织,通过指针进行链接,覆盖了系统中的所有进程。

2.杂凑表:但是在这个组织中根据PID找到一个具体的进程还真不容易,因为进程号的分配是非常随机的,而根据PID找到其task_struct结构却又是非常重要的常用操作。于是,就有了第二个组织,那就是一个以杂凑表为基础的进程队列的阵列。当给定一个PID要找到该进程时,先对PID进行杂凑计算,以计算的结果为下标在杂凑表中找到相应的队列,再顺着这个队列就可以很容易找到特定的进程了。

杂凑表pidhash是在kernel/fork.c中定义的:

 

杂凑表的大小为1024,由于每个指针的大小都是4字节,所以整个杂凑表(不包括指针指向的每个具体队列)正好4K,刚刚好是一个页面的大小。

每个进程的task_struct结构都是通过pidhash_next和pidhash_pprev两个指针放入到杂凑表中的某一个队列中,同一队列中的所有进程的pid都具有相同的杂凑值。所以由于杂凑表的使用,通过pid找到具体的进程就很迅速了。

3.第三个组织是一个线性队列:它的存在是为了解决特定任务,比如系统需要对每一个进程都做点什么事的时候,那么这个线性队列就很方便了,只需要一个简单的for循环或者while循环就可以遍历完所有进程的task_struct结构。

系统中第一个建立的进程为init_task,这个进程就是所有进程的祖先,所以这个线性队列的队头就是init_task这个进程,后续每创建一个进程就通过其task_struct中的next_task指针和prev_task指针插入到这个队列中。

4.每个进程都会必然同时处于这三个队列中,直到进程消亡才从这三个队列中摘除,所以这三个队列都是静态的。

在运行的过程中,一个进程还可以动态的链接到“可执行队列”中接收系统的调度。实际上这是最重要的队列,一个进程只有在可执行队列中才有可能受到系统的调度而投入运行。与前几个队列不同的是,链接到可执行队列是通过task_struct中的list_head结构体类型的run_list这个队列头,而不是通过指针来进行插入的。

5.而且可执行队列的插入和删除是非常频繁的,一个进程进入睡眠时就要从可执行队列中出来,被唤醒时又要插入可执行队列中去,在调度的过程中也会改变一个进程在此队列中的位置,所以可执行队列用的是双向链接的通用数据结构(双向循环链表)。

 

三:进程的创建:

系统调用fork(),clone(),vfork()的区别:

  1. fork():无参数,资源全部复制,父进程所有的资源都全部通过数据结构的复制,传递给子进程。
  2. clone():有参数,资源有选择的复制,父进程将资源有选择的复制给子进程,没有复制的数据结构则通过指针的复制让子进程共享。极端的情况下,一个进程可以clone出一个线程。
  3. vfork():无参数,除了task_struct结构和系统空间堆栈外,其他的资源全部通过数据结构指针的方式进行复制遗传,所以vfork()出来的是线程而不是进程。vfork()是出于效率的考虑而设计的。

我们看一看这几个系统调用函数的实现代码:

 

我们可以看到fork(),clone(),vfork(),这三个系统调用函数底层其实都是调用的do_fork()这个函数完成的,不同的只是对do_fork()的调用参数。

不过,在现代的Linux内核中,fork()实际上是由clone()系统调用实现的。

 

四:进程描述符(进程控制块PCB)及任务结构

1.内核把进程的列表存放在任务队列的双向循环链表中,链表中的每一项都是类型为task_struct,称之为进程描述符或者叫做进程控制块的结构,该结构包含着一个具体进程的全部信息。

task_struct在32位操作系统上大小约为1.7KB,看着挺大,但是要考虑到该结构体内包含着一个进程的所有信息,那么也就挺小的了。task_struct包含的信息包括:打开的文件,进程的地址空间,挂起的信号,进程的状态,等等。

结构如图所示:

 

2.那么这个进程描述符放在哪里呢?

我们在上面说过,系统会在内核中开辟连续的两个物理页面(共8K),其中底部放task_struct结构,其余为这个进程的系统空间堆栈。

但是这个是内核2.6版本之前的方式了,方便只通过栈指针就可以计算出其task_struct的地址。

现在Linux已经不使用这种方式了,而是通过slab分配器动态分配task_struct,所以只需要在内核栈底开辟一个新的结构struct thread_info,这个thread_info结构体内会存在一个指向task_struct的指针。

为什么不继续在进程内核栈的尾端存储task_struct呢?

而是改成了在内核栈底创建一个新的结构体thread_info呢?

答案是因为:Linux2.6之后,通过slab分配器动态分配task_struct可以能达到对象复用和缓存着色的目的,通过预先分配和重复使用task_struct,可以避免动态分配和缓存带来的资源消耗,所以进程创建非常的迅速。

我们可以看一看struct thread_info的定义:

 

3.程序上下文

当进程在用户空间上正常执行时,若是执行了系统调用或者触发了某个异常,这时进程就会陷入内核空间。此时为了进程从内核出来后可以接着正常运行,所以就将当时的环境存储下来。

 

 

4.进程创建

Linux进程的创建很特殊,其他操作系统一般会提供生产(spawn)函数,这个函数分两步,第一步是在新的地址空间上开辟进程,第二步是读入可执行文件并执行。

而Linux是将上述步骤分开进行,分成了两个函数,一个fork函数,一个exec函数,首先调用fork()通过拷贝当前进程创建一个子进程,然后再调用exec()函数负责读取可执行文件并将其加载到地址空间上进行运行。

exec()指的是exec()这一族的函数,Linux内核为此实现的系统调用函数为execve()函数,而在C语言的程序库中则在此基础上又提供了一整套的库函数,包括execl(),execlp(),execle(),execleo(),execv()和execvp()。

5.写时拷贝

传统的fork()系统调用是直接把所有的资源复制给新的进程,这种方式是简单但是效率很低,因为要复制的数据可能并不共享。更极端的是,如果创造出来的新进程立刻就需要执行一个新的映像,那么之前的拷贝一点意义也不存在。所以Linux的fork()系统调用使用了写时拷贝技术,写时拷贝技术顾名思义,就以一种写入时才进行拷贝的技术,从而再让父子进程拥有各自的拷贝,也就是说资源的拷贝是在进行写入的时候才进行,在此之前,只是以只读的方式进行共享。

这种技术的实现使得地址空间上的页的拷贝推迟到了写入的时候,在页根本就不会被写入的情况下,就不会重复拷贝了。(比如:fork()之后立即调用exec(),这种优化可以避免拷贝大量根本就不会被使用的数据)

那么fork()的实际开销就是复制父进程的页表,以及给子进程创建唯一的进程描述符pcb。

 

五:fork()执行的流程

Linux通过clone()系统调用来实现fork(),由于clone()可以自主选择需要复制的资源,所以这个系统调用需要传入很多的参数标志用于指明父子进程需要共享的资源。

fork(),vfork(),__clone()函数都需要根据各自传入的参数去底层调用clone()系统调用,然后再由clone()去调用do_fork()。

do_fork()完成了创建的大部分工作,该函数调用copy_process()函数,然后让进程开始运行。

copy_process()函数完成的工作分为这几步:

  1. 调用dup_task_struct()为新进程创建一个内核栈,thread_info结构和task_struct,这些值和当前进程的值相同。也就是说,当前子进程和父进程的进程描述符是一致的。
  2. 检查一次,确保创建新进程后,拥有的进程数目没有超过给它分配的资源和限制。所有进程的task_struct结构中都有一个数组rlim,这个数组中记载了该进程对占用各种资源的数目限制,所以如果该用户当前拥有的进程数目已经达到了峰值,则不允许继续fork()。这个值为PID_MAX,大小为0x8000,也就是说进程号的最大值为0x7fff,即短整型变量short的大小32767,其中0~299是为系统进程(包括内核线程)保留的,主要用于各种“保护神进程”。
  3. 子进程为了将自己与父进程区分开来,将进程描述符中的许多成员全部清零或者设为初始值。不过大多数数据都未修改。
  4. 子进程的状态设置为TASK_UNINTERRUPTIBLE深度睡眠,不可被信号唤醒,以保证子进程不会投入运行。
  5. copy_process()函数调用copy_flags()以更新task_struct中的flags成员。其中表示进程是否拥有超级用户管理权限的PF_SUPERPRIV标志被清零,表示进程还没有调用exec()函数的PF_FORKNOEXEC标志也被清零。
  6. 调用alloc_pid为子进程分配一个有效的PID
  7. 根据传递给clone()的参数标志,调用do_fork()->copy_process()拷贝或共享父进程打开的文件,信号处理函数,进程地址空间和命名空间等。一般情况下,这些资源会给进程下的所有线程共享。
  8. 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。

最终再回到do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行,内核一般有意让子进程首先运行,因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间内写入。

 

六:进程创建除task_struct结构外其他资源的复制

我们可以看一看这部分代码:

我们可以看到:

 

①:函数copy_files()可以有条件的复制已打开文件的控制结构file_struct,这种复制只有在clone_flags中的CLONE_FILES标志位为0时进行,否则就以共享的方式共享父进程的已打开文件。该结构体记录的是文件描述符的使用情况,是进程的私有数据。比如:共享该表的进程数,当前文件描述符的最大数,等等。

②:函数copy_fs()可以有条件的复制fs_struct文件系统信息,这种复制只有在clone_flags中的CLONE_FS标志位为0时进行,否则也是共享,该结构体记录的是进程的根目录,当前工作目录pwd,一个文件操作权限管理的umask,以及一个计数器,

③:接下来是关于信号的处理方式。是否复制父进程对信号的处理是由标志位CLONE_SIGHAND控制的。信号基本上是一种进程间通讯手段,进程可以为各种信号设置用于该信号的处理方式,如果一个进程设置了信号处理程序,其task_struct结构中的sig指针就指向signal_struct数据结构:

 

我们可以看到,其中的数组action[],记录着进程对于各种信号(以信号的数值为下标)的反应和处理,子进程可以通过复制或共享的方式把它从父进程继承下来。

像copy_files(),和copy_fs()一样,函数copy_sighand()也只有在clone_flags中的CLONE_SIGHAND标志位为0时进行复制,否则就共享父进程的sig指针,并将父进程的signal_struct中的共享计数+1

④:接下来是对用户空间的继承,进程的task_struct结构中有一个指针mm,它指向一个代表着进程的用户空间的mm_struct结构体。当然,内核线程没有自己的用户空间,所以这个mm指针设置为0,显然对于mm_struct的复制也只是在clone_flags中CLONE_VM标志为0的时候才通过copy_mm()函数真正进行复制,否则也是通过共享的方式进行。

⑤:不过最重要的是,这里对mm_struct的复制并不仅仅局限于这个结构体本身,也包括着对更深层数据结构的复制。 其中最重要的就是对vm_area_struct数据结构和页面映射表的复制,这是由函数dup_mmap()复制的,其中的copy_page_range()函数是关键点,这个函数可以逐层处理页面目录项和页面表项,但是我们看源码的话会发现,其实这个函数一个页面都没有真正的复制,全是通过共享的方式,这也是为什么Linux内核可以很迅速的fork()或clone()一个新进程的原因。

⑥:那么vm_area_struct结构体是什么呢?

答:vm_area_struct,简称VMA,也被称为进程地址空间或进程线性区,在创建之后会插入到mm->mm_rb红黑树和mm_mmap链表中。该结构体定义的是一段连续的,具有相同访问权限的虚拟内存空间,大小为物理内存页面的整数倍。是Linux虚存管理的最基本的管理单元。

⑦:当cpu从copy_mm()回到do_fork()中时,所有需要有条件复制的资源已经复制完毕。

⑧:前面我们通过alloc_task_struct()分配了两个连续的页面,其低端用作task_struct结构,基本已经复制完毕,那么用作系统空间堆栈的高端,当然也要复制了,这就是copy_thread()这个函数的作用了:

 

最后我们回顾一下:

①:当系统调用fork()通过sys_fork()进入到do_fork()中的时候,其clone_flags标志为SIGCHILD,也就意味着所有的标志位为0,所以其copy_files(),copy_fs(),copy_sighand(),copy_mm()这四个分别为已打开文件,文件系统信息,信号处理函数,用户存储空间的拷贝工作已经完成。

②:而系统调用vfork(),经过sys_vfork()进入do_fork()中的时候,其clone_flags为VFORK|CLONE_VM|SIGCHILD,所以只会执行copy_files(),copy_fs(),copy_sighand()这三个函数,而copy_mm()函数则因为标志位CLONE_VM为1,所以不会执行,只会通过指针共享的方式共享其父进程的mm_struct结构体,这也就是说,vfork()复制的是个线程,只能靠共享其父进程的存储空间生存,包括用户空间堆栈在内。

③:至于__clone(),则取决于调用时的参数,当然前提是父进程要有,要是父进程都没有,那么即使调用相应函数复制了,也还是空的。

④:前面我们知道在fork一个新进程的时候,第一步dup_task_struct()函数会给新进程创建新的系统空间堆栈,task_struct以及thread_info结构,其中alloc_task_struct()则是创建task_struct用的,并且aa在进程内核栈中创建了thread_info结构体,thread_info结构体中有一个task_struct类型的结构体指针task指向task_struct这个结构体。

 

所以总结一下dup_task_struct()这个函数的主要功能如下:

  1. 在专业高速缓存上通过alloc_task_struct_node()分配task_struct,并完成初始化。
  2. 在普通内存中通过alloc_thread_info_node()分配thread_info及两个连续的物理页面,完成初始化
  3. 将task_struct与thread_info联系起来
  4. 完成其他的设置

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值