Bootstrap

进程概念与基本操作

基本概念

  1. 程序与进程:程序是一系列指令的集合,而进程是程序在特定数据集上的一次执行活动。简单来说,程序是静态的代码,进程是动态的执行实例(我们这样认为的话就有点肤浅了:后面我们知道宏观的概念:进程=PCB+自己的代码和数据)

  2. 内核观点从操作系统内核的角度来看,进程是资源分配的基本单位。内核通过进程来管理CPU时间、内存等系统资源。

  3. 进程的状态:进程在其生命周期中会经历不同的状态,如新建(创建)、就绪(等待CPU时间)、运行、阻塞(等待某个事件如I/O操作完成)和终止(执行完毕或异常终止)。

这张图片展示了操作系统如何管理进程的概念模型。以下是对图片内容的分析:

  1. 进程和程序的关系

    • 图片中显示,进程是程序执行的实例,它包括程序的代码和数据。当程序被加载到内存中执行(先描述再组织)时,它就变成了一个进程。
  2. 进程控制块(PCB)

    • PCB是操作系统用来管理进程的数据结构,它包含了进程的所有属性,如代码地址、数据地址、进程ID、优先级和状态等。
    • 在Linux系统中,PCB的实现是struct task_struct
    • 进程的所有属性我们都可以直接或间接通过task_struct找到。
  3. 进程列表

    • 操作系统维护一个进程列表,这个列表包含了所有当前运行的进程的PCB。
    • 图片中用链表的形式表示了进程列表,每个进程通过指针链接到下一个进程。
  4. 内存管理

    • 操作系统负责将程序从磁盘加载到内存中,并在内存中管理进程的代码和数据。
    • 图片中显示了操作系统如何将磁盘上的可执行程序(如./cmd)加载到内存中,并在内存中为每个进程分配空间。
  5. 操作系统的角色

    • 操作系统本身也是一个被加载到内存中的软件,它负责管理内存中的多个进程。
    • 图片中强调了操作系统需要对多个被加载到内存中的程序进行管理,这需要先描述(即定义PCB)再组织(即通过进程列表管理进程)。
  6. 进程的组织

    • 图片中用结构体(struct)的形式描述了PCB,并通过指针(next)将进程组织成链表,这体现了进程在操作系统中的组织方式。

总结来说,这张图片通过一个概念模型展示了操作系统如何通过PCB和进程列表来管理和组织内存中的进程。这对于理解操作系统的进程管理机制非常有帮助。

也就是可以理解为:进程=PCB(task_struct) + 自己的代码和数据。对进程的管理就变成了对链表的增删查改!!!

当操作系统创建一个新的进程时,它会首先为该进程分配一个PCB(进程控制块),并初始化其中的信息,如进程ID、状态、优先级等。然后,操作系统将这个新的PCB添加到进程列表中,这类似于在链表中添加一个新的节点。当需要查找特定进程时,操作系统会遍历进程列表,查找具有特定标识(如进程ID)的PCB,这类似于在链表中查找特定值的节点。如果需要删除一个进程,操作系统会找到相应的PCB,并将其从进程列表中移除,同时释放与该进程相关的资源(比如加载到内存的自己的代码和数据),这类似于从链表中删除一个节点。最后,当操作系统需要更新进程的状态或属性时,它会直接修改相应PCB中的信息,这类似于在链表中更新节点的值。

通过这种方式,操作系统能够有效地管理进程的生命周期,包括创建、查找、删除和更新进程,确保系统资源得到合理分配和利用

描述进程-PCB

基本概念
  • 进程信息被放在⼀个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
  • 课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
  • 在Linux中描述进程的结构体叫做task_struct。
  • task_struct是Linux内核的⼀种数据结构,它会被装载到RAM(内存)⾥并且包含着进程的信息。
task_struct

内容分类:(属性)

  1. 标识符:描述进程的唯一标识符,用于区分系统中的其他进程。(可以说是ID,好比我们在公式的编号1,2,3....)

  2. 状态:包括任务状态、退出代码、退出信号等信息,用于描述进程的当前状态。(在未来并不是所有进程都会被调度,有各自的状态)

  3. 优先级:描述进程相对于其他进程的优先级,影响进程的调度。(可以说在链表头往下链接,依次调度,优先级是不断减小的,好比面试官看我们简历的时候,简历放在前面的优先级是最高的,随之依次降低)

  4. 程序计数器:存储程序中即将被执行的下一条指令的地址。

  5. 内存指针:包括指向程序代码和进程相关数据的指针,以及与其他进程共享的内存块的指针。(为了让PCB能够找到自己的代码和数据,其实在CPU要调度某个进程,需要执行的是我们的代码和数据,就好比面试官面试一个人是对着我们的简历去问的,而是要找到我们这个人,同样的,要执行就需要找到我们的代码和数据,而不是单纯的的PCB,就好比我们自己的电话)

  6. 上下文数据:存储进程执行时处理器寄存器中的数据,用于进程切换时保存和恢复状态。

  7. I/O状态信息:包括显示的I/O请求、分配给进程的I/O设备和被进程使用的文件列表。

  8. 记账信息:可能包括处理器时间总和、使用的时钟数总和、时间限制、记账账号等信息。

  9. 其他信息:包括其他可能有用的进程相关信息。

组织进程

  • 所有运行在系统里的进程都以 task_struct 链表的形式存在内核里。

  • 图示展示了一个双向链表的结构,每个节点包含 nextprev 指针,用于链接前后的 task_struct 结构。

  • 这种链表结构使得内核能够高效地管理和调度进程。

具体详细信息

  • 后续可能会介绍 task_struct 的具体实现细节,包括各个字段的详细定义和作用。

  • 了解这些细节有助于更好地理解操作系统的进程管理和调度机制。

上图这种结构和组织方式是现代操作系统中常见的进程管理方法,有助于提高系统的稳定性和效率。

查看进程

示例代码:

#include <stdio.h>
#include<unistd.h>
int main()
{
    while(1)
    {
        sleep(1);
        printf("我是一个进程!\n");
    }
    return 0;
}

我们历史上所有的指令,工具,自己的程序,运行起来,全部都是进程。

进程是PCB+代码和数据,代码和数据是我们自己写的,那么程序运行起来变成了进程,这个进程相关的属性值我们应该怎么看呢?

接下来,我们学习我们人生中第一个系统调用:

man getpid

 getpid 是一个在 Unix 和类 Unix 操作系统中用于获取当前进程标识符(PID)的系统调用。

我们查看的man手册是2号手册:

也可以看出是系统调用。

我们getpid是获得pid,那么我们的pid在哪?

pid在我们当前进程的task_struct里面,对应的标识符里,所以当我们调getpid本质是让操作系统把我们当前进程的PCB里把我们的pid拷贝出来,让我们用户看到我们自己的id是什么。

我们在我们的源文件(示例代码)中添加头文件和相关代码:

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

int main()
{
    while(1)
    {
        sleep(1);
        printf("我是一个进程!,我的pid:%d\n", getpid());
    }
    return 0;
}

我们运行(./my*)得到:

我们就可以得到我们当前的进程pid。

我们就可以进一步证明我们运行的程序(代码示例)是一个进程。

在我们的系统层上,我们也应该有相应的一批指令,是我们能够去查当前系统里面,我们所启动的进程有哪些,我们有指令:

ps axj

 在 Linux 系统中,ps axj 命令用于以树状图形式展示所有进程及其层级关系。(我们也可以使用top去查看进程,看自己的意愿)

我们可以过滤来观察我们程序的进程:

这好多些数据又是什么意思?我们可以查看属性列(头标)

ps axj | head -1  #这是数字1

 我们可以使用分号或者&&来同时执行指令:

为什么还会有grep的进程信息呢?

当我们去查进程的时候,这个grep选项总是会显示出来,是因为整条指令从左向右查的时候,grep也是一个命令,当他要为我们显示的结果做过滤的时候,grep命令一旦跑起来,自己也是一个进程,而自己的过滤关键字里就含有myprocess,所以会自己把自己查出来。

我们也可以不显示grep的相关进程属性:

我们可以Ctrl+c来终止程序,杀掉进程。

我们也可以通过:

kill -9 PID  #进程的pid

我们也可以将指定的进程杀掉,其中-9是一个信号编号

进程的信息也可以通过 /proc 系统⽂件夹查看>>> 如:要获取PID为1的进程信息,你需要查看 /proc/1 这个⽂件夹。
proc是process的缩写,说白了,就是我们可以通过文件的方式去查看进程。操作系统不仅仅可以把磁盘上的文件:就我们使用ls这样的指令,用目录结构让我们查到,他把内存的相关数据也以文件的方式给我们呈现出来,让我们能够动态的看到内存相关的数据,像/proc就是一种内存级的文件系统,跟磁盘没有半毛钱的关系,所有的数据都是内存里面的数据。(举个例子:比如说我们学校里有学生1000人,有100人去国外参加国外的交流活动,学校当前只剩下900人,但是教育局给学校说,要把学校的学生的基本信息名单上报,所以学校上报的照样是1000人,虽然其中100人不在学校内部,跑到国外了,但依旧是我们学校内的学生,数据保留在学校里。同样的,我们在系统当中查到的数据,无论这些数据来源是内存还是磁盘,最终以文件的方式为我们呈现出来。)(这不就是Linux上一切皆文件的观点嘛,也就是每个进程,Linux也是可以以文件的方式为我们呈现出来的)

删除对应进程,相关目录文件就会被删除。 

我们补充两个知识点:

第一个小知识:exe:我们我们将我们的可执行文件删除后,程序还会再跑吗?

答案是会的,因为我们自己删掉的是磁盘上的对应文件,而进程启动时,该程序的拷贝已经在内存里了,所以我们将对应的文件删除,不会直接影响这个进程,当然后面可能会影响,我们后面再议,现在是没有影响的,从分证明了,我们的代码已经从磁盘拷贝到内存了,所以我们的进程还在进行,但是我们再次查看该进程的属性的时候,exe会飘红:

告诉我们文件被delete了。

我们重新编译生成myprocess可执行文件。

第二个小知识:cwd:cwd称为current work dir,cwd会保留当前程序所在的路径信息,我们之前学习过fopen:

  • fopen("路径", "w"); 会以写模式打开指定路径的文件,若文件不存在则在指定路径下创建,存在则覆盖。
  • fopen("文件名", "w"); 会在当前路径下尝试创建并打开一个新文件,若文件已存在则操作失败。那么这个“当前路径”其实是因为进程在启动时会记录下来自己的当前路径,在使用fopen其实是在文件名前面拼接cwd

那我们如果要更改一个进程的当前路径:

man chdir

chdir 是一个 C 语言标准库函数,用于改变当前进程的工作目录。其原型通常如下:

#include <unistd.h>

int chdir(const char *path);

参数 path 指向一个字符串,该字符串包含了你想要切换到的目录的路径。

我们假设将路径改为:

/home/lfz

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

int main()
{
    chdir("/home/lfz");
    fopen("hello.txt", "a");
    while(1)
    {
        sleep(1);
        printf("我是一个进程!,我的pid:%d\n", getpid());
    }
    return 0;
}

 编译运行后:

我们上面通过查阅man_gitpid手册的时候发现还有一个getppid,那么ppid是什么,和pid又有什么区别呢?

  • 进程id(PID)
  • ⽗进程id(PPID)

PPID(父进程ID)和PID(进程ID)之间的联系体现在进程的创建和层级关系上:

层级关系

  • 每个进程都有一个PID来唯一标识自己。
  • 每个进程(除了系统的初始化进程,如init或systemd)也都有一个PPID,即创建它的父进程的PID。

上面我们知道了如何去查看进程,我们可以查看该进程的父进程:

 我们可以看到我们的父进程是-bash,bash是我们的命令行解释器。

命令行解释器:本质是一个进程!

知识点:我们每一次登入云服务器时,OS会为每一个登入用户分配一个bash,其中我们的bash前面加“-”是表示远程登入的。

在 Linux 系统中,bash 是 Bourne Again SHell 的缩写,它是一种流行的 Unix shell 和命令行解释器。bash 提供了一个命令行界面,允许用户与操作系统进行交互,执行命令和脚本。

bash 的确类似于 scanf 在程序中的作用,但它是在操作系统层面上工作的。scanf 是一个 C 语言标准库函数,用于从标准输入(通常是键盘)读取用户输入的数据。而 bash 是一个更高级的命令行解释器,它不仅等待用户输入,还能解释和执行各种复杂的命令和脚本。

当在 Linux 系统中看到父进程是 -bash 时,这意味着您的进程是由一个 bash 会话启动的。这通常发生在以下几种情况:

  1. 交互式会话:您在终端中直接输入命令时,每个命令(ls,ll,touch,pwd.......)都在一个新的子进程中执行,而该子进程的父进程就是 -bash

  2. 脚本执行:当您运行一个 shell 脚本时,bash 会读取并执行脚本中的命令,脚本中的命令也会在子进程中执行。

  3. 后台进程:即使您将进程放到后台运行(使用 & 符号),它仍然是由当前的 bash 会话启动的,因此其父进程也是 -bash

简而言之,bash 是 Linux 系统中用于启动和管理进程的 shell 环境,它为用户提供了一个交互式的命令行界面,允许用户执行各种操作。

创建过程

  • 当一个进程通过fork()系统调用创建子进程时,子进程继承父进程的某些属性,但拥有自己的独立PID。
  • 子进程的PPID是父进程的PID。

进程树

  • 在系统的进程树中,每个进程的PPID都指向其父进程的PID,形成了一个层级结构。
  • 通过跟踪PPID,可以追溯到任何进程的祖先,直至系统的根进程。

进程管理

  • PID用于标识和管理具体的进程。
  • PPID用于确定和管理进程的父子关系。

系统调用

  • getpid()系统调用用于获取当前进程的PID。
  • getppid()系统调用用于获取当前进程的PPID。

生命周期

  • 父进程可以通过PPID来监控和管理子进程。
  • 子进程可以通过PPID来识别其父进程,并在需要时与其通信。

总之,PPID和PID共同定义了系统中进程的层级关系和组织结构。PID用于唯一标识每个进程,而PPID用于表示进程之间的父子关系。通过这两个ID,可以构建和管理复杂的进程树,实现进程的组织、监控和通信。

通过系统调⽤创建进程-fork初识

代码创建子进程的方式!

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

int main()
{
    printf("父进程开始运行,pid:%d\n", getpid());
    fork();//创建子进程
    printf("进程开始运行,pid:%d\n", getpid());
    return 0;
}

我们编译运行的话,第二句printf会被输出两次,而对应打印出来的pid应该是不一样的,因为执行下来,一个是父进程自己,一个是创建出来的子进程。

代码执行流程:

  1. 程序开始执行,进入main函数。

  2. 执行printf("父进程开始运行,pid:%d\n", getpid());这句代码。此时,程序还在父进程中运行,getpid()函数获取的是父进程的进程ID(假设为1541162),所以输出“父进程开始运行,pid:1541162”。

  3. 接下来执行fork()函数。fork()函数是 Unix 和类 Unix 系统中的一个重要系统调用,用于创建一个新进程,即子进程。这个子进程是父进程的副本,它继承了父进程的数据空间、堆和栈等资源,但它们是独立的进程,有自己的进程ID。

    • 在父进程中,fork()函数返回子进程的进程ID,这个值是一个大于0的整数(假设为1541163)。这个返回值可以用于父进程后续对子进程的管理和控制,比如通过wait()函数等待子进程结束等。

    • 在子进程中,fork()函数返回0。子进程从fork()函数返回后,会继续执行fork()之后的代码,就像它是从头开始执行这部分代码一样。

  4. 由于fork()创建了子进程,接下来的printf("进程开始运行,pid:%d\n", getpid());这句代码会被父进程和子进程各执行一次。(下图:子进程被创建,但是没有自己的代码和数据!!!子进程默认共享父进程的代码和数据。虽然fork之前也有代码,但是代码逻辑从上往下,是在fork之后继续执行代码)

    • 在父进程中执行时,getpid()获取的是父进程的pid(1541162),所以输出“进程开始运行,pid:1541162”。

    • 在子进程中执行时,getpid()获取的是子进程的pid(1541163),所以输出“进程开始运行,pid:1541163”。

进程创建和执行细节:

  • 进程ID(PID):每个进程在操作系统中都有一个唯一的标识符,即进程ID。父进程和子进程的PID是不同的,操作系统通过PID来区分和管理不同的进程。在fork()调用后,父进程和子进程各自拥有独立的PID,这使得它们在系统中是完全独立的实体。

  • 内存空间:虽然子进程在创建时是父进程的副本,但它们在内存中是独立的。父进程和子进程各自有自己的地址空间,对内存的修改不会相互影响。例如,如果父进程和子进程都修改了相同的变量,这些修改是独立的,不会相互干扰。

  • 资源分配:操作系统会为子进程分配必要的资源,如文件描述符、信号处理等。子进程继承了父进程的资源状态,但这些资源在子进程中是独立管理的。例如,如果父进程打开了一个文件,子进程也会继承这个文件的打开状态,但父进程和子进程对文件的读写操作是独立的。

最终的输出结果是:

父进程开始运行,pid:1541162
进程开始运行,pid:1541162
进程开始运行,pid:1541163
  • 第一行输出是在fork()调用之前,由父进程执行的,打印的是父进程的PID。

  • 第二行输出是在fork()调用之后,由父进程执行的,同样打印的是父进程的PID。

  • 第三行输出是在fork()调用之后,由子进程执行的,打印的是子进程的PID。

通过这个例子,你可以看到fork()函数如何创建一个新的进程,并且父子进程如何独立地执行后续的代码。这种机制在多进程编程中非常有用,可以用于并发处理、任务分解等多种场景。

fork() 的两个返回值
  1. 在父进程中fork() 返回子进程的进程ID(PID)。这个 PID 是一个正整数,用于标识新创建的子进程。

  2. 在子进程中fork() 返回 0。这是子进程特有的返回值,用于区分子进程和父进程。

  3. 在出错时:如果 fork() 调用失败(例如,由于系统资源限制无法创建新进程),它会返回 -1,并设置 errno 以指示错误的原因。

代码示例解释

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

int main()
{
    int ret = fork();
    if (ret < 0)
    {
        perror("fork");
        return 1;
    }
    else if (ret == 0)
    { // 子进程
        while(1)
        {
            sleep(1);
            printf("I am child : %d!, ret: %d\n", getpid(), ret);
        }
    }
    else
    { // 父进程
        while(1)
        {
            sleep(1);
            printf("I am father : %d!, ret: %d\n", getpid(), ret);
        }
    }
    sleep(1);
    return 0;
}

 

  • fork() 被调用时,操作系统创建一个新的进程(子进程),这个新进程几乎完全复制父进程的地址空间。

  • 在父进程中,fork() 返回新创建的子进程的 PID,因此 ret 是一个正整数。

  • 在子进程中,fork() 返回 0,因此 ret 是 0。

  • 通过检查 ret 的值,程序可以确定当前是在父进程中还是在子进程中执行,并相应地打印不同的信息。

关于变量同时让 if 和 else if 成立的问题:

在 C 语言中,ifelse if 结构确保了一旦某个条件为真,后续的 else if 块就不会被评估。这是因为一旦进入一个 ifelse if 块,程序就会跳过所有后续的 else ifelse 块,直接执行该块后的代码。因此,不可能出现一个变量同时让 ifelse if 同时成立的情况。

问题:
  • 为什么fork()给父子返回各自的不同返回值?

看上图的测试结果,我们子进程的返回值是0,父进程的返回值是子进程的pid,主要原因是我们的Linux系统里,子进程 : 父进程(比值关系)=n : 1,可以说任何一个父进程可以有一个或者多个孩子,当让也可以是0个,所以一定要把孩子的pid返回给父进程,因为父进程要通过不同的pid来区分他不同的子进程,方便未来父进程对其子进程的管理,而子进程就不需要获得父进程的pid,因为子进程getppid(),就可以获得父进程的pid了,所以子进程只需要确定自己是否被创建就行了。

  • 为什么一个函数会返回两次?

我们思考:一个函数已经执行到 return 返回值; 了,那么核心功能做完了没有?答案是已经做完了!!!基于这个共识,fork()这个函数本质上是系统调用,所以一但调fork(),就会进入fork()函数所对应的系统调用,fork()是创建子进程的:申请新的PCB,拷贝父进程的PCB给子进程,将子进程的PCB链接到进程链表中,甚至放入调度队列中!当执行到fork()函数的return处,创建子进程的核心工作已经完成,就是说核心工作做完后,子进程已被创建,甚至被调度了。return作为一个语句,代码被共享,父进程被执行,相应的,子进程也被执行,都会执行return,所以return被返回两次。

  • 为什么一个变量,即==0,又大于0?导致if - else同时成立???

这个问题,我们在虚拟地址空间再谈论。

不过我们解决该问题前,我们可以提前理解某些知识:

进程具有独立性!(我们打开腾讯视频和爱奇艺,这是两个进程,我们知道如果爱奇艺挂掉并不会影响腾讯视频)(不会因为父进程挂了,子进程全部out)

代码是只读的,没有任何父子能够修改,对我们来讲,代码本身是互相共享的,但是因为大家都不可修改,所以两者是不会互相影响的;

在 Linux 系统中,父子进程在创建时确实共享相同的地址空间,包括代码段、数据段和堆栈等。这种共享机制是通过一种称为“写时复制”(Copy-On-Write,简称 COW)的技术实现的。

写时复制(Copy-On-Write,COW):

写时复制是一种优化技术,用于减少进程创建时的内存复制开销。其核心思想是:只有在进程实际修改共享数据时,才进行数据的复制。

具体来说,写时复制的工作机制如下:

  1. 初始状态:当父进程创建子进程时,父子进程共享相同的物理内存页。这些内存页被标记为不可写(只读)。

  2. 尝试修改:当任一进程(父进程或子进程)尝试修改这些共享的内存页时,操作系统会捕获到这个写操作。

  3. 复制数据:操作系统会将被修改的内存页复制一份,并分配给尝试修改它的进程。这样,每个进程都有自己的私有副本,可以独立地进行修改。

  4. 独立修改:从这时起,两个进程的地址空间中相应的内存页指向不同的物理内存区域,它们可以独立地修改各自的数据,而不会影响对方。

优点:

  • 节省内存:在进程创建时,不需要立即复制所有数据,从而节省了内存资源。

  • 提高效率:只有在实际需要修改数据时才进行复制,避免了不必要的内存操作。

示例:

以下是一个简单的示例,演示了写时复制的效果:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int shared_data = 0;

    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) { // 子进程
        printf("Child: modifying shared data...\n");
        shared_data = 1; // 尝试修改共享数据
        printf("Child: shared_data = %d\n", shared_data);
    } else { // 父进程
        wait(NULL); // 等待子进程完成
        printf("Parent: shared_data = %d\n", shared_data);
    }

    return 0;
}

在这个示例中,父进程和子进程最初共享 shared_data 变量。当子进程尝试修改 shared_data 时,操作系统会触发写时复制,为子进程创建一个私有的副本。因此,父进程的 shared_data 值保持不变。

通过写时复制技术,操作系统有效地优化了进程创建和内存管理,提高了系统的整体性能。

;