Linux 进程基础

一、进程概述

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 &

参考文章1
参考文章2

三、进程管理 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) 表示进程正常退出,返回 1
  • exit(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 中包含了多个字段,直接使用这个值是没有意义的,我们需要用宏定义取出其中的每个字段。退出信息相关宏函数可分为如下三组:
    1. WIFEXITED(status) 为非0 → 进程正常结束
      WEXITSTATUS(status) 如上宏为真,使用此宏 → 获取进程退出状态
    2. WIFSIGNALED(status) 为非0 → 进程异常终止
      WTERMSIG(status) 如上宏为真,使用此宏 → 取得使进程终止的那个信号的编号。
    3. WIFSTOPPED(status) 为非0 → 进程处于暂停状态
      WSTOPSIG(status) 如上宏为真,使用此宏 → 取得使进程暂停的那个信号的编号。
      WIFCONTINUED(status)如上宏为真 → 进程暂停后已经继续运行

示例

#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 () 函数执行了三步操作:

  1. fork 一个子进程;如果对于 fork 失败,system () 函数返回 - 1。
  2. 在子进程中调用 exec 函数去执行 command;
    1. 如果 exec 执行成功,也即 command 顺利执行完毕,则返回 command 通过 exit 或 return 返回的值。【注意】command 顺利执行不代表执行成功,比如 command:“rm debuglog.txt”,不管文件存不存在该 command 都顺利执行了。
    2. 如果 exec 执行失败,也即 command 没有顺利执行,比如被信号中断,或者 command 命令根本不存在,system () 函数返回 127。
    3. 如果 command 为 NULL,则 system () 函数返回非 0 值,一般为 1.
  3. 在父进程中调用 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
参考文档2

四、孤儿进程与僵尸进程

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 命令把它清除掉的。但是我们可以杀死它的父进程,让它变成孤儿进程,并进一步被系统中管理孤儿进程的进程收养并清理。具体步骤如下:

  1. 首先,需要确定僵尸进程的相关信息,比如父进程 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 字母开头。
  2. 然后,可以 kill -9 父进程 pid。kill 之后,僵尸进程将被 init 进程收养并清理

【补充】现在大多数 linux 系统,会将僵尸进程标识为 defunct,所以也可以通过如下命令来获取僵尸进程信息:

ps -ef | grep "defunct"

3、总结

孤儿进程与僵尸进程是两种特殊的进程,一种是父进程先退出,子进程变成孤儿,这种进程没有危害;一种是子进程先退出,父进程没有回收资源导致子进程变成僵尸,会占用系统资源。他们都发生过在父子进程之间。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值