linux内核-进程三部曲:创建、执行与消亡

就像世上万物都有产生、发展与消亡的过程一样,每个进程也有被创建、执行某段程序以后最后消亡的过程。在linux系统中,第一个进程时系统固有的、与生俱来的或者说是由内核的设计者安排好了的。内核在引导并完成了基本的初始化以后,就有了系统的第一个进程(实际上是内核线程)。除此之外,所有其他的进程和内核线程都由这个原始进程或其子孙进程所创建,都是这个原始进程的后代。在linux系统中,一个新的进程一定要由一个已经存在的进程复制出来,而不是创造出来(而所谓创建实际就是复制)。所以,linux系统(Unix也一样)并不向用户(即进程)提供类似这样的系统调用。

int creat_proc(int (*fn)(void *), void * arg, unsigned long options)

可是在很多操作系统(包括一些Unix的变种)中都采用了一揽子的方法。它创造出一个进程,并使该进程从函数指针fn所指的地方开始执行。根据不同的情况和设计,参数fn也可以换成一个可执行程序的文件名。这里所谓创造,包括为进程分配所需的资源、包括属于最低限度的task_struct数据结构和系统空间堆栈,并初始化这些资源;还要设置其系统空间堆栈,使得这个新进程看起开就好像是一个本来就已经存在而正在睡眠的进程。当这个进程被调度运行的时候,其返回地址,也就是恢复运行时的下一条指令,则就在fn所指的地方。这个子进程生下来时两手空空,却可以完全独立,并不与其父进程共享资源。

但是,linux(以及Unix)采用的方法却不同。

linux将进程的创建与目标程序的执行分成两步。第一步是从已经存在的父进程中像细胞分裂一样地复制出一个子进程。这里所谓像细胞分裂一样。只是打个比方,实际上,如复制出来的子进程有自己的task_struct结构和系统空间堆栈,但与父进程共享其他所有的资源。例如,要是父进程打开了5个文件,那么子进程也有5个打开的文件,而且这些文件的当前读写指针也停在相同的地方。所以,这一步所做的是复制、linux为此提供了两个系统调用,一个是fork,另一个是clone。两者的区别在于fork是全部复制,父进程所有的资源全都通过数据结构的复制遗传给子进程。而clone则可以将资源由选择地复制给子进程,而没有复制的数据结构则通过指针的复制让子进程共享。在极端情况下,一个进程可以clone除一个线程。所以,系统调用fork是无参数的,而clone则带有参数。对着也许已经意识到,fork其实比clone更接近本来意义的克隆。确实是这样,原因在于fork从Unix的初期即已存在,那时候克隆这个词还不像现在这么流行,而既然业已存在,就不宜更改了。否则,也许应该互换一下名字。后来,又增设了一个系统调用vfork,也不带参数,但是除task_struct结构和系统空间堆栈以外的资源全都通过数据结构指针的复制遗传,所以vfork出来的是线程而不是进程。读者将会看到,vfork主要是出于效率的考虑而设计并提供的。

第二步是目标程序的执行。一般来说,创建一个新的子进程时因为有不同的目标程序要让新的程序去执行(但也不一定),所以,复制完成以后,子进程通常要与父进程分道扬镳。走自己的路。linux为此提供了一个系统调用execve,让一个进程执行以文件形式存在的一个可执行程序的映像。

读者也许要问:这两种方案到底哪一种好?应该说是各有利弊。但是更应该说,linux从Unix继承下来的这种分两步走,并且在第一步中采取复制方式的方案,利远大于弊。从效率的角度看,分两步走很有好处。所谓复制,只是进程的基本资源的复制,如task_struct数据结构、系统空间堆栈、页面表等等,对父进程的代码及全局变量则并不需要复制,而只是通过只读访问的形式实现共享,仅在需要写的时候才通过copy_on_write的手段为所涉及的页面建立一个新的副本。所以,总的来说复制的代价是很低的,但是通过复制而继承下来的资源则往往对子进程很有用。读者以后会看到,在计算机网络的实现中,以及在client/server系统中的server一方的实现中,fork或clone常常是最自然的、最有效、最适宜的手段。笔者有时候简直怀疑,到底是先有fork还是先有client、server,因为fork视乎就是专门为此而设计的。更重要的好处是,这样有利于父子进程间通过pipe来建立起一种简单有效的进程间通信管道,并且从而产生了操作系统的用户界面即shell的管道机制。这一点,对于Unix的发展和应用,对于Unix程序设计环境的形成,对于Unix程序设计风格的形成,都有这非常深远的影响。可以说,这是一项天才的发明,它在很大程度上改变了操作系统的发展方向。

当然,从另一个角度,也就是从程序设计界面的角度来看,则一揽子的方案更为简洁。不过fork加execve的方案也并不复杂很多。进一步说,这也像练武或演戏一样有个固定的招式,一旦掌握了以后就不觉得复杂,也很少变化了。再说,如果有必要也可以通过程序库提供一个一揽子的库函数,将这两步包装在一起。

创建了子进程以后,父进程由三个选择。第一是继续走自己的路,与子进程分道扬镳。只是如果子进程先于父进程去世,则由内核给父进程发一个报丧的信号。第二是停下来,也就是进入睡眠状态,等待子进程完成其使命而最终趋势,然后父进程再继续运行。linux为此提供了两个系统调用,wait4和wait3。两个系统调用基本相同,wait4等待某个特定的子进程去世,而wait3则等待任何一个子进程去世。第三个选择是自行退出历史舞台,结束自己的生命。linux为此设置了一个系统调用exit。这里的第三个选择其实不过是第一个选择的一种特例,所以从本质上说是两种选择:一种是父进程不受阻的(non blocking)方式,也称为异步的方式;另一种是父进程受阻的(blocking)方式,或者也称为同步的方式。

下面是一个用来演示进程的生命周期的简单程序:

#include <stdio.h>

int main()
{
    int child;
    char *args[] = {"/bin/echo", "Hello", "World", NULL};
    
    if (!(child = fork()))
    {
        /*child*/
        printf("pid %d: %d is my father\n", getpid(), getppid());
        execve("/bin/echo", args, NULL);
        printf("pid %d: I am back, something is wrong!\n", getpid());
    }
    else
    {
        int myself = getpid();
        printf("pid %d: %d is my son\n", myeself, child);
        wait4(child, NULL, 0, NULL);
        printf("pid %d: done\n", myself);
    }

    return 0;
}

这里,进入main的进程为父进程,它执行了系统调用fork创建一个子进程,也就是复制一个子进程。子进程复制出来以后,就像其父进程一样地接受内核的调度,而且具有相同的返回地址。所以,当父进程和子进程受调度继续运行而从内核空间返回时都返回到同一个点上。以前的代码只有一个进程执行,而从这一点开始却有两个进程在执行了。复制出来的子进程全面地继承了父进程的所有资源和特性,但还是有一些细微却重要的区别。首先,子进程有一个不同于父进程的进程号pid,而且子进程的task_struct中有几个字段说明谁是它的父亲,就像人们的户口或档案中也有相应的栏目一样。其次,也许更为重要的是,二者从fork返回时所具有的返回值不一样。当子进程从fork返回时,其返回值为0;而父进程从fork返回时的返回值却是子进程的pid,这是不可能为0的。这样,就可以根据这个特征把二者区分开来,使两个进程各自知道我是谁。然后,if中的代码属于子进程,else中的属于父进程,虽然两个进程具有相同的视野,都能看到对方所要执行的代码,但是if语句将它们各自的执行路线分开了。在这个程序中,我们选择了让父进程停下来等待,所以父进程执行wait4;而子进程则通过execve执行/bin/echo。子进程在执行echo以后不会回到这里的低13行,而是壮士一去不复返。这是因为/bin/echo中必定有一个exit调用,使子进程结束它的生命。对exit的调用时每一个可执行程序映像必有的,虽然在我们这个程序中并没有调用它,而是以return语句从main返回,但是gcc在编译和链接时会自动加上,所以谁也逃不过这一关。

由于子进程与父进程一样接受内核调度,而每次系统调用都有可能引起调度,所以二者返回的先后次序是不定的,也不能根据返回的先后来确定谁是父进程谁是子进程。

还要指出,linux内核汇总却是有个貌似一揽子创建内核线程的函数(常常称为原语)kernel_thread,供内核线程调用。但是,实际上这只是对clone的包装,它并不能像调用execve时那样执行一个可执行映像文件,而只是执行内核中的某一个函数。我们不妨看一下它的代码:


/*
 * Create a kernel thread
 */
int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
{
	long retval, d0;

	__asm__ __volatile__(
		"movl %%esp,%%esi\n\t"
		"int $0x80\n\t"		/* Linux/i386 system call */
		"cmpl %%esp,%%esi\n\t"	/* child or parent? */
		"je 1f\n\t"		/* parent - jump */
		/* Load the argument into eax, and push it.  That way, it does
		 * not matter whether the called function is compiled with
		 * -mregparm or not.  */
		"movl %4,%%eax\n\t"
		"pushl %%eax\n\t"		
		"call *%5\n\t"		/* call fn */
		"movl %3,%0\n\t"	/* exit */
		"int $0x80\n"
		"1:\t"
		:"=&a" (retval), "=&S" (d0)
		:"0" (__NR_clone), "i" (__NR_exit),
		 "r" (arg), "r" (fn),
		 "b" (flags | CLONE_VM)
		: "memory");
	return retval;
}

这里445和455行的指令int $0x80就是系统调用。那么系统调用号时在哪里设置的呢?请看第457行的输出部,这里寄存器EAX与变量retval相结合作为%0,而458行开始的输入部又规定,%0应事先赋值为__NR_clone。所以,在进入454行时寄存器EAX已经被设置成__NR_clone,即clone的系统调用号。从clone返回以后,这里采用了一种不同的方法区分父进程与子进程,就是将返回时的堆栈指针与保存在寄存器ESI中的父进程的堆栈指针进行比较。由于每一个内核线程都有自己的系统空间堆栈,子进程的堆栈指针必然与父进程不同。那么,为什么不采用像fork返回时所用的方法呢?这是因为clone所产生的子线程可以具有与父线程相同的pid,如果pid为0的内核线程再clone一个子线程,则子线程的pid就业有可能是0.所以,这里采用的比较堆栈指针的方法,是更为可靠的。当然,这个方法只有对内核线程才适用,因为普通的进程都在用户空间,根本就不知道其系统堆栈空间到底在哪里。

前面讲过,内核线程不能像进程一样执行一个可执行映像文件,而只能执行内核中的一个函数。453行的call指令就是对这个函数的调用。函数指针%5是什么呢?从457行的输出部开始数一下,就可以知道%5与变量fn结合,而那正是kernel_thread的第一参数。内核线程与进程在执行目标程序的方式上的这种不通,又引发出另一个重要的不同,那就是进程在调用execve之后不再返回,而是客死他乡,在所执行的程序中去世。可是内核线程只不过是调用一个目标函数,当然要从那个函数返回。所以,这里在455行又进行一次系统调用,而这次的系统调用号在%3中,那是__NR_exit。

以后,我们将围绕着前面的那个程序来介绍系统调用fork、clone、execve、wait4以及exit的实现,使读者对进程的创建、执行以及消亡有更深入的理解。

猜你喜欢

转载自blog.csdn.net/guoguangwu/article/details/121449469