[Linux系统编程] 第一章:多进程 (详细解析)

 参考:

  1. 《鸟叔的Linux私房菜》
  2. 并发与并行的区别-CSDN博客
  3. 【Linux】进程概念——父子进程、僵尸进程和孤儿进程-CSDN博客

PID (Process ID)

  • 含义:PID 是进程标识符(Process Identifier),它是操作系统分配给每个进程的一个唯一数字,用于标识进程。
  • 用途:PID 用于跟踪和管理进程。你可以使用 PID 来查找有关进程的信息,或者向进程发送信号(如 SIGTERM 或 SIGKILL)来控制进程的行为。

UID (User ID)

  • 含义:UID 是用户标识符(User Identifier),它是一个整数,用于标识系统中的用户。
  • 用途:每个进程都有一个 UID,表示拥有该进程的用户。UID 决定了进程的权限和访问级别。例如,超级用户的 UID 通常是 0。

GID (Group ID)

  • 含义:GID 是组标识符(Group Identifier),它也是一个整数,用于标识系统中的用户组。
  • 用途:每个进程也有一个 GID,表示该进程所属的用户组。这影响了进程对文件和资源的访问权限。

一、进程基础

1.1 什么是进程(process)

        在Linux系统中,每当一个事件被触发时,系统都会创建一个新的进程,并为这个进程分配一个唯一的标识符,称为进程ID(PID)。PID是操作系统用来唯一标识进程的数字。与此同时,系统会根据触发该进程的用户身份及其相关属性,为该进程设置一组有效的权限。这意味着,从这一刻起,这个PID能够在系统上执行的操作范围就与其权限紧密相关。权限的设置确保了进程只能访问那些它被授权访问的资源,从而保障了系统的安全性和稳定性。例如,如果一个普通用户触发了一个进程,那么该进程通常将继承该用户的权限,这意味着它只能执行与该用户权限相匹配的操作,如访问特定的文件或执行特定的命令。

1.2 进程与程序 (process & program)

·        那要怎么产生一个进程呢?【执行一个程序或命令】就可以触发一个事件而获取一个PID。系统本质上只认识二进制文件(binary files),因此当我们想要让系统执行特定任务时,就需要启动一个二进制文件。这个二进制文件就是我们所说的程序(program)。

        那我们知道,每个进程都有三组权限,每组都具有r,w,x的权限,所以【不同用户身份执行这个程序时,系统给予的权限也都不同】。举个例子,我们可以使用 touch 命令来创建一个空文件。当 root 用户执行这个 touch 命令时,他所获得的是 UID/GID = 0/0 的权限,这意味着他具有最高的系统权限。而当 eric 用户(假设其 UID/GID=501/501)执行相同的 touch 命令时,他所获得的权限则与 root 用户不同,即受限于其自身的权限设置。

        程序一般是放置在物理磁盘中,然后通过用户的执行来触发。触发后会加载到内存中成为一个个体,那就是进程。为了让操作系统可以管理这个进程,进程会给予执行者权限/属性等参数,以及进程所需要的脚本或数据等,最后再给予一个PID。操作系统通过这个PID来判断该进程是否具有执行权限。如下图所示。

        程序是一系列指令和数据的集合,它是静态的。在计算机科学中,程序是指令集的有序排列,这些指令用来指导计算机执行特定任务或计算。程序本身并不直接参与任何计算活动,而是需要通过某种方式(如编译或解释)转换成计算机可以执行的形式。

        进程是程序在计算机上的执行过程,它是一个动态的概念。当一个程序被加载到内存中并开始执行时,它就成为了一个进程。每个进程都有其独立的地址空间,其中包括几个关键部分:

  • 文本区域:存储实际的机器代码。
  • 数据区域:存储全局变量和静态变量,以及动态分配的内存。
  • 堆栈区域:用于管理函数调用的上下文,包括函数参数、局部变量和返回地址等。

        进程不仅包含了程序的指令集,还包括了运行时的状态信息,如寄存器值、内存映射等。此外,进程还可以与其他进程进行通信和同步,以完成更复杂的任务。

总结来说:

  • 程序是一个静态的指令集合,是构成软件的基础。
  • 进程是程序在某个时刻的执行实例,是操作系统资源分配和调度的基本单位。

1.4 什么是子进程与父进程?

        在 Unix-like 操作系统(如 Linux)中,每当一个程序启动另一个程序时,就会形成一种父子进程的关系。这种关系是由操作系统通过创建新的进程来实现的。新创建的进程称为“子进程”,而启动它的原始进程则称为“父进程”。

        父进程负责启动子进程,并通常会监控子进程的状态,比如等待子进程结束或接收子进程的信号。父进程还可以向子进程发送信号来控制其行为,例如终止或暂停。此外,父进程可以向子进程传递环境变量,使子进程能够访问父进程的环境设置。每个父进程都有一个唯一的进程标识符(PID)。

        子进程继承父进程的一些属性,例如环境变量、文件描述符等。尽管如此,子进程是一个独立的进程,拥有自己的地址空间,这意味着它可以独立于父进程执行特定的任务。每个子进程也有一个唯一的进程标识符(PID),并且它的父进程标识符(PPID)等于父进程的 PID。

1.5 进程之间的调用

        Linux的程序调用通常称为 fork-and-exec 的流程。首先,父进程通过 fork() 创建一个与自身几乎完全相同的子进程;然后,子进程利用 exec() 来替换自身的映像,执行实际的目标程序。

        fork() 是一个特殊的系统调用,它将一个已存在的进程复制成两个完全相同的进程。在英语中,“fork” 可以译作 “叉子”。当我们调用 fork() 时,程序的内存空间会发生变化。在调用 fork() 之前,程序只有一个内存空间。然而,当调用 fork() 时,操作系统会创建一个新的进程(子进程),并为其分配一份独立的内存空间。这样做是为了确保子进程与父进程之间保持数据隔离,防止彼此之间的干扰。

        在子进程中,我们可以使用 exec() 系列函数(如 execve()execlp())来替换当前进程的映像,执行一个全新的程序。这通常发生在子进程中,因为父进程可能不需要改变自己的执行路径。exec() 不会创建新的进程,而是直接替换当前进程的内容,使其执行指定的程序。

        在计算机科学中,“映像”(Image)是指一个程序在内存中的完整表示,包括程序代码、数据、全局变量以及其他资源。当一个程序被加载到内存中并开始执行时,它就在内存中形成了一个映像。这个映像包含了程序的所有组成部分,使程序能够在内存中正常运行。

        在 Unix-like 操作系统(如 Linux)中,一个程序的映像通常包含以下几个部分:

  1. 可执行代码:这部分包含了程序的实际指令,告诉 CPU 如何执行操作。
  2. 数据区:存储程序运行所需的静态数据和全局变量。
  3. :动态分配的内存区域,用于存放程序运行时申请的内存块。
  4. :保存局部变量和函数调用相关信息。
  5. 库函数:程序依赖的外部函数,如标准 C 库或其他库函数。
  6. 环境变量:程序运行时使用的环境设置。
  7. 文件描述符:程序打开的文件和设备的引用。

        当一个进程调用 exec() 系统调用时,它会替换当前进程的映像,也就是说,它会把当前正在执行的程序替换成一个新的程序。这个新的程序将会被加载到内存中,覆盖掉原来的代码、数据和其他资源。这样一来,子进程就可以执行新的程序,而不需要重新创建一个进程。

        exec() 的主要功能是让一个进程能够动态地改变自己的行为,就像穿上了不同的“衣服”一样。例如,一个简单的脚本可以调用多个不同的应用程序,而无需每次都创建新的进程。此外,exec() 还可以用于启动守护进程,这些进程通常在后台运行,持续提供服务。 

        总结起来,映像是一个程序在内存中的完整表示,而 exec() 则是用来替换这个映像的系统调用。通过 exec(),一个进程可以改变自己的身份,执行其他程序,从而实现灵活性和高效性。 

 1.6 验证父子程序的运行

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    int pid;
	
    pid = fork(); //  fork **********
    if (pid < 0) {
        fprintf(stderr, "Fork failed!\n");
        exit(1);
    }
    else if (pid == 0) {
        // 子进程 P1
        printf("I am P1 with PID %d ,my father is %d\n", getpid(),getppid());

        pid = fork(); //  fork **********
        if (pid < 0) {
            fprintf(stderr, "Fork failed!\n");
            exit(1);
        }
        else if (pid == 0) {
            // 子进程 P2
            printf("I am P2 with PID %d ,my father is %d\n", getpid(),getppid());
        }
        else if(pid > 0){
            // 子进程 P3
            printf("I am P3 with PID %d ,my father is %d\n", getpid(),getppid());
        }
    }
    else if(pid > 0){
        // 父进程 P0
        printf("I am P0 with PID %d ,my father is %d\n", getpid(),getppid());

        pid = fork();  //  fork **********
        if (pid < 0) {
            fprintf(stderr, "Fork failed!\n");
            exit(1);
        }
        else if (pid == 0) {
            // 子进程 P4
            printf("I am P4 with PID %d ,my father is %d\n", getpid(),getppid());

            pid = fork(); //  fork **********
            if (pid < 0) {
                fprintf(stderr, "Fork failed!\n");
                exit(1);
            }
            else if (pid == 0) {
                // 子进程 P5
                printf("I am P5 with PID %d ,my father is %d\n", getpid(),getppid());
            }
            else if(pid > 0){
                // 子进程 P6
                printf("I am P6 with PID %d ,my father is %d\n", getpid(),getppid());
            }
        }
    }
	
	exit(1);
    return 0;
}

         下面的树形图是对应代码中的关系:

P0 (PID: 264)
|--- P1 (PID: 265)
     |--- P2 (PID: 267)
     |
     \--- P3 (PID: 265)
|
\--- P4 (PID: 266)
     |--- P5 (PID: 268)
     |
     \--- P6 (PID: 266)

        我们通过这个代码来看看,子进程是怎么样运行程序的。进过编译后的输出如下图所示:

根据输出我们可以看出有三个问题:

第一个问题:为什么 P0 的 ppid 是 121?

        这是因为 P0 是由另一个进程创建的,它的父进程的 PID 是 121。通常情况下,系统的第一个进程(称为 init 或 systemd)的 PID 是 1,它是所有其他进程的祖先。因此,如果我们没有特别设置过,那么 P0 的父进程就是这个 init 进程。

        pid 为 1 通常表示系统的初始进程,也称为 init 进程。它是所有其他进程的祖先,负责启动系统服务和其他必要的初始化任务。在 Linux 中,init 进程通常是 systemd,在某些旧版本的 Unix 系统中则是 init。

第二个问题:为什么 P4 的 ppid 是 1?

        理论上来讲 P4 的父进程应该是 P0,所以它的 ppid 应该是 P0 的 PID(即 264)。如果 P4 的 ppid 显示为 1,则说明可能存在一些异常情况,比如 P0 已经退出,导致 P4 成为了孤儿进程,被 init 进程收养。init 进程会接管孤儿进程,将其视为自己的孩子,因此它们的 ppid 将变为 1。

第三个问题: 为什么P0-P6不是按顺序输出?

        我们明显看到我们写代码的时候是按顺序P0-P6写的,但是实际输出的顺序却是乱的,这是因为这里涉及到了程序的并发, 以及这里提到了孤儿进程的概念,后续我们会讲解。

1.7 为什么有父子进程? 

        在Linux系统中,进程是程序执行的基本单位。一个进程可以创建新的进程,新创建的进程称为原进程的子进程,而创建子进程的进程则称为父进程。

        我们可以看到每个进程都有自己的独立地址空间和资源(如文件描述符、环境变量等),子进程继承了父进程的部分属性和资源,但它们在内存中是独立的,这样可以避免一个进程中的错误影响到另一个进程,这就实现了资源的隔离当一个进程需要做一些可能发生阻塞或中断的任务,父进程可通过子进程去做,来避免自己进程的崩溃。因此子进程也被称为父进程的守护进程。    

二、查看进程列表

2.1 ps -l 显示详细格式

ps -l    # 查看当前正在运行的进程列表

  •  F: 表示进程的状态。在这个例子中,第一个进程(bash)的状态是 "S",表示它处于睡眠状态(即等待 I/O 操作完成);第二个进程(ps)的状态是 "R",表示它正在运行。
  • S: 表示进程的类型。在这个例子中,所有的进程都是普通进程(S)。
  • UID: 显示进程所有者的用户 ID。在这张图片里,两个进程的 UID 都是 1001,表明它们属于同一个用户。
  • PID: 显示进程的进程 ID。第一个进程的 PID 是 4522,第二个进程的 PID 是 4646。
  • PPID: 显示进程的父进程 ID。第一个进程没有父进程(因为它是根进程),所以显示为 0;第二个进程的父进程是第一个进程,所以它的 PPID 是 4522。
  • C: 表示 CPU 使用率。数值越接近 100%,表示进程占用的 CPU 时间越多。
  • PRI: 显示进程的优先级。数值越高,表示进程的优先级越低。
  • NI: 显示进程的 nice 值。nice 值决定了进程的优先级,正值表示降低优先级,负值表示提高优先级。
  • ADDR: 显示进程使用的虚拟内存地址空间。在这个例子中,显示为 "-",可能是因为它不在屏幕上完整显示。
  • SZ: 显示进程使用的物理内存大小,单位是 KB。
  • WCHAN: 显示进程当前挂起的位置。在这个例子中,第一个进程(bash)挂起在 ttyp2 上,第二个进程(ps)挂起在自身上。
  • TTY: 显示进程所在的终端设备。在这个例子中,两个进程都在相同的终端设备 ttyp2 上。
  • TIME: 显示进程消耗的 CPU 总时间。在这个例子中,两个进程都没有消耗 CPU 时间。
  • CMD: 显示进程的名称和参数。第一个进程是 bash,第二个进程是 ps。

2.2 ps axj 以 Job Control 格式显示所有进程

ps axj

  •  PID:进程ID
  • PPID:父进程ID
  • PGID:进程组ID
  • SID:会话 ID
  • TTY:终端类型
  • TPGID:终端所属进程组ID
  • STAT:状态
  • TIME:CPU实际使用时间
  • CMD:命令名及参数

2.3 实时动态的进程监控工具 top   

        top 是一个实时动态的进程监控工具,它可以显示系统资源使用情况(例如 CPU 使用率、内存使用量等),并且能够实时更新进程列表。用户可以通过交互式操作来排序、过滤和管理进程。

top

        我们自己常用的一般是 ps axj 和 top

三、进程的组成与运行

3.1 进程的组成

        进程是程序在一个计算机系统中的执行实例,它包含了一系列的组件和数据结构,这些组件共同构成了进程的完整状态。首先,每个进程都有一个程序代码段,这部分存储了程序的实际机器代码,即程序执行时所需的指令集。程序代码段通常分为两个主要部分:文本区域和数据区域。文本区域包含了程序的机器指令,而数据区域则用于存储全局变量和静态变量。

        除了程序代码外,进程还包含一个进程控制块(PCB),它是操作系统用来管理进程的数据结构。PCB 中包含了有关进程的重要信息,例如进程标识符(PID)、状态信息(如运行、就绪或阻塞状态)、CPU寄存器值(如程序计数器、通用寄存器等)、内存管理信息(如页表或段表)、时间信息(如CPU时间和等待时间)、I/O状态、父进程和子进程的标识符、优先级、信号和信号处理函数以及进程间的通信信息。

        进程还包括一个或多个内存区域,这些区域用于存储程序执行时的各种数据。其中最重要的内存区域是堆栈区域,它用于存储函数调用时的局部变量和函数调用的上下文,如参数、返回地址等。此外,还有堆区域,它用于存储程序运行时动态分配的对象和数据结构。数据区域则用于存储全局变量和静态变量。

        进程还需要一个文件描述符表,这个表记录了进程打开的文件和设备的描述符,以及与这些文件相关的状态信息。通过这个表,操作系统可以管理进程对文件的访问,并确保正确的I/O操作。

        最后,进程还包含一组环境变量,这些变量是由操作系统提供的配置信息,如路径、临时目录等,用于影响程序的行为。

        通过这些组成部分,操作系统能够有效地管理进程的生命周期,并确保进程之间的隔离性和安全性。每个进程都有其独立的地址空间,这意味着每个进程都可以拥有自己的内存布局,包括上述提到的所有组件。这种设计使得进程能够独立地运行,并且能够与其他进程并行执行。

进程
程序代码进程控制块(PCB)内存区域文件描述符表环境变量信号处理函数
文本区域数据区域

进程标识符(PID)

状态信息(运行、就绪、阻塞)

优先级

I/O状态堆栈区域堆区域数据区域文件相关的状态信息路径临时目录中断异常

         假设有一个简单的C程序,它包含一个主函数和几个辅助函数。当这个程序被加载到内存中并开始执行时,操作系统会创建一个进程,并为它分配必要的资源。此时,进程的组成如下:

  • 程序代码:包含主函数和其他辅助函数的机器代码。
  • 数据区域:用于存储全局变量和静态变量。
  • 堆栈区域:用于管理函数调用的上下文,包括函数参数、局部变量和返回地址。
  • 堆区域:用于存储程序运行时动态分配的对象和数据结构。
  • 进程控制块(PCB):包含进程的状态信息、内存管理信息、I/O状态等。
  • 文件描述符表:记录进程打开的文件描述符。
  • 环境变量:操作系统提供的配置信息。
  • 信号处理函数:处理接收到的信号。

3.2 进程的状态(就绪、运行、阻塞)

        Linux系统中,进程可以根据其当前的活动状态被分类。进程的状态反映了进程在其生命周期中的不同阶段。看起来进程就像一个人一样,也会存在着生老病死。

1、运行状态 (Running):

  • 定义:当进程正在处理器上执行时,它处于“运行”状态。
  • 特点:CPU 正在处理该进程的指令,进程拥有处理器的时间片。
  • 示例:当你打开一个应用程序,比如文本编辑器,它就在运行状态,处理你的输入并显示结果。

2、就绪状态 (Ready):

  • 定义:进程准备好运行,但由于其他进程正在使用处理器而处于等待状态。
  • 特点:进程准备好运行,但需要等待调度器将其置于处理器上。
  • 示例:假设你打开了多个应用程序,当其中一个应用程序暂停执行时,另一个应用程序可能处于就绪状态,等待调度器将其放到处理器上运行。

3、阻塞状态 (Blocked):

  • 定义:进程因某种原因不能运行,通常是因为等待 I/O 操作完成。
  • 特点:类似于等待状态,但阻塞状态通常指的是等待 I/O 或者资源不可用的情况。
  • 示例:一个程序可能在等待磁盘读取操作完成时被阻塞。

4、僵尸状态 (Zombie):

  • 定义:一个子进程结束执行后,如果父进程没有通过 wait() 或 waitpid() 调用来读取子进程的退出状态,则子进程会变成僵尸状态。
  • 特点进程实际上已经结束,但父进程未对其进行清理。
  • 示例:如果你运行了一个后台命令,比如 ls &,然后没有通过 wait 或 waitpid 清理这个子进程,那么它将成为一个僵尸进程。

5、停止状态 (Stopped):

  • 定义:进程被暂停执行,通常是由于接收到信号(如 SIGSTOPSIGTSTP 等)。
  • 特点:停止状态的进程不会消耗 CPU 时间,但它仍然保留了所有的资源和状态。
  • 示例:当你按下 Ctrl+Z 键暂停一个前台进程时,该进程会进入停止状态。

6、挂起状态 (Suspended):

  • 定义:挂起状态有两种类型:可追踪的挂起状态(trace stopped)和不可追踪的挂起状态(untrace stopped)。
  • 特点:可追踪的挂起状态通常是因为进程被调试器跟踪;不可追踪的挂起状态通常是因为进程被信号(如 SIGSTOP)暂停。
  • 示例:当使用 strace 跟踪一个进程时,该进程处于可追踪的挂起状态;当你按下 Ctrl+\ 键发送 SIGQUIT 信号给一个进程时,该进程会进入不可追踪的挂起状态。

7、退出状态 (Exit):

  • 定义:进程已经结束执行,所有资源都被释放。
  • 特点:退出状态不是真正意义上的“状态”,而是表示进程已经完全结束,不再存在于进程表中。
  • 示例:当你关闭了一个应用程序,该应用程序的进程将进入退出状态,所有资源被释放。

进程的状态并不是一成不变的,它们会在不同的状态之间转换。以下是一些常见的状态转换:

  • 就绪 → 运行: 当调度器选择一个就绪状态的进程来执行时。
  • 运行 → 就绪: 当进程的时间片用完或者主动放弃CPU时。
  • 运行 → 阻塞: 当进程发起一个I/O操作或其他阻塞调用时。
  • 阻塞 → 就绪: 当等待的事件完成时,如I/O操作完成。
  • 运行/就绪 → 停止: 当进程接收到暂停信号时。
  • 停止 → 运行/就绪: 当进程被重新启动(例如,通过发送SIGCONT信号)。
  • 运行/就绪 → 退出: 当进程正常结束或因错误而终止时。

        我们可以看到,进程在其生命周期中不断地在不同的状态之间切换。在现代操作系统中,一个主程序往往会产生多个子进程,这些进程之间会相互协作以完成复杂的任务。为了有效地管理这些进程并实现多任务的并发执行,操作系统采用了时间片轮转的调度策略。

        时间片轮转是一种高效的进程调度机制,它允许操作系统在多个进程中公平地分配 CPU 时间。常见的调度算法包括先进先出 (FIFO)、最短作业优先 (SJF)、时间片轮转 (RR) 等。在时间片轮转这个机制下,每个进程都会获得一定长度的时间片(例如几毫秒到几十毫秒之间),在这段时间内,进程可以独占 CPU 并执行其任务。当一个进程的时间片用尽时,操作系统会中断当前进程的执行,并将其放入就绪队列的末尾,等待下一次调度。接着,操作系统会选择下一个进程执行,这个过程会不断重复,直到所有进程都完成执行或被终止。

        通过时间片轮转调度,操作系统能够有效地实现多任务的并发执行。这意味着多个进程可以在表面上同时执行,尽管实际上它们是通过快速切换来共享 CPU 资源的。这种方式提高了系统的响应速度和资源利用率,因为即使一个进程暂时没有完成执行,其他进程也可以利用这段时间片来执行自己的任务。这种方法特别适合交互式系统和实时系统,因为它能够提供快速的响应时间和较低的进程切换延迟。

四、进程的相关概念

4.1 并行 (Parallelism) 

        并行是指两个或多个进程同时执行的能力。这种执行模式通常涉及到硬件级别的支持,例如多核处理器或多台计算机。在并行计算中,多个任务可以真正地同时执行,而不是通过调度器在时间上交错执行。例如,在一台具有多个处理器核心的计算机上,不同的进程可以在不同的核心上同时执行。在分布式计算环境中,多个计算机可以同时处理不同的任务或数据集的一部分。并行计算的优势在于它可以显著提高处理大量数据或复杂计算任务的速度,特别是在需要大量计算资源的情况下。

4.2 并发 (Concurrency) 

        并发是指操作系统在宏观上同时执行多个进程的能力,但实际上这些进程是在微观上交替执行的。并发强调的是在一段时间内多个进程看起来像是同时执行的,但实际上它们是通过调度器在时间上交错执行的。并发可以通过单个处理器或多个处理器实现。例如,在一个单核处理器的计算机上,操作系统通过时间片轮转调度策略使多个进程看起来像同时执行一样。在多核处理器的计算机上,操作系统可以将一部分进程分配给不同的核心,而另一部分进程则通过调度在同一个核心上交错执行。并发的优势在于它可以提高系统的响应性和资源利用率,尤其是在处理交互式任务时。

4.3 并行与并发的区别

        并行和并发虽然听起来相似,但它们之间存在关键的区别。并行关注的是真正的同时执行,通常涉及到硬件级别的支持,例如多核处理器或多台计算机。另一方面,并发关注的是在宏观上同时执行多个进程的能力,实际上是通过调度器在时间上交错执行。并行侧重于硬件层面的真正同时执行,而并发则是通过软件调度在宏观上实现多个进程的看似同时执行。

        并行是指多个处理器或者是多核的处理器同时处理多个不同的任务,并发是指一个处理器同时处理多个任务。

        想象你在厨房里准备晚餐。并行就像是你在厨房里同时使用多个炉灶。例如,你可以一边在第一个炉灶上煮汤,一边在第二个炉灶上煎牛排,同时在第三个炉灶上蒸蔬菜。这样,不同的菜肴可以在不同的炉灶上同时烹饪,就像是多个进程在不同的处理器核心上同时执行。

        现在,想象你在厨房里只有一个炉灶。并发就像是你在同一个炉灶上轮流烹饪不同的菜肴。例如,你可以先煮汤几分钟,然后把它放在一旁,接着煎牛排几分钟,然后再回来继续煮汤,最后再蒸蔬菜。这样,不同的菜肴看起来像是同时在烹饪,但实际上它们是在时间上交错执行的,就像是多个进程在同一个处理器上通过调度轮流执行。

4.4 多线程时的并行和并发     

        当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态.这种方式我们称之为并发(Concurrent)。

        当系统有一个以上CPU时,则线程的操作有可能非并发.当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

4.4 竞争性

  1. 竞争性:指系统中进程的数量多于可用的CPU资源。由于资源有限,进程之间会竞争CPU、内存、IO等资源。为了高效地完成任务,进程之间需要根据优先级进行竞争,以便合理地分配资源并提高系统性能。
  2. 独立性:多进程运行时,每个进程都有自己的内存空间和执行环境,彼此之间相互独立,互不干扰。这意味着一个进程的错误或异常不会直接影响其他进程的正常运行,提高了系统的稳定性和可靠性。

4.5 独立性

五、特殊进程

5.1 孤儿进程

        孤儿进程是指父进程终止后,子进程仍然存在的进程。简单来说就是父进程先于子进程退出,子进程就会变成孤儿进程。在Linux系统中,init进程(PID为1)是所有进程的祖先。当一个进程成为孤儿进程时,它会被init进程收养,即它的父进程ID变为1,成为init进程的子进程。init 进程负责清理孤儿进程,并收集它们的退出状态。这样做是为了确保系统中的所有进程都有一个有效的父进程。注意的是,有孤儿进程,但是没有孤儿状态。

        成为孤儿进程后,该进程的状态不会改变,它将继续执行直到正常或异常终止。孤儿进程可以继续运行,执行其预定的任务。那为什么会出现孤儿进程呢?

孤儿进程通常出现在以下情况下:

  • 父进程意外终止,例如由于错误或系统崩溃。
  • 父进程正常终止,但没有等待子进程完成执行。
#include <stdio.h>
#include <unistd.h>

// 测试孤儿进程
// 让父进程先于子进程退出

int main()
{
    int ret = fork();
    if(ret < 0)
    {
        return -1;
    }
    if(ret == 0)
    {
        // 子进程
        while(1)  //让子进程不退出观察其状态
        {
            printf("this is child!\n");
            printf("my pid:%d, my ppid:%d\n",getpid(),getppid());
            sleep(3);
        }
    }
    else
    {
        // 父进程
        printf("this is father!\n");
        printf("my pid:%d, my ppid:%d\n",getpid(),getppid());
        sleep(10);
    }
    return 0;
}

        这里的PCB是指进程控制块,在3.1进程的组成中。

5.2 如何避免孤儿进程

        为了防止出现孤儿进程,我们可以将父进程等待子进程结束。这样,即使父进程在子进程之前终止,也不会留下孤儿进程。在父进程中添加 wait(NULL) 行,使得父进程等待子进程结束。子进程继续无限循环输出消息,便于观察进程状态。

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h> // 添加此头文件以使用 wait()

int main() {
    int ret = fork();
    if (ret < 0) {
        return -1;
    }
    if (ret == 0) { // 子进程
        while (1) { // 让子进程不退出观察其状态
            printf("this is child!\n");
            printf("my pid:%d, my ppid:%d\n", getpid(), getppid());
            sleep(3);
        }
    } else { // 父进程
        printf("this is father!\n");
        printf("my pid:%d, my ppid:%d\n", getpid(), getppid());
        wait(NULL); // 添加这一行,使父进程等待子进程结束
        return 0;
    }
    return 0;
}

        可以看到父进程一直在等待子程序结束的话,子程序的ppid一直保持是311,而不是 init进程的1。

5.2 僵尸进程

        僵尸进程是指已终止但尚未释放资源的进程。简单来说就是子进程终止了,但是父进程没有对该子进程进行清理回收资源。当一个进程终止时,它的内存空间、打开的文件描述符和其他资源仍保留在系统中,直到其父进程调用 wait()waitpid() 函数来获取其退出状态。这种状态下,进程的状态被称为“僵尸”。

        僵尸进程仍然占用一部分系统资源(如进程表中的条目),但其实体已经不存在,只是其状态还保留在系统中。僵尸进程占用的是进程表中的条目,而不是实际的内存或CPU资源。这意味着僵尸进程本身不会消耗大量的系统资源,但过多的僵尸进程会逐渐耗尽进程表的空间,导致无法创建新的进程。

        父进程有责任收集其子进程的退出状态,并进行适当的清理。如果父进程没有及时收集子进程的状态,子进程就会变成僵尸进程。当父进程通过wait()waitpid()系统调用收集了僵尸进程的退出状态后,僵尸进程会被系统回收。此时,僵尸进程所占用的进程表中的条目会被释放,以便用于新的进程。

        那为什么会产生僵尸进程呢?

1、父进程意外终止:

  • 如果父进程在子进程终止之前就已经意外终止,那么子进程会变成孤儿进程,并被init进程收养。init进程通常不会关心孤儿进程的退出状态,所以孤儿进程会变成僵尸进程。

2、父进程疏忽:

  • 如果父进程没有正确地处理子进程的终止信号,或者父进程忽略了子进程的退出状态,也会导致僵尸进程的产生。

3、编程错误:

  • 编程时如果没有正确地使用wait()waitpid()系统调用来处理子进程的退出状态,也可能导致僵尸进程的产生。
#include <stdio.h>
#include <unistd.h>

// 制作僵尸进程
// 子进程在父进程运行时退出
// 子进程的退出状态信息父进程就无法回收
int main()
{
    // 创建子进程
    int ret = fork();
    if(ret < 0)
    {
        return -1;
    }
    else if(ret == 0)
    {
        // 子进程
        printf("this is child!\n");
    }
    else
    {
        // 父进程
        // 一直死循环运行
        while(1)
        {
            printf("this is father!\n");
            sleep(1);
        }
    }
    return 0;
}

5.3 守护进程 (daemon)

        守护进程(Daemon)是Linux系统中一种特殊的后台进程,它们独立于控制终端,并且通常在系统启动时启动,持续运行直到系统关闭。守护进程的主要特点是它们在后台运行,不与任何终端或用户直接交互,因此它们可以在无人值守的服务器环境中持续运行。

        守护进程在运行时不与任何终端关联,这意味着它们不能接收来自终端的输入或向终端输出信息。这一特性使得守护进程能够在关闭终端后仍能继续运行。守护进程通常在系统引导装入时启动,在系统关闭时终止。它们的生存周期较长,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。

        守护进程的主要功能是提供持续运行的服务,如网络服务、系统日志记录、定时任务执行等。它们通常执行系统级的任务,为其他程序和用户提供关键服务。守护进程是非交互式程序,没有控制终端。任何输出,无论是向标准输出设备stdout还是标准出错设备stderr的输出都需要特殊处理。

常见的守护进程包括:

  • sshd: 提供SSH服务,允许远程登录和管理。
  • httpd: HTTP守护进程,用于提供Web服务。
  • syslogd: 系统日志守护进程,用于记录系统日志。
  • cron: 定时任务调度守护进程。

        控制守护进程通常包括启动、停止、重启和查询状态等功能。启动通常使用命令行工具或系统初始化脚本来完成。停止则是通过发送特定信号(如SIGTERM)来请求守护进程优雅地停止。重启则先停止再启动守护进程。查询状态则用于查看守护进程是否正常运行。 

 一个简单的守护进程创建示例如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

int main(void)
{
    pid_t pid;

    // 第一次fork()
    if ((pid = fork()) < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    } else if (pid > 0) {
        // 父进程退出
        exit(EXIT_SUCCESS);
    }

    // 成为会话领导者
    if (setsid() < 0) {
        perror("setsid");
        exit(EXIT_FAILURE);
    }

    // 第二次fork()
    if ((pid = fork()) < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    } else if (pid > 0) {
        // 子进程退出
        exit(EXIT_SUCCESS);
    }

    // 改变工作目录
    if (chdir("/") < 0) {
        perror("chdir");
        exit(EXIT_FAILURE);
    }

    // 关闭标准文件描述符
    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);

    // 开始执行守护进程的任务
    while (1) {
        printf("Hello from the daemon process!\n");
        sleep(5);  // 模拟任务执行
    }

    return 0;
}

 为了创建一个守护进程,通常需要执行以下步骤:

1、重定向标准文件描述符:

  • 关闭标准输入、输出和错误文件描述符。
  • 通常将它们重定向到/dev/null,以避免打开不必要的文件。

2、更改工作目录:

  • 更改当前工作目录到根目录/,以避免锁定当前目录。
  • 这样做可以确保守护进程不会阻止当前目录被卸载。

3、设置文件权限掩码:

  • 设置文件权限掩码,确保守护进程创建的文件具有适当的权限。
  • 通常使用umask(0)来设置文件权限掩码。

4、成为会话领导:

  • 创建一个新的会话,使进程成为会话领导者。
  • 使用setsid()系统调用创建一个新的会话,并使进程成为该会话的领导者。

5、分离进程:

  • 通过两次fork()来创建一个新的进程,使守护进程与控制终端完全分离。
  • 第一次fork()创建一个子进程,父进程退出。
  • 第二次fork()确保子进程不成为会话领导者。

6、更改用户和组ID:

  • 更改守护进程的用户和组ID,以便以较低的权限运行。
  • 这有助于提高系统的安全性。

7、打开日志文件:

  • 打开日志文件以记录守护进程的输出。
  • 日志文件可以帮助调试和监控守护进程的状态。

8、执行守护进程的任务:

  • 守护进程开始执行其预定的任务。

5.4 守护进程的应用

        比如我们在安装docker容器的时候,用到了daemon.json文件。/etc/docker/daemon.json 是Docker守护进程(dockerd)的配置文件,其中包含了用于配置Docker引擎的各种选项,"daemon"这个词在Unix-like系统中常用来表示在后台运行的长期存在的服务进程。

         Docker守护进程(dockerd)是一个长期运行的后台进程,它负责管理Docker容器、镜像以及其他相关的资源。它是Docker的核心组成部分之一,通常在系统启动时自动启动,并一直保持运行,直到系统关闭。这个守护进程接受客户端(如命令行工具 docker 或者其他的API调用)发来的指令,然后执行相应的操作,比如拉取镜像、运行容器等等。[汇总] Docker容器详解 Macvlan 创建不同容器独立跑仿真_docker macvlan-CSDN博客icon-default.png?t=N7T8https://blog.csdn.net/m0_52980547/article/details/139824855

六、进程的创建

  1. fork() 函数
  2. vfork() 函数
  3. clone() 函数
  4. 创建子进程的注意事项

七、进程的控制

  1. 进程控制

    • 进程执行 (exec*() 系列函数)
    • 进程等待 (wait()waitpid())
    • 进程终止 (exit()_exit()abort())

八、进程间的通信 

进程间通信(IPC)

管道
无名管道 (pipe())
命名管道 (mkfifo())
消息队列
创建 (msgget())
操作 (msgsnd(), msgrcv())
共享内存
创建 (shmget())
附着 (shmat())
操作共享内存区域
信号量
创建 (semget())
操作 (semop()

  • 12
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值