目录
进程创建
进程退出
进程等待
进程替换
在操作系统中,除了系统启动之后的第一个进程(根进程,1号进程)由系统来创建外,其余进程都必须由已存在的进程来创建。其中,这个新创建的进程叫做子进程,而创建子进程的进程叫做父进程。其中,根进程是Linux中所有进程的祖宗,其余进程都是根进程的子孙。所有命令行下执行的指令都是 shell/bash 的子进程。具体如下所示:
而在Linux下我们通常用一个fork系统调用函数来为当前进程创建一个子进程。fork函数对子进程的返回值为0,对父进程的返回值是子进程的pid。特别的,如果创建子进程失败则返回-1。父进程不仅负责创建子进程,还负责对子进程的资源进行回收处理,而且父进程只对子进程负责,不对孙子进程负责。至于fork之后生成的子进程与其父进程谁先执行谁后执行是不确定的,这与操作系统的进程调度策略有关,不能一概而论。
那么新进程(子进程)如何被创建出来的呢?一个新进程的创建大致可以概括为如下过程:
- 为新进程分配一个进程标识符PID。
- 在内核中为其创建并分配一个PCB。
- 复制父进程的进程上下文,即将父进程PCB中的大部分内容拷贝覆盖到子进程,但像PID这种内容是不会进行覆盖的。
- 创建页表,并复制父进程的虚拟地址空间内容(包括命令行参数和环境变量等信息)与页表映射关系等内容,并将页表权限都设为只读。
其中,上述的这些过程是在fork函数内部完成的。也就是说,在拿到fork的返回值之前,上述的这些工作就已经完成了。
所以,子进程在创建初期并不会真的为其在物理内存中分配内存,而是用的一种“写时拷贝”的技术。子进程起初与父进程共享代码与数据, 当父进程或子进程试图对某一块区域的数据进行修改时,就会触发缺页中断,然后才会为子进程真正的分配一块物理内存,此时父子进程的这块区域的内容才是真正的独立开来了。
需要注意的是,并不是只有数据段的内容会发生写时拷贝,在进程替换的过程中,代码段的内容也会发生写时拷贝,父子进程的代码段内容独立开来,不再共享。
相关拓展 - vfork函数
Linux下除了fork函数可以创建一个进程,还有一个不常用的vfork函数也可以创建进程。vfrok函数与fork基本上类似,不同之处在于fork创建子进程之后父子进程的执行顺序是不一定的,而vfork创建子进程之后,是保证子进程先运行,在子进程调用exec(进程替换)或exit(退出进程)之后父进程才可能被调度运行。所以,用vfork创建进程时,子进程里一定要调用exec或exit,否则程序会出问题,没有意义。
之前我们在编写C/C++程序时,一般会在main函数的最后加一个return 0,至于这个return 0是干什么的我们那时还不知道,也许有人曾经尝试过return不为0的值,好像对最后形成的那个可执行程序并没有什么影响。
其实,main函数的返回值就是当前程序的退出码,我们可以利用这个退出码分析当前进程的退出状态,一般我们可以将一个进程的退出场景分为如下三类:
- 正常终止,代码运行结果正确
- 正常终止,代码运行结果不正确
- 异常终止,与信号有关
进程的异常终止,本质上是进程收到了某种信号(比如 kill -9),导致代码发生异常,立即终止。
而前两者正常终止的情况则可以利用进程的退出码来分析。如果退出码为0,就表示正常终止,且代码的运行结果正确。如果退出码不为0,就表示进程虽然正常终止,但代码的运行结果不正确,此时我们就需要根据不同的退出码来分析问题了。如果退出码是用的语言内嵌的像errno等变量,可以通过相关的函数打印或者查看对应的信息,如果进程的退出码是我们自己设的,就要根据实际情况来分析了。其中,可以用 echo $? 输出最近一次进程退出时的退出码。
所以,判断一个进程运行的怎么样,归根结底可以通过两个数字来判断:信号的数字,退出码的数字。也就是说,父进程只需要监视子进程的信号信息和退出码即可。
在main函数中我们可以用return返回一个退出码说明当前进程的执行结果如何。例如我们可以在打开一个文件时判断是否打开成功,如果打开失败则可以直接返回一个退出码:
int main()
{
FILE* fp = open("myfile.txt", "r");
if(fp == NULL) // 打开文件失败,直接返回
{
// errno,C语言自己维护的一个错误码, 可以通过相关函数查询或打印对应的错误信息
return errno;
}
return 0;
}
而真正用来终止退出一个进程的其实是exit和_exit等函数。exit和_exit都是直接终止程序,并向上层返回一个退出码。exit和_exit的一个很明显的区别是,exit在终止程序时,会强制刷新缓冲区中的内容,而_exit只是单纯的退出进程,并不会刷新缓冲区的内容。
如果不知道什么是缓冲区,可以参考如下概念
缓冲区的概念:
在Linux的标准函数库中,有一种被称为"缓冲I/O"的操作,其特征就是对应每一个打开的文件,在内存中都有一片缓冲区。每次读文件时,会连续读出若干条记录,这样在下次读文件时就可以直接从内存的缓冲区当中读取。同样的,每次写文件的时候,也仅仅是写入内存的 缓冲区,等满足了一定的条件时(如达到一定数量或遇到特定字符等,最典型的就是咱们的vim中使用的 : w命令),再将缓冲区中的内容一次性写入文件。这种技术大大增加了文件读写的速度,但是也给编程带来了一点麻烦。比如有些数据你认为已经被写入到文件中,实际上因为没有满足特定的条件,他们还只是被保存在缓冲区内,这时用_exit()函数直接将进程关闭掉,缓冲区中的数据就会丢失。因此,为了数据的完整性,请使用exit()函数
究其原因就是,exit是C语言封装好的一个函数,而_exit是Linux下的一个系统调用。而其实C语言从exit函数底层上就是封装了_exit函数。所以,其实C语言代码中的缓冲区,是由C语言来维护的,Linux操作系统并不知道这个缓冲区的存在,故而_exit不会执行C语言缓冲区的清理操作。
所以,其实return并没有终止进程的作用,return只是用于终止一个函数,而main函数的返回值之所以就是退出码,是因为一个进程的执行是按照main函数的代码逻辑来的,在main函数结束时,会隐式地调用exit函数。所以,执行进程退出操作的本质上还是exit这一类的函数。
除了exit,C语言中还有一个abort函数用来终止一个程序。
与exit之间的区别是,exit是正常退出一个程序,而abort则是异常终止程序。
我们知道,当父进程负责管理子进程,既负责子进程的创建工作,也负责子进程的资源回收以及处理结果的接收。这里的子进程资源回收指的是解决僵尸进程及其带来的内存泄露问题。而父进程创建子进程一般是为了让子进程来完成某些任务,所以就需要获取子进程的退出信息,进而就能知道子进程的任务完成的如何。那么这些后续的善后工作,就是通过进程等待的来做的。其中,让父进程对子进程资源进行回收的等待过程就叫做进程等待。
Linux下,通常是通过 wait/waitpid 这两个系统调用函数进行进程等待的。
我们以waitpid为例来介绍一下进程等待,waitpid搞清楚了之后,wait也就不难了。
首先来介绍返回值:返回值大于0时表示等待成功,且子进程已退出,此时返回的就是子进程的PID。返回值为-1就表示等待失败,并会设置errno变量的值。而当返回值等于0的时候,表示等待成功,但子进程目前还没有退出(非阻塞等待的情况)。
第一个参数(pid)表示要等待的进程PID,当pid大于0时,就表示等待指定PID的进程,如果pid是-1,就表示随机等待一个进程,即哪个进程先退出,他就等待哪个进程。
而第三个参数options表示等待的方式,当options为0时表示为阻塞式等待,options为WNOHANG (一个宏)时,表示为非阻塞等待。阻塞等待是指,父进程将一直等待子进程的退出,期间不会进行任何其它的活动。与硬件阻塞类似,阻塞等待时就是把父进程的PCB链入到子进程的等待队列中(随之把父进程的状态设为s),当子进程退出时,操作系统再把父进程从子进程的等待队列中拿出来,放到运行队列再继续运行。而非阻塞等待是指,等待的过程中并不会一直在那里等待,而是得到等待结果之后立即返回。所以通常非阻塞式等待还要配合循环轮询使用。好处就是我们在等待的过程中可以做一些占用时间不多的小事情。
最后第二个参数status是一个int*的指针,这是一个输出型参数,表示进程等待之后,将这个status设置为一种特殊格式的数据。我们知道一个int型数据占4个字节32位,这里我们只讨论status的低16位。我们不能对status整体使用,只能截取其部分内容,以获取我们想要得到的信息。
如果被等待进程是正常退出,那么其低8位(0-7位)全部为0,8-15位的内容才是的我们想拿到的退出码。而如果是异常退出(被信号所杀),那么status的高8位(8-15位)的内容是未使用区域,内容是未知的,我们也不关心,而其低7位(0-6)位的内容就是终止信号对应的代码,其第8位(7)的内容是core dump标志,至于什么是core dump,可以参考下面的概念。
core dump的概念:
Core Dump 也称之为“核心转储”, 若当前操作系统开启了 core dump ,当程序运行过程中发生异常或接收到某些信号使得程序进程异常退出时, 由操作系统把程序当前的内存状况以及相关的进程状态信息存储在一个 Core 文件中, 即 Core Dump 。通常,Linux 中如果内存越界会收到 SIGSEGV 信号,然后就会进行 Core Dump 相关操作。
由于status的规格比较复杂,所以要获取某些值的话就要用到一些麻烦的位运算,所以其对应的头文件中为我们提供了一些宏,以简便我们的操作,如下是部分常用操作。
WIFEXITED(status):如果子进程正常终止,则返回true
WEXITSTATUS(status):返回子进程的退出码。只有当WIFEXITED返回true时才能使用。
WIFSIGNALED(status):如果子进程被信号终止,则返回true。
WTERMSIG(status):返回导致子进程终止的信号值。只有当WIFSIGNALED返回true时才能使用。
WCOREDUMP(status):如果子进程产生了core dump,则返回true。只有当WIFSIGNALED返回true时才能使用。
而wait则要相对简单一些,wait只有一个status参数,只能以阻塞式等待的方式进行进程等待,也不能指定等待的进程PID,只能随机等待一个最先退出的进程。
其它注意事项如下
- wait/waitpid 在等待成功且子进程退出的情况下,会自动对子进程进行回收。
- 一般而言,父子进程谁先运行不一定,但一般都是父进程后退出。因为父进程需要回收其子进程资源。进而我们能知道,多进程的多执行流,是由父进程发起,最后由父进程来统一回收的。
- 对于异常退出的情况,可以通过kill -l指令查看退出码所对应的信号信息。
- 当一个进程异常退出时,其退出码就已经没有意义了。
- 父进程是没法直接获取到子进程的数据的,所以需要通过wait等系统调用来获取子进程的退出信息。
- 父进程是如何获取子进程退出信息的,即wait和waitpid在系统层面是怎么做到的呢?当子进程在退出时,会将子进程的退出码、所接收的信号等一系列推出信息写入到其PCB中(状态变成z,并释放内存资源,变成僵尸状态)。所以wai/waitpid的底层的工作就是将退出信号和退出码等信息整合成一个status,向上返回给父进程(同时将子进程PCB的状态由z改为x)
进程替换,严格来讲应该叫进程程序替换,并不是指的替换进程,而是指的替换进程中的程序内容。是指将当前进程的程序内容替换为另一个程序的内容。比如当我们创建一个子进程后,有时会让子进程执行当前代码的内容,但有时也需要子进程去执行另一个程序的内容,这时就需要用到进程替换,将当前进程的程序代码替换为另一个程序的。
在Linux下,操作系统为我们提供了一系列的进程替换函数,俗称exec函数簇,它们分别是:
- int execl(const char *path, const char *arg, ...);
- int execlp(const char *file, const char *arg, ...);
- int execle(const char *path, const char *arg, ...,char *const envp[]);
- int execv(const char *path, char *const argv[]);
- int execvp(const char *file, char *const argv[]);
- int execve(const char *filename, char *const argv[], char *const envp[]);
- int execve(const char *filename, char *const argv[], char *const envp[]);
这些函数看起来很混乱,但其实只要掌握了规律就很好记了。
首先,这些函数的第一个参数通常为程序的所在路径(精确到具体的文件名),剩下的参数表示传入的命令行参数或是环境变量等信息。也就是说,先找到程序所在位置,再执行对应的程序。
而exec后续的字母,也是有规律的:
- l - list,表示命令行参数的传入采用列表传参的方式(仅限于命令行参数,环境变量的内容依旧是采用数组传递),比如printf的传参。arg为程序名,后续部分为执行参数,且必须以NULL结尾。
- v - vector,表示命令行参数以数组的形式传参。其中,数组的0号元素表示程序名,后续数组元素为执行程序时的参数,argv的最后以NULL结尾。
- p - path,表示这个函数会自动到环境变量PATH中根据file(第一个参数)去寻找对应的程序。所以带p的第一个参数不用写路径信息,只需要写文件名就可以了。也就是说,带p的比较适合用Linux内部的指令的进程替换。
- e - env,表示可以传入环境变量(都是以数组的形式传入),其中envp数组的最后一个元素必须为NULL以表示结束。
其中,前六个函数是统一在3号手册中的,最后一个函数单独在2号手册中,那么为什么 execve 这么特殊呢?这是因为上述的那些3号手册的那函数,其实底层都是封装的这个execve。也就是说,其实最终被调用的也就只有execve这一个。那么既然已经有了execve,还要有这些其它的函数呢?其实主要还是为了满足各种调用的场景,简化操作。
进程替换的原理
进程替换就是,将替换进程在磁盘中的代码和数据在当前进程对应的内存中的代码和数据内容覆盖掉。进程覆盖对进程的整体影响并不大,仅执行一些例如重新建立页表映射和重置虚拟地址空间内容等操作。而对于进程的绝大部分属性,比如PCB、PID、优先级等等都是保持原状,不会改变。
也就是说,在进程替换的过程中并没有创建新进程,只是替换程序的代码和数据放到当前进程的壳子当中,进程的本质属性并不受影响。
注意,如果此时代码段的内容还是处于父子进程的共享状态,那么替换程序对代码内容进行覆盖时,也会触发缺页中断,进而发生写时拷贝。
注意事项
- 可以认为命令行怎么撰写指令,我们的arg/argv就按照什么样的顺序撰写。其中,在执行自定义程序时,arg等内容也是可以不用指明路径的,因为第一个参数已经为我们指明路径了。
- 一个小技巧,exec簇中带p的,第一个参数是直接传argv[0]的。
- argv和envp这两个命令行参数和环境变量的数组必须以NULL结尾,以表示结束。
- exec簇的健壮性很强,有些小错误是不影响的,比如第二个参数写错,但第一个参数路径没错,这种情况是可以正常跑通的。但最好还是按标准的写法来。
- 除了可以将父进程默认的环境变量表传递给子进程外,exec簇的函数还可以将父进程自定义的一张环境变量表传递给子进程,以替换默认的那张环境变量表。即传递的环境变量表不是新增的,而是覆盖式的。
- 进程替换如果成功了,后续部分的代码是不会被执行的。因为内存中的代码和数据已经被替换了,执行的都已经不是当前的这份代码了。也就是说,如果进程替换成功,就不会执行原来的内容了。如果进程替换失败,就会继续执行剩下的内容。
- exec簇等函数只有失败的返回值(即-1),因为调用成功之后是不会再执行后续内容的。所以不用判断返回值,只要继续执行后续代码,就是程序替换失败了。
- 程序替换不单能替换C/C++语言的程序,还能替换其他所有语言程序,比如shell、Python、Java等等。这是因为exec簇等函数叫做进程替换,只要是个进程就能进行替换。也就是说,系统大于一切进程。
- 子进程的环境变量是不会影响其父进程的。但父进程的环境变量能够被子进程继承。也就是说,环境变量被子进程继承下去是一种默认行为,不受程序替换的影响。
- 那么为什么程序替换之后,环境变量也不变呢?这是因为,程序替换只会替换新程序的代码和数据。命令行参数环境变量等一系列信息不会被替换。也就是说,main函数的命令行参数和环境变量等信息是可以通过程序替换的方式,交付给子进程的。
内容补充
- ELF头有一个entry字段,记录的是可执行程序的入口地址。程序计数器,又叫pc、eip,是CPU中的一个寄存器,存放的是下一条指令的地址。不同的进程都有单独的eip信息,所以程序在执行过程中会不断更新eip的内容。那么exec簇中的这些的函数只需要将可执行程序的entry字段填到子进程的eip中,就能使得每次程序替换之后都可以从新程序的起始位置开始执行。
- 所以,bash/shell下执行的进程在创建时,是进程部分的PCB等一些内核数据结构先被创建,然后再通过进程替换操作将程序的内容和数据加载到内存。也就是说,是先创建的内核数据结构,然后再将代码和数据加载到内存的。