3.5 进程的创建
进程创建是Unix类操作系统中发生最频繁的活动之一。例如,只要用户输入一条命令,shell进程就创建一个新进程,新进程执行shell的另一个拷贝。
很多操作系统都提供了产生进程的机制,其采取的方式是首先在新的地址空间里创建进程,然后读可执行文件,最后开始执行。Unix采用了与众不同的实现方式,它把上述步骤分为创建和执行两步,也就是fork()和exec()两个函数。首先,fork()通过拷贝当前进程创建一个子进程。然后,exec()函数负责读取可执行文件并将其载入进程的地址空间开始运行。把这两个函数组合起来使用的效果跟其他系统使用的单一函数的效果类似。
传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下。Linux的fork()使用写时复制(Copy-on-write)来实现。也就是在调用fork()时内核并没有把父进程的全部资源给子进程复制一份,而是将这些内容设置为只读状态,当父进程或子进程试图修改某些内容时,内核才在修改之前将被修改的部分进行拷贝。因此,fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的PCB。
3.5.1 创建进程
新进程是通过克隆父进程(当前进程)而建立的。fork() 和 clone()(用于线程)系统调用可用来建立新的进程。当这两个系统调用结束时,内核在内存中为新的进程分配新的PCB,同时为新进程要使用的堆栈分配物理页。Linux 还会为新进程分配新的进程标识符。然后,新的PCB地址保存在链表中,而父进程的PCB内容被复制到新进程的 PCB中。该部分也是Linux 2.4的内核代码来说明。
在克隆进程时,Linux 允许父子进程共享相同的资源。可共享的资源包括文件、信号处理程序和进程地址空间等。当某个资源被共享时,该资源的引用计数值会增加 1,从而只有在两个进程均终止时,内核才会释放这些资源。
不管是fork() 还是 clone()系统调用,最终都调用了内核中的do_fork()函数,该函数的主要操作为:
调用alloc_task_struct( )函数以获得8KB的union task_union内存区,用来存放进程的PCB和新进程的内核栈。
让当前指针指向父进程的PCB,并把父进程PCB的内容拷贝到刚刚分配的新进程的PCB中,此时,子进程和父进程的PCB是完全相同的。
检查新创建这个子进程后,当前用户所拥有的进程数目没有超出给他分配的资源的限制。
现在, do_fork( )已经获得它从父进程能利用的几乎所有的东西;剩下的事情就是集中建立子进程的新资源,并让内核知道这个新进程已经诞生。
接下来,子进程的状态被设置为TASK_UNINTERRUPTIBLE以保证它不会马上投入运行。
调用get_pid()为新进程获取一个有效的PID。
然后,更新不能从父进程继承的PCB的其他所有域,例如,进程间亲属关系的域。
根据传递给clone()的参数标志,拷贝或共享打开的文件、文件系统信息、信号处理函数、进程的虚拟地址空间(参见下一章)等。如果进程包含有线程,则其所有线程共享这些资源,无需拷贝;否则,这些资源对每个进程是不同的,因此被拷贝。
把新的PCB插入进程链表,以确保进程之间的亲属关系。
把新的PCB插入pidhash哈希表。
把子进程PCB的状态域设置成TASK_RUNNING,并调用wake_up_process( )把子进程插入到运行队列链表。
让父进程和子进程平分剩余的时间片。
返回子进程的PID,这个PID最终由用户态下的父进程读取
现在有了处于可运行状态的完整子进程,但是,它还没有实际运行,由调度程序来决定何时把CPU交给这个子进程。在fork()或clone()系统调用结束时,新创建的子进程将开始执行。内核有意选择子进程首先执行,这是因为一般子进程都会马上调用exec()函数,这样可以避免写时复制的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。
子进程创建结束后,就该从内核态返回用户态了。用户态进程根据fork()的返回值分别安排父子进程执行不同的代码。
3.5.2线程及其创建
线程是现代编程技术中常用的一种机制。该机制提供了在同一程序内可以运行多个线程,这些线程共享内存地址空间,除此之外还可以共享打开的文件和其他资源。
Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux把所有的线程都当作进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个使用某些共享资源的进程。每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程,只是该进程和其它一些进程共享某些资源,如地址空间。
Linux的内核线程是由kernel_thread( )函数在内核态下创建的,这个函数在内核中的实现是C语言中嵌套着汇编语言,但在某种程度上等价于下面的代码:
int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
{
pid_t p;
p = clone( 0, flags | CLONE_VM );
if ( p ) /* 父*/
return p;
else { /* 子*/
fn(arg);
exit( );
}
}
clone()有很多标志,其中CLONE_VM表示父子进程共享地址空间。在kernel_thread()返回时,父线程退出,并返回一个指向子线程的PID。子线程开始运行fn指向的函数,arg是运行时需要用到的参数。
一般情况下,内核线程会把在创建时得到的函数永远执行下去(除非系统重起)。该函数通常由一个循环构成,在需要的时候,这个内核线程就会被唤醒和执行,完成了当前任务,它会自行睡眠。
内核线程也可以叫内核任务,它们周期性地执行,例如,磁盘高速缓存的刷新,网络连接的维护,页面的换入换出等等。在Linux中,内核线程与普通进程有一些本质的区别,从以下几个方面可以看出二者之间的差异:
(1) 内核线程执行的是内核中的函数,而普通进程只有通过系统调用才能执行内核中的函数。
(2) 内核线程只运行在内核态,而普通进程既可以运行在用户态,也可以运行在内核态。
(3) 因为内核线程只运行在内核态,因此,它只能使用大于PAGE_OFFSET(3G)的地址空间。另一方面,不管在用户态还是内核态,普通进程可以使用4GB的地址空间(参见第四章)。
下面描述几个特殊的内核线程
1.进程0
内核是一个大程序,它可以控制硬件,并创建、运行、终止及控制所有进程。内核被加载到内存后,首先由完成内核初始化工作的start_kernel函数从无到有的创建一个内核线程swap,并设置其PID为0。因为Linux对进程和线程统一编号,我们也把它叫进程0,又叫闲逛进程(idle process)。进程0执行的是cpu_idle()函数,该函数中只有一条hlt汇编指令,hlt指令在系统闲置时不仅能降低电力的使用还能减少热的产生。如前所述,进程0的PCB叫做init_task,在很多链表中起链表头的作用。当就绪队列没有其他进程时,闲逛进程0就被调度程序选中,以此达到省电的目的。
2.进程1
如前所述,init进程是1号进程,实际上,Linux2.6在初始化阶段首先把它建立为一个内核线程kernel_init: kernel_thread(kernel_init, NULL, CLONE_FS | CLONE_SIGHAND);
参数CLONE_FS | CLONE_FILES | CLONE_SIGHAND表示0号线程和1号线程分别共享文件系统(CLONE_FS)、打开的文件(CLONE_FILES)和信号处理程序(CLONE_SIGHAND)。当调度程序选择到kernel_init内核线程时,kernel_init就开始执行内核的一些初始化函数将系统初始化。
那么,kernel_init()内核线程是怎样变为用户进程的呢?实际上,kernel_init()内核函数中调用了execve()系统调用,该系统调用装入用户态下的可执行程序init(/sbin/init)。注意,内核函数kernel_init()和用户态下的可执行文件init是不同的代码,处于不同的位置,也运行在不同的状态,因此,init是内核线程启动起来的一个普通的进程,这也是用户态下的第一个进程。init进程从不终止,因为它创建和监控操作系统外层所有进程的活动。