Linux 进程控制总结
文章目录
- 一、进程概述
- 1. 并发和并行
- 2. PCB - 进程控制块
- 3. 进程状态
- 4. 进程常用命令
- 二、进程创建
- 1. 创建进程有哪几种方式
- 2. 父子进程
- 3. fork和vfork的区别
- 三、execl和execlp函数
- 1. execl()
- 2. execlp()
- 3. 示例
- 四、进程控制
- 1. 孤儿进程
- 2. 僵尸进程
- 3. 回收进程
- 3.1 wait
- 3.2 waitpid
一、进程概述
从严格意义上来讲,程序和进程是两个不同的概念,他们的状态,占用的系统资源都是不同的。
- 程序:就是磁盘上的可执行文件文件, 并且只占用磁盘上的空间,是一个静态的概念。
- 进程:被执行之后的程序叫做进程,不占用磁盘空间,需要消耗系统的内存,CPU资源,每个运行的进程的都对应一个属于自己的虚拟地址空间,这是一个动态的概念。
1. 并发和并行
CPU在某个时间点只能处理一个任务,但是操作系统都支持多任务的,那么在计算机CPU只有一个的情况下是怎么完成多任务处理的呢?
CPU会给每个进程被分配一个时间段,进程得到这个时间片之后才可以运行,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,CPU的使用权将被收回,该进程将会被中断挂起等待下一个时间片。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换,这样就可以避免CPU资源的浪费。
因此可以得知,在我们使用的计算机中启动的多个程序,从宏观上看是同时运行的,从微观上看由于CPU一次只能处理一个进程,所有它们是轮流执行的,只不过切换速度太快,我们感觉不到罢了,因此CPU的核数越多计算机的处理效率越高。
并发和并行:
- 并发的同时运行是一个假象,实际上是通过快速切换 (时间片轮转)在多个任务之间交替执行
- 并行是指系统真正同时执行多个任务,依赖多核CPU/GPU 或分布式计算资源**(必须有多核硬件支持)**
2. PCB - 进程控制块
PCB - 进程控制块(Processing Control Block),Linux内核的进程控制块本质上是一个叫做 task_struct
的结构体。在这个结构体中记录了进程运行相关的一些信息,下面介绍一些常用的信息:
-
进程id:每一个进程都一个唯一的进程ID,类型为
pid_t
, 本质是一个整形数 -
进程的状态:进程有不同的状态, 状态是一直在变化的,有就绪、运行、挂起、停止等状态。
-
进程对应的虚拟地址空间的信息。
-
描述控制终端的信息,进程在哪个终端启动默认就和哪个终端绑定。
-
当前工作目录:默认情况下, 启动进程的目录就是当前的工作目录
-
umask掩码:在创建新文件的时候,通过这个掩码屏蔽某些用于对文件的操作权限。
-
文件描述符表:每个被分配的文件描述符都对应一个已经打开的磁盘文件
-
和信号相关的信息:在Linux中
调用函数, 键盘快捷键, 执行shell命令
等操作都会产生信号。- 阻塞信号集:记录当前进程中阻塞哪些已产生的信号,使其不能被处理
-
未决信号集:记录在当前进程中产生的哪些信号还没有被处理掉。
-
用户id和组id:当前进程属于哪个用户, 属于哪个用户组
-
会话(Session)和进程组:多个进程的集合叫进程组,多个进程组的集合叫会话。
-
进程可以使用的资源上限:可以使用shell命令
ulimit -a
查看详细信息。
3. 进程状态
进程可以分为五个状态,分别是:
- 创建状态
- 就绪状态
- 运行状态
- 阻塞状态
- 终止状态
创建状态
一个应用程序从系统上启动,首先就是进入创建状态,需要获取系统资源创建进程管理块(PCB: Process Control Block)完成资源分配。
就绪状态
在创建状态完成之后,进程已经准备好,但是还未获得处理器资源,无法运行。
运行状态
获取处理器资源,被系统调度,开始进入运行状态。如果进程的时间片用完了就进入就绪状态。
阻塞状态
在运行状态期间,如果进行了阻塞的操作,如耗时的I/O操作,此时进程暂时无法操作就进入到了阻塞状 态,在这些操作完成后就进入就绪状态。
终止状态
进程结束或者被系统终止,进入终止状态 进程的状态转换图
进程的状态转换图
4. 进程常用命令
在初学进程我只需要知道这两个命令即可,一个是查看进程,一个是杀死进程。
- 查看进程
$ ps aux- a: 查看所有终端的信息- u: 查看用户相关的信息- x: 显示和终端无关的进程信息
当然也可以单独使用 ps a, ps u, ps x
查看。
- 杀死进程
kill命令可以发送某个信号到对应的进程,进程收到某些信号之后默认的处理动作就是退出进程,如果要给进程发送信号,可以先查看一下Linux给我们提供了哪些标准信号。
查看Linux中的标准信号:执行命令 kill -l
9号信号(SIGKILL)的行为是无条件杀死进程,想要杀死哪个进程就可以把这个信号发送给这个进程,操作如下:
# 无条件杀死进程, 进程ID通过 ps aux 可以查看
$ kill -9 进程ID
$ kill -SIGKILL 进程ID
二、进程创建
1. 创建进程有哪几种方式
创建进程主要在:
- 系统初始化(查看进程 linux中用ps命令, windows中用任务管理器,前台进程负责与用户交互,后台运行的进程与用户无关,运行在后台并且只在需要时才唤醒的进程,称为守护进程,如电子邮件、 web页面、新闻、打印)
- 一个进程在运行过程中开启了子进程,比如使用 fork 函数创建子进程。
- 用户的交互式请求,比如用户用鼠标打开其它软件等。
2. 父子进程
启动磁盘上的应用程序, 得到一个进程, 如果在这个启动的进程中调用fork()函数,就会得到一个新的进程,我们习惯将其称之为子进程。
我们通过判断 fork() 函数的返回值来判断当前进程是子进程还是父进程,父进程返回值 > 0,子进程返回值 = 0。
每个进程都对应一个属于自己的虚拟地址空间,子进程的地址空间是基于父进程的地址空间拷贝出来的,虽然是拷贝但是两个地址空间中存储的信息不可能是完全相同的。
拷贝完成之后,两个地址空间中的用户区数据是相同的。用户区数据主要数据包括:
- 代码区:默认情况下父子进程地址空间中的源代码始终相同。
- 全局数据区:父进程中的全局变量和变量值全部被拷贝一份放到了子进程地址空间中
- 堆区:父进程中的堆区变量和变量值全部被拷贝一份放到了子进程地址空间中
- 动态库加载区(内存映射区):父进程中数据信息被拷贝一份放到了子进程地址空间中
- 栈区:父进程中的栈区变量和变量值全部被拷贝一份放到了子进程地址空间中
- 环境变量:默认情况下,父子进程地址空间中的环境变量始终相同。
- 文件描述符表: 父进程中被分配的文件描述符都会拷贝到子进程中,在子进程中可以使用它们打开对应的文件。
注意:
两个进程中是不能通过全局变量实现数据交互的,因为每个进程都有自己的地址空间,两个同名全局变量存储在不同的虚拟地址空间中,二者没有任何关联性。如果要进行进程间通信需要使用:管道,共享内存,本地套接字,内存映射区,消息队列等方式。
示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>// 定义全局变量
int number = 10;int main()
{printf("创建子进程之前 number = %d\n", number);pid_t pid = fork();// 父子进程都会执行这一行printf("当前进程fork()的返回值: %d\n", pid);// 父进程if(pid > 0){printf("我是父进程, pid = %d, number = %d\n", getpid(), ++number);printf("父进程的父进程(终端进程), pid = %d\n", getppid());sleep(1);}else if(pid == 0){// 子进程number += 100;printf("我是子进程, pid = %d, number = %d\n", getpid(), number);printf("子进程的父进程, pid = %d\n", getppid());}return 0;
}
3. fork和vfork的区别
- fork( )的子进程拷贝父进程的代码段和数据段;vfork( )的子进程与父进程共享数据段
- fork( )的父子进程的执行次序不确定;vfork( )保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。
- 如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
- 当需要改变共享数据段中变量的值,则拷贝父进程
三、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个函数做了进一步的封装,下面介绍一下。
1. execl()
该函数可用于执行任意一个可执行程序,函数需要通过指定的文件路径才能找到这个可执行程序。
#include <unistd.h>
// 变参函数
int execl(const char *path, const char *arg, ...);
参数:
- path: 要启动的可执行程序的路径, 推荐使用绝对路径
- arg: ps aux 查看进程的时候, 启动的进程的名字, 可以随意指定, 一般和要启动的可执行程序名相同
- … : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。
返回值:如果这个函数执行成功, 没有返回值,如果执行失败, 返回 -1
2. execlp()
该函数常用于执行已经设置了环境变量的可执行程序,函数中的 p 就是path,也是说这个函数会自动搜索系统的环境变量 PATH
,因此使用这个函数执行可执行程序不需要指定路径,只需要指定出名字即可。
// p == path
int execlp(const char *file, const char *arg, ...);
参数:
- file: 可执行程序的名字
- 在环境变量PATH中,可执行程序可以不加路径
- 没有在环境变量PATH中, 可执行程序需要指定绝对路径
- arg: ps aux 查看进程的时候, 启动的进程的名字, 可以随意指定, 一般和要启动的可执行程序名相同
- … : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。
- 返回值:如果这个函数执行成功, 没有返回值,如果执行失败, 返回 -1
3. 示例
关于exec族函数,我们一般不会在进程中直接调用,如果直接调用这个进程的代码区代码被替换也就不能按照原来的流程工作了。我们一般在调用这些函数的时候都会先创建一个子进程,在子进程中调用 exec 族函数,子进程的用户区数据被替换掉开始执行新的程序中的代码逻辑,但是父进程不受任何影响仍然可以继续正常工作。
execl() 或者 execlp() 函数的使用方法如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>int main()
{// 创建子进程pid_t pid = fork();// 在子进程中执行磁盘上的可执行程序if(pid == 0){// 磁盘上的可执行程序 /bin/ps
#if 1execl("/bin/ps", "title", "aux", NULL);// 也可以这么写// execl("/bin/ps", "title", "a", "u", "x", NULL);
#elseexeclp("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;
}
四、进程控制
1. 孤儿进程
在一个启动的进程中创建子进程,这时候父子进程同时运行,但是父进程由于某种原因先退出了,子进程还在运行,这时候这个子进程就可以被称之为孤儿进程(跟现实是一样的)。
操作系统是非常关爱运行的每一个进程的,当检测到某一个进程变成了孤儿进程,这时候系统中就会有一个固定的进程领养这个孤儿进程(有干爹了)。如果使用Linux没有桌面终端,这个领养孤儿进程的进程就是 init 进程(PID=1),如果有桌面终端,这个领养孤儿进程就是桌面进程。
那么问题来了,系统为什么要领养这个孤儿进程呢?在子进程退出的时候, 进程中的用户区可以自己释放, 但是进程内核区的pcb资源自己无法释放,必须要由父进程来释放子进程的pcb资源,孤儿进程被领养之后,这件事儿干爹就可以代劳了,这样可以避免系统资源的浪费。
代码示例:
#include <stdio.h>
#include <unistd.h>int main()
{// 创建子进程pid_t pid = fork();// 父进程if(pid > 0){printf("father pid=%d\n", getpid());}else if(pid == 0){sleep(1); // 强迫子进程睡眠1s, 这个期间, 父进程退出, 当前进程变成了孤儿进程// 子进程printf("son pid = %d, father pid = %d\n", getpid(), getppid());}return 0;
}
虚拟机编译运行结果:
按照正常的执行方式,孤儿进程应该被 init 进程收养(pid = 1),但是测试过程中发现不为1,这其实和运行界面得到不同有关。
在这里的桌面终端为伪终端,我们可以按 crtl + alt + F3
切换成字符化界面控制终端(切换回去就按 crtl + alt + F2
) ,然后重新执行程序就可以看到正确结果:
在上面我们可以发现显示有点异常,看似进程还没有执行完成,貌似是因为什么原因被阻塞了,实际上终端是正常的,当我们通过键盘输入一些命令,终端也能接受输入并且输出相关信息,那么为什么终端会显示成这个样子呢?
原因是:
- a.out 进程启动之后,其实 a.out 也是有父进程的就是当前的终端
- 终端只能检测到 a.out 进程的状态,a.out执行期间终端切换到后台,a.out执行完毕之后终端切换回前台
- 当终端切换到前台之后,a.out的子进程还没有执行完毕,当子进程输出的信息就显示到终端命令提示符的后边了,导致终端显示有问题,但是此时终端是可以接收键盘输入的,只是看起来不美观而已。
- 想要解决这个问题,需要让所有子进程退出之后再退出父进程,比如:在父进程代码中调用 sleep(),当然这样就无法测试父进程退出后子进程成为孤儿进程了,这个我们了解即可。
2. 僵尸进程
用 fork 函数创建一个正常运行的子进程(子进程 = 0,父进程 > 0),如果子进程退出,父进程没有及时调用wait或waitpid函数回收子进程的系统资源,该进程就变成僵尸进程。
危害:
在进程退出的时候,内核会释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号 PID,退出状态 the termination status of the process,运行时间 the amount of CPU time taken by the process 等),直到父进程通过 wait / waitpid 来回收子进程时才释放。
如果父进程不回收子进程的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程。
示例代码:
#include <stdio.h>
#include <unistd.h>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 表示进程已经死亡;
注意:
- 消灭僵尸进程的方法是,杀死这个僵尸进程的父进程,这样僵尸进程的资源就被系统回收了。
- 通过 kill -9 僵尸进程PID的方式是不能消灭僵尸进程的,这个命令只对活着的进程有效,僵尸进程已经死了,鞭尸是不能解决问题的。
在上面说过,为了避免僵尸进程的产生,一般我们会在父进程中进行子进程的资源回收,回收方式有两种,一种是阻塞方式wait(),一种是非阻塞方式waitpid()。
3. 回收进程
3.1 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 (没有子进程资源可以回收了, 函数的阻塞会自动解除或回收子进程资源的时候出现了异常)
示例代码:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#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;
}
编译运行结果:
3.2 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 <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#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;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;
}
示例代码:非阻塞回收多个子进程资源
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#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){// 回收子进程的资源// 子进程由多个, 需要循环回收子进程资源// 子进程退出了就回收, // 没退出就不回收, 返回0int 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;
}
编译运行结果: