这里写目录标题
进程相关概念
程序和进程
一个剧本,可以同时上演多出戏,比如,我们同时开两个终端,也就是两个bash,同时运行一个可执行文件,是没问题的
并发
并发:指的是同一时间段,CPU进行多个进程
对于多核CPU处理器,确实是实打实的在进行多个进程
但是对于早先的单核CPU处理器,虽然在我们人类的感官上,CPU确实是在并发,但是实际上CPU是在轮流的执行他们,也就是说,在任何一个时刻,只有一个进程在运行,之所以感觉不到,是因为CPU的处理速度远在人类的感知范围之上
单道程序设计与多道程序设计
以上的解释,均是基于单核CPU
单道程序设计,就是顺序执行进程,一个进行执行完了才能执行下一个,若一个进程阻塞,那么后续的进程都要排队
多道程序设计,是基于单核CPU的前提下解释的,也就是在CPU穿插运行多个进程,其中,时钟中断,是指CPU强制执行的,满足时钟中断则进行进程的切换
CPU和MMU
左边是CPU图示,右边是存储介质
当我们运行一个write时,首先从存储介质拿到文件,经过处理后交给CPU执行,CPU将执行结果又给到存储介质,之后屏幕可以从存储介质拿到处理后的结果进行展示
从硬盘拿到源代码,交给内存生成进程,之后二进制指令给到缓冲区,给到寄存器(32位系统是32位bit,即4个字节)
给到CPU,CPU一顿计算之后,将结果返回到存储介质的寄存器,之后返回到缓冲区,又给到内存的进程,此时屏幕可以获取到结果,进行展示
而CPU中间的MMU,是虚拟内存映射表,MMU的大小是4KB
虚拟内存与物理内存的映射关系
每个程序启动之后,会形成一个进程,而每个进程都会有一个0~4G的一个“进程地址空间”
(32位系统:0~4G,64位要更多)
而其中,0~3G区间,是用户区域,3G-4G区间,是内核区域,每个进程的进程地址空间在内核区域都会有一个pcd进程控制块,里面记录着当前进程的各种信息
但是我们的内存条的大小,有时候会很小,比如只有512M,那么很显然,这样的内存大小连一个进程都放不下,更别说什么并发了
实际上,0~4G的内存空间,是一个虚拟地址空间,他只是表示可用的地址编号是这个范围,实际上,每个“进程地址空间”的虚拟地址都会通过MMU(一个虚拟地址与物理地址的对应表),去对应到实际的物理地址,由此可以得知MMU的作用就是“一个虚拟内存与物理内存之间的映射关系”
而假如现在不同的进程,有相同命名的变量,而且虚拟地址也相同,那么:
那么他们会通过MMU,映射到两个不同的物理内存,并不会因为他们变量名相同,虚拟地址相同而映射到同一个内存,二者根本没有任何关系
而当一个进程需要一个数组时,假如物理内存上没有连续的空间可用的话,MMU会负责将剩下的离散的内存地址,映射给数组,类似于链表,也就是物理上不联系,但是MMU会记录顺序,逻辑上是连续的
而对于不同进程的PCD进程控制块:
对于不同进程的PCD进程控制块,他们会通过MMU,映射到内存的同一块区域,这个区域很大,可以容纳许多个PCD结构体,即他们不会被覆盖,虽然是在同一块区域,但是这一块区域不是只有一个PCD的空间,所以说,内核区的物理地址是共享的,但是不会覆盖
PCD进程控制块
其中:
进程ID,也就是ps时,出现的PID
进程状态:详情见下图
进程保存的CPU寄存器缓冲,当进程切换走,之后又切换回来时,会根据该记录继续执行进程
虚拟地址空间信息
终端信息,就是记录当前进程是否需要终端
当前工作目录,就是当前进程(这里指的是当前shell)的当前工作目录的位置,比如在不同的目录下ls,内容是不同的,所以工作目录发生了改变
umask掩码,每个shell刚打开时都默认是0002,我们可以通过“umask xxxxx”去修改当前shell的umask,修改完之后,再去创建文件,其默认权限会改变
。。。
五种状态,但是初始态和就绪态经常会被放到一起,其中,挂起态是指进程暂停
进程控制
fork
函数原型
返回值:父进程中的fork会返回其创建的子进程的PID,而子进程虽然不会执行fork代码,但是他会进行值的返回,如果成功创建了子进程,在子进程中的fork会返回0
如果失败,父进程返回-1,设置errno,子进程没有,因为没被创建
原理图
在一个a.out进程运行时,在一个位置执行了fork,一旦执行了fork,就会该a.out进程就会创建出一个子进程,那么该a.out就变成了父进程,子进程里的代码与父进程一模一样,但是,子进程不会执行fork之前的代码,因为已经被父进程执行完了,子进程没机会再去执行上面的代码了,但是子进程会执行fork后面的代码,同时,父进程也会继续执行父进程中fork后面的代码
总体来说,是右边的那个图,在fork之后,会分为两个进程一起执行
返回值:父进程中的fork会返回其创建的子进程的PID,而子进程虽然不会执行fork代码,但是他会进行值的返回,如果成功创建了子进程,在子进程中的fork会返回0
如果失败,父进程返回-1,设置errno,子进程没有,因为没被创建
代码
这里检查fork的返回值时,既然是父进程在执行,为什么还要检查pid == 0,这不是子进程的返回值吗
要注意,因为子进程被创建之后,用的还是父进程的代码,所以,编写代码时,要站在父进程和子进程两个视角来看问题,子进程会从返回fork为0开始,并向下执行代码,同时父进程继续向下执行代码
编译:
编译时有个小tips,可以看到当前目录下并没有makefile,但是还是可以进行make,这里系统默认提供的makefile,如果使用系统默认的makefile,那么指定的可执行文件的名字必须与源文件同名,这不仅仅对进程有效,对所有的源文件都有效,当然我们可以写自己的makefile
效果:
由于子进程会从fork返回开始,向下执行代码。且父进程继续向下执行代码,所以,最后的效果就如上图所示
getpid、getppid
代码
这两个函数很简单,其中:
getpid,是得到当前进程的PID
getppid,是得到当前进程的父进程的PID
注意,上图中三角标出的pid,是fork返回的pid,表示当前父进程创建出的子进程的PID
效果:
可以看到,父进程打印的子进程PID、自己的PID
与子进程打印的自己的PID、父进程PID,一一对应
而父进程打印的他的父进程PID,是bash的PID,在LINUX上,所有的./进程,其父进程都是bash
bash是一种shell,大多数linux系统默认的shell版本就是bash
循环创建N个进程
假如说,我们就像上图那样,直接在原来的基础上加上一个for循环,那么最后的结果一定不止5个位,因为fork创建出子进程之后,在子进程中,for循环还没有结束,所以, 子进程中还回执行后续的for循环,实际上,最后会产生2的N次方-1个子进程
更正后的代码:
判断如果是子进程,直接break即可,这样,在子进程中,会进入if语句,就会break,而父进程不会进入if,就会继续后续的循环
让每个进程打印编号:
这里判断的是,退出for循环时,i 的大小,当其小于5时,说明被break了,那就是子进程,当等于5时,说明正常结束,那就是父进程,当然这个输出也可以放到 if (fork() == 0) 之内,在break之前
这样的话,会输出:
可以看到他们会随机输出,这是因为,虽然子进程在逻辑上是被一个一个创建的,但是,对于CPU的告诉计算来说,他们可以认为是同一个时间被创建的,那么他们就会在同一个起跑线去争夺打印自己的输出语句,父进程、父进程的子进程、父进程的父进程(输出itcast…$)在争夺CPU的执行权,所以他们打印的顺序完全随机
人为干预,使其顺序正确:
只干预父进程:
我们将父进程和所有的子进程视为同一个起跑线,这时,让父进程sleep(1)一秒,那么子进程肯定都执行完了,父进程最后输出,而只有父进程执行完,bash才会输出itcast…$,所有,这时的效果就是,先输出子进程,之后父进程,最后cast… $,但是子进程之间的顺序还是随机
我们再干预子进程:
将他们视为同一起跑线,那么,编号靠前的,sleep的时间短一些,编号越大,sleep的时间越长即可
这样最终的效果就是:
总结:
进程共享
概念
也就是读时共享,写时复制,
这个原则对父进程和子进程都适用,也就是子进程遵循“读时共享,写时复制”,父进程也遵循“读时共享,写时复制”
但是:
注意,不能简单的认为父子进程共享全局变量,具体的说,他们只是在读的时候,共享原来的全局变量,但是一旦有进程要对某个全局变量进行写操作,该进程就会将原来的全局变量复制一份,后续就不再共享了,而是使用新的全局变量
代码
父子进程都进行写操作:
可以看到,父子进程都对原来的全局变量拷贝了一份,之后自己用自己的
父进程进行写,子进程单纯读:
可以看到,父进程进行了写操作,那么父进程的那个进行写操作的全局变量不再使用原来的全局变量,而是复制一份
而子进程并没有进行写操作,所以,还是使用原来的全局变量
实际上,他们真正共享的东西有两个:
总结
gdb调试
简介
gdb调试的时候,只能跟踪一个进程,所以,在gdb跟踪到fork之前,进行“跟踪哪个进程”的设置,就可以选择一个进程进行后续的调试了
代码
要在fork前设置断点,并且使用set进行设置,如果不设置,默认跟踪父进程,上图中我们在执行到fork之前,(fork为断点时,输出显示fork时还未执行,输入n之后才执行,也就是显示下一句的时候,说明刚刚显示的那句代码执行了),我们要在fork执行之前,设置跟踪哪个进程
exec函数族
原理
父进程fork之后,会对fork返回进行判断,这里,我们知道,被分成了三部分,一部分,是父进程独有的要执行的代码(pid>0),一部分是子进程独有的要执行的代码(pid == 0),还有一部分是父子进程后续都要执行的代码(fork检查完返回值之后的代码)
而exec函数,作用就是可以打开运行一个可执行程序,且成功的话,没有返回值,不用进行返回,至于失败该如何检查,我们看下面的详细解释
所以,我们只要在pid == 0 的代码块内放入exec函数,就可以使子进程执行一个全新的程序,而不再执行从从父进程拷贝来的代码,但是虽然执行的是全新的程序,但是仍然依托这个子进程在执行,所以,子进程还是那个子进程,里面的内容是全新的程序,称“换核不换壳”
exec族函数的返回值:
这类函数只有在失败的时候,才会有返回值,返回-1,设置errno,成功的话没有返回值
所以,我们可以:
无需进行检查,因为成功的话,不会返回,就不会执行perror以及后续代码
失败的话,不用检查,肯定是-1,肯定会执行后续的perror,所以,也不用接收其返回值
execlp
简介
该函数加载一个被记录到环境变量“PATH”中的一个程序,而一般记录到“PATH”中的路径,都是系统程序的bin目录,所以,该函数通常用来调用系统程序
代码
注意,参数一,是要执行的可执行程序的名字(命令名)
后续的参数,是可变参数,也就是数量是动态的,表示执行可执行程序时的参数,但是注意,这里的参数要从argv[0]开始,而argv[0]又代表着程序名称,所以,使用execlp时,第一个参数,以及可变参数的第一个,都是命令名,之后就是参数,最后要加上一个NULL,表示哨兵
对于没有参数的命令:
传入两边命令名,后跟上哨兵即可
execl
简介
他与execlp的区别就是,他负责进入执行我们自己的可执行程序
代码
第一个参数,传入可执行程序的路径(可以是相对路径,也可以是绝对路径),第二个参数直接传入函数名即可(经过测试,无论a.out在哪个位置,直接传入a.out即可),最后跟上哨兵NULL
补充
如果我们想实现,使用execl执行系统可执行程序(即命令),
如何实现:
我们在第一个参数,传入ls的绝对路径,也就是/bin/命令名,第二个参数直接传入命令名即可
demo
需求:将ps的内容,重定向到一个文件中
1、打开ps.out,由此可以得出结论:open打开时,并不在乎文件类型,都可以打开
后面设置权限(只写、不存在则创建、存在则清空),第二个参数服务于O_CREAT,创建时指定文件权限为644
注意,这里的ps.out是我们自己定义的一个文件,他只负责接收最后的结果,相当于他就是那个重定义的目标文件,所以我们可以随意操作,可以进行清空操作(这里最好是使用一个txt文件)
2、将该文件的fd拷贝给STDOUT,这样,输出到屏幕上的内容,就会写到fd中,通过fd写到ps.out文件
3、执行ps即可,这样,执行ps的结果就会被写入ps.out文件中
execvp
原型
代码
也就是将原来的可变参数,放入了一个字符串数组中进行传入,他的第一个参数传入环境变量PATH中的可执行程序(或者说命令)(虽然上图说的是env,但是env的作用就是查看当前系统所有的环境变量,而所有的环境变量中,只有PATH是可执行程序的路径,所以还是PATH),与execlp作用一致
一般规律
回收子进程
孤儿进程
概念
父进程先于子进程结束,子进程就变为了孤儿进程,此时,子进程的父进程就变为了init进程,init是系统规定的孤儿进程的父进程,所以也称为孤儿进程院
我们可以使用代码进行验证:
让子进程保持死循环,等待父进程结束,子进程就会变为孤儿进程
父进程结束之前:
可以看到,父进程结束之前,我们使用ps ajx 可以查看所有进程的PPID(第一列,父进程ID) 与 PID(第二列,自己进程的ID),可以看到,子进程的PPID,仍然是父进程的PID
(从控制台输出也可以看出)
父进程结束后:
子进程的PPID变为了1721
可以看到,1721就是init进程
补充:
当执行上述验证代码之后,想要关掉子进程,使用ctrl + c 是关不掉的,因为此时ctrl + c 是对父进程作用的,作用不到子进程中,我们想要关掉子进程,可以再开一个终端,使用kill命令,关掉子进程
僵尸进程
概念
僵尸进程,就是指子进程终止了,但是其残留资源还没被父进程回收,就称之为僵尸进程
每个进程都会有一段时间处于僵尸进程,因为一个进程终止之后,父进程会过一段时间才来回收,在父进程来回收之前,这个终止的进程就叫做僵尸进程
而他残留的资源,就是PCB进程控制块,其他的资源都被系统隐式回收了,只有PCB会等待父进程来回收,PCB中记录着子进程终止的原因等一些信息
验证代码:
我们让父进程一直工作着,让其一直在循环,而子进程在10s之后结束,由于父进程一直在执行任务,所以没有时间去回收子进程,于是子进程就变成了僵尸进程
子进程结束前:
可以看到一切正常,子进程的PPID与父进程的PID是对应的,正常的
子进程结束之后:
子进程父进程的关系还在,但是由于子进程结束父进程没有回收,父进程一直在忙自己的任务,所以,ps ajx显示子进程的名字被[ ] 框起来,并且后面跟着 表示该进程死亡
这时我们尝试使用kill -9 3465 来试图回收子进程,但是是无效的,因为kill只是结束一个进程,子进程已经被结束了,再怎么kill都没用,这就相当于在鞭尸,没啥作用
所以,当前我们的做法,可以kill父进程:
当父进程被kill,子进程就会被init进程收下,init进程孤儿院就会把子进程的残留资源进行回收
wait函数
简介
该函数会阻塞等待,只有子进程结束了,他才会去回收子进程的PCB,之后该函数才会返回
函数原型
参数:传出参数,传出回收的子进程的退出状态(整型变量)
所谓退出状态,包括:进程是自己退出,还是外界介入退出
如果是自己退出,其返回值是正常还是异常退出
如果是外界介入,是谁介入
返回值:成功的话,返回回收的子进程的ID,失败返回-1
代码
可以看到父进程回收的确实是那个结束的子进程,且子进程没有结束时,父进程会阻塞在wait上
获取子进程退出状态(wait、waitpid都可以使用)
自己退出
wait提供了一系列的宏函数,帮助我们对传出参数status所携带的信息进行解析
首先是自己退出
对于第一个宏函数:
如果传入status后,第一个宏返回值为真,说明子进程是自己退出结束的
对于第二个宏函数:
这时,如果其返回为真,我们就可以紧接着调用第二个宏,他会返回子进程的返回值
代码:
上图代码,最终父进程会捕捉到子进程的返回值为73
对于正常退出:那么会捕捉到正常的return的返回值
对于异常退出:如果在一个库函数或者系统调用检查返回值时出错,虽然该库函数会返回-1,但是并不意味着子进程返回-1,这里如果没有exit,那么会继续向下执行,只要执行到最终的return,其返回值与正常退出的返回值无异,因为其只是内部发送了异常,退出还是正常退出(因为执行到了return)
但是,如果有exit,那么exit中的数字就是返回值,这时会捕捉到exit返回的返回值(所以,这也论证了,exit中的数字,表示的其实是返回值的意思。而不是说必须是1,也可以是其他数字,照样可以退出返回)
总之,该宏函数会捕捉子进程的返回值,而不是子进程某个函数的返回值,只会捕捉确定整个进程最终会返回什么
被其他信号kill
对于第一个宏函数:
如果传入status后,第一个宏返回值为真,说明子进程是被信号终止的
对于第二个宏函数:
这时,如果其返回为真,我们就可以紧接着调用第二个宏,他会返回终止子进程的那个信号的编号
代码:
我们编译之后,将程序启动起来,程序会睡10s,并且程序会打印他的PID,那么我们再开一个终端,将该程序kill掉,我们看最终的效果:
可以看到,程序会捕捉到是被哪个信号所终结,我们使用的是9,当然也可以使用其他信号编号
所有的kill的信号编号如下:
补充
总结
waitpid函数
使用场景
1、当有多个子进程,由于一次wait或者一次waitpid调用一次只能回收一个进程,而且如果没有使用waitpid指定回收进程,回收哪个进程是随机的,看谁先结束,所以,可以使用waitpid指定回收哪个子进程
2、wait默认只能是阻塞,使用waitpid可以改为非阻塞
函数原型
参数一:指定回收某个子进程的PID
参数二:退出状态
参数三:选项(设置非阻塞)(传入WNOHANG设置非阻塞)
返回值:
成功回收,返回子进程PID
回收失败,返回-1
若参数三设置了非阻塞,由于没有任何一个子进程死亡,则返回0
代码
20行:首先设置了非阻塞,且第一个参数传入-1,当waitpid第一个参数传入-1时,其表示不指定回收进程,进行随机回收,这样他的作用就跟wait一样了,只不过是非阻塞
由于没有设置超时,且子进程都会进行sleep,所以,该程序一启动,waitpid就会返回0
效果:
代码2
需求:指定回收第三个子进程
错误展示:
我们在fork之后,判断如果是第三个子进程,那么就将其值给到PID,之后将pid传给31行的waitpid,实际上,当fork之后,23行之前, 就一直属于子进程,所以,pid是子进程“写时复制”拷贝的一份,与父进程的waitpid没有任何的关系
更正:
在fork时,就拿到返回的pid,如果pid == 0,那么就是子进程的路线。否则,就是父进程的路线,子父进程都有一个pid,这里其实也没必要使用tmpid,直接使用pid即可
注意,父进程(即 i == 5),内要sleep(5),后面35行之后,有else {sleep( i ) … },每个子进程睡相应的秒数
这里如果父进程没有进行sleep,那么父进程的waitpid会立刻返回,因为子进程在sleep,没有子进程结束,而父进程也没有sleep,也没有设置超时时长
或者:
不进行sleep、超时设置,而是使用阻塞(传入0),且指定回收第三个进程,这样,父进程会等待指定的子进程结束之后,进行回收,才继续执行后续代码
补充
进程组:一个父进程会创建一个以自己PID为编号的进程组编号(GID),其所有的子进程都与自己是一个进程组,但是子进程也可以在我们的操作下,离开旧的组,创建或者加入新的组
当参数一传入0,表示,回收当前进程组内的任意子进程,只要是还在组内的子进程,谁先结束回收谁
当参数一传入<-1,表示回收指定进程组的任意子进程,比如1003的进程组,我们就传入-1003
当参数一传入-1,表示回收任意子进程(不管是不是一个组,哪怕其中一个子进程到别的组了,也可以回收)
回收多个子进程
代码:
上面代码分别演示了使用阻塞和使用非阻塞,在父进程中,对多个子进程进行回收
其中,对于非阻塞,我们在循环中判断其返回值,只要不是出错(出错返回-1),那么就一直进行循环监听,而非阻塞无数据的话是返回0,所以还是会继续循环
waitpid参数一传入-1,表示无差别回收,回收所有已经结束的自己的子进程(进行无差别回收,与传入0的区别是,不管是不是一个组,哪怕其中一个子进程到别的组了,也可以回收)
总结