目录
一、进程创建
1.1 深入 fork 函数
1.2 写时拷贝
二、进程终止
2.1 进程退出码
2.2 exit 与 _exit
三、进程等待
3.1 进程等待必要性
3.2 进程等待
3.2 wait 与 waitpid
3.3 获取子进程 status
3.4 非阻塞等待
一、进程创建
1.1 深入 fork 函数
在 Linux 中 fork 函数是一个非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
fork 函数的返回值:
- 给父进程返回子进程的 pid
- 给子进程返回0
接下来我们举例使用一下fork函数 ()
我们编译,然后运行一下:
fork 的常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
在重温了一下 fork 函数的使用后,接下来我们来研究一个话题:
fork() 创建子进程,操作系统做了哪些操作?
进程调用 fork,当控制转移到内核中的fork代码后,内核做了以下操作:
- 分配新得内存块和内核数据结构给子进程。
- 将父进程部分数据结构内容拷贝至子进程。
- 添加子进程到系统进程列表中。
- fork返回,开始调度器调度。
父进程执行完 fork之前的代码(before)后,调用 fork 创建子进程,父子两个执行流分别执行。注意:fork 之后,谁先执行完全由调度器决定。
这里还有一个问题,当fork之后,父子进程代码共享是 after 共享,还是所有代码都进行共享?为什么子进程总准确地执行 fork 之后对应的代码?
答案: 所有代码共享,因为CPU记录了进程的执行位置。
- 代码进行汇编之后,会有很多行代码,而且每行代码加载到内存之后,都有对应的地址。
- 因为进程随时都可能被中断(可能并没有执行完),下次继续执行时,还必须从之前的位置继续运行(并不是程序最开始或main函数处),这就要求 CPU 必须实时记录下当前进程执行的位置。
- 所以,CPU内有对应的寄存器数据,用来记录当前进程的执行位置,此寄存器叫做EIP,也称作为pc(point code 程序计数器),用来记录正在执行代码的下一行代码的地址(上下文数据)。
- 当子进程创建时,会修改其EIP。此时子进程便会认为EIP的中保存下的数据,就是要执行的代码。
创建子进程时,操作系统给子进程分配对应的数据结构,子进程独立运行,因为进程具有独立性。
理论上,子进程也要有自己的代码和数据,但是一般而言,创建子进程没有加载的过程,子进程本身并没有自己的代码和数据。
所以,子进程只能 "使用" 父进程的代码和数据,而代码是只读的,父子共享不会冲突;而数据是可能被修改的,必须进行分离。
这时,操作系统便采用写时拷贝的策略。
1.2 写时拷贝
OS 为何采用写时拷贝技术,对父子进程进行分离
- 写入时再进行拷贝,是高效使用内存的一种表现。
- 提高了系统的运行效率。
- OS无法在代码执行前预知哪些空间会被访问。
二、进程终止
当进程终止时,操作系统释放了进程申请的相关内核数据结构和对应的代码的数据,其本质就是释放系统资源,
2.1 进程退出码
进程终止的常见方式:
- 代码跑完,结果正确。
- 代码跑完,结果不正确。
- 代码没有跑完,程序崩溃了。
区分第一种情况和第二种情况我们可以通过进程的退出码很清晰的辨别。
关于学习的 C语言中 main 函数的返回值,其中 main 函数的返回值就是进程的退出码。其意义是返回给上一级进程,用来评判该进程执行结果。
现在我们编写一个简单的 C 程序。
然后我们可以通过 echo $? 获取最近一个进程的退出码。
进程的返回值有 0 和非0两种情况,其中 0 表示程序成功运行并结果正确,而非0表示成功运行但结果有误,非零值有无数个,不同的非零值就可以就表示着不同的错误,方便我们定义错误的原因。
那常见的错误信息有哪些呢?
我们可以使用 strerror 将其打印出来
结果如下:
发现 linux 下,共有133条错误码
当然,程序崩溃的时候,退出码没有意义。
众所周知,Linux 是用C语言写的,其中命令本质就是C语言程序,所以我们可以简单的拿 ls 命令来举例
而 2 号退出码对应的报错信息:
2.2 exit 与 _exit
关于终止一个进程可以使用 return 语句,还可以调用 exit 和 _exit 函数
exit函数:
_exit函数:
关于这两个函数的区别有很多,我们先举一个小例:
我们接下来使用 printf 打印一条信息,然后sleep三秒,再使用 exit 退出,并观察结果
因为我们带上了 \n ,加上 \n 会刷新缓冲区,屏幕上即出现我们打印的内容。
如果我们不带上 \n ,我们再观察结果:
发现:因为没有 \n ,所以 printf 中的内容并没有在休眠前被打印出来,而是调用 exit 后将缓冲区的内容刷新出来输出在屏幕上。
接下来我们使用 _exit 函数
运行可执行文件 b:
发现什么内容都没有被打印出来,使用 echo $? 打印最近进程退出码,发现 b 文件确实被执行了。
这说明,exit是库函数,而_exit 是系统调用,其退出进程时并没有刷新缓冲区中的内容。
此时我们便能得出一个结论:
printf 数据是保存在"缓冲区"中的,exit可以将其刷新,而系统调用接口_exit不能将其刷新。所以,缓冲区必定不在操作系统内部,而是由C标准库维护的。
三、进程等待
3.1 进程等待必要性
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成"僵尸进程"的问题,进而造成内存泄漏。
- 另外,进程一旦编程僵尸状态,那就刀枪不入,即使 kill -9 命令也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成得如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程的资源,获取子进程的退出信息。
3.2 进程等待
接下来我们编写一个示例,子进程退出,父进程仍然在运行的程序。
3.2 wait 与 waitpid
以上子进程就进入了僵尸状态,既然kill -9都终止不了的进程,我们应该采取什么样的方法才能让子进程退出呢?
接下来介绍的两个系统调用接口——wait与waitpid。其功能是等待一个进程的状态改变。
pid_t wait(int*status);
- 返回值: 成功返回被等待进程pid,失败返回-1.
- 参数:输出型参数,获取子进程的退出状态(即status),不关心则可以设置为NULL 。
pid_t waitpid(pid_t pid,int* status,int options);
- 返回值:
- 正常返回的时候 waitpid 返回收集到的子进程的进程ID。
- 如果设置了选项WNOHANG,而调用 waitpid 发现没有已退出的子进程,则返回0
- 如果调用中出错,则返回-1。
- 参数
- pid
- pid = -1,等待任一子进程。与wait等效。
- pid > 0 ,等待其进程ID与pid相等的子进程。
- status
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出) WEXITSTATUS(status): 若 WIFEXITED 非零,提取子进程退出码。(查看进程的退出码。 options
传入 0 ,表示阻塞式等待。 传入 WNOHANG,表示非阻塞等待,若 pid 指定的子进程没有结束,则waitpid()函数返回0,不予以等待,若正常结束,返回子进程的pid。
接下来,我们就可以使用wait函数将上面的代码进行修改了。
运行结果:
接下来我们使用waitpid()接口
waitpid中的option选项默认为0,表示阻塞等待,WNOHANG为非阻塞等待。
结果如下:
这样,使用wait和waitpid的方法就成功解决了子进程运行结束进入僵尸进程无法回收的问题。
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立刻返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在却正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。
3.3 获取子进程 status
- wait 和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status 不能简单的当作整体来看待,可以当作位图来看待,具体细节如下图:(只研究)
其中status的低7位,是子进程收到的信号,可以使用 status&0x7f 取出。
status的次低8位,是子进程的退出码吗,可以使用 (status>>8)&0xff 取出。
让我们来看看结果:
除了以上的位操作,还可以使用系统为我们预设的宏来提取信号和退出码:
- WIFEXITED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否正常退出)
- WEXITSTATUS(status):若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
写段代码来使用一下:
结果如下:
其中这个信号是什么东西呢?我们接下来简要的了解一下。
进程退出可以分为三种情况:
- 代码跑完,结果正确。即退出码为0。
- 代码跑完,结果有误,即退出码非0。
- 代码运行,程序异常退出或奔溃,操作系统杀死进程。
那既然程序异常退出或者奔溃是操作系统杀掉了进程,那操作系统是如何杀掉进程的呢?
本质就是通过发送信号的方式来杀死进程,那常见的信号有哪些呢?
接下来我们来展示一下子进程收到的信号。
比如我们在子进程中进行整数除以0、指针的操作,然后从父进程中提取出子进程的信号。
11号信号,就是上图中的11) SIGSEGV,linux中最常见的段错误报错(在windows下就是红色弹窗的形式)。
程序异常,除了上面程序内部出现问题的情况,还有一种也可能是外力直接杀掉。例如,现在给子进程设置死循环,然后外部使用kill -9命令杀死子进程,观察其信号:
结果:
注意!!!程序既然奔溃了,退出码便没有意义,即使上图显示子进程退出码显示为0,其实也没有任何意义了。
3.4 非阻塞等待
以上的这些方式都是阻塞式等待,父进程什么都不做,进入阻塞队列等待子进程状态结束。这种等待方式很明显是不好的,所以这里我们来学习非阻塞等待。
设置为阻塞等待还是非阻塞等待就是根据 waitpid 中的第三个参数 option 来决定的。
接下来我们写一段代码来感受一下非阻塞等待。
结果如下:
不能发现,非阻塞等待的最大优势就在于,不会傻等子进程状态;而是调用waitpid时,发现子进程未退出,就可以暂时去执行自己本身的代码。
这里还有最后两个问题:
1.父进程通过wait/waitpid可以拿到子进程的退出结果,为什么要用wait/waitpid函数?
答:因为进程具有独立性,那么数据会发生写时拷贝,父进程无法拿到。
2.既然进程具有独立性,进程退出码,不也是子进程的数据吗?wait/waitpid是如何拿到的?
答:task_struct中保留了任何进程退出时的退出结果信息,而wait/waitpid就是从子进程的task_struct中取出数据然后位操作放入status中。