一、进程创建 - fork()
vfork()不推荐使用,因为坑多。
通过系统调用fork()来创建进程
#include<unistd.h>//该头文件是系统编程领域最重要的头文件,没有之一,里面包含了大部分系统调用的API函数声明。
pid_t fork(void);
返回值:成功的话,父进程返回子进程的pid,子进程返回0。printf(“%d\n”, fork());
失败的话,父进程返回-1。这时候会设置一个errno错误码。perror(“fork”);
fork()返回值的含义:
1、父进程返回子进程的pid。因为一个父亲可能有多个儿子,作为一个父亲他必须得知道每一个儿子的pid。
2、子进程返回的是0。因为一个儿子他的父亲是唯一的,子进程通过getppid()就能知道父亲是谁了。
通过系统调用获取进程标识符
#include <unistd.h>
pid_t getpid(void);//进程在每次运行的时候,操作系统给的pid是随机的。
pid_t getppid(void);//第一个p代表parent(父亲),第二个p代表process。
//pid每次运行可能都不一样,但是ppid是不会发生变化的,因为每开一个xshell窗口/虚拟机的终端,操作系统就会创建出一个对应的bash进程,而我们正在写的这个程序是以当前的bash进程为父进程所创建出的子进程。
//操作系统启动创建的第一个进程叫做init进程,后续操作系统上所有的进程都是由init进程直接或间接创建出来的,init的pid是1。在1号进程之前还有个0号进程,0号进程只是为了创建1号进程,当1号进程创建好了,0号进程也就没了。
//init只是通用的叫法,不同的操作系统是有一定差别。
3、fork执行失败返回-1。
两个失败的核心原因:
1、内存不够
2、子进程太多
(其实是有5种失败原因的,两种是与内存不够有关,一种是父进程创建的子进程数量达到上限,一种是操作系统根本就不支持fork(),还有最后一种是系统调用被“即将被重启”的信号打断,电脑都要被重启了,也就无所谓创建进程了。)
敲下./main命令,操作系统就会创建出一个./main进程。通过./main进程的task_struct结构体的内存指针,即将要执行的代码是fork(),一旦./main进程执行了fork动作,操作系统就会创建出一个子进程,也就是创建出一个新进程,也就是创建一个新的task_struct结构体,也就是操作系统把./main进程的task_struct结构体复制一份,稍加修改后加到双向链表里面。两个进程的task_struct结构体的大部分内容是相同的,其中,pid不一样,ppid也不一样。
父进程创建子进程的过程,就是以父进程为模板创建子进程。为模板是指把父进程的task_struct复制过来稍加修改(例如pid/ppid),其余大部分的内容是相同的。此时就意味着内存指针相同,上下文也相同(核心是eip相同)。父子进程执行的代码是同一份二进制代码,因为内存指针相同。再加上eip也相同,所以fork执行完毕之后,父子进程都要从fork返回的位置继续执行下去,也就是说子进程并不是从main函数开始执行的。所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。fork之后通常要用if进行分流。
父子进程代码共享。数据各自开辟空间,私有一份(写时拷贝:写的时候才分配内存空间)。
父子进程在不修改数据时,数据也是共享的。当其中任意一个进程试图修改数据时,操作系统会分配+复制数据给这个进程使用,而原数据留给另一个进程使用。
父子进程都得执行代码,谁先执行?不确定,取决于操作系统调度器的具体实现。
调度就是靠操作系统里面有一个具体的模块叫做调度器来完成的。调度器,不同的操作系统上实现可能有一定的差异。
僵尸进程和孤儿进程
只要子进程退出,父进程还在运行,但父进程却没有读取子进程的退出状态码,此时,子进程便进入到Z状态,处于Z状态的进程就是僵尸进程。而僵尸进程会被一直保存在双向链表中,直到父进程读取其退出状态码。
为什么操作系统要提供僵尸进程这样一种机制呢?
僵尸进程其实是子进程向父进程汇报工作结果的一种机制,在父进程查看返回结果之前,这样的返回结果不应该被释放掉,而返回结果就保存在子进程的task_struct中。
如何更加科学的避免僵尸进程?
如果父进程能够及时地读取子进程的返回结果,那么,这个返回结果也就可以释放掉了,那么,僵尸进程也就不复存在了。而父进程读取子进程的返回结果的具体方式是进程等待。
其实僵尸进程并不是一件坏事,它只不过是为了完成某个特定的要求而迫不得已付出的一些代价。为了让父进程能够看到子进程的返回结果,所以必须得开辟内存来保存这个返回结果。如果父进程没有去读这个返回结果,那这个返回结果确实不能被释放;如果读了的话,那就可以被释放掉了。父进程到底怎么样来读这个返回结果?这样的一个做法叫做进程等待。
僵尸进程的危害
子进程执行完了,为什么ps还能看到它?其实ps是在遍历双向链表,之所以还能看到这个僵尸进程,说明链表上还有这个僵尸进程对应的task_struct结构体存在。想想看,如果一个父进程创建了很多的子进程,但就是不回收,而操作系统的内存资源又是有限的,一旦你的机器上出现了大量的僵尸进程,这会占用大量的内存空间,这些内存空间如果没有被及时回收的话,那么,操作系统的可利用内存就会越来越小,进而造成内存泄漏。
如何处理?
在Linux下也有一个类似于Windows下结束任务的功能。
kill指令:给某个进程发送某个信号。
发送一个特定信号就能完成进程的销毁,强制干掉该进程。
Kill -9 +pid//其中,-9表示发送一个9号信号,其功能就是终止进程。
需要注意一点的是,直接kill僵尸进程是杀不掉的,因为僵尸进程已经死了,但是可以kill掉僵尸进程的父进程,这样的话,父进程和僵尸进程就都销毁了。
对于kill父进程的情况是这样的,kill掉父进程之后,对于原有的子进程就成了孤儿进程,就是父进程结束了,子进程还在。
对于孤儿进程来说,父进程就成了1号init进程,1号init进程就会完成结果的读取和内存的清理动作。注意:孤儿进程可不是进程的一种状态。
此时,子进程是在后台运行着的,也就是状态显示没有“+”号。
我的操作系统先调度的是父进程。所以,父进程先于子进程结束,子进程变成“孤儿”进程。所以,子进程的ppid打印出是1。此时你可以直接输入命令。
二、进程终止
1、代码执行完,结果正确。
2、代码执行完,结果不正确。
3、代码还没执行完就异常终止。
main中的return 0;//进程的退出码。约定,如果进程的退出码为0,则认为结果是正确的;如果非0,就不正确。
//改成return 1;后程序的执行和return 0;并没有什么区别,但是,这里的正确与否还是有区别的。
echo:查看上一个进程的退出码。其中,$?是个特殊的变量,代表上一个进程的退出码。main函数的返回值返回给了操作系统。
return -1;// echo $? 打印的结果是255。虽然main函数的返回值类型是int,但返回的数字范围并不是所有的整数都能返回,只有其中的一小部分(低 8 位)。
3种正常终止,代码执行完了,具体正确与否还是要看退出码是不是0:
1、main 的 return 是一种进程终止的方式。
2、exit函数,exit(1);//C语言中的一个库函数,exit(1)表示代码执行到这一行,进程就会终止,并且返回结果就是1。头文件<stdlib.h>
3、_exit函数,这是一个系统调用。也是终止进程,并且退出码就是函数参数。头文件<unistd.h>。
exit和_exit区别:
(1) exit会关闭流(文件流),并且刷新缓冲区(printf函数先放到缓冲区,一旦刷新缓冲区,才会真正把内存中,也就是缓冲区中的数据写到I/O设备上,也就是显示器上)。而_exit不会。
其中,图1、2、3均会打印出main,而图4什么也没打印,而且图2和图3是等价的,虽然我们在代码中没有写return 0; 但是编译器会自动帮我们加上,但是最好还是自己写上。
(2) exit会调用结束函数(atexit函数(头文件stdlib)/on_exit函数,都是注册一个回调函数,回调函数的意思就是你拿一个函数指针交给你的操作系统或者交给某一个程序框架,然后这个函数指针指向的函数它什么时候调用不是由程序员来决定的,而是由框架来决定的。注意:是在main函数结束之后才调用回调函数的。)。而_exit不会。
return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做exit的参数。
其实,exit最后也会调用_exit,但在调用之前,还做了其他工作:
1、执行用户通过atexit或on_exit定义的函数。
2、关闭所有打开的流(文件流),并刷新缓冲区。
3、调用_exit。
系统调用:操作系统给程序员提供的API接口,程序员借助系统调用来完成和操作系统内核之间的一个交互过程。而库函数这是后来一些大佬们封装出来的函数。
2种异常终止:
1、Ctrl+c,它本质上也是在给进程发送信号。发送的是2号信号。
kill -l:显示所有信号。共62个信号,没有32号和33号信号。
2、信号终止,例如,kill -9 +pid
服务器相关的开发,100%会用到负载均衡。
收到一个请求之后,先交给服务器A进行处理,下一个请求交给服务器B,下一条请求再交给A,再交给B,……,依此类推,就保证任何一台服务器如果挂掉了,还有另外一台服务器兜着呢。
三、进程等待 - waitpid()
虽然是进程等待,但更核心的在于说让父进程主动地去读取一下子进程的返回结果。
避免僵尸进程,进程等待才是处理僵尸进程最科学的方案。很多情况下不是说进程想杀就能杀的,尤其对于服务器来说,服务器程序要求7*24小时运行,你一言不合就kill,把服务器杀了,这可能就会导致外界的用户用不了,虽说可以用多台服务器来提供服务,但是因为代码bug导致出现僵尸进程,那可不是一台服务器有问题啊,可能所有服务器都有僵尸进程,那你总不能全杀了吧。
1、wait函数
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int *status);
返回值:返回子进程的pid,失败返回-1,失败的原因你都没有子进程,还等个锤子啊。
参数:输出型参数,获取子进程的退出状态,不关心则可以设置成NULL。
C语言并不支持一个函数返回多个返回值的情况,只能通过输出型参数的方式来进行返回,status其实就是通过这样一个指针返回一个int类型的变量。
这个指针指向的那个变量里面存的是子进程的退出状态。这是调用wait的时候,由操作系统来进行填充的。如果不关心子进程的退出状态,可以把这个指针置为空,这时候wait只会进行一个等待的操作。
wait是一个阻塞等待的函数,前面了解到的函数,一旦执行完,函数也就返回了,但是现在,一旦某个函数调用wait,那就卡在这函数里面出不来了,直到子进程结束,这个函数才能返回。
注意:从测试来看,子进程的退出码应用低 16 位(其中低 8 位用来存储异常终止信号,高 8 位用来存储正常退出码),而父进程的退出码应用低 8 位。
wait它来者不惧啊,只要这是我的娃,我就把他接走。但是有的时候也不能乱接啊,这就意味着在等待的时候得有针对性的等待,到底要接哪个娃,得心里有数。而且调用一次wait仅能回收一个子进程。
2、waitpid函数
#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
返回值:
(1) 成功,返回子进程的pid;
(2) 失败,返回-1;
(3) 如果设置了选项WNOHANG,而调用中waitpid发现还有子进程,只不过子进程还没退出,则返回0。如果waitpid发现已经没有子进程了,依旧返回-1。
参数:
(1) 参数1:指定等待某个子进程;
其中,pid = -1表示等待任一个子进程。pid > 0表示等待其进程id与pid相等的子进程。
(2) 参数2,同wait函数;
(3) 参数3:扩展选项,这样一个选项虽然看起来它是一个int,但其实它是一个位图。它里面不同的位具有不同的含义,这样就可以设置很多的选项了。
其中,WNOHANG选项的含义是非阻塞等待。
status和退出码的关系
虽说status的类型是4节字,但只有低2字节是有意义的,所以,只研究status低2字节。
退出状态就是退出码,一个进程的退出码(exit 或者 main 的 return)的范围:0—255。一旦退出码是1,那么0000000100000000就是status的值。
被信号所杀的,15—7位都不要了,后7为表示当前的进程被哪个信号所杀死的。凡是进程异常终止,一定是被信号杀死。7位表示的范围0—127,够用,信号一共才只有62个。
如何判定当前是正常终止还是异常终止?
core dump标志
你如果是一个服务器端的开发程序员,十有八九是用不到调试器这样的工具的。调试器对于我们理解程序的运行是有很大帮助的,但在实际工作中却很难有机会用得到,因为以后我们所接触到的代码它在编译过程中都是经过高度优化的,以至于在你调试的时候,你所看到的程序执行过程和你代码写的过程已经不太一样了,这是其一,其二,以后在服务器端开发的程序会遇到很多概率性出现的问题,断点10000次才出现一个bug。
在工作中只有一种场景会直接用到调试器,那就是用调试器来分析core dump文件,相当于是进程一旦异常终止的时候,操作系统就会把当前进程相关的运行信息保存到一个core dump文件里面,方便日后对这个问题进行分析,其实就是车祸现场。