基本概念
-
程序与进程:程序是一系列指令的集合,而进程是程序在特定数据集上的一次执行活动。简单来说,程序是静态的代码,进程是动态的执行实例(我们这样认为的话就有点肤浅了:后面我们知道宏观的概念:进程=PCB+自己的代码和数据)。
-
内核观点:从操作系统内核的角度来看,进程是资源分配的基本单位。内核通过进程来管理CPU时间、内存等系统资源。
-
进程的状态:进程在其生命周期中会经历不同的状态,如新建(创建)、就绪(等待CPU时间)、运行、阻塞(等待某个事件如I/O操作完成)和终止(执行完毕或异常终止)。
这张图片展示了操作系统如何管理进程的概念模型。以下是对图片内容的分析:
-
进程和程序的关系:
- 图片中显示,进程是程序执行的实例,它包括程序的代码和数据。当程序被加载到内存中执行(先描述再组织)时,它就变成了一个进程。
-
进程控制块(PCB):
- PCB是操作系统用来管理进程的数据结构,它包含了进程的所有属性,如代码地址、数据地址、进程ID、优先级和状态等。
- 在Linux系统中,PCB的实现是
struct task_struct
。 - 进程的所有属性我们都可以直接或间接通过task_struct找到。
-
进程列表:
- 操作系统维护一个进程列表,这个列表包含了所有当前运行的进程的PCB。
- 图片中用链表的形式表示了进程列表,每个进程通过指针链接到下一个进程。
-
内存管理:
- 操作系统负责将程序从磁盘加载到内存中,并在内存中管理进程的代码和数据。
- 图片中显示了操作系统如何将磁盘上的可执行程序(如
./cmd
)加载到内存中,并在内存中为每个进程分配空间。
-
操作系统的角色:
- 操作系统本身也是一个被加载到内存中的软件,它负责管理内存中的多个进程。
- 图片中强调了操作系统需要对多个被加载到内存中的程序进行管理,这需要先描述(即定义PCB)再组织(即通过进程列表管理进程)。
-
进程的组织:
- 图片中用结构体(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
内容分类:(属性)
-
标识符:描述进程的唯一标识符,用于区分系统中的其他进程。(可以说是ID,好比我们在公式的编号1,2,3....)
-
状态:包括任务状态、退出代码、退出信号等信息,用于描述进程的当前状态。(在未来并不是所有进程都会被调度,有各自的状态)
-
优先级:描述进程相对于其他进程的优先级,影响进程的调度。(可以说在链表头往下链接,依次调度,优先级是不断减小的,好比面试官看我们简历的时候,简历放在前面的优先级是最高的,随之依次降低)
-
程序计数器:存储程序中即将被执行的下一条指令的地址。
-
内存指针:包括指向程序代码和进程相关数据的指针,以及与其他进程共享的内存块的指针。(为了让PCB能够找到自己的代码和数据,其实在CPU要调度某个进程,需要执行的是我们的代码和数据,就好比面试官面试一个人是对着我们的简历去问的,而是要找到我们这个人,同样的,要执行就需要找到我们的代码和数据,而不是单纯的的PCB,就好比我们自己的电话)
-
上下文数据:存储进程执行时处理器寄存器中的数据,用于进程切换时保存和恢复状态。
-
I/O状态信息:包括显示的I/O请求、分配给进程的I/O设备和被进程使用的文件列表。
-
记账信息:可能包括处理器时间总和、使用的时钟数总和、时间限制、记账账号等信息。
-
其他信息:包括其他可能有用的进程相关信息。
组织进程
-
所有运行在系统里的进程都以
task_struct
链表的形式存在内核里。 -
图示展示了一个双向链表的结构,每个节点包含
next
和prev
指针,用于链接前后的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是一个信号编号
删除对应进程,相关目录文件就会被删除。
我们补充两个知识点:
第一个小知识: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 会话启动的。这通常发生在以下几种情况:
-
交互式会话:您在终端中直接输入命令时,每个命令(ls,ll,touch,pwd.......)都在一个新的子进程中执行,而该子进程的父进程就是
-bash
。 -
脚本执行:当您运行一个 shell 脚本时,bash 会读取并执行脚本中的命令,脚本中的命令也会在子进程中执行。
-
后台进程:即使您将进程放到后台运行(使用
&
符号),它仍然是由当前的 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应该是不一样的,因为执行下来,一个是父进程自己,一个是创建出来的子进程。
代码执行流程:
-
程序开始执行,进入
main
函数。 -
执行
printf("父进程开始运行,pid:%d\n", getpid());
这句代码。此时,程序还在父进程中运行,getpid()
函数获取的是父进程的进程ID(假设为1541162),所以输出“父进程开始运行,pid:1541162”。 -
接下来执行
fork()
函数。fork()
函数是 Unix 和类 Unix 系统中的一个重要系统调用,用于创建一个新进程,即子进程。这个子进程是父进程的副本,它继承了父进程的数据空间、堆和栈等资源,但它们是独立的进程,有自己的进程ID。-
在父进程中,
fork()
函数返回子进程的进程ID,这个值是一个大于0的整数(假设为1541163)。这个返回值可以用于父进程后续对子进程的管理和控制,比如通过wait()
函数等待子进程结束等。 -
在子进程中,
fork()
函数返回0。子进程从fork()
函数返回后,会继续执行fork()
之后的代码,就像它是从头开始执行这部分代码一样。
-
-
由于
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() 的两个返回值
-
在父进程中:
fork()
返回子进程的进程ID(PID)。这个 PID 是一个正整数,用于标识新创建的子进程。 -
在子进程中:
fork()
返回 0。这是子进程特有的返回值,用于区分子进程和父进程。 -
在出错时:如果
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 语言中,if
和 else if
结构确保了一旦某个条件为真,后续的 else if
块就不会被评估。这是因为一旦进入一个 if
或 else if
块,程序就会跳过所有后续的 else if
和 else
块,直接执行该块后的代码。因此,不可能出现一个变量同时让 if
和 else 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):
写时复制是一种优化技术,用于减少进程创建时的内存复制开销。其核心思想是:只有在进程实际修改共享数据时,才进行数据的复制。
具体来说,写时复制的工作机制如下:
-
初始状态:当父进程创建子进程时,父子进程共享相同的物理内存页。这些内存页被标记为不可写(只读)。
-
尝试修改:当任一进程(父进程或子进程)尝试修改这些共享的内存页时,操作系统会捕获到这个写操作。
-
复制数据:操作系统会将被修改的内存页复制一份,并分配给尝试修改它的进程。这样,每个进程都有自己的私有副本,可以独立地进行修改。
-
独立修改:从这时起,两个进程的地址空间中相应的内存页指向不同的物理内存区域,它们可以独立地修改各自的数据,而不会影响对方。
优点:
-
节省内存:在进程创建时,不需要立即复制所有数据,从而节省了内存资源。
-
提高效率:只有在实际需要修改数据时才进行复制,避免了不必要的内存操作。
示例:
以下是一个简单的示例,演示了写时复制的效果:
#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
值保持不变。
通过写时复制技术,操作系统有效地优化了进程创建和内存管理,提高了系统的整体性能。