进程控制
进程的概念
程序和进程是两个不同的概念,它们的状态、占用的系统资源都是不同的。
程序:就是磁盘上的可执行文件文件,并且只占用磁盘上的空间,是一个静态的概念。
进程:被执行之后的程序叫做进程,不占用磁盘空间,需要消耗系统的内存、CPU资源。每个运行的进程的都对应一个属于自己的虚拟地址空间,这是一个动态的概念。
并行与并发
(1)CPU 时间片
CPU 在某个时间点只能处理一个任务,但是操作系统都支持多任务的。CPU 只有一个的情况下是怎么完成多任务处理?
CPU 会给每个进程被分配一个时间片,进程得到这个时间片之后才可以运行,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,CPU 的使用权将被收回,该进程将会被中断挂起等待下一个时间片;如果进程在时间片结束前阻塞或结束,CPU 当即进行切换,避免 CPU 资源的浪费。
因此可知,使用的计算机中启动多个程序,从宏观上看是同时运行的,从微观上看由于 CPU 一次只能处理一个进程,所有它们是轮流执行的,只不过切换速度太快,我们感觉不到罢了。因此 CPU 的核数越多计算机的处理效率越高。
(2)并发
并发的同时运行是一个假象,CPU 也好在某一个时间点只能为某一个个体来服务(举例:咖啡机),因此不可能同时处理多任务,这是通过CPU 快速的时间片切换实现的。
并发是针对某一个硬件资源而言的,在某个时间段之内处理的任务的总量,量越大效率越高。
(3)并行
并行的多进程同时运行是真实存在的,可以在同一时刻同时运行多个进程。
并行需要依赖多个硬件资源,单个是无法实现的(举例:两台咖啡机)。
PCB
进程控制块(Processing Control Block, PCB),Linux 内核的进程控制块本质上是一个叫做 task_struct 的结构体。
在这个结构体中记录了进程运行相关的一些信息,下面介绍一些常用的信息:
(1)进程 id:
每一个进程都一个唯一的进程 ID,类型为 pid_t, 本质是一个整形数。
(2)进程的状态:
进程有不同的状态,状态是一直在变化的,包括就绪、运行、挂起、停止等状态。
(3)进程的虚拟地址空间的信息
(4)描述控制终端的信息:
进程在哪个终端启动,默认和哪个终端绑定。
(5)当前工作目录:
默认情况下,启动进程的目录就是当前的工作目录。
(6)umask 掩码:
在创建新文件的时候,通过这个掩码屏蔽某些用于对文件的操作权限。
(7)文件描述符表:
每个被分配的文件描述符都对应一个已经打开的磁盘文件。
(8)和信号相关的信息:
在 Linux 中 调用函数、键盘快捷键、执行 shell 命令等操作都会产生信号。
阻塞信号集:记录当前进程中阻塞哪些已产生的信号,使其不能被处理。
未决信号集:记录在当前进程中产生的哪些信号还没有被处理掉。
(9)用户 id 和组 id:
当前进程属于哪个用户,属于哪个用户组。
(10)会话(Session)和进程组:
多个进程的集合叫进程组,多个进程组的集合叫会话。
(11)进程可以使用的资源上限:
使用 shell 命令 ulimit -a
查看详细信息。
进程的状态
进程一共有五种状态分别为:创建态、就绪态、运行态、阻塞态(挂起态)、退出态(终止态) 。
其中创建态和退出态维持的时间是非常短的,主要是需要理解就绪态、运行态、挂起态之间的状态切换。
(1)就绪态(缺少CPU资源)
进程被创建出来,有运行的资格但是还没有运行,需要抢 CPU 时间片。
得到 CPU 时间片,进程开始运行,从就绪态转换为运行态。
进程的 CPU 时间片用完了,再次失去 CPU,从运行态转换为就绪态。
(2)运行态
获取到 CPU 资源的进程,进程只有在这种状态下才能运行。
运行态不会一直持续,进程的 CPU 时间片用完之后,再次失去 CPU,从运行态转换为就绪态。
只要进程没有退出,在就绪态和运行态之间不停地切换。
(3)阻塞态
进程被强制放弃 CPU,并且没有抢夺 CPU 时间片的资格。
在程序中调用了某些函数(如: sleep()
),进程又运行态转换为阻塞态(挂起态)。
当某些条件被满足(如:sleep()
睡醒),进程的阻塞状态也就被解除,进程从阻塞态转换为就绪态。
(4)退出态
进程被销毁,占用的系统资源被释放了。
任何状态的进程都可以直接转换为退出态。
进程的命令
查看进程
1 |
|
杀死进程
1 |
|
kill 命令可以发送某个信号到对应的进程,进程收到某些信号之后默认的处理动作就是退出进程。
如果要给进程发送信号,可以先查看一下 Linux 给我们提供了哪些标准信号。
9号信号(SIGKILL
)的行为是无条件杀死进程。
进程的创建
Linux 中进程 ID 为 pid_t 类型,其本质是一个正整数。PID 为 1 的进程是 Linux 系统中创建的第一个进程。
函数
1 |
|
Linux 中看似创建一个新的进程非常简单,函数连参数都没有,实际上如果想要真正理解这个函数还是不容易的。
fork()的理解
启动磁盘上的应用程序,得到一个进程,如果在这个启动的进程中调用 fork()
函数,就会得到一个新的进程,将其称之为子进程。
前面说过每个进程都对应一个属于自己的虚拟地址空间,子进程的地址空间是基于父进程的地址空间拷贝出来的,虽然是拷贝但是两个地址空间中存储的信息不可能是完全相同的。
(1)相同点
拷贝完成之后(注意这个时间点),两个地址空间中的用户区数据是相同的。用户区数据,主要包括:
代码区:默认情况下父子进程地址空间中的源代码始终相同。
全局数据区:父进程中的全局变量和变量值全部被拷贝一份放到了子进程地址空间中。
堆区: 父进程中的堆区变量和变量值全部被拷贝一份放到了子进程地址空间中。
动态库加载区(内存映射区):父进程中数据信息被拷贝一份放到了子进程地址空间中。
栈区:父进程中的栈区变量和变量值全部被拷贝一份放到了子进程地址空间中。
环境变量:默认情况下,父子进程地址空间中的环境变量始终相同。
文件描述符表:父进程中被分配的文件描述符都会拷贝到子进程中,在子进程中可以使用它们打开对应的文件。
(2)不同
父子进程各自的虚拟地址空间是相互独立的,不会互相干扰和影响。
父子进程地址空间中代码区代码虽然相同,但是父子进程执行的代码逻辑可能是不同的。
由于父子进程可能执行不同的代码逻辑,因此地址空间拷贝完成之后相互独立的,用户区数据会各自发生变化也不会互相覆盖数据。
由于每个进都有自己的进程 ID,因此内核区存储的父子进程 ID 是不同的。
进程启动之后进入就绪态,运行需要争抢 CPU 时间片而且可能执行不同的业务逻辑,所以父子进程的状态可能是不同的。
fork()
调用成功之后,会返回两个值,父子进程的返回值是不同的。该函数调用成功之后,从一个虚拟地址空间变成了两个虚拟地址空间,每个地址空间中都会将 fork()
的返回值记录下来,这就是为什么会得到两个返回值的原因。
父进程的虚拟地址空间中将该返回值标记为一个大于 0 的数(其实是子进程的进程 ID)
子进程的虚拟地址空间中将该返回值标记 0
在程序中需要通过 fork()
的返回值来判断当前进程是子进程还是父进程。
父子进程
进程执行位置
在父进程中成功创建了子进程,子进程就拥有父进程代码区的所有代码,那么子进程中的代码是在什么位置开始运行的呢?
父进程肯定是从 main()
函数开始运行的,子进程是在父进程中调用 fork()
函数之后被创建,子进程就从 fork()
之后开始向下执行代码。
父子进程中代码的执行流程:
如果在程序中对 fork()
的返回值做了判断,就可以控制父子进程的行为;
如果没有做任何判断这个代码块父子进程都可以执行。
在编写多进程程序的时候,直观上看代码就一份,但实际上数据都是多份,并且多份数据中变量名都相同,但是它们的值却不一定相同。
循环创建子进程
掌握了进程创建函数之后,实现一个简单的功能,在一个父进程中循环创建 3 个子进程,也就是最后需要得到 4 个进程。
为了方便验证程序的正确性,要求在程序中打印出每个进程的进程 ID。
1 |
|
通过程序打印的信息发现程序循环了三次,最终得到了 8 个进程,也就是创建出了 7 个子进程。
对应多进程的程序,一定要代码分成很多份去分析,并且如果没有在程序中加条件控制,所有的代码父子进程都是有资格执行的。
解决方案:只让父进程创建子进程,如果是子进程不让其继续创建子进程,因此只需要在程序中添加关于父子进程的判断。
1 |
|
在多进程序中,进程的执行顺序是没有规律的,因为所有的进程都需要在就绪态争抢 CPU 时间片,抢到了就执行,抢不到就不执行。
但是不用担心,默认进程的优先级是相同的,操作系统不会让某一个进程一直抢不到 CPU 时间片。
进程计数
当父进程创建一个子进程,那么父子进程之间可以通过全局变量互动,实现交替数数的功能吗?
不确定可以写一段测试代码:
1 |
|
通过验证得到结论:两个进程中是不能通过全局变量实现数据交互的,因为每个进程都有自己的地址空间,两个同名全局变量存储在不同的虚拟地址空间中,二者没有任何关联性。如果要进行进程间通信需要使用:管道、内存映射、共享内存、消息队列、本地套接字等方式。
execl和execlp函数
在项目开发过程中,有时候有这种需求,需要通过现在运行的进程启动磁盘上的另一个可执行程序。
通过一个进程启动另一个进程,这种情况下可以使用 exec族函数。
这些函数执行成功后不会返回,因为调用进程的实体,包括代码段、数据段和堆栈等都已经被新的内容取代(用户区数据基本全部被替换),只留下进程 ID 等一些表面上的信息仍保持原样。只有调用失败了,它们才会返回一个 -1,从原程序的调用点接着往下执行。
也就是说 exec
族函数并没有创建新进程的能力,启动的新进程寄生到自己虚拟地址空间之内,把新启动的进程数据填充进去。
execl()
该函数可用于执行任意一个可执行程序,函数需要通过指定的文件路径才能找到这个可执行程序。
1 |
|
execlp()
该函数常用于执行已经设置了环境变量的可执行程序,函数中的 p 就是 path,这个函数会自动搜索系统的环境变量 PATH。
因此,使用这个函数执行可执行程序不需要指定路径,只需要指定出名字。
1 |
|
函数的使用
关于 exec
族函数,一般不会在进程中直接调用,如果直接调用这个进程的代码区被替换也就不能按照原来的流程工作。
一般在调用这些函数的时候都会先创建一个子进程,在子进程中调用 exec 族函数,子进程的用户区数据被替换掉开始执行新的程序中的代码逻辑,但是父进程不受任何影响仍然可以继续正常工作。
1 |
|
进程控制
进程控制主要是指进程的退出、进程的回收和进程的特殊状态(孤儿进程、僵尸进程)。
结束进程
如果想要直接退出某个进程可以在程序的任何位置调用 exit()
或者 _exit()
函数。
函数的参数相当于退出码,如果参数值为 0 程序退出之后的状态码就是 0, 如果是 100 退出的状态码就是 100。
1 |
|
在 main()
函数中直接使用 return 也可以退出进程。
假如是在一个普通函数中调用 return 只能返回到调用者的位置,而不能退出进程。
1 |
|
孤儿进程
在一个启动的进程中创建子进程,父子进程同时运行,但是父进程由于某种原因先退出,子进程还在运行,这个子进程被称为孤儿进程。
操作系统检测到某一个进程变成了孤儿进程,这时候系统中就会有一个固定的进程领养这个孤儿进程。
如果使用 Linux 没有桌面终端,这个领养孤儿进程的进程就是 init
进程(PID = 1);
如果有桌面终端,这个领养孤儿进程就是桌面进程。
系统为什么要领养这个孤儿进程?
在子进程退出的时候,进程中的用户区可以自己释放,但是内核区的pcb资源自己无法释放,必须由父进程来释放子进程的pcb资源。
1 |
|
僵尸进程
在一个启动的进程中创建子进程,这时候就有了父子两个进程,父进程正常运行,子进程先于父进程结束。
子进程无法释放自己的 PCB
资源,需要父进程来完成,但是如果父进程也不管,这时候子进程就变成了僵尸进程。
僵尸进程的出现是由于这个已死亡的进程的父进程不作为造成。
1 |
|
消灭僵尸进程的方法:杀死这个僵尸进程的父进程,这样僵尸进程的资源就被系统回收了。
通过 kill -9 僵尸进程PID
不能消灭僵尸进程,这个命令只对活着的进程有效,僵尸进程已经死了。
进程回收
为了避免僵尸进程的产生,一般在父进程中进行子进程的资源回收,回收方式有两种:
一种是阻塞方式 wait()
,一种是非阻塞方式 waitpid()
。
(1)wait
这是个阻塞函数,如果没有子进程退出,函数会一直阻塞等待,当检测到子进程退出了,该函数阻塞解除回收子进程资源。
这个函数被调用一次,只能回收一个子进程的资源,如果有多个子进程需要资源回收,函数需要被调用多次。
函数原型如下:
1 |
|
示例:通过 wait()
回收多个子进程资源
1 |
|
(2)waitpidwaitpid()
函数可以看做是 wait()
函数的升级版,通过该函数可以控制回收子进程资源的方式是阻塞还是非阻塞,
另外还可以精确指定回收某个或者某一类或者是全部子进程资源。
函数原型如下:
1 |
|
示例:通过 waitpid()
阻塞回收多个子进程资源
1 |
|
示例:通过 waitpid()
非阻塞回收多个子进程资源
1 |
|
参考资料
https://subingwen.cn/linux/process/
https://subingwen.cn/linux/file-descriptor/