一、进程概述
1、进程和程序
什么是程序呢?
其实程序就是存放在存储介质上的可执行文件,它包含一系列信息,这些信息描述了如何在运行时创建一个进程。
那有人又会问什么是进程呢?
直观的来说,我们平时写的 C/C++ 语言代码,通过编译器编译,最终它会成为一个可执行程序,当这个可执行程序运行起来后,它就成为了一个进程。
所以程序是存放在存储介质上的一个可执行文件,而进程是程序执行的过程。进程的状态是变化的,其包括进程的创建、调度和消亡。程序是静态的,进程是动态的。
打个比方:程序就类似于剧本(纸),代码就相当于剧本稿纸上文章(字),进程类似于戏(舞台、演员、灯光、道具…)。同一个剧本可以在多个舞台同时上演。同样,同一个程序也可以加载为不同的进程(彼此之间互不影响)。
示例:
理解程序与进程之后,下面来一个官方的说法:
- 进程是正在运行的程序的实例。是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。
- 在 linux 中,操作系统是通过进程去完成一个又一个的任务,所以进程是操作系统管理事务的基本单元。
- 可以用一个程序来创建多个进程,进程是由内核定义的抽象实体,并为该实体分配用以执行程序的各项系统资源。所以进程拥有自己独立的处理环境(如:当前需要用到哪些环境变量,程序运行的目录在哪,当前是哪个用户在运行此程序等)和系统资源(如:处理器 CPU 占用率、存储器、I/O 设备、数据、程序)。
- 从内核的角度看,进程由用户内存空间和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。记录在内核数据结构中的信息包括许多与进程相关的标识号(IDs)、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量的其他信息;
- 程序是文件,占用磁盘空间,但是不占用系统资源(CPU、内存等);而进程则占用系统资源。
我们可以这么理解,公司相当于操作系统,部门相当于进程,公司通过部门来管理(系统通过进程管理),对于各个部门,每个部门有各自的资源,如人员、电脑设备、打印机等。
【补充】ulimit -a
命令可以显示当前系统的一些资源的上限,也可以 ulimit
命令修改这些资源的上限。
2、单道多道程序设计
单道程序设计
所有进程一个一个排队执行。若 A 阻塞,B 只能等待,即使 CPU 处于空闲状态,比如人机交互时阻塞的出现是必然的,与此同时 CPU 处于空闲等待状态。所以这种模型在系统资源利用上及其不合理,在计算机发展历史上存在不久,大部分便被淘汰了。
多道程序设计
在计算机内存中同时存放几道相互独立的程序,它们在管理程序控制之下,相互穿插的运行。多道程序设计必须有硬件基础作为保证。在多道程序设计模型中,两个或两个以上程序在计算机系统中同处于开始到结束之间的状态,这些程序共享计算机系统资源。引入多道程序设计技术的根本目的是为了提高 CPU 的利用率。
时间片(timeslice)又称为“量子(quantum)”或“处理器片(processor slice)”是操作系统分配给每个正在运行的进程微观上的一段 CPU 时间。
事实上,虽然一台计算机通常可能有多个 CPU,但是同一个 CPU 永远不可能真正地同时运行多个任务,程序同时处于运行状态只是一种宏观上的概念,它们虽然都已经开始运行,但就微观而言,任意时刻,CPU 上运行的程序只有一个:在只考虑一个 CPU 的情况下,这些进程“看起来像”同时运行的,实则是轮番穿插地运行,由于时间片通常很短(在 Linux 上为 5ms-800ms),用户反应不过来,所以看似同时在运行。
当下常见 CPU 为纳秒级,1 秒可以执行大约 10 亿条指令,人眼的反应速度是毫秒级。
1s = 1000ms
1ms = 1000us
1us = 1000ns
1s = 1000000000ns
时间片由操作系统内核的调度程序分配给每个进程。首先,内核会给每个进程分配相等的初始时间片,然后每个进程轮番地执行相应的时间,当所有进程都处于时间片耗尽的状态时,内核会重新为每个进程计算并分配时间片,如此往复。【注意】时间片不能太短,也不能太长:太短的话,切换成本太高,CPU要消耗大量的时间进行时间片切换;太长的话,人都有可能反应过来,宏观上感觉不到多个程序同时运行。
3、并行和并发
并行(parallel)
指在同一时刻,有多条指令在多个处理器上同时执行。
并发(concurrency)
指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
在计算机中,时钟中断是多道程序设计模型的理论基础。并发时,任意进程在执行期间都不希望放弃 cpu。因此系统需要一种强制让进程让出 cpu 资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。 操作系统中的中断处理函数,来负责调度程序执行。
举例说明:
- 并行是两个队列同时使用两台咖啡机
- 并发是两个队列交替使用一台咖啡机
4、MMU 与虚拟内存空间
MMU
MMU 是 Memory Management Unit 的缩写,中文名是内存管理单元,它是中央处理器(CPU)中用来管理虚拟存储器、物理存储器的控制线路,同时也负责虚拟地址映射为物理地址,以及提供硬件机制的内存访问授权,多用户多进程操作系统。
[[03_Linux 常用 API 函数#2. 虚拟内存空间|虚拟内存空间]]
5、进程控制块 PCB
为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。所以内核为每个进程分配一个PCB(进程控制块),维护进程相关的信息。Linux内核的进程控制块是task_struct 结构体。
在 /usr/src/linux-headers-xxx/include/linux/sched.h
文件中可以查看 struct task_struct 结构体的定义。其内部成员有很多,了解掌握以下部分即可:
- 进程 id:系统中每个进程有唯一的 id,在 C 语言中用 pid_t 类型表示,其实就是一个非负整数。
- 进程的状态:有就绪、运行、挂起、停止等状态。
- 进程切换时需要保存和恢复的一些CPU寄存器。
- 描述虚拟地址空间的信息。
- 描述控制终端的信息。
- 当前工作目录(Current Working Directory)。
- umask掩码。
- 文件描述符表,包含很多指向 file 结构体的指针。
- 和信号相关的信息。
- 用户 id 和组 id。
- 会话(Session)和进程组。
- 进程可以使用的资源上限(Resource Limit)。
6、进程的状态
进程状态反映进程执行过程的变化。这些状态随着进程的执行和外界条件的变化而转换。
在三态模型中,进程状态分为三个基本状态,即就绪态,运行态,阻塞态。
- 运行态:进程占有处理器正在运行;
- 就绪态:进程具备运行条件,等待系统分配处理器以便运行。当进程已分配到除 cpu 以外的所有必要资源后,只要再获得 cpu,便可立即执行。在一个系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列;
- 阻塞态:又称为等待(wait)态或睡眠(sleep)态,指进程不具备运行条件,正在等待某个事件的完成。除了调用 sleep 或者 wait 会进入阻塞态之外,程序运行时,可能需要和用户进行一个交互,那么等待用户录入数据也会进入阻塞状态。
在五态模型中,进程分为新建态、就绪态,运行态,阻塞态,终止态。
-
新建态:进程刚被创建时的状态,尚未进入就绪队列;
-
终止态:进程完成任务到达正常结束点,或出现无法克服的错误而异常终止,或被操作系统及有终止权的进程所终止时所处的状态。进入终止态的进程以后不再执行,但依然保留在操作系统中等待善后。一旦其他进程完成了对终止态进程的信息抽取之后,操作系统将删除该进程。
除了运行态可以终止进程进入终止态之外,就绪态也可以直接到终止态,阻塞态也可以直接到终止态。
二、进程相关的常用命令
1、进程查看命令
进程信息介绍
每个进程都由一个进程号来标识,其类型为 pid_t
,进程号的类型—pid_t
其实为一个短整形,所以 pid_t
能表示的范围是:0~32767。进程号总是唯一的,但进程号可以重用:同一时刻,只能有一个进程使用一个进程号,当一个进程终止后,该进程号就可以再次被其他进程使用。
接下来,再给介绍三个不同的进程号。
- 进程号(PID):标识进程的一个非负整型数。
- 父进程号(PPID):任何进程( 除 init 进程)都是由另一个进程创建,该进程称为被创建进程的父进程,对应的进程号称为父进程号(PPID)。例如A 进程创建了 B 进程,A 的进程号就是 B 进程的父进程号。
- 进程组号(PGID):进程组是一个或多个进程的集合。他们之间相互关联,进程组可以接收同一终端的各种信号,关联的进程有一个进程组号(PGID) 。这个过程有点类似于 QQ 群,组相当于 QQ 群,各个进程相当于各个好友,把各个好友都拉入这个 QQ 群里,主要是方便管理,特别是通知某些事时,只要在群里吼一声,所有人都收到,简单粗暴。但是,这个进程组号和 QQ 群号是有点区别的,默认的情况下,当前的进程号会当做当前的进程组号。
ps 查看命令
ps 命令可以查看进程的详细状况,常用选项(选项可以不加“-”)如下:
选项 | 含义 |
---|---|
-a | 显示终端上的所有进程,包括其他用户的进程 |
-u | 显示进程的详细状态 |
-x | 显示没有控制终端的进程 |
-w | 显示加宽,以便显示更多的信息 |
-r | 只显示正在运行的进程 |
ps 常见用法:
(1)ps aux:显示这个操作系统上所有进程的信息,相当于一个拍照,不能动态显示。
(2)ps -ef:效果与 ps aux 差不多, ps aux 最初用到 Unix Style 中,而 ps -ef 被用在 System V Style 中,两者输出略有不同。现在的大部分Linux系统都是可以同时使用这两种方式的。
(3)ps ajx:以比较完整的格式显示所有的进程,会显示进程的父进程 ID、进程组 ID、会话 ID 等
(4)ps a:显示当前终端下的所有进程,包括其他用户的进程。
(5)查找某个进程:根据进程的名字或者其他信息,结合 grep 命令找到目标进程。
【补充】如上图所示,STAT 表示进程状态,具体参数意义如下:
参数 | 含义 |
---|---|
D | 不可中断 Uninterruptible(usually IO) |
R | 正在运行,或在队列中的进程 |
S(大写) | 处于休眠状态 |
T | 停止或被追踪 |
Z | 僵尸进程 |
W | 进入内存交换(从内核2.6开始无效) |
X | 死掉的进程 |
< | 高优先级 |
N | 低优先级 |
s | 包含子进程 |
+ | 位于前台的进程组 |
top 动态查看命令
top 命令用来动态显示运行中的进程。top 命令能够在运行后,在指定的时间间隔更新显示信息,可以在使用 top命令时加上 -d 来指定显示信息更新的时间间隔。在top命令执行后,可以按下按键得到对显示的结果进行排序:
按键 | 含义 |
---|---|
M | 根据内存使用量来排序 |
P | 根据CPU占有率来排序 |
T | 根据进程运行时间的长短来排序 |
U | 可以根据后面输入的用户名来筛选进程 |
K | 可以根据后面输入的PID来杀死进程。 |
q | 退出 |
h | 获得帮助 |
【备注】top 命令类似于 windows 操作系统上的任务管理器。 |
jobs 命令
jobs 命令用于查看当前终端的所有后台进程。该命令可以显示任务号及其对应的进程号。其中,任务号是以普通用户的角度进行的,而进程号则是从系统管理员的角度来看的。一个任务可以对应于一个或者多个进程号。常用选项如下:
参数 | 含义 |
---|---|
-l | 显示进程号 |
-p | 仅任务对应的显示进程号 |
-n | 显示任务状态的变化 |
-r | 仅输出运行状态(running)的任务 |
-s | 仅输出停止状态(stoped)的任务 |
2、进程控制命令
kill 命令
参考:Linux 基础介绍-基础命令 一文 20.3 小结
killall 命令
参考:Linux 基础介绍-基础命令 一文 20.4 小结
前后台进程相关控制命令
Linux下,需要经常使用进程的前后台调度命令,比如一个需要长时间运行的命令,我们就希望把它放入后台,这样就不会阻塞当前的操作;还有一些服务型的命令进程我们则希望能把它们长期运行于后台。
- ctrl + c 组合键:终止并退出前台命令的执行,回到当前终端;
- kill:ctrl + c 组合键只能终止前台命令的执行,而 kill 命令不但能终止前台命令的执行,还能终止后台命令的执行
- 通过 jobs 命令查看任务号,假设为 num,可以通过 kill %num 杀死任务;
- 通过 ps 命令查看进程号,假设为 pid,可以通过 kill -9 pid
- ctrl + z 组合键:暂停前台命令的执行,将该进程放入后台,回到当前终端;
- & 命令:运行命令时,在命令末尾加上 & 可让命令在后台执行。但关闭当前终端可能导致该后台进程退出;
-
- fg N:将任务号为 N 的后台进程放到前台执行;
- bg N:将任务号为 N 的暂停的后台进程,继续执行。
- nohup 命令:不挂断地运行命令。用来让进程始终在后台执行,即使关闭当前的终端也一样并输出日志,这点 & 命令做不到,例如:
nohup ./server &
。【补充】在默认情况下(非重定向时),会输出一个名叫 nohup.out 的文件到当前目录下,如果当前目录的 nohup.out 文件不可写,输出重定向到 $HOME/nohup.out 文件中。
示例 1:
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ jobs -l # 列出后台进程,无后台进程
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ ./server # 运行程序
Accepting connections ...
^Z # 执行 ctrl + z,暂停并转为后台
[1]+ Stopped ./server
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ jobs -l # 列出后台进程,存在后台进程
[1]+ 20743 Stopped ./server
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ bg 1 # 将暂停的后台进程恢复运行
[1]+ ./server &
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ fg 1 # 将后台进程转为前台
./server
^C # 执行 ctrl + c 结束进程
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$
示例 2:
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ jobs -l # 列出后台进程,无后台进程
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ nohup ./server & # 以非挂起的方式运行程序
[1] 21292
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ nohup: ignoring input and appending output to 'nohup.out'
yxm@192:~/myshare/NetworkIO/BIO/mul_process_server$ jobs -l # 列出后台进程,无后台进程
[1]+ 21292 Running nohup ./server &
三、进程管理 API
1、进程信息查询 API
(1)getpid 函数
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
功能:
获取本进程号(PID)
参数:
无
返回值:
本进程号(注意:该函数总是执行成功,所以返回值不需要进行错误检测)
(2)getppid函数
#include <sys/types.h>
#include <unistd.h>
pid_t getppid(void);
功能:
获取调用此函数的进程的父进程号(PPID)
参数:
无
返回值:
调用此函数的进程的父进程号(PPID)
(3)getpgid函数
#include <sys/types.h>
#include <unistd.h>
pid_t getpgid(pid_t pid);
功能:
获取进程组号(PGID)
参数:
pid:进程号
返回值:
参数为 0 时返回当前进程组号,否则返回参数指定的进程的进程组号
示例程序:
// test.c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
// 获取进程号、父进程号、进程组号
int main() {
pid_t pid, ppid, pgid;
pid = getpid();
printf("pid = %d\n", pid);
ppid = getppid();
printf("ppid = %d\n", ppid);
pgid = getpgid(pid);
printf("pgid = %d\n", pgid);
return 0;
}
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
pid = 27095
ppid = 26906
pgid = 27095
2、进程创建
系统允许一个进程创建新进程,新进程即为子进程,子进程还可以创建新的子进程,形成进程树结构模型。
进程创建 API
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
功能:
用于从一个已存在的进程中创建一个新进程,新进程称为子进程,原进程称为父进程。
参数:
无
返回值:
成功:本函数返回值会返回两次,一次是子进程中返回 0,一次是父进程中返回子进程 ID。
失败:父进程中返回 -1,API。失败的两个主要原因是:
1)当前的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN。
2)系统内存不足,这时 errno 的值被设置为 ENOMEM。
示例代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
fork();
printf("id ==== %d\n", getpid()); // 获取进程号
return 0;
}
运行结果如下:
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
id ==== 27332 # 父进程的进程号
id ==== 27333 # 子进程的进程号
从运行结果,我们可以看出,fork() 之后的打印函数打印了两次,而且打印了两个进程号,这说明,fork()
之后确实创建了一个新的进程,新进程为子进程,原来的进程为父进程。
父子进程关系
上一小节中,我们使用 fork()
函数得到的子进程实际上是父进程的一个复制品,它从父进程处复制了整个进程的虚拟地址空间:包括进程上下文(进程执行活动全过程的静态描述)、进程堆栈、打开的文件描述符、信号控制设定、进程优先级、进程组号等。
子进程所独有的只有它的进程号,计时器等(只有小量信息)。因此,使用 fork() 函数的代价是很大的。
- 理论来说, 一个进程调用
fork()
函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。 - 实际上,更准确来说,Linux 的
fork()
使用是通过写时拷贝 (copy- on-write) 技术来实现克隆自己的。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核一开始并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只有在需要写入的时候才会复制地址空间,从而使各个进程拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享——写时拷贝,读时共享。 fork()
之后父子进程共享文件,fork()
产生的子进程与父进程相同的文件文件描述符指向相同的文件表,引用计数增加,共享文件文件偏移指针。
区分父子进程
子进程是父进程的一个复制品,可以简单认为父子进程的代码一样的。那如果这样的话,父进程做了什么事情,子进程也做什么事情(如上面的例子),是不是不能满足实现多任务的要求(多任务一般是父进程做一件事,子进程做另外一件事,从而实现并发)。
实际上可以通过 fork()
的返回值区别父子进程:fork() 函数被调用一次,但返回两次。两次返回的区别是:子进程的返回值是 0,而父进程的返回值则是新子进程的进程 ID。
测试程序如下:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
pid_t pid;
pid = fork();
if (pid < 0) {
// 没有创建成功
perror("fork");
return 0;
}
if (0 == pid) { // 子进程
while (1) {
printf("I am son\n");
sleep(1);
}
} else if (pid > 0) { // 父进程
while (1) {
printf("I am father\n");
sleep(1);
}
}
return 0;
}
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
I am father
I am son
I am father
I am son
...
^C
yxm@192:~$
运行结果如下:通过运行结果,可以看到,父子进程各做一件事(各自打印一句话)。这里,我们只是看到只有一份代码,实际上,fork()
以后,有两个地址空间在独立运行着,有点类似于有两个独立的程序(父子进程)在运行。
- 一般来说,在 fork() 之后是父进程先执行还是子进程先执行是不确定的。这取决于内核所使用的调度算法。
- 需要注意的是,在子进程的地址空间里,子进程是从
fork()
这个函数后才开始执行代码。 - 父子进程地址空间:父子进程各自的地址空间是独立的,也包括堆区、栈区和全局数据区。只不过在运行的时候,会根据
fork()
函数的返回值不一样而调用不同的代码段。 - 子进程还可以创建新的子进程,形成进程树结构模型:因为子进程是从
fork()
这个函数后才开始执行代码,所以在连续创建子进程或者循环创建子循环时候需要小心,否则创建的子进程数量可能远远超过预想的子进程数量,如下所示
上例中,想要连续创建两个子进程,但实际上创建了三个子进程,因为第一个#include <sys/types.h> #include <unistd.h> // 想要连续创建两个进程 int main() { pid_t pid; // 连续创建两个子进程 fork(); fork(); return 0; }
fork()
之后创建了一个子进程,而该子进程从第一个fork()
函数后才开始执行代码,所以会执行第二个fork()
创建了一个孙进程,再加上父进程执行第二个fork()
函数又会创建新的子进程,一共创建了三个进程
3、进程退出
既然可以创建进程,那如何结束一个进程呢?
- 当遇到 main 函数中的 return 语句时,c++ 程序将停止执行。但其他函数结束时,程序的控制将返回到函数调用之后的位置,程序并不会停止。
- 还有一种方法可以使得程序在
main()
以外的函数中终止,要实现这一点可以使用exit()
函数。
#include <stdlib.h>
void exit(int status);
#include <unistd.h>
void _exit(int status);
功能:
结束调用此函数的进程。
参数:
status:父进程回收子进程资源的时获取进程退出时的一个状态信息(在父子进程中,如果子进程退出了,_exit就能得到子进程退出的状态)。
返回值:无
_exit()
和 exit()
函数功能和用法是一样的,但还是有两点区别:
- 使用时,所包含的头文件不一样;
exit()
属于标准库函数(标准 c 库中的函数),_exit()
属于系统调用函数(linux 系统中的函数)。由于exit()
底层会调用_exit()
函数,其在调用_exit()
函数之前,会做一些安全处理,所以使用exit()
相对于直接使用_exit()
更加安全。系统调用请参考:Linux 常用 API 函数 一文 1.1 小结
exit()
示例:
#include <stdio.h>
#include <stdlib.h>
int main() {
printf("hello\n");
printf("world");
exit(0); // 等价于return 0;
return 0;
}
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
hello
worldyxm@192:~$
_exit()
示例:
#include <stdio.h>
#include <stdlib.h>
int main() {
printf("hello\n");
printf("world");
_exit(0);
return 0;
}
worldyxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
hello
yxm@192:~$
exit()
与 _exit()
的运行结果如下:
- 第一个
printf()
中有\n
换行,带上换行之后,printf()
内部会自动实现刷新缓冲区的功能,所以exit()
示例与_exit()
示例中的 ‘hello’ 都被打印出来了; - 第二个
printf()
中没有\n
换行,且_exit()
的内部没有刷新缓冲区,所以_exit()
示例中的 ‘world’ 遗留在缓冲区,在还没来得及输出到标准输出文件(stdout)的情况系,程序就已经结束, ‘world’ 没有来得及打印。
常用调用
exit(1)
表示进程正常退出,返回 1exit(0)
表示进程非正常退出,返回 0.
4、进程回收
进程回收概述
在每个进程退出的时候,内核释放该进程所有的资源、包括打开的文件、占用的内存等。但是仍然为其保留一定的信息,这些信息主要是进程控制块 PCB 的信息(包括进程号、退出状态、运行时间等)。
父进程可以通过调用 wait()
或 waitpid()
等到它的子进程退出状态同时彻底清除掉这个进程。所以子进程运行结束或者进程退出时,父进程有义务回收子进程的资源。
wait()
和 waitpid()
函数的功能一样,区别在于,wait()
函数会阻塞,waitpid()
可以设置不阻塞,waitpid()
还可以指定等待哪个子进程结束,下文会详细介绍。
【注意】一次 wait()
或 waitpid()
调用只能清理一个子进程,清理多个子进程要使用循环。
进程回收 API
(1)wait函数
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
功能:
等待任意一个子进程结束,如果任意一个子进程结束了,此函数会回收该子进程的资源。
参数:
status:进程退出时的状态信息,本参数是传出参数。
返回值:
成功:已经结束子进程的进程号
失败:返回 -1, 并设置errno,一般失败的原因:
1、没有任何子进程;
2、所有的子进程都已结束;
3、函数调用函数失败。
- 调用 wait() 函数的进程会挂起(阻塞),直到它的一个子进程退出或收到一个不能被忽视的信号时才被唤醒(相当于继续往下执行)。
- 若调用进程没有子进程,函数立刻返回,返回-1;若它的子进程已经结束,也会立即返回,返回-1。
- 如果参数 status 的值不是 NULL,
wait()
就会把子进程退出时的状态取出并存入其中,这是一个整数值(int),指出了子进程是正常退出还是被非正常结束的。这个退出信息在一个 int 中包含了多个字段,直接使用这个值是没有意义的,我们需要用宏定义取出其中的每个字段。退出信息相关宏函数可分为如下三组:- WIFEXITED(status) 为非0 → 进程正常结束
WEXITSTATUS(status) 如上宏为真,使用此宏 → 获取进程退出状态 - WIFSIGNALED(status) 为非0 → 进程异常终止
WTERMSIG(status) 如上宏为真,使用此宏 → 取得使进程终止的那个信号的编号。 - WIFSTOPPED(status) 为非0 → 进程处于暂停状态
WSTOPSIG(status) 如上宏为真,使用此宏 → 取得使进程暂停的那个信号的编号。
WIFCONTINUED(status)如上宏为真 → 进程暂停后已经继续运行
- WIFEXITED(status) 为非0 → 进程正常结束
示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
int status = 0;
int i = 0;
int ret = -1;
pid_t pid = -1;
// 创建子进程
pid = fork();
if (pid < 0) {
// 没有创建成功
perror("fork");
return -1;
}
if (0 == pid) {
// 子进程
for (i =0; i < 5; i++) {
printf("child process do thing %d\n", i + 1);
sleep(1);
}
exit(10); //子进程终止
}
// 父进程执行
printf("父进程等待子进程退出,回收其资源\n");
ret = wait(&status); // 父进程在此处会阻塞,等待子进程退出,返回值为exit函数的参数
if (-1 == ret) {
perror("wait");
return -1;
}
printf("父进程回收了子进程资源\n");
if (WIFEXITED(status)) {
//属于正常退出
printf("子进程退出状态码:%d\n", WEXITSTATUS(status));
}
else if (WIFSIGNALED(status)) {
//属于异常终止退出
printf("子进程被信号%d杀死了...\n", WTERMSIG(status));
}
else if (WIFSTOPPED (status)) {
//属于进程暂停
printf("子进程被信号%d暂停...\n", WSTOPSIG(status ));
}
return 0;
}
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
父进程等待子进程退出,回收其资源
child process do thing 1
child process do thing 2
child process do thing 3
child process do thing 4
child process do thing 5
父进程回收了子进程资源
子进程退出状态码:10
(2)waitpid函数
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
功能:
等待指定进程号终止,如果子进程终止了,此函数会回收子进程的资源,可以设置是否阻塞
参数:
pid:参数 pid 的值有以下几种类型:
pid > 0 某个子进程的进程号,相当于等待并回收指定子进程。
pid = 0 等待并回收同一个进程组中的任何子进程,如果子进程已加入了别的进程组,waitpid不会等待它。
pid = -1 等待并回收任一子进程,此时 waitpid 和 wait 作用一样(最常用)。
pid < -1 等待并回收指定进程组中的任何子进程,这个进程组的 ID 等于 pid 的绝对值。
status:进程退出时的状态信息。和 wait() 用法一样。
options:options提供了一些额外的选项来控制 waitpid()。
0:同 wait() 一样,阻塞父进程,等待子进程退出。
WNOHANG:非阻塞。
WUNTRACED:如果子进程暂停了则此函数马上返回,并且不予以理会子进程的结束状态。(由于涉及到一些跟踪调试方面的知识,加之极少用到)
返回值:waitpid() 的返回值比 wait() 稍微复杂一些,一共有 3 种情况:
1) 当正常返回的时候,waitpid() 返回收集到的已经回收子进程的进程号,则返回 > 0;
2) 如果设置了选项 WNOHANG,而调用中 waitpid() 发现没有已退出的子进程可等待,则返回 0;
3) 如果调用中出错,则返回-1,这时 errno 会被设置成相应的值以指示错误所在,如:当 pid 所对应的子进程不存在,或此进程存在,但不是调用进程的子进程, waitpid() 就会出错返回,这时 errno 被设置为 ECHILD;
示例:
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
// 有一个父进程,创建5个子进程(兄弟)
pid_t pid;
// 创建5个子进程
for(int i = 0; i < 5; i++) {
pid = fork();
if(pid == 0) {
// 子进程还会创建新的子进程,所以需要判断语句以保证只会创建 5 个子进程
break;
}
}
if(pid > 0) {
// 父进程
while(1) {
printf("parent, pid = %d\n", getpid());
sleep(1);
int st;
// int ret = waitpid(-1, &st, 0);
int ret = waitpid(-1, &st, WNOHANG);
if(ret == -1) {
break;
} else if(ret == 0) {
// 说明还有子进程存在
continue;
} else if(ret > 0) {
if(WIFEXITED(st)) {
// 是不是正常退出
printf("退出的状态码:%d\n", WEXITSTATUS(st));
}
if(WIFSIGNALED(st)) {
// 是不是异常终止
printf("被哪个信号干掉了:%d\n", WTERMSIG(st));
}
printf("child die, pid = %d\n", ret);
}
}
}
else if (pid == 0) {
// 子进程
while(1) {
printf("child, pid = %d\n",getpid());
sleep(1);
}
exit(0);
}
return 0;
}
5、进程替换方法
在 windows 平台下,我们可以通过双击运行可执行程序,让这个可执行程序成为一个进程;而在 linux 平台,我们可以通过 ./ 让一个可执行程序成为一个进程。
但是,如果我们本来就运行着一个程序(进程),如何在这个进程内部启动一个外部程序,由内核将这个外部程序读入内存,使其执行起来成为一个进程呢?这里就可以通过进程替换相关的 API 来实现!
进程替换之库函数
Linux 下我们可以通过库函数实现进程替换—— exec 函数族。
进程替换 API
exec 函数族是一簇函数,Linux 中并不存在 exec()
函数,exec 指的是一组函数,一共有 6 个,其中使用最多的是 execl()
和 execlp()
:
#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[]);
int execve(const char *filename, char *const argv[], char *const envp[]);
【注意】exec函数族的参数都是 const char *,不是 std::string 类型
(1)exec 函数族的作用是根据指定的文件名或目录名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。
(2)exec 函数族与一般的函数不同,exec 函数族中的函数执行成功后不会返回,而且,exec 函数族后面的代码执行不到。只有调用失败才会返回 -1,失败后从原程序的调用点接着往下执行。
(3)exec 函数族使用说明:exec 函数族的 6 个函数看起来似乎很复杂,但实际上无论是作用还是用法都非常相似,只有很微小的差别。
参数类型 | 说明 |
---|---|
l(list) | 参数地址列表,以空指针结尾 |
v(vector) | 存有各参数地址的指针数组的地址 |
p(path) | 按 PATH 环境变量指定的目录搜索可执行文件 |
e(environment) | 存有环境变量字符串地址的指针数组的地址 |
函数名 | 参数传递形式 | 路径 | 是否导入环境变量 |
---|---|---|---|
execl | 列表 | 需要可执行程序路径 | 不导入 使用当前环境变量 |
execlp | 列表 | 默认在环境变量中找 | 不导入 使用当前环境变量 |
execle | 列表 | 需要可执行程序路径 | 导入 使用导入的环境变量 |
execv | 数组 | 需要可执行程序路径 | 不导入 使用当前环境变量 |
execvp | 数组 | 默认在环境变量中找 | 不导入 使用当前环境变量 |
execve | 数组 | 需要可执行程序路径 | 导入 使用导入的环境变量 |
(4)事实上,只有 execve 是真正意义上的系统调用,其它都是在此基础上经过包装的库函数,即其他五个函数最终都调用 execve。
进程替换原理
进程替换不会创建新的进程,进程 PCB未发生改变,进程实体(数据代码内容)被替换:进程调用 exec 函数时,该进程完全由新程序替换,而新程序则从其 main 函数开始执行。因为调用 exec 并不创建新进程,所以前后的进程 ID (当然还有父进程号、进程组号、当前工作目录……)并未改变。exec 只是用另一个新程序替换了当前进程的正文、数据、堆和栈段(进程替换)。
进程替换实例
// main程序,main程序中再进行进程替换成ps -f程序
#include <stdio.h>
#include <string.h>
#include <unistd.h>
// ls -l /home
int main ()
{
printf("hello itcast\n");
// arg0 arg1 arg2 .... argn
// arg0一般是可执行文件名, argn必须是NULL
//execlp("ls", "ls", "-l", "/home", NULL);
// 第一个参数是可执行文件的相对路径或者绝对路径
// 第二个参数是可执行文件的名字
// 中间的参数是可执行文件的参数
// 最后一个参数必须是NULL
//execl("/bin/ls", "ls", "-l", "/home", NULL);
// 第一个参数是可执行文件的名字
// 第二个参数是指针数组,最后一定以NULL结束
// char *argv[] = {"ls", "ls", "-l", "/home", NULL};
// execvp("ls", argv);
// 最后一个参数是环境变量指针数组,最后一定以NULL结束
char *envp[] = {"ADDR=BEIJING", NULL};
execle("ls", "ls", "-l", "/home", NULL, envp);
printf("hello world\n"); // 注意:如果进程替换执行成功,本行不会被执行
return 0;
}
当然我们也可以用 fork 创建子进程后,主进程继续执行原有程序,子进程调用一种 exec 函数以执行另一个程序。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
char *argv[8];
int argc = 0;
void do_parse(char *buf) {
int i;
int status = 0;
for(argc=i=0; buf[i]; i++) {
if(!isspace(buf[i]) && status == 0) {
argv[argc++] = buf+i;
status = 1;
} else if (isspace(buf[i])) {
status = 0;
buf[i] = 0;
}
}
argv[argc] = NULL;
}
void do_execute(void) {
pid_t pid = fork();
switch(pid) {
case -1;
perror("fork");
exit(EXIT_FAILURE);
break;
case 0;
execvp(argv[0], argv);
perror("execvp");
exit(EXIT_FAILURE);
default:
{
int st;
while(wait(&st) != pid)
;
}
}
}
int main() {
char buf[1024] = {};
while(1) {
printf("myshell>");
scanf("%[^\n]%*c", buf);
do_parse(buf);
do_execute();
}
}
替换的过程
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
进程替换之系统调用
除了 exec 函数族之外,还有另外一种方法可以在一个进程内部启动一个外部程序—— system 系统调用。
函数介绍
先来看一下 system()
函数的简单介绍:
//头文件
#include <stdlib.h>
//函数定义
int system(const char * string);
参数:被请求变量名称的 C 字符串。
返回值:如果发生错误,则返回值为 -1,否则返回命令的状态。
system()
会调用fork()
产生子进程,由子进程来调用 /bin/sh-cstring 来执行参数 string 所代表的命令。此命令执行完后随即返回原调用的进程。- 在调用
system()
期间 SIGCHLD 信号会被暂时搁置,SIGINT 和 SIGQUIT 信号则会被忽略。 - 返回值:
- 如果
fork()
失败返回 -1:出现错误 - 如果 exec 失败,表示不能执行 Shell,返回值相当于 Shell 执行了exit(127)
- 如果执行成功则返回子进程的终止状态,即父进程
waitpid()
函数获得的子进程的返回状态。
- 如果
函数原理
为了更好的理解 system () 函数返回值,做好出错处理,需要了解其执行过程,实际上 system () 函数执行了三步操作:
- fork 一个子进程;如果对于 fork 失败,system () 函数返回 - 1。
- 在子进程中调用 exec 函数去执行 command;
- 如果 exec 执行成功,也即 command 顺利执行完毕,则返回 command 通过 exit 或 return 返回的值。【注意】command 顺利执行不代表执行成功,比如 command:“rm debuglog.txt”,不管文件存不存在该 command 都顺利执行了。
- 如果 exec 执行失败,也即 command 没有顺利执行,比如被信号中断,或者 command 命令根本不存在,system () 函数返回 127。
- 如果 command 为 NULL,则 system () 函数返回非 0 值,一般为 1.
- 在父进程中调用 wait 去等待子进程结束。
看一下 system () 函数的源码
int system(const char * cmdstring) {
pid_t pid;
int status;
if(cmdstring == NULL) {
return (1); //如果cmdstring为空,返回非零值,一般为1
}
if((pid = fork())<0) {
status = -1; //fork失败,返回-1
} else if(pid == 0) {
execl("/bin/sh", "sh", "-c", cmdstring, (char *)0);
_exit(127); // exec执行失败返回127,注意exec只在失败时才返回现在的进程,成功的话现在的进程就不存在啦~~
} else {
//父进程
while(waitpid(pid, &status, 0) < 0) {
if(errno != EINTR) {
status = -1; //如果waitpid被信号中断,则返回-1
break;
}
}
}
return status; //如果waitpid成功,则返回子进程的返回状态
}
exec 函数族与 system()
的区别
- 执行 exec 族中某个函数后,老的进程上下文将被 exec 出来的新的进程上下文覆盖,新进程代替原进程执行。
- 执行
system()
后则相当于fork()
出一个子进程,并等待此子进程执行完毕。所以system()
只能在一个进程内部启动一个外部程序,但是并不能真正替换原来的进程。实际开发中,建议使用system()
,因为system()
会创建子进程,更加安全(当然效率比 exec 低一些)。
四、孤儿进程与僵尸进程
1、孤儿进程
什么是孤儿进程?
父进程运行结束,但子进程还在运行(未运行结束),这样的子进程就称为孤儿进程(Orphan Process)
每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为 init ,而 init 进程会循环地 wait()
它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init 进程就会代表党和政府出面处理它的一切善后工作。 因此孤儿进程并不会有什么危害。
总之:孤儿进程就是父进程退出了,但子进程还在执行。
示例:
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
pid_t pid = -1;
// 创建子进程
pid = fork();
if (pid < 0) {
// 没有创建成功
perror("fork");
return 1;
}
// 父进程
if (pid > 0) {
printf("父进程休息3秒后退出。。。\n");
printf("父进程: pid:%d\n", getpid());
sleep(1);
printf("父进程等太累了,现退出了。。。\n");
exit(0);
}
while (1) {
printf("子进程不停的工作,子进程:pid:%d,父进程:ppid:%d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
运行结果:
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
父进程休息3秒后退出。。。
父进程: pid:32075
子进程不停的工作,子进程:pid:32076,父进程:ppid:32075
父进程等太累了,现退出了。。。
子进程不停的工作,子进程:pid:32076,父进程:ppid:32075
yxm@192:~$ 子进程不停的工作,子进程:pid:32076,父进程:ppid:1 # 终端可以输入,同时有数据在输出
子进程不停的工作,子进程:pid:32076,父进程:ppid:1
子进程不停的工作,子进程:pid:32076,父进程:ppid:1
- 一般情况下,运行一个程序时,默认会切换到后台运行,当有输出的时候再切换到前台。
如上面的运行结果所示:创建子进程后,子进程复制了父进程内核部分的某些数据(比如标准输入、标准输出、标准错误),所以父进程和子进程的标准输出都是当前终端。又因为父进程是前台进程,所以会占用当前终端,但是父进程死亡后,终端占用被解除,但是子进程(变成孤儿进程)没有死亡,其标准输出依旧是当前终端。最终形成了,终端可以输入,同时有数据在输出的特殊情况。 - 【注意】ubuntu 系统中,字节界面中,产生的孤儿进程会被 1 号( 即 init 进程)进程收养,但是在图形界面中孤儿进程会被非1号进程收养。
2、僵尸进程
僵尸进程介绍
每个进程结束之后, 都会释放自己地址空间中的用户区数据,内核区的 PCB 没有办法自己释放掉(子进程残留资源(PCB)存放于内核中),需要父进程去释放,进程终止时,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。
僵尸进程不能被 kill -9 杀死,这样就会导致一个问题,如果父进程不调用 wait()
或 waitpid()
的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害,应当避免。
总之:僵尸进程就是子进程结束了,但父进程没有回收其资源。
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
int i = 0;
pid_t pid = -1;
// 创建子进程
pid = fork();
if (-1 == pid) {
// 没有创建成功
perror("fork");
return 1;
}
// 子进程
if (0 == pid) {
for (int i = 0; i < 5; i++) {
printf("子进程做事%d\n", i);
sleep(1);
}
printf("子进程想不开,结束了自己。。。。\n");
exit(0);
} else if (pid > 0) {
while(1) {
printf("父进程休眠了, pid : %d, ppid : %d\n", getpid(), getppid());
sleep(1);
}
}
return 0;
}
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
父进程休眠了, pid : 33087, ppid : 30344
子进程做事0
父进程休眠了, pid : 33087, ppid : 30344
子进程做事1
子进程做事2
父进程休眠了, pid : 33087, ppid : 30344
父进程休眠了, pid : 33087, ppid : 30344
子进程做事3
子进程做事4
父进程休眠了, pid : 33087, ppid : 30344
父进程休眠了, pid : 33087, ppid : 30344
子进程想不开,结束了自己。。。。
父进程休眠了, pid : 33087, ppid : 30344
^C
yxm@192:~$ ps -aux
...
...
yxm 33087 0.0 0.0 4516 756 pts/0 S+ 00:10 0:00 ./test
yxm 33088 0.0 0.0 0 0 pts/0 Z+ 00:10 0:00 [test] <defunct>#僵尸进程
yxm 33125 0.0 0.0 7476 832 ? S 00:10 0:00 sleep 180
yxm 33180 0.0 0.1 37860 3420 pts/1 R+ 00:10 0:00 ps -aux
解决办法
方式一:僵尸进程的产生是因为父进程没有 wait()
子进程。所以如果我们自己写程序的话一定要,最好在父进程中通过 wait()
和 waitpid()
来避免僵尸进程的产生。
方式二:当系统中出现了僵尸进程时,我们是无法通过 kill 命令把它清除掉的。但是我们可以杀死它的父进程,让它变成孤儿进程,并进一步被系统中管理孤儿进程的进程收养并清理。具体步骤如下:
- 首先,需要确定僵尸进程的相关信息,比如父进程 ppid、僵尸进程的 pid 以及命令行等信息。可以执行如下命令:
参数说明:ps -e -o stat,ppid,pid,cmd | egrep '^[Zz]'
- -e:参数用于列出所有的进程;
- -o:参数用于设定输出格式,这里只输出进程的stat(状态信息)、ppid(父进程pid)、pid(当前进程的pid),cmd(进程的可执行文件);
- egrep:是linux下的正则表达式工具:
- ‘^’:这是正则表达式,表示第一个字符的位置
- [Zz],表示 z 或者大写的 Z 字母,即表示第一个字符为 Z 或者 z 开头的进程数据,因为僵尸进程的状态信息以 Z 或者 z 字母开头。
- 然后,可以
kill -9 父进程 pid
。kill 之后,僵尸进程将被 init 进程收养并清理
【补充】现在大多数 linux 系统,会将僵尸进程标识为 defunct,所以也可以通过如下命令来获取僵尸进程信息:
ps -ef | grep "defunct"
3、总结
孤儿进程与僵尸进程是两种特殊的进程,一种是父进程先退出,子进程变成孤儿,这种进程没有危害;一种是子进程先退出,父进程没有回收资源导致子进程变成僵尸,会占用系统资源。他们都发生过在父子进程之间。