Linux系统编程之进程
1.进程相关概念
1.1 程序和进程
程序:是指编译好的二进制文件,在磁盘上,不占用系统资源(cpu、内存、打开的文件、 设备、锁….),是一个静态的概念 。
进程:被执行之后的程序叫做进程,不占用磁盘空间,需要消耗系统的内存,CPU资源,每个运行的进程的都对应一个属于自己的虚拟地址空间,这是一个动态的概念。
程序 → 剧本(纸)
进程 → 戏(舞台、演员、灯光、道具…)
同一个剧本可以在多个舞台同时上演。同样,同一个程序也可以加载为不同的进程(彼此之间互不影响)
如:同时开两个终端。各自都有一个 bash 但彼此 ID 不同。
1.2 并行和并发
并发
- 并发的同时运行是一个假象, CPU 在某一个时间点只能为某一个个体来服务,因此不可能同时处理多任务,这是通过计算机CPU的时间片快速切换实现的。
- 并发是针对某一个硬件资源而言的,在某个时间段之内处理的任务的总量,量越大效率越高。
并行
- 并行的多进程同时运行是真实存在的,可以在同一时刻同时运行多个进程
- 并行需要依赖多个硬件资源,单个是无法实现的。
1.3 PCB进程控制块
PCB - 进程控制块(Processing Control Block),Linux 内核的进程控制块本质上是一个叫做 task_struct 的结构体。在这个结构体中记录了进程运行相关的一些信息,下面介绍一些常用的信息:
- 进程 id:每一个进程都一个唯一的进程 ID,类型为 pid_t, 本质是一个整形数
- 进程的状态:进程有不同的状态,状态是一直在变化的,有就绪、运行、挂起、停止等状态。
- 进程对应的虚拟地址空间的信息。
- 描述控制终端的信息,进程在哪个终端启动默认就和哪个终端绑定。
- 当前工作目录:默认情况下,启动进程的目录就是当前的工作目录
- umask 掩码:在创建新文件的时候,通过这个掩码屏蔽某些用于对文件的操作权限。
- 文件描述符表:每个被分配的文件描述符都对应一个已经打开的磁盘文件和信号相关的信息:在 Linux 中 调用函数 , 键盘快捷键 , 执行shell命令等操作都会产生信号。
- 阻塞信号集:记录当前进程中阻塞哪些已产生的信号,使其不能被处理
- 未决信号集:记录在当前进程中产生的哪些信号还没有被处理掉。
- 用户 id 和组 id:当前进程属于哪个用户,属于哪个用户组
- 会话(Session)和进程组:多个进程的集合叫进程组,多个进程组的集合叫会话。
- 进程可以使用的资源上限:可以使用 shell 命令 ulimit -a 查看详细信息。
1.4 单道程序设计和多道程序设计
单道程序设计
所有进程一个一个排对执行。若 A 阻塞,B 只能等待,即使 CPU 处于空闲状态。而在人机交互时阻塞的出现时必然的。所有这种模型在系统资源利用上及其不合理,在计算机发展历史上存在不久,大部分便被淘汰了。
多道程序设计
在计算机内存中同时存放几道相互独立的程序,它们在管理程序控制之下,相互穿插的运行。多道程序设计必须有硬件基础作为保证。时钟中断即为多道程序设计模型的理论基础。 并发时,任意进程在执行期间都不希望放弃cpu。因此系统需要一种强制让进程让出 cpu 资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。 操作系统中的中断处理函数,来负责调度程序执行。
在多道程序设计模型中,多个进程轮流使用 CPU (分时复用 CPU资源)。而当下常见 CPU为纳秒级,1 秒可以执行大约 10 亿条指令。由于人眼的反应速度是毫秒级,所以看似同时在运行。
1.5 进程状态
进程一共有五种状态分别为:创建态
,就绪态
,运行态
,阻塞态(挂起态)
,退出态(终止态)
其中创建态和退出态维持的时间是非常短的,稍纵即逝。我们主要是需要将就绪态
,运行态
, 挂起态
,三者之间的状态切换搞明白。
就绪态:
- 进程被创建出来了,有运行的资格但是还没有运行,需要抢 CPU 时间片
- 得到 CPU 时间片,进程开始运行,从就绪态转换为运行态。
- 进程的 CPU 时间片用完了,再次失去 CPU, 从运行态转换为就绪态。
运行态:
- 获取到 CPU 资源的进程,进程只有在这种状态下才能运行
- 运行态不会一直持续,进程的 CPU 时间片用完之后,再次失去 CPU,从运行态转换为就绪态
- 只要进程还没有退出,就会在就绪态和运行态之间不停的切换。
阻塞态:
- 进程被强制放弃 CPU,并且没有抢夺 CPU 时间片的资格
- 比如:在程序中调用了某些函数(比如: sleep ()),进程由运行态转换为阻塞态(挂起态)
- 当某些条件被满足了(比如:sleep()睡醒了),进程的阻塞状态也就被解除了,进程从阻塞态转换为就绪态。
退出态:
- 进程被销毁,占用的系统资源被释放了
- 任何状态的进程都可以直接转换为退出态。
1.6 环境变量
echo $PATH
查看环境变量
path环境变量里记录了一系列的值,当运行一个可执行文件时,系统会去环境变量记录的位置里查找这个文件并执行。
echo $TERM
查看终端
echo $LANG
查看语言
env
查看所有环境变量
补充
ps aux
返回结果里,第二列是进程id号
2.进程创建
2.1 getpid()/getppid()/fork()函数原型
Linux 中进程 ID 为 pid_t 类型,其本质是一个正整数,通过上边的 ps aux 命令可以查看每个进程的进程ID号。PID 为 1 的进程是 Linux 系统中创建的第一个进程。
获取当前进程的进程 ID(PID)
1 |
|
获取当前进程的父进程 ID(PPID)
1 |
|
创建一个新的进程
1 |
|
2.2 fork()函数返回值
fork ()
调用成功之后,会返回两个值,父子进程的返回值是不同的。
父进程的虚拟地址空间中将该返回值标记为一个大于 0 的数(其实记录的是子进程的进程 ID)
子进程的虚拟地址空间中将该返回值标记 0
在程序中需要通过 fork () 的返回值来判断当前进程是子进程还是父进程。
2.3 fork()函数创建子进程示例
1 | int main() |
编译运行,如下所示
代码理解:我们在父进程中成功创建了子进程,子进程就拥有父进程代码区的所有代码,那么子进程中的代码是在什么位置开始运行的呢?父进程肯定是从 main () 函数开始运行的,子进程是在父进程中调用 fork () 函数之后被创建,子进程就从 fork () 之后开始向下执行代码。上代码演示了父子进程中代码的执行流程,可以看到如果在程序中对 fork() 的返回值做了判断,就可以控制父子进程的行为,如果没有做任何判断这个代码块父子进程都可以执行。在编写多进程程序的时候,一定要将代码想象成多份进行分析,因为直观上看代码就一份,但实际上数据都是多份,并且多份数据中变量名都相同,但是他们的值却不一定相同。
2.4 循环创建多个子进程
1 |
|
编译运行,如下图所示:
可以看出,这和我们预想的不一样,为什么呢,明明我用for循环只想创建出5个进程,但是却fork创建了这么多的子进程,这是因为在循环中创建出的子进程还会创建出孙子进程,孙子进程会创建出重孙进程,所以才会创建出这么多的进程,下面给出解决方法,我们可以只让父进程创建子进程,如果是子进程不让其继续创建子进程,因此只需要在程序中添加关于父子进程的判断即可,如果fork()==0,说明是子进程,那么我们就直接让子进程break出for循环,下面给出改进过后的代码
1 |
|
编译运行,如下图所示:
看到如上代码,可能有的人或许有疑惑,为什么要调用sleep()函数,因为如果我们不调用sleep()函数的话,最终在终端显示的时候会是乱序的,如下图所示
这是要因为,对操作系统而言,这几个子进程几乎是同时出现的,它们和父进程一起争夺cpu,谁抢到,谁打印,所以出现顺序是随机的。还有就是终端提示符混在了输出里,这是因为,fork1 进程启动之后,共创建了 5 个子进程,其实 fork1 也是有父进程的就是当前的终端,终端只能检测到fork1进程的状态,fork1执行期间终端切换到后台,a.out 执行完毕之后终端切换回前台,当终端切换到前台之后,fork1的子进程还没有执行完毕,所以子进程输出的信息就显示到终端命令提示符的后边了,导致终端显示有问题。因此想要解决这个问题,调用sleep()函数即可完美解决。
2.5 父子进程共享哪些内容
刚fork之后:(注意时间点)
父子进程相同部分:
data段、text段、堆、栈、环境变量、全局变量、宿主目录位置、进程工作目录位置、信号处理方式
父子进程不同部分:
进程id、返回值、各自的父进程、进程创建时间、闹钟、未决信号集
解释:
在主进程使用fork()函数后,会创建出子进程,因为每个进程都会有自己的虚拟内存空间,所以子进程也会有自己单独的虚拟内存空间,他和主进程的虚拟内存空间是完全独立的,只不过子进程的虚拟内存空间是基于父进程的虚拟内存空间拷贝出来的,拷贝完成后,他们两0-3G的用户区是相同的,但是由于每个进程都有自己的进程 ID,因此内核区存储的父子进程 ID 是不同的。虽然他们在代码区的代码相同,但是执行时可以通过fork()函数的返回值来区分主进程和子进程的执行逻辑,似乎,子进程复制了父进程 0-3G 用户空间内容,以及父进程的 PCB,但 pid 不同。真的每 fork 一个子进程都要将父进程的 0-3G 地址空间完全拷贝一份,然后在映射至物理内存吗?当然不是!父子进程间遵循读时共享写时复制的原则。这样设计,无论子进程执行父进程的逻辑还是执行自己的逻辑都能节省内存开销。
有的人可能会想通过全局变量来让父子进程之间进行通信,但是这是行不通的,因为每个进程都有自己的地址空间,两个同名全局变量存储在不同的虚拟地址空间中,二者没有任何关联性。如果想让父子进程之间进行通信交流,可以使用:管道,共享内存,本地套接字,内存映射区,消息队列等方式。后面会介绍这些方法。
2.6 父子进程gdb调试
使用 gdb 调试的时候,gdb 只能跟踪一个进程。可以在 fork 函数调用之前,通过指令设置 gdb 调试工具跟踪父进程或者是跟踪子进程。默认跟踪父进程。
- 设置父进程调试路径:
set follow-fork-mode parent (默认)
- 设置子进程调试路径:
set follow-fork-mode child
注意,一定要在fork函数调用之前设置才有效。
3.exec函数族
fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用 exec 并不创建新进程,所以调用 exec 前后该进程的 id 并未变。
函数原型如下:
1 |
|
这些函数执行成功无返回值,失败返回 -1
exec族函数
中最常用的有两个execl()
和 execlp()
,这两个函数是对其他 4 个函数做了进一步的封装,下面介绍一下。
3.1 execlp()函数
加载一个进程,借助 PATH环境变量
函数原型
int execlp(const char *file, const char *arg, …)
函数参数
参数1:要加载的程序名字,该函数需要配合PATH环境变量来使用,当PATH所有目录搜素后没有参数1则返回出错。
参数2:ps aux 查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同。
参数3 : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。
函数返回值
执行成功,无返回,执行失败,返回-1
该函数通常用来调用系统程序。如ls、date、cp、cat命令。
execlp这里面的p,表示要借助环境变量来加载可执行文件
3.2 execl()函数
该函数可用于执行任意一个可执行程序,函数需要通过指定的文件路径才能找到这个可执行程序。
1 |
|
函数参数
参数1:要启动的可执行程序的路径,推荐使用绝对路径。
参数2:ps aux 查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同。
参数3:要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。
函数返回值
执行成功,无返回,执行失败,返回-1
函数原型
3.3 小练习
1.通过execlp让子进程去执行ls命令:
1 |
|
编译运行,如下图所示:
2.使用execl来让子程序调用自定义的程序。
注意
- 和execlp不同的是,第一个参数是路径,不是文件名。
- 这个路径用相对路径和绝对路径都行。
==hello.c==
==exec.c==
编译运行,结果如下所示:
3.4 exec函数族特性
写一个程序,使用execlp执行进程查看,并将结果输出到文件里。
编译运行,结果如下所示:
exec函数一旦调用成功,即执行新的程序,不返回。只有失败才返回,错误值-1。所以通常我们直接在exec函数调用后直接调用perror(),和exit(),无需if判断。
l(list) 命令行参数列表
p(path) 搜索file时使用path变量
v(vector) 使用命令行参数数组
e(environment) 使用环境变量数组,不适用进程原有的环境变量,
设置新加载程序运行的环境变量
事实上,只有execve是真正的系统调用,其他5个函数最终都调用execve,是库函数,所以execve在man手册第二节,其它函数在man手册第3节。这些函数之间的关系如下图所示。
4.回收子进程
4.1孤儿进程和僵尸进程
孤儿进程:
- 父进程先于子进终止,子进程沦为“孤儿进程”,会被 init 进程(1号进程)领养。
下面这段代码可以得到一个僵尸进程:
1 | int main() |
僵尸进程:
- 子进程终止,父进程尚未对子进程进行回收,在此期间,子进程为“僵尸进程”。 kill 对其无效。这里要注意,每个进程结束后都必然会经历僵尸态,时间长短的差别而已。
- 子进程终止时,子进程残留资源PCB存放于内核中,PCB记录了进程结束原因,进程回收就是回收PCB。回收僵尸进程,得kill它的父进程,让孤儿院去回收它。
下面这段代码可以得到一个僵尸进程:
1 | int main() |
4.2 wait()函数和waitpid()函数
为了避免僵尸进程的产生,一般我们会在父进程中进行子进程的资源回收,回收方式有两种,一种是阻塞方式 wait()
,一种是非阻塞方式 waitpid()
。
4.2.1wait()函数
这是个阻塞函数,如果没有子进程退出,函数会一直阻塞等待,当检测到子进程退出了,该函数阻塞解除回收子进程资源。这个函数被调用一次,只能回收一个子进程的资源,如果有多个子进程需要资源回收,函数需要被调用多次。
函数原型
1 | // man 2 wait |
函数参数
status:传出参数,回收进程的状态。
函数返回值
- 成功:返回被回收的子进程的进程 ID
- 失败: -1
- 没有子进程资源可以回收了,函数的阻塞会自动解除,返回 - 1
- 回收子进程资源的时候出现了异常
函数作用
- 阻塞等待子进程退出
- 清理子进程残留在内核的 pcb 资源
- 通过传出参数,得到子进程结束状态
4.2.2 获取子进程退出值和异常终止信号
- 一个进程终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。
- 这个进程的父进程可以调用wait或者waitpid获取这些信息,然后彻底清除掉这个进程。
- 一个进程的退出状态可以在shell中用特殊变量$?查看,因为shell是它的父进程,当它终止时,shell调用wait或者waitpid得到它的退出状态,同时彻底清除掉这个进程。
获取子进程正常终止值:
WIFEXITED(status) –> 为真 –>调用 WEXITSTATUS(status) –> 得到 子进程 退出值。
获取导致子进程异常终止信号:
WIFSIGNALED(status) –> 为真 –>调用 WTERMSIG(status) –> 得到 导致子进程异常终止的信号编号。
小案例:
捕获程序异常终止的信号并打印:
1 | int main(void) |
4.2.3 waitpid()函数、
指定某一个进程进行回收。可以设置非阻塞。
waitpid(-1, &status, 0) == wait(&status);
函数原型
1 | // man 2 waitpid |
函数参数
pid:指定回收某一个子进程pid
- 大于0 回收指定 ID 的子进程
- -1 回收任意子进程(相当于wait)
- 0 回收和当前调用waitpid 一个组的所有子进程
- 小于 -1 回收指定进程组内的任意子进程
status:(传出参数) 回收进程的状态,同wait()函数
options:控制函数是阻塞还是非阻塞
- WNOHANG :指定回收方式为,非阻塞
- 0: 函数是行为是阻塞的 ==> 和 wait 一样
函数返回值
大于0 : 表成功回收的子进程 pid
等于0 : 函数是非阻塞的,并且子进程还在运行
-1: 失败。并设置errno
小案例:
回收指定子进程:
1 | int main(int argc, char *argv[]) |
4.2.4 waitpid()回收多个子进程
一次wait/waitpid函数调用,只能回收一个子进程。下面来演示回收多个子进程
1 | // 非阻塞处理 |