程序是存储在某种存储介质上的可执行文件,是目标码和用户数据的集合。程序装载进内存后可以执行,处于可执行状态的程序称为进程。
但是进程并不仅仅局限于一段可执行代码以及一些用户数据,通常它还要包括很多其他的资源,比如打开的文件、用于保存临时数据的堆栈、挂起的信号
等。因此,进程可以看作处于执行状态的程序以及它所包含的资源的总称。
从内核的角度来看,进程是操作系统分配内存、CPU时间片等资源的基本单位,为正在运行的程序提供的运行环境。它代表了程序的一个执行过程,是一个
动态的实体,随着程序中指令的执行而不断变化。
进程与程序的主要区别如下:
程序是静态的的,进程是动态的。程序作为一个静态文件存储在硬盘等存储介质中,而进程则是为处于执行状态的程序提供的动态运行环境。
1个程序可以对应多个进程,但1个进程只能对应1个程序。
进程从创建直至退出具有一定的生命期,而程序则只是指令与数据的集合。
线程是在进程基础上进一步的抽象,一个进程可以分为两个部分:线程集合和资源集合。线程是进程中的一个动态对象,是一组独立的指令流,进程中的所有
线程将共享进程里的资源,但同时各个线程也拥有独立的程序计数器、堆栈和寄存器上下文。
所有的进程都至少拥有一个线程。相对于进程是操作系统进行资源管理的最小的单元,线程则是程序执行的最小单元。
内核使用进程描述符,即结构task_struct来描述与一个进程相关的所有信息。该结构定义在文件include/linux/sched.h,因为容纳了大量的信息,不仅包括了
很多进程属性相关的字段,还包括了很多指向其他数据结构的指针,所以它显得非常的复杂,主要组成部分如下:
进程状态信息:描述进程当前的状态。
进程调度信息:由调度程序使用,决定系统中哪个进程最应该运行。
标识符:进程相关的一些标识符。
进程间通信:Linux支持经典的UNIX IPC机制,如信号(Signals),管道(Pipes),也支持System V IPS机制,如共享内存(Shared Memory)、信号量和
消息队列(Message Queues).
进程组织信息:Linux系统中所有进程之间都是相互联系的。除了初始化进程外,所有进程都有一个父进程。新进程由之前的进程克隆而来。每个进程
对应的进程描述符中包含有指向其父进程、兄弟进程以及子进程的指针。
时间和定时器:记录进程的创建时间以及在其生命周期中消耗的CPU时间。定时器用于在判断到达某个时刻时执行相关的操作,比如发送信号。
文件系统信息:记录进程使用文件的情况。
虚拟内存:多数进程都拥有自己的虚拟地址空间(内核线程没有),内核必须跟踪虚拟内存与系统物理内存的映射关系。
和处理器相关的上下文信息:进程执行时,它将使用处理器的寄存器以及堆栈。进程挂起时,所有处理器相关的状态信息必须保存在它的进程描述符
结构中,当该进程被调度重新执行时再进行恢复,即恢复这些寄存器和堆栈的值。
多处理器系统相关信息:与多处理器系统支持相关的一些字段。
(1)可运行状态
表示进程正在被执行,或者已经准备就绪随时可由调度程序调度执行。进程刚被创建后就处于TASK_RUNNING状态。
(2)可中断等待状态
进程被挂起(睡眠)处于等待状态,不会被调度执行,直到某个条件为真。等待的资源可用,系统产生一个硬件中断,或收到一个信号都可以唤醒进程进入
准备就绪状态的TASK_RUNNING状态。
(3)不可中断等待状态。
与TASK_INTERRUPTINLE状态的唯一区别就是该状态下不能被收到的信号唤醒。这种状态很少用到,但在一些特殊的情况下(进程必须等待,直到一个不能
被中断的事件发生,比如发送硬盘I/O请求而等待I/O完成的状态,等待TTY终端的输入状态等),这种状态是很有用的。例如,当进程打开一个设备文件,
其相应的设备驱动程序开始探测相应的硬件设备时会用到这种状态。探测完成以前,设备驱动程序不能被中断,否则,硬件设备会处于不可预知的状态。
(4)暂停状态
进程暂停执行。收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU信号后,进程就会进入TASK_STOPPED状态。
(5)跟踪状态
进程的执行被调试器暂停。当一个进程被另一个进程监控时,任何信号都可以把这个进程置于TASK_TRACED状态。
(6)僵死状态
该状态为exit_state字段的值,表示进程的执行被终止,但是其父进程还没有调用wait4或waitpid系统调用来返回有关终止进程的信息。父进程调用wait
类系统调用前,内核不能丢弃包含在子进程描述符中的数据,因为父进程可能还需要它来取得子进程的退出进程。
(7)僵死撤销状态
该状态也是exit_state字段的值,表示进程的最终状态。父进程已经调用了wait4或waitpid系统调用。
(8)不可交互等待状态
该状态必须和TASK_INTERRUPTIBLE或者TASK_UNINTERRUPTIBLE状态组合使用。它在进程处于等待状态并且它是否可以交互都不提供任何信息的
时候使用。它最初是在进程等待管道缓冲区的时候使用,这样是为了防止使用管道的进程获得更多的交互。
(9)死亡状态
EXIT_DEAD只能用于exit_state字段,为了避免与其引起混乱引入了这个新的状态。一个进程在退出时,state字段被置于TASK_DEAD状态。
(1)进程标识符PID
进程标识符PID与进程一一对应,内核通过进程标识符来标识不同的进程。PID是32位的无符号整数,它被顺序编号,新建进程的PID通常是前一个进程
的PID加1.然而,为了与16位硬件平台的传统UNIX系统保持兼容,在Linux上允许的最大PID号是32767,当内核在系统中创建第32768个进程时,就必须重新
开始使用以闲置的PID号。
(2)线程组标识符TGID
通过进程描述符的thread_group字段,同一线程组中的所有轻量级进程组成一个双向链表,线程组标识符TGID即为线程组中第一个轻量级进程的PID。
对于普通进程,tgid字段和pid字段具有相同的值,而对于线程组中的各个轻量级进程,除了组中的第一个轻量级进程外,其他轻量级进程的tgid字段和pid字段
并不相等。
(3)用户和组标识符
运行进程的用户的标识符和该用户所属用户组的标志符。
(4)有效用户和组标识符
有些程序可以在执行过程中将进程的uid和gid改成其程序自身的uid和gid。这些程序被称为setuid程序,常在严格控制对某些服务的访问时使用,特别是
那些为别的进程而运行的进程。有效uid和gid是那些setuid程序执行过程在执行时变化出的uid和gid。当进程试图访问特权数据或代码时,内核将检查
进程的有效gid和uid。
(5)备份用户和组标识符
POSIX标准要求实现这两个标志符,它们被那些通过系统调用改变进程uid和gid的程序程序使用。当进程的原始uid和gid变化时,它们被用来保存真正的
uid和gid。
(6)文件系统用户和组标识符
它们和有效uid和gid相似,但用来检验进程的文件系统访问权限。如运行在用户模式下的NFS服务器存取文件时,NFS文件系统将使用这些标识符,此时只有
文件系统uid和gid发生了改变(而非有效uid和gid),这样可以避免恶意用户向NFS服务器发送KILL信号。
(1)任务队列tasks
系统中同时存在有很多进程有效地管理众多进程是一个很重要的问题,最简单的方式是将这些进程的进程描述符通过双向循环链表组成一个队列,这样就可以
遍历所有的进程。内核也确实采用了这种方式,任务队列tasks即用于链接所有的进程。
(2)可运行队列run_list
可运行队列run_list中保存的是状态为TASK_RUNNING的进程描述符。当内核要寻找找一个新的进程运行时,必须只考虑处于可运行状态的进程,为此而遍历
整个任务队列是相当低效的,所以引入了链接可运行状态进程的双向循环链表,也称为可运行队列。
(3)children、sibling和thread_group
系统中的进程有两种组织方式:一种是上面所述的双向循环链表;另一种是各进程间的父子关系。通过children和sibling可以遍历一个进程家族的所有进程,
通过thread_group可以形成一个进程组。
(4)real_parent,parent和group_leader
real_parent指向创建此进程的进程描述符,如果此进程的父进程不存在就指向进程l(init)的描述符。
parent指向当前父进程的进程描述符,与real_parent通常相同,但有时不同。
group_leader指向线程组领头进程的描述符。
进程0与进程1是系统中比较特殊的两个进程。其中进程0是系统中所有进程的祖先,而进程1则由进程0负责创建。
(1)进程0
进程0创建进程1,即init进程之后,它就执行cpu_idle函数进程无限循环,因此进程0又被称之为idle进程。当没有其他进程处于TASK_RUNNING时,进程0
被执行。
idle进程是唯一一个不通过fork()产生的进程,它的进程描述符init_task不是动态分配的,而是由INIT_TASK宏静态配置。在多处理器系统中,每个CPU都有一个
idle进程,即有多少处理器单元就有多少idle进程。系统的空闲时间,其实就是指idle进程的运行时间。
idle进程并不执行什么有意义的工作,但是因为在没有其他进程可执行的时候就会被执行,所以它必须具有很低的能耗,这通过在cpu_idle函数中调用idle
函数来实现。
(2)进程1
进程0最终会通过调用kernel_thread创建一个内核进程,这个新创建的内核线程即进程1,又称为init进程,此时进程1与进程0共享地址空间等资源。
init进程继续完成剩余的内核初始化,并在最后通过execve系统调用装入用户空间的可执行程序/sbin/init,这时init进程就拥有了自己的地址空间等资源,
成为一个普通进程。
进程是动态在不断变化的实体,所以用于描述进程运行信息的进程描述符必须常驻内存之中。另外,进程从用户态切换到内核态后,也需要栈空间用于进行
函数调用。因此,内核为每个进程都分配了一个固定大小的内核栈,用于保存进程在内核态中的函数调用链以及进程描述符。
内核中有关进程处理部分的代码大都是通过进程描述符进行的,因此能够快速地查找到当前进程的进程描述符显得尤为重要。
内核提供了current宏用于获取当前进程的描述符地址,硬件体系结构不同,current宏的实现也不同,它针对专门的硬件体系结构进行了专门的处理,以便
提供访问速度。
Linux的进程依据特点可以分为3种:idle进程、内核线程(kernel threads)、用户进程(user process)。
idle进程即进程0,在系统启动时静态配置。用户进程主要分为两种情况:shell中输入Linux命令时所执行的用户进程;由init进程即进程1所执行用户进程。
shell中用户进程的执行流程如下。
(1)用户键入Linux命令。
(2)shell在用户指定的路径中搜索该命令对应的可执行文件。
(3)shell调用fork()创建子进程。
(4)shell的子进程调用exec()装入该命令的可执行文件并执行。
(5)shell等待命令结束,或者说子进程退出。可以通过输入Ctrl+Z发送一个SIGSTOP信号给子进程,把子进程停止并放到后台,让shell重新运行。
有上述流程可知,Linux的进程创建分解为两个独立的函数:fork()和exec()。其中fork()负责复制父进程的资源,创建子进程,exec()负责读取可执行文件,并
将其载入地址空间取代当前进程开始运行。
与Windows中的spawn()不同,fork()只是复制父进程,创建一个与父进程完全相同的子进程,并不会去执行可执行文件。而spawn()则是在新的地址空间
里创建进程后,还要读入可执行文件并执行。fork()和exec()联合起来可以实现spawn()风格的进程创建。
(1)exec()会读取以一个外部程序来取代当前进程,所以不能在父进程调用exec(),否则父进程会消失。
(2)fork()创建子进程,并且父进程不会消失。
(3)所以,首先使用fork()创建子进程,再调用exec()使外部程序取代子进程,即可实现spawn()风格的进程创建。
除了fork()之外,C库中另外提供了vfork()与clone()用于进程创建,它们分别通过系统调用fork、vfork与clone来实现。
系统调用fork、vfork与clone在内核中的服务例程分别为sys_fork()、sys_vfork()与sys_clone()。进程创建时函数
调用层次如图7.4所示。
1.fork()
使用fork()创建进程由如下两个特点。
(1)子进程完全复制父进程的资源,并独立与父进程,父子进程具有良好的并发性,但是它们之间的通信需要使用专门的进程间通信机制。
(2)如果不进行优化,使用fork()创建进程时,因为要将父进程的所有资源都复制给新创建的子进程,系统开销将非常之大。而且对于新进程创建后
立即调用exec()运行一个可执行文件的情况。这些资源复制所产生的开销是毫无必要的。
内核采用了写时复制技术对fork()创建进程的过程进行了优化,此时父子进程以只读的方式共享父进程的资源(父进程的页表还是被复制了),只有在子进程
试图修改进程地址空间上的某一页时,才进行该页的复制。
2.vfork()
为了避免使用fork()创建进程时进行大量无用的资源复制,vfork()被设计,它与fork()相比主要有以下两个区别。
(1)使用vfork()创建进程时,并不会复制父进程的页表,子进程完全共享父进程的地址空间,即子进程完全运行在父进程的地址空间里,子进程对其中任何
数据的修改都为父进程所见。
(2)由于父子进程共享堆栈,一个进程进行了关于函数调用或返回的操作,则另一个进程的调用栈也将被影响。所以使用vfork()创建子进程后,子进程将被
确保首先执行,父进程则被阻塞,直到子进程拥有自己的地址空间(调用exec())或退出(调用exit()),在此期间,父进程不会被调度,子进程也不能调用除
exec()和exit()之外的其他函数,否则程序将出错。
vfork()所带来的系统开销非常小,但是因为使用了写时复制技术对fork()的实现进行了优化,与fork()相比,使用vfork()的好处便仅限于不复制父进程的页表。
所以vfork()存在的意义并不是很大,将会逐渐从内核中淡出。
3.clone()
使用fork()创建进程时,子进程完全复制父进程的资源,使用clone()创建时,则可以通过不同的参数组合选择性地复制父进程的资源,甚至可以让新创建
的进程和父进程不再是父子关系,而是兄弟关系。clone()的原型为:
int clone(int (*fn)(void *),void *child_stack,int flags,void *arg);
参数fn指向一个由新进程执行的函数,child_stack指向为新进程分配的系统堆栈,arg为传递给新进程的参数,flags则用于标志需要从父进程复制的资源。
由于内核对于进程与线程不作区分,所以内核线程同时又被称为内核进程,不能将之理解为普通进程中属于内核的一个线程。
用于进程退出的系统调用有两个,为exit和exit_group,exit只终止某一个进程,exit_group则终止整个线程组内的所有进程。它们在内核中的服务例程分别是
sys_exit()和sys_group()。
sys_exit()和sys_group()只是分别调用do_exit()和do_group_exit()完成工作,do_group()_exit()的处理也很简单:向同一线程组内的其他进程发送SIGKILL
信号(它们收到SIGKILL信号后,会调用do_exit()),然后在调用do_exit()完成自身的退出。
do_exit()的目的是删除对当前进程的所有引用(对于所有非共享的资源),它定义在kernel/exit.c文件,代码中的关键部分如下:
(1)获取当前被终止进程的进程描述符
(2)设置进程的标志位PF_EXITING,表明进程正在退出,以免内核的其他部分在此期间处理该进程。
(3)删除该进程在其生命周期得到的各种资源(将各种资源的数据结构从该进程的描述符中分离出来,如果没有其他进程引用这些资源,才将它们彻底删除)。
(4)调用exit_notify()更新父子进程的亲属关系,并发出通知,比如告诉父进程自己正在退出。
如果同一线程组中有正在运行的进程,就让终止进程所创建的所有子进程成为同一线程组另一个进程的子进程,否则,让它们成为init进程的子进程。
如果进程描述符的exit_signal字段值为-1(说明退出时必须向父进程发送信号),且该进程没有被追踪(父进程还没有调用wait()系列函数等待该进程退出),则将
进程的退出状态设置为EXIT_DEAD,并收回用过的内存。否则将退出状态设置为EXIT_ZOMBIE,变为僵死进程,此时该进程的描述符仍然存在。
(5)调用schedulev()切换到其他进程,因为处于僵死状态的进程不会再被调度,所以这将是该进程最后执行的代码。
僵死进程指的是一个进程已经退出,它的内存和相关资源已经被内核释放,但是为了使系统在它退出后能够获得它的退出状态等信息,它的进程描述符
仍然被保留。
僵死进程是非常特殊的一种进程,它没有任何可执行代码,也不能被调度,仅仅保留自己的描述符,记载该进程的退出状态等信息供其他进程收集,
除此之外,僵死进程不再占有任何内存空间。
避免僵死进程的途径主要如下。
(1)在父进程中捕获SIGCHLD,执行wait()系列函数。
(2)父进程创建子进程前,调用signal(SIGCHLD,SIG_IGN),将子进程的退出信号完全忽略(因为内核做了特别处理,忽略SIGCHLD信号就意味着子进程
可以全然不顾地退出了)。
(3)杀死父进程。父进程被杀死后,僵死进程将由新的父进程负责清楚。
如果父进程在子进程之前退出,则子进程成为孤儿进程。如果不为孤儿进程寻找新的父进程,在它们退出之后,将会永远处于僵死状态。
exit_notify()会调用forget_original_parent()完成这个寻父过程。forget_original_parent()定义在kernel/exit.c文件,主要完成两个工作。
(1)寻找合适的父进程。首先在当前线程组内为孤儿进程寻找父进程,如果没有,则指定init进程为新的父进程。
(2)将寻找到的新的父进程指定指定给所有的兄弟孤儿进程。
后台进程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
后台进程必须与其运行前的环境隔离开来。这些环境包括未关闭的文件描述符、控制终端、会话和进程组、工作目录以及文件创建掩码等。这些环境
通常是后台进程从执行它的父进程(特别是shell)中继承下来的。