前面两节从C语言的源码层面简单讨论了Linux系统中进程的基本概念。我们知道Linux内核是如何描述和记录进程的资源的,以及进程的五种基本状态和进程的家族树。其实就进程管理而言,Linux还是有一些独到之处的。Linux系统中的进程创建许多操作系统都提供了特殊的进程生成机制。典型的流程是:先在内存的一个新地址空间创建一个进程,然后读取可执行程序,加载到内存中执行。Linux系统并没有使用上述经典流程创建线程,而是将创建流程拆分为两个独立的函数执行:fork()函数和exec()函数族。基本流程是这样的:首先,fork()函数复制当前进程创建子进程。生成的子进程与父进程的区别仅在于PID和PPID以及某些资源和统计信息,例如未决信号等。准备好进程运行的地址空间后,由exec()函数族负责读取可执行程序加载到相应位置开始执行。Linux系统使用的这两组函数创建进程的效果与其他操作系统经典的进程创建方法类似。有的读者可能会认为这样做会使流程创建过于繁琐,其实不然。Linux之所以这样做,其中一个原因是为了提高代码重用率,得益于Linux的高层抽象,不需要设计额外的创建进程的机制。“Copyonwrite”早期Linux中的fork()函数直接将父进程的所有资源分配给创建的子进程。这种机制自然简单,但是效率比较低。原因很明显:子进程不一定使用父进程的资源,或者子进程可能只需要以只读方式访问父进程的资源。这时,“复制一个资源”纯粹是多余的开销。针对此类问题,Linux后续版本中的fork()函数开始采用“copy-on-write”机制。Copy-on-write技术可以延迟复制需求,甚至消除复制,减少开销。具体来说,当Linux调用fork()创建子进程时,并不急于复制整个进程地址空间,而是暂时让父子进程以只读方式共享同一份副本。只有在子进程需要写入时才会发生复制动作,以保证每个进程都有自己独立的内存空间。如果子进程不需要或只需要读取共享空间数据,那么复制动作就被省略了,Linux减少了开销。例如,exec()在系统调用fork()之后立即被调用。这时候exec()会加载一个新的image来覆盖fork()的地址空间,复制动作就可以完全省略了。实际上,fork()函数的实际开销是复制父进程的页表,为子进程创建唯一的进程描述符。大多数情况下,Linux会在创建进程后立即运行新的可执行程序,因此“写时复制”机制可以避免相当多的数据复制。快速创建进程是Linux系统的一个特点,所以“copy-on-write”是一个很重要的优化。当一个进程被创建时,内存地址空间往往包含几十MB的数据。如果每次创建进程都复制数据,开销显然非常大。fork()函数Linux中的fork()函数实际上是基于clone()实现的。clone()函数可以通过一系列的参数标志来指定父子进程需要共享的资源。Linux下输入man命令查看clone()函数C语言原型:clone()函数的C语言原型及相关参数符号:相关参数符号Linux下fork()函数最终调用do_fork()函数,其C语言代码如下,参见(do_fork()函数的C语言代码比较长,下面只列出一部分):do_fork()函数的C语言代码do_fork()函数完成进程创建的大部分工作,从相关的C语言源码可以看出,它调用了copy_process()函数,copy_process()函数的C语言源码如下,请看:C语言copy_process()函数的源代码copy_process()函数的代码也比较长,在我的Linux系统中,达到了将近400行,但是代码整体逻辑清晰:(1)copy_process()函数首先检查一些标志位,然后调用dup_task_struct()函数为新进程创建内核栈,上一节提到thread_info和task_struct结构:调用dup_task_struct()函数为新进程创建内核栈。创建完成后,接下来的arch_dup_task_struct()函数会将orig结构复制到新创建的结构中。查看相关的C语言代码。这个过程很明确:复制到新创建的结构中。这时候子进程和父进程的描述符是完全一样的。(2)接下来需要查看一些flags和统计信息。相关的C语言代码如下,请看:查看一些标志位和统计信息(3)清除一些统计信息,初始化一些区分成员。这时候虽然新进程的task_struct结构的大部分成员没有修改,但是父子进程已经不同了。该进程相关的C语言代码片段如下,请看:清除一些统计信息,并初始化一些不同的成员(4)将新创建的子进程的状态设置为TASK_UNINTERRUUPTIBLE,保证暂时不会投入运行,这个过程的C语言代码比较简单。(5)调用alloc_pid()函数为新进程分配一个唯一的pid。相关C语言代码如下,请看:为新进程分配一个唯一的pid(6)根据clone()函数的参数flag,复制或共享打开的文件、文件系统、信号处理函数、进程地址空间和其他资源,比如下面的C语言代码:复制或共享打开的资源(7)会将新进程创建的task_struct结构体的指针返回给调用者或者,即do_fork()函数。此时新创建的进程还没有投入运行。现在回到do_fork()函数。如果调用clone()函数时没有传递CLONE_STOPPED参数,新创建的进程将被唤醒并投入运行。这个进程的C语言代码如下:唤醒并运行到这里,Linux创建一个新的进程就结束了。Linux内核有意让新创建的子进程先运行,因为子进程往往会立即调用exec()函数将新程序加载到内存中运行,从而避免了copy-on-write的额外开销。如果父进程先执行,显然很有可能开始写入地址空间,导致复制动作发生。小结本节从C语言代码层面详细分析Linux内核中创建进程的过程。可见,即使是复杂的操作系统代码,也是通过一系列基本的C语言语法和函数来实现的。那么,Linux是如何创建线程的呢?前面我们提到过,Linux系统是不区分进程和线程的。线程其实是一个特殊的进程。Linux是如何实现这个“特殊”进程的呢?限于篇幅,我会在下一节讲到,敬请期待。
