(一)、进程控制
1、进程概述
并行和并发
时间片:
CPU 会给每个进程被分配一个时间段,进程得到这个时间片之后才可以运行,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,CPU 的使用权将被收回,该进程将会被中断挂起等待下一个时间片。如果进程在时间片结束前阻塞或结束,则 CPU 当即进行切换,这样就可以避免 CPU 资源的浪费。
因此可以得知,在我们使用的计算机中启动的多个程序,从宏观上看是同时运行的,从微观上看由于 CPU 一次只能处理一个进程,所有它们是轮流执行的,只不过切换速度太快,我们感觉不到罢了,因此 CPU 的核数越多计算机的处理效率越高。
并发和并行:
这两个概念呢都,可以笼统的解释为:多个进程同时运行,但是他们两个的同时并不是同一个概念。
- 并发的同时运行是一个假象,咖啡机也好, CPU 也好在某一个时间点只能为某一个个体来服务,因此不可能同时处理多任务,这是通过上图的咖啡机 / 计算机的 CPU 快速的时间片切换实现的 。
- 并发是针对某一个硬件资源而言的,在某个时间段之内处理的任务的总量,量越大效率越高。
- 并发也可以理解为是一个屌丝通过不断努力自我升华的结果。
- 并行的多进程同时运行是真实存在的,可以在同一时刻同时运行多个进程 。
- 并行需要依赖多个硬件资源,单个是无法实现的(图中有两台咖啡机)。
- 并行可以理解为是一个高富帅,出生就有天然的硬件优势,资源多自然办事效率就高。
PCB
PCB - 进程控制块(Processing Control Block),Linux 内核的进程控制块本质上是一个叫做 task_struct 的结构体。在这个结构体中记录了进程运行相关的一些信息,下面介绍一些常用的信息:
- 进程 id:每一个进程都一个唯一的进程 ID,类型为 pid_t, 本质是一个整形数
- 进程的状态:进程有不同的状态,状态是一直在变化的,有就绪、运行、挂起、停止等状态。
- 进程对应的虚拟地址空间的信息。
- 描述控制终端的信息,进程在哪个终端启动默认就和哪个终端绑定。
- 当前工作目录:默认情况下,启动进程的目录就是当前的工作目录
- umask 掩码:在创建新文件的时候,通过这个掩码屏蔽某些用于对文件的操作权限。
- 文件描述符表:每个被分配的文件描述符都对应一个已经打开的磁盘文件
- 和信号相关的信息:在 Linux 中 调用函数 , 键盘快捷键 , 执行shell命令等操作都会产生信号。
- 阻塞信号集:记录当前进程中阻塞哪些已产生的信号,使其不能被处理
- 未决信号集:记录在当前进程中产生的哪些信号还没有被处理掉。
- 用户 id 和组 id:当前进程属于哪个用户,属于哪个用户组
- 会话(Session)和进程组:多个进程的集合叫进程组,多个进程组的集合叫会话。
- 进程可以使用的资源上限:可以使用 shell 命令 ulimit -a 查看详细信息。
进程状态
进程一共有五种状态分别为:创建态,就绪态,运行态,阻塞态(挂起态),退出态(终止态) 其中创建态和退出态维持的时间是非常短的,稍纵即逝 。
- 就绪态:万事俱备,只欠东风(CPU资源)
- 进程被创建出来了,有运行的资格但是还没有运行,需要抢 CPU 时间片
- 得到 CPU 时间片,进程开始运行,从就绪态转换为运行态
- 进程的 CPU 时间片用完了,再次失去 CPU, 从运行态转换为就绪态。
- 运行态:获取到 CPU 资源的进程,进程只有在这种状态下才能运行
- 运行态不会一直持续,进程的 CPU 时间片用完之后,再次失去 CPU,从运行态转换为就绪态
- 只要进程还没有退出,就会在就绪态和运行态之间不停的切换
- 阻塞态:进程被强制放弃 CPU,并且没有抢夺 CPU 时间片的资格
- 比如:在程序中调用了某些函数(比如: sleep ()),进程又运行态转换为阻塞态(挂起态)
- 当某些条件被满足了(比如:slee () 睡醒了),进程的阻塞状态也就被解除了,进程从阻塞态转换为就绪态。
- 退出态:进程被销毁,占用的系统资源被释放了
- 任何状态的进程都可以直接转换为退出态。
进程命令
$ ps aux
- a: 查看所有终端的信息
- u: 查看用户相关的信息
- x: 显示和终端无关的进程信息
# tty 代表进程关联的终端,如果不关联终端会显示 ?
kill 命令可以 发送某个信号到对应的进程,进程收到某些信号之后 默认的处理动作就是 退出进程,如果要给进程发送信号,可以先查看一下 Linux 给我们提供了哪些标准信号。
$ kill -l 是查看Linux 中的标准信号
9 号信号(SIGKILL)的行为是无条件杀死进程,想要杀死哪个进程就可以把这个信号发送给这个进程,操作如下:
# 无条件杀死进程, 进程ID通过 ps aux 可以查看
$ kill -9 进程ID
$ kill -SIGKILL 进程ID
进程的创建
Linux 中进程 ID 为 pid_t 类型,其本质是一个正整数。
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); // 获取当前进程的进程 ID
pid_t getppid(void); // 获取当前进程的父进程 ID
pid_t fork(void); // 创建一个新的进程
fork()剖析
启动磁盘上的应用程序,得到一个进程,如果在这个启动的进程中调用 fork() 函数,就会得到一个新的进程,我们习惯将其称之为子进程。
前面说过每个进程都对应一个属于自己的虚拟地址空间,子进程的地址空间是基于父进程的地址空间拷贝出来的,虽然是拷贝但是两个地址空间中存储的信息不可能是完全相同的:
相同点:
拷贝完成之后(注意这个时间点),两个地址空间中的用户区数据是相同的。用户区数据主要数据包括:
- 代码区:默认情况下父子进程地址空间中的源代码始终相同。
- 全局数据区:父进程中的全局变量和变量值全部被拷贝一份放到了子进程地址空间中
- 堆区:父进程中的堆区变量和变量值全部被拷贝一份放到了子进程地址空间中
- 动态库加载区(内存映射区):父进程中数据信息被拷贝一份放到了子进程地址空间中
- 栈区:父进程中的栈区变量和变量值全部被拷贝一份放到了子进程地址空间中
- 环境变量:默认情况下,父子进程地址空间中的环境变量始终相同。
- 文件描述符表: 父进程中被分配的文件描述符都会拷贝到子进程中,在子进程中可以使用它们打开对应的文件。
不同点:
- 父子进程各自的虚拟地址空间是相互独立的,不会互相干扰和影响
- 父子进程地址空间中代码区代码虽然相同,但是父子进程执行的代码逻辑可能是不同的。
- 由于父子进程可能执行不同的代码逻辑,因此地址空间拷贝完成之后,全局数据区 , 栈区 , 堆区 , 动态库加载区(内存映射区) 数据会各自发生变化,由于地址空间是相互独立的,因此不会互相覆盖数据。
- 由于每个进都有自己的进程 ID,因此内核区存储的父子进程 ID 是不同的。
- 进程启动之后进入就绪态,运行需要争抢 CPU 时间片而且可能执行不同的业务逻辑,所以父子进程的状态可能是不同的。
- fork () 调用成功之后,会返回两个值,父子进程的返回值是不同的。
- 该函数调用成功之后,从一个虚拟地址空间变成了两个虚拟地址空间,每个地址空间中都会将 fork() 的返回值记录下来,这就是为什么会得到两个返回值的原因。
- 父进程的虚拟地址空间中将该返回值标记为一个大于 0 的数(其实记录的是子进程的进程 ID)
-
子进程的虚拟地址空间中将该返回值标记 0
-
在程序中需要通过 fork () 的返回值来判断当前进程是子进程还是父进程。
-
int main() { // 在父进程中创建子进程 pid_t pid = fork(); printf("当前进程fork()的返回值: %d\n", pid); if(pid > 0) { // 父进程执行的逻辑 printf("我是父进程, pid = %d\n", getpid()); } else if(pid == 0) { // 子进程执行的逻辑 printf("我是子进程, pid = %d, 我爹是: %d\n", getpid(), getppid()); } else // pid == -1 { // 创建子进程失败了 } // 不加判断, 父子进程都会执行这个循环 for(int i=0; i<5; ++i) { printf("%d\n", i); } return 0; }
父子进程
在父进程中成功创建了子进程,子进程就拥有父进程代码区的所有代码,那么子进程中的代码是在什么位置开始运行的呢?父进程肯定是从 main () 函数开始运行的,子进程是在父进程中调用 fork () 函数之后被创建,子进程就从 fork () 之后开始向下执行代码。
循环创建子进程
// process_loop.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main()
{
for(int i=0; i<3; ++i)
{
pid_t pid = fork();
printf("当前进程pid: %d\n", getpid());
}
return 0;
}
# 编译
$ gcc process_loop.c
# 执行
$ ./a.out
# 最终得到了 8个进程
当前进程pid: 18774 ------ 1
当前进程pid: 18774 ------ 1
当前进程pid: 18774 ------ 1
当前进程pid: 18777 ------ 2
当前进程pid: 18776 ------ 3
当前进程pid: 18776 ------ 3
当前进程pid: 18775 ------ 4
当前进程pid: 18775 ------ 4
当前进程pid: 18775 ------ 4
当前进程pid: 18778 ------ 5
当前进程pid: 18780 ------ 6
当前进程pid: 18779 ------ 7
当前进程pid: 18779 ------ 7
当前进程pid: 18781 ------ 8
- 循环第一次 i = 0,创建出一个子进程,即红色节点,子进程变量值来自父进程拷贝,因此 i=0
- 循环第二次 i = 1,蓝色父进程和红色子进程都去创建子进程,得到两个紫色进程,子进程变量值来自父进程拷贝,因此 i=1
- 循环第三次 i = 2,蓝色父进程和红色、紫色子进程都去创建子进程,因此得到 4 个绿色子进程,子进程变量值来自父进程拷贝,因此 i=2
- 循环第三次 i = 3,所有进程都不满足条件 for(int i=0; i<3; ++i) 因此不进入循环,退出了。
通过上面的分析,最终得到解决方案,我们可以只让父进程创建子进程,如果是子进程不让其继续创建子进程,因此只需要在程序中添加关于父子进程的判断即可。
// 需要在上边的程序中控制不让子进程, 再创建子进程即可
// process_loop.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main()
{
pid_t pid;
// 在循环中创建子进程
for(int i=0; i<3; ++i)
{
pid = fork();
if(pid == 0)
{
// 不让子进程执行循环, 直接跳出
break;
}
}
printf("当前进程pid: %d\n", getpid());
return 0;
}
# 编译
$ gcc process_loop.c
# 执行
$ ./a.out
当前进程pid: 2727
当前进程pid: 2730
当前进程pid: 2729
当前进程pid: 2728
在多进程序中,进程的执行顺序是没有规律的,因为所有的进程都需要在就绪态争抢CPU时间片,抢到了就执行,抢不到就不执行,但是不用担心,默认进程的优先级是相同的,操作系统不会让某一个进程一直抢不到CPU时间片。
终端显示问题
pid_t pid = fork();
if(pid > 0)
{
sleep(3); // 让父进程睡一会儿
}
else if(pid == 0)
{
// 子进程
}
进程数数
两个进程中是不能通过全局变量实现数据交互的,因为每个进程都有自己的地址空间,两个同名全局变量存储在不同的虚拟地址空间中,二者没有任何关联性。如果要进行进程间通信需要使用:管道,共享内存,本地套接字,内存映射区,消息队列等方式。
execl 和 execlp 函数
在项目开发过程中,有时候有这种需求,需要通过现在运行的进程启动磁盘上的另一个可执行程序,也就是通过一个进程启动另一个进程,这种情况下我们可以使用 exec族函数,函数原型如下:
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, .../* (char *) NULL */);
int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
int execle(const char *path, const char *arg, .../*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
这些函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代(也就是说用户区数据基本全部被替换掉了),只留下进程 ID 等一些表面上的信息仍保持原样,颇有些神似” 三十六计” 中的” 金蝉脱壳”。看上去还是旧的躯壳,却已经注入了新的灵魂。只有调用失败了,它们才会返回一个 -1,从原程序的调用点接着往下执行。
也就是说 exec族函数并没有创建新进程的能力,只是有大无畏的牺牲精神,让启动的新进程寄生到自己虚拟地址空间之内,并挖空了自己的地址空间用户区,把新启动的进程数据填充进去。
exec族函数中最常用的有两个 execl() 和 execlp(),这两个函数是对其他 4 个函数做了进一步的封装,下面介绍一下。
execl()
该函数可用于执行任意一个可执行程序,函数需要通过指定的文件路径才能找到这个可执行程序。
#include <unistd.h>
// 变参函数
int execl(const char *path, const char *arg, ...);
path: 要启动的可执行程序的路径,推荐使用绝对路径
arg: ps aux 查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同
... : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。
返回值:如果这个函数执行成功,没有返回值,如果执行失败,返回 -1
execlp()
该函数常用于执行,已经设置了环境变量的可执行程序,函数中的 p 就是 path,也是说这个函数会自动搜索系统的环境变量 PATH,因此使用这个函数执行可执行程序不需要指定路径,只需要指定出名字即可。
// p == path
int execlp(const char *file, const char *arg, ...);
file: 可执行程序的名字
在环境变量 PATH 中,可执行程序可以不加路径
没有在环境变量 PATH 中,可执行程序需要指定绝对路径
arg: ps aux 查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同
... : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。
返回值:如果这个函数执行成功,没有返回值,如果执行失败,返回 -1
函数的使用
关于 exec 族函数,我们一般不会在进程中直接调用,如果直接调用这个进程的代码区代码被替换也就不能按照原来的流程工作了。我们一般在调用这些函数的时候都会先创建一个子进程,在子进程中调用 exec 族函数,子进程的用户区数据被替换掉开始执行新的程序中的代码逻辑,但是父进程不受任何影响仍然可以继续正常工作。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main()
{
// 创建子进程
pid_t pid = fork();
// 在子进程中执行磁盘上的可执行程序
if(pid == 0)
{
// 磁盘上的可执行程序 /bin/ps
#if 1
execl("/bin/ps", "title", "aux", NULL);
// 也可以这么写
// execl("/bin/ps", "title", "a", "u", "x", NULL);
#else
execlp("ps", "title", "aux", NULL);
// 也可以这么写
// execl("ps", "title", "a", "u", "x", NULL);
#endif
// 如果成功当前子进程的代码区与 ps中的代码区代码替换
// 下面的所有代码都不会执行
// 如果函数调用失败了,才会继续执行下面的代码
perror("execl");
printf("++++++++++++++++++++++++\n");
printf("++++++++++++++++++++++++\n");
printf("++++++++++++++++++++++++\n");
printf("++++++++++++++++++++++++\n");
printf("++++++++++++++++++++++++\n");
printf("++++++++++++++++++++++++\n");
}
else if(pid > 0)
{
printf("我是父进程.....\n");
}
return 0;
}
进程控制
进程控制主要是指进程的退出 , 进程的回收和进程的特殊状态 孤儿进程和僵尸进程。
结束进程
如果想要直接退出某个进程可以在程序的任何位置调用 exit() 或者_exit() 函数。函数的参数相当于退出码,如果参数值为 0 程序退出之后的状态码就是 0, 如果是 100 退出的状态码就是 100。
// 专门退出进程的函数, 在任何位置调用都可以
// 标准C库函数
#include <stdlib.h>
void exit(int status);
// Linux的系统函数
// 可以这么理解, 在linux中 exit() 函数 封装了 _exit()
#include <unistd.h>
void _exit(int status);
孤儿进程
在一个启动的进程中创建子进程,这时候父子进程同时运行,但是父进程由于某种原因先退出了,子进程还在运行,这时候这个子进程就可以被称之为孤儿进程(跟现实是一样的)。
操作系统是非常关爱运行的每一个进程的,当检测到某一个进程变成了孤儿进程,这时候系统中就会有一个固定的进程领养这个孤儿进程(有干爹了)。如果使用 Linux 没有桌面终端,这个领养孤儿进程的进程就是 init 进程(PID=1),如果有桌面终端,这个领养孤儿进程就是桌面进程。
那么问题来了,系统为什么要领养这个孤儿进程呢?在子进程退出的时候, 进程中的用户区可以自己释放, 但是进程内核区的pcb资源自己无法释放,必须要由父进程来释放子进程的pcb资源,孤儿进程被领养之后,这件事儿干爹就可以代劳了,这样可以避免系统资源的浪费。
int main()
{
// 创建子进程
pid_t pid = fork();
// 父进程
if(pid > 0)
{
printf("我是父进程, pid=%d\n", getpid());
}
else if(pid == 0)
{
sleep(1); // 强迫子进程睡眠1s, 这个期间, 父进程退出, 当前进程变成了孤儿进程
// 子进程
printf("我是子进程, pid=%d, 父进程ID: %d\n", getpid(), getppid());
}
return 0;
}
# 程序输出的结果
$ ./a.out
我是父进程, pid=22459
我是子进程, pid=22460, 父进程ID: 1 # 父进程向退出, 子进程变成孤儿进程, 子进程被1号进程回收
僵尸进程
在一个启动的进程中创建子进程,这时候就有了父子两个进程,父进程正常运行,子进程先与父进程结束,子进程无法释放自己的 PCB 资源,需要父进程来做这个件事儿,但是如果父进程也不管,这时候子进程就变成了僵尸进程。
僵尸进程不能将它看成是一个正常的进程,这个进程已经死亡了,用户区资源已经被释放了,只是还占用着一些内核资源(PCB)。 僵尸进程就相当于是一副已经腐烂只剩下骨头的尸体。
僵尸进程的出现是由于这个已死亡的进程的父进程不作为造成的。
int main()
{
pid_t pid;
// 创建子进程
for(int i=0; i<5; ++i)
{
pid = fork();
if(pid == 0)
{
// 如果是子进程,就退出循环
break;
}
}
// 父进程
if(pid > 0)
{
// 需要保证父进程一直在运行
// 一直运行不退出, 并且也不做回收, 就会出现僵尸进程
while(1)
{
printf("我是父进程, pid=%d\n", getpid());
sleep(1);
}
}
else if(pid == 0)
{
// 子进程, 执行这句代码之后, 子进程退出了
printf("我是子进程, pid=%d, 父进程ID: %d\n", getpid(), getppid());
}
return 0;
}
# ps aux 查看进程信息
# Z+ --> 这个进程是僵尸进程, defunct, 表示进程已经死亡
robin 22598 0.0 0.0 4352 624 pts/2 S+ 10:11 0:00 ./app
robin 22599 0.0 0.0 0 0 pts/2 Z+ 10:11 0:00 [app] <defunct> # 子进程
robin 22600 0.0 0.0 0 0 pts/2 Z+ 10:11 0:00 [app] <defunct> # 子进程
robin 22601 0.0 0.0 0 0 pts/2 Z+ 10:11 0:00 [app] <defunct> # 子进程
robin 22602 0.0 0.0 0 0 pts/2 Z+ 10:11 0:00 [app] <defunct> # 子进程
robin 22603 0.0 0.0 0 0 pts/2 Z+ 10:11 0:00 [app] <defunct> # 子进程
消灭僵尸进程的方法是,杀死这个僵尸进程的父进程,这样僵尸进程的资源就被系统回收了。通过 kill -9 僵尸进程PID 的方式是不能消灭僵尸进程的,这个命令只对活着的进程有效,僵尸进程已经死了,鞭尸是不能解决问题的。
进程回收
为了避免僵尸进程的产生,一般我们会在父进程中进行子进程的资源回收,回收方式有两种,一种是阻塞方式 wait(),一种是非阻塞方式 waitpid()。
wait :
这是个阻塞函数,如果没有子进程退出,函数会一直阻塞等待,当检测到子进程退出了,该函数阻塞解除回收子进程资源。这个函数被调用一次,只能回收一个子进程的资源,如果有多个子进程需要资源回收,函数需要被调用多次。
// man 2 wait
#include <sys/wait.h>
pid_t wait(int *status);
参数:传出参数,通过传递出的信息判断回收的进程是怎么退出的,如果不需要该信息可以指定为 NULL。取出整形变量中的数据需要使用一些宏函数,具体操作方式如下:
WIFEXITED(status): 返回 1, 进程是正常退出的
WEXITSTATUS(status):得到进程退出时候的状态码,相当于 return 后边的数值,或者 exit () 函数的参数
WIFSIGNALED(status): 返回 1, 进程是被信号杀死了
WTERMSIG(status): 获得进程是被哪个信号杀死的,会得到信号的编号
返回值:
成功:返回被回收的子进程的进程 ID
失败: -1
// wait 函数回收子进程资源
#include <sys/wait.h>
int main()
{
pid_t pid;
// 创建子进程
for(int i=0; i<5; ++i)
{
pid = fork();
if(pid == 0)
{
break;
}
}
// 父进程
if(pid > 0)
{
// 需要保证父进程一直在运行
while(1)
{
// 回收子进程的资源
// 子进程由多个, 需要循环回收子进程资源
pid_t ret = wait(NULL);
if(ret > 0)
{
printf("成功回收了子进程资源, 子进程PID: %d\n", ret);
}
else
{
printf("回收失败, 或者是已经没有子进程了...\n");
break;
}
printf("我是父进程, pid=%d\n", getpid());
}
}
else if(pid == 0)
{
// 子进程, 执行这句代码之后, 子进程退出了
printf("我是子进程, pid=%d, 父进程ID: %d\n", getpid(), getppid());
}
return 0;
}
waitpid:
waitpid () 函数可以看做是 wait () 函数的升级版,通过该函数可以控制回收子进程资源的方式是阻塞还是非阻塞,另外还可以通过该函数进行精准打击,可以精确指定回收某个或者某一类或者是全部子进程资源。
// man 2 waitpid
#include <sys/wait.h>
// 这个函数可以设置阻塞, 也可以设置为非阻塞
// 这个函数可以指定回收哪些子进程的资源
pid_t waitpid(pid_t pid, int *status, int options);
pid:
-1:回收所有的子进程资源,和 wait () 是一样的,无差别回收,并不是一次性就可以回收多个,也是需要循环回收的
大于0:指定回收某一个进程的资源 ,pid 是要回收的子进程的进程 ID
0:回收当前进程组的所有子进程 ID
小于 -1:pid 的绝对值代表进程组 ID,表示要回收这个进程组的所有子进程资源
status: NULL, 和 wait 的参数是一样的
options: 控制函数是阻塞还是非阻塞
0: 函数是行为是阻塞的 ==> 和 wait 一样
WNOHANG: 函数是行为是非阻塞的
返回值:
如果函数是非阻塞的,并且子进程还在运行,返回 0
成功:得到子进程的进程 ID
失败: -1
// 非阻塞处理
#include <sys/wait.h>
int main()
{
pid_t pid;
// 创建子进程
for(int i=0; i<5; ++i)
{
pid = fork();
if(pid == 0)
{
break;
}
}
// 父进程
if(pid > 0)
{
// 需要保证父进程一直在运行
while(1)
{
// 回收子进程的资源
// 子进程由多个, 需要循环回收子进程资源
// 子进程退出了就回收,
// 没退出就不回收, 返回0
int status;
pid_t ret = waitpid(-1, &status, WNOHANG); // 非阻塞
if(ret > 0)
{
printf("成功回收了子进程资源, 子进程PID: %d\n", ret);
// 判断进程是不是正常退出
if(WIFEXITED(status))
{
printf("子进程退出时候的状态码: %d\n", WEXITSTATUS(status));
}
if(WIFSIGNALED(status))
{
printf("子进程是被这个信号杀死的: %d\n", WTERMSIG(status));
}
}
else if(ret == 0)
{
printf("子进程还没有退出, 不做任何处理...\n");
}
else
{
printf("回收失败, 或者是已经没有子进程了...\n");
break;
}
printf("我是父进程, pid=%d\n", getpid());
}
}
else if(pid == 0)
{
// 子进程, 执行这句代码之后, 子进程退出了
printf("===我是子进程, pid=%d, 父进程ID: %d\n", getpid(), getppid());
}
return 0;
}
// 和wait() 行为一样, 阻塞
#include <sys/wait.h>
int main()
{
pid_t pid;
// 创建子进程
for(int i=0; i<5; ++i)
{
pid = fork();
if(pid == 0)
{
break;
}
}
// 父进程
if(pid > 0)
{
// 需要保证父进程一直在运行
while(1)
{
// 回收子进程的资源
// 子进程由多个, 需要循环回收子进程资源
int status;
//函数是行为是阻塞的 ==> 和 wait 一样
pid_t ret = waitpid(-1, &status, 0); // == wait(NULL);
if(ret > 0)
{
printf("成功回收了子进程资源, 子进程PID: %d\n", ret);
// 判断进程是不是正常退出
if(WIFEXITED(status))
{
printf("子进程退出时候的状态码: %d\n", WEXITSTATUS(status));
}
if(WIFSIGNALED(status))
{
printf("子进程是被这个信号杀死的: %d\n", WTERMSIG(status));
}
}
else
{
printf("回收失败, 或者是已经没有子进程了...\n");
break;
}
printf("我是父进程, pid=%d\n", getpid());
}
}
else if(pid == 0)
{
// 子进程, 执行这句代码之后, 子进程退出了
printf("===我是子进程, pid=%d, 父进程ID: %d\n", getpid(), getppid());
}
return 0;
}
(二)管道
1、什么是管道
管道的是进程间通信(IPC - InterProcess Communication)的一种方式,管道的本质其实就是内核中的一块内存 (或者叫内核缓冲区),这块缓冲区中的数据存储在一个环形队列中,因为管道在内核里边,因此我们不能直接对其进行任何操作。
因为管道数据是通过队列来维护的,我们先来分析一个管道中数据的特点:
- 管道对应的内核缓冲区大小是固定的,默认为 4k(也就是队列最大能存储 4k 数据)
- 管道分为两部分:读端和写端(队列的两端),数据从写端进入管道,从读端流出管道。
- 管道中的数据只能读一次,做一次读操作之后数据也就没有了(读数据相当于出队列)。
- 管道是单工的:数据只能单向流动,数据从写端流向读端。
- 对管道的操作(读、写)默认是阻塞的
- 读管道:管道中没有数据,读操作被阻塞,当管道中有数据之后阻塞才能解除
- 写管道:管道被写满了,写数据的操作被阻塞,当管道变为不满的状态,写阻塞解除
管道在内核中,不能直接对其进行操作,我们通过什么方式去读写管道呢?其实管道操作就是文件 IO 操作,内核中管道的两端分别对应两个文件描述符,通过写端的文件描述符把数据写入到管道中,通过读端的文件描述符将数据从管道中读出来。读写管道的函数就是 Linux 中的文件 IO 函数:read/write。
// 读管道
ssize_t read(int fd, void *buf, size_t count);
// 写管道的函数
ssize_t write(int fd, const void *buf, size_t count);
在上图中假设父进程通过一系列操作,可以通过文件描述符表中的文件描述符 fd3 写管道,通过 fd4 读管道,然后再通过 fork() 创建出子进程,那么在父进程中被分配的文件描述符 fd3, fd4也就被拷贝到子进程中,子进程通过 fd3可以将数据写入到内核的管道中,通过fd4将数据从管道中读出来。
也就是说管道是独立于任何进程的,并且充当了两个进程用于数据通信的载体,只要两个进程能够得到同一个管道的入口和出口(读端和写端的文件描述符),那么他们之间就可以通过管道进行数据的交互。
2、匿名管道
匿名管道是管道的一种,既然是匿名也就是说这个管道没有名字,但其本质是不变的,就是位于内核中的一块内存。匿名管道只能实现有血缘关系的进程间通信,比如:父子进程,兄弟进程,爷孙进程,叔侄进程。
#include <unistd.h>
// 创建一个匿名的管道, 得到两个可用的文件描述符
int pipe(int pipefd[2]);
pipefd:传出参数,需要传递一个整形数组的地址,数组大小为 2,也就是说最终会传出两个元素
返回值:成功返回0,失败返回-1
使用匿名管道实现进程间通信
需求描述:
在父进程中创建一个子进程, 父子进程分别执行不同的操作:
- 子进程: 执行一个shell命令 "ps aux", 将命令的结果传递给父进程
- 父进程: 将子进程命令的结果输出到终端需求分析:
- 子进程中执行 shell 命令相当于启动一个磁盘程序,因此需要使用 execl ()/execlp () 函数。
- execlp(“ps”, “ps”, “aux”, NULL)
- 子进程中执行完 shell 命令直接就可以在终端输出结果,如果将这些信息传递给父进程呢?
- 数据传递需要使用管道,子进程需要将数据写入到管道中
- 将默认输出到终端的数据写入到管道就需要进行输出的重定向,需要使用 dup2() 做这件事情
- dup2(fd[1], STDOUT_FILENO); // STDOUT_FILENO关联fd[1]的文件
- 父进程需要读管道,将从管道中读出的数据打印到终端
- 父进程最后需要释放子进程资源,防止出现僵尸进程
在使用管道进行进程间通信的注意事项:必须要保证数据在管道中的单向流动。
如何理解?
- 第一步:在父进程中创建了匿名管道,得到了两个分配的文件描述符,fd3 操作管道的读端,fd4 操作管道的写端。
- 第二步:父进程创建子进程,父进程的文件描述符被拷贝,在子进程的文件描述符表中也得到了两个被分配的可以使用的文件描述符,通过 fd3 读管道,通过 fd4 写管道。通过下图可以看到管道中数据的流动不是单向的,有以下这么几种情况:
-
父进程通过 fd4 将数据写入管道,然后父进程再通过 fd3 将数据从管道中读出
-
父进程通过 fd4 将数据写入管道,然后子进程再通过 fd3 将数据从管道中读出
-
子进程通过 fd4 将数据写入管道,然后子进程再通过 fd3 将数据从管道中读出
-
子进程通过 fd4 将数据写入管道,然后父进程再通过 fd3 将数据从管道中读出
前边说到过,管道行为默认是阻塞的,假设子进程通过写端将数据写入管道,父进程的读端将数据读出,这样子进程的读端就读不到数据,导致子进程阻塞在读管道的操作上,这样就会给程序的执行造成一些不必要的影响。如果我们本来也没有打算让进程读或者写管道,那么就可以将进程操作的读端或者写端关闭。
-
3、第三步:为了避免两个进程都读管道,但是可能其中某个进程由于读不到数据而阻塞的情况,我们可以关闭进程中用不到的那一端的文件描述符,这样数据就只能单向的从一端流向另外一端了,如下图,我们关闭了父进程的写端,关闭了子进程的读端:
// 管道的数据是单向流动的:
// 操作管道的是两个进程, 进程A读管道, 需要关闭管道的写端, 进程B写管道, 需要关闭管道的读端
// 如果不做上述的操作, 会对程序的结果造成一些影响, 对管道的操作无法结束
#include <fcntl.h>
#include <sys/wait.h>
int main()
{
// 1. 创建匿名管道, 得到两个文件描述符
int fd[2];
int ret = pipe(fd);
if(ret == -1)
{
perror("pipe");
exit(0);
}
// 2. 创建子进程 -> 能够操作管道的文件描述符被复制到子进程中
pid_t pid = fork();
if(pid == 0) //进入子进程
{
// 关闭读端
close(fd[0]);
// 3. 在子进程中执行 execlp("ps", "ps", "aux", NULL);
// 在子进程中完成输出的重定向, 原来输出到终端现在要写管道
// 进程打印数据默认输出到终端, 终端对应的文件描述符: stdout_fileno
// 标准输出 重定向到 管道的写端
dup2(fd[1], STDOUT_FILENO); //输出重定向
execlp("ps", "ps", "aux", NULL); //如果成功当前子进程的代码区与 ps中的代码区代码替换
perror("execlp"); //如果函数调用失败了,才会继续执行这行代码
}
// 4. 父进程读管道
else if(pid > 0)
{
// 关闭管道的写端
close(fd[1]);
// 5. 父进程打印读到的数据信息
char buf[4096];
// 读管道
// 如果管道中没有数据, read会阻塞
// 有数据之后, read解除阻塞, 直接读数据
// 需要循环读数据, 管道是有容量的, 写满之后就不写了
// 数据被读走之后, 继续写管道, 那么就需要再继续读数据
while(1)
{
memset(buf, 0, sizeof(buf));
int len = read(fd[0], buf, sizeof(buf));
if(len == 0)
{
// 管道的写端关闭了, 如果管道中没有数据, 管道读端不会阻塞
// 没数据直接返回0, 如果有数据, 将数据读出, 数据读完之后返回0
break;
}
printf("%s, len = %d\n", buf, len);
}
close(fd[0]);
// 回收子进程资源
wait(NULL);
}
return 0;
}
3、有名管道
有名管道拥有管道的所有特性,之所以称之为有名是因为管道在磁盘上有实体文件,文件类型为 p 。
有名管道文件大小永远为 0,因为有名管道也是将数据存储到内存的缓冲区中,打开这个磁盘上的管道文件就可以得到操作有名管道的文件描述符,通过文件描述符读写管道存储在内核中的数据。
有名管道也可以称为 fifo (first in first out),使用有名管道既可以进行有血缘关系的进程间通信,也可以进行没有血缘关系的进程间通信。创建有名管道的方式有两种,一种是通过命令,一种是通过函数。
通过命令
$ mkfifo 有名管道的名字
通过函数
#include <sys/types.h>
#include <sys/stat.h>
// int open(const char *pathname, int flags, mode_t mode);
int mkfifo(const char *pathname, mode_t mode);
pathname: 要创建的有名管道的名字
mode: 文件的操作权限,和 open () 的第三个参数一个作用,最终权限: (mode & ~umask)
返回值:创建成功返回 0,失败返回 -1
通过有名管道进行进程间通信
有名管道操作需要通过 open () 操作得到读写管道的文件描述符,如果只是读端打开了或者只是写端打开了,进程会阻塞在这里不会向下执行,直到在另一个进程中将管道的对端打开,当前进程的阻塞也就解除了。所以当发现进程阻塞在了 open () 函数上不要感到惊讶。
写管道的进程:
/*
1. 创建有名管道文件
mkfifo()
2. 打开有名管道文件, 打开方式是 o_wronly
int wfd = open("xx", O_WRONLY);
3. 调用write函数写文件 ==> 数据被写入管道中
write(wfd, data, strlen(data));
4. 写完之后关闭文件描述符
close(wfd);
*/
#include <fcntl.h>
#include <sys/stat.h>
int main()
{
// 1. 创建有名管道文件
int ret = mkfifo("./testfifo", 0664);
if(ret == -1)
{
perror("mkfifo");
exit(0);
}
printf("管道文件创建成功...\n");
// 2. 打开管道文件
// 因为要写管道, 所有打开方式, 应该指定为 O_WRONLY
// 如果先打开写端, 读端还没有打开, open函数会阻塞, 当读端也打开之后, open解除阻塞
int wfd = open("./testfifo", O_WRONLY);
if(wfd == -1)
{
perror("open");
exit(0);
}
printf("以只写的方式打开文件成功...\n");
// 3. 循环写管道
int i = 0;
while(i<100)
{
char buf[1024];
sprintf(buf, "hello, fifo, 我在写管道...%d\n", i);
write(wfd, buf, strlen(buf));
i++;
sleep(1);
}
close(wfd);
return 0;
}
读管道的进程
/*
1. 这两个进程需要操作相同的管道文件
2. 打开有名管道文件, 打开方式是 o_rdonly
int rfd = open("xx", O_RDONLY);
3. 调用read函数读文件 ==> 读管道中的数据
char buf[4096];
read(rfd, buf, sizeof(buf));
4. 读完之后关闭文件描述符
close(rfd);
*/
#include <fcntl.h>
#include <sys/stat.h>
int main()
{
// 1. 打开管道文件
// 因为要read管道, so打开方式, 应该指定为 O_RDONLY
// 如果只打开了读端, 写端还没有打开, open阻塞, 当写端被打开, 阻塞就解除了
int rfd = open("./testfifo", O_RDONLY);
if(rfd == -1)
{
perror("open");
exit(0);
}
printf("以只读的方式打开文件成功...\n");
// 2. 循环读管道
while(1)
{
char buf[1024];
memset(buf, 0, sizeof(buf));
// 读是阻塞的, 如果管道中没有数据, read自动阻塞
// 有数据解除阻塞, 继续读数据
int len = read(rfd, buf, sizeof(buf));
printf("读出的数据: %s\n", buf);
if(len == 0)
{
// 写端关闭了, read解除阻塞返回0
printf("管道的写端已经关闭, 拜拜...\n");
break;
}
}
close(rfd);
return 0;
}
4、管道的读写行为
读管道,需要根据写端的状态进行分析:
- 写端没有关闭 (操作管道写端的文件描述符没有被关闭)
- 如果管道中没有数据 ==> 读阻塞 , 如果管道中被写入了数据,阻塞解除
- 如果管道中有数据 ==> 不阻塞,管道中的数据被读完了,再继续读管道还会阻塞
- 写端已经关闭了 (没有可用的文件描述符可以写管道了)
- 管道中没有数据 ==> 读端解除阻塞,read 函数返回 0
- 管道中有数据 ==> read 先将数据读出,数据读完之后返回 0, 不会阻塞了
写管道,需要根据读端的状态进行分析:
- 读端没有关闭
- 如果管道有存储的空间,一直写数据
- 如果管道写满了,写操作就阻塞,当读端将管道数据读走了,解除阻塞继续写
- 读端关闭了/管道破裂 (异常), 进程直接退出