【Linux】进程的基本概念
目录
- 一、进程概念的推导
- 二、描述进程(PCB)
- 2.1 task_struct
- 2.2 task_struct内容
- 2.2.1 task_struct内容分类
- 2.2.2 PID(进程ID)
- 2.2.3 PPID(父进程的进程ID)
- 三、查看进程
- 四、进程的创建
- 4.1 命令行直接启动进程
- 4.2 通过代码来创建进程
- 4.2.1 通过fork函数来创建进程
- 4.2.2 fork函数的原理
- 结尾
一、进程概念的推导
谈到进程的概念大家可能在教材上看到过,进程是运行起来的程序,进程是在内存中的概念,但是这样的概念或许并不能让我们很快的理解,如果是你认为你理解了,那么请解释一下进程和程序的区别,如果知道那就是真的理解了,有人就会说了,进程是动态的,程序是静态的,那么什么是动态的什么是静态的呢?所以说很多人可能都不理解进程是什么东西。
我们编译文件生成的可执行程序是保存在磁盘中的,当我们要运行程序时,在体系结构层面上是要将程序加载(拷贝)到内存中的,拷贝到内存中的程序就是进程了?就有动态属性了?程序中的代码数据在磁盘中有,在内存中也有,凭什么说在内存中的就是进程呢?这里发现讲不明白,我们就暂且认为他是“进程”(双引号的进程,并不是真正意义上的进程)
在运行程序之前,有一个软件早就启动了,那就是操作系统,当你运行程序时,操作系统就会识别到你要运行程序,操作系统会将你的程序加载到内存中,在日常生活中你会使用很多的程序,操作系统将这些程序全部加载到内存中,操作系统中就会有非常多的“进程”,所以操作系统就要对这些“进程”进行管理,在上一篇文章操作系统中我们知道了管理的本质就是先描述再组织,操作系统是用C语言写出来的,描述“进程”就需要定义一个struct结构体,结构体中有进程的属性,每一个“进程”有它对应的结构体对象,要对这些对象进行再组织,所以结构体中需要有一个结构体指针用于结构体对象之间的连接,到这就使得操作系统对“进程”的管理转换为对数据结构的管理。进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
struct PCB
{
// “进程”的属性
// 结构体指针,用于链接结构体对象
struct PCB* next
};
磁盘中有很多程序,我们将几个程序运行起来,操作系统就会将程序加载(拷贝)到内存中,操作系统为了管理“进程”,会为每个“进程”创建对应的PCB,并且PCB中有足够的信息能够让操作系统找到程序中的代码和数据,PCB中有连接指针,每启动一个程序,就将新创建的PCB与之前启动程序的PCB进行链接,那么操作系统管理进程就被转换为操作系统对某种数据结构的管理,假设这里的数据结构是链表,并且命名为process_list,在操作系统层面上就多了一个链表的数据结构,操作系统管理进行就转换为管理链表,之后想要再启动程序,操作系统会将程序加载到内存,创建对应程序的PCB并添加到链表结构中即可,如果想终止程序,操作系统会在链表结构中删除程序在内存中的代码和数据,并在链表中找到对应的PCB删除。
在体系结构中有一个设备叫做CPU,当CUP要运行一个程序的时,会从链表PCB中存储优先级的变量找到优先级最高的PCB,再通过PCB找到对应程序的代码和数据,并将这些数据交给CPU,这就是CPU调度了一次进程。
相信大家都听说过一个概念进程在排队,假设这里要维护一个CPU的运行队列,那么假设第三个进程要排队,就是将第三个进程的PCB移动到运行队列中去,那么这个进程就是在运行队列中排队。在现实中也有这种例子,去公司面试进行排队,是将简历交给面试官进行排队,并不是真正的人去排队。
通过上面的内容可以得到最终的结论:进程 = 可执行程序 + 内核数据结构
在当前这个阶段中的内核数据结构只讲了PCB,在后面的文章中会讲到其他的内核数据结构,内核数据结构存在的意义就为了方便操作系统管理进程。
到这里也能解释一下进程与程序的区别了
- 进程在内存中,程序在磁盘中
- 进程比程序多了内核数据结构
- 进程是动态的,程序是静态的
由于CPU不是一下子运行完某个进程,CPU会根据状态去不同的运行队列中挑选进程运行,进程在内存中随时可能被操作系统和CPU进行调度运行,这里调度运行的意思就是将进程的PCB放在各种运行队列,CPU在不同的运行队列选择运行,进程是动态的体现在当程序加载到内存后,该进程就随时可能被调度,CPU一行行的运行该进程的代码,那么这样的进程就被赋予了动态的属性。
二、描述进程(PCB)
2.1 task_struct
task_struct-PCB的一种
- 在Linux中描述进程的结构体叫做task_struct。
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
在当代新版本内核中进程的PCB通常是用双链表的数据结构组织起来的
在之前学习双链表定义结构体时,链接指针可能以下面这种方式被定义
struct task_struct
{
// 进程的各种属性
// 链接指针
struct task_struct* next;
struct task_struct* prve;
};
使用该结构体实现的双链表的链接方式如下图
而结构体PCB中的链接指针是以下面这种方式被定义的
struct dlist
{
struct dlist* next;
struct dlist* prev;
};
struct task_struct
{
// 进程的各种属性
// 链接指针
struct dlist* list;
};
使用该结构体实现的双链表的链接方式如下图
这样做的好处就是当遍历到某个list时想访问它的对应的task_struct时,可以通过地址偏移量找到它对应的task_struct,(struct task_struct*)((int)&list-(int)&(task_struct)0->list)
。
这里的(int)&(task_struct)0->list)
是计算出list在task_struct中的地址偏移量,(int)&list
减去地址偏移量再强转为struct task_struct*
即可找到list对应task_struct的地址了,置于这里为什么要将&list
和&(task_struct)0->list
强制为int类型,是因为在指针这篇文章中讲到过,指针 - 指针的结果是两个指针之间所隔的元素个数,而这里是需要两个值真实的相减,所以要强转为int类型。
通常内核中会将上面的表达式定义成宏:
#define curr(list) (struct task_struct*)((int)&list-(int)&(task_struct)0->list)
如果我们要访问task_struct中的变量pid,就可以这样访问curr(list)->pid
。
问题:第一个版本和第二个版本的task_struct都能够完成链接,为什么内核中要使用第二个版本呢???
解答:因为进程中的PCB可以存在于多种数据结构中,版本一只能将进程的PCB放在一个链表中,而版本二通过下面的实现就可以将PCB存放在多个链表中。
struct dlist
{
struct dlist* next;
struct dlist* prev;
};
struct task_struct
{
// 进程的各种属性
// 链接指针
struct dlist* list; // PCB可以存在于双向链表中
struct dlist* queue; // PCB也可以存在于队列中
// ... PCB还可以存在于其他数据结构中
};
到此为止,我们已经知道了进程的基本框架了,那么接下来要详细介绍task_struct(PCB)中有哪些核心字段了。
2.2 task_struct内容
2.2.1 task_struct内容分类
ps指令
功能:ps 命令在 Linux 系统中用于显示当前系统中的进程状态。
语法:ps [选项]
常用选项:
-
-e 或 -A:显示所有进程。
-
-f:全格式显示,包括 UID、PID、PPID、C、STIME、TTY、TIME 和 CMD 等信息。
-
-l:长格式显示,包含更多关于进程的信息,如优先级、nice 值、进程组ID等。
-
-j:显示进程的会话和组信息。通常包括进程ID(PID)、父进程ID(PPID)、进程组ID(PGID)等。
-
-x:显示没有控制终端的进程。这通常包括后台进程和系统服务。
-
-u 用户名:显示指定用户的进程。
-
-ajx:这是一个常用的选项组合,等价于 -a -j -x 的组合,显示所有用户的进程,并包含关于进程的会话和组信息,同时还会包括没有控制终端的进程。
-
-aux:这是一个常用的选项组合,等价于 -a -u -x 的组合,显示系统上所有用户的所有进程,同时还会包括没有控制终端的进程。
在Linux中,指令、软件以及自己写的程序在运行时都是以进程的形式存在的。
当我们运行一个程序后就它就叫做进程了,我们可以通过ps指令经过管道进行行过滤,快速查找程序是否在运行,通过下图可以看出来,通过对./mytest
不同的操作,ps指令能够查出来./mytest
是否在运行。有人会问了,我查找的是mytest,为什么下面会有个grep(行过滤)呢?它也是一个进程吗?它就是一个进程,因为前面指令的结果通过管道作为grep的输入,grep进行行过滤时,grep自己也需要运行起来,那它也就变成了一个进程,因为它的字符串信息中也包含了mytest,下面grep进程也能够被查到,,如果你不想显示这个,可以使用grep -v “grep”,输出不包含grep的行。
task_struct 是 Linux 内核中用于描述进程的一种数据结构,也被称为进程控制块。它包含了操作系统为了管理进程所需的所有关键信息。以下是对 task_struct 内容的大致分类,下面的内容我并不会都在这篇文章中讲到,因为有些知识点需要结合后面的知识才能够讲好。
- 标示符
- PID(进程标识符):用于唯一标识系统中的每个进程。
- UID/GID(用户标识符/组标识符):表示进程的所有者和所属组。
- 状态信息
- 状态字段:描述进程当前的状态,如运行状态(R)、可中断睡眠状态(S)、不可中断睡眠状态(D)、停止状态(T)、跟踪停止状态(t)、僵尸状态(Z)等。
- 退出码和信号:进程退出时的状态码和引起退出的信号。
- 优先级与调度信息
- 优先级:包括静态优先级和动态优先级,用于决定进程在调度时的优先级。
- 调度策略:如时间片轮转、实时调度等。
- 调度器相关信息:如时间片剩余、最后一次运行的CPU等。
- 链接信息
- 家族关系:包括父进程、子进程等家族成员的信息,通过指针链接。
- 链表信息:进程以链表的形式组织在内核中,如运行队列、等待队列等。
- 程序计数器与上下文数据
- 程序计数器:指向即将被执行的下一条指令的地址。
- 上下文数据:包括CPU寄存器、堆栈等进程执行时的环境信息,以便在进程切换时能够恢复现场。
- 内存管理信息
- 内存指针:指向程序代码、数据、堆栈等内存区域的指针。
- 虚拟内存信息:描述进程拥有的地址空间。
- 文件系统信息
- 文件描述符:打开文件的列表和相关信息。
- 系统打开文件表:记录系统级别的文件打开情况。
- I/O状态信息
- I/O请求:包括等待处理的I/O请求。
- I/O设备和文件列表:分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息
- 处理器时间总和:进程使用CPU的时间。
- 时钟数总和:进程使用的时钟周期数。
- 时间限制和记账号:与进程相关的时间限制和记账信息。
- 其他信息
- 执行环境:包括进程的启动参数、环境变量等。
- 信号处理:进程对信号的响应方式和处理函数。
- 资源限制:如文件大小、内存使用量等的限制。
2.2.2 PID(进程ID)
每个进程在系统中都有一个唯一的PID,这使得操作系统能够区分和管理不同的进程。
当一个程序启动时,我们可以通过ps查看当前进程的PID
当我们不想使用ps指令来查询进程的PID,而是哪个程序运行起来,该进程自己获取自己的PID,这种情况可以使用系统函数getpid来实现。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
int i = 0;
while(i <= 100)
{
int pid = getpid();
printf("我已经是一个进程了, 我的进程ID是:%d\n",pid);
i++;
sleep(1);
}
return 0;
}
当我们不停的启动停止一个进程时,我们会发现它每一次的PID都是不一样的,这是正常情况。
2.2.3 PPID(父进程的进程ID)
PPID(Parent Process IDentifier)是指一个进程的父进程的标识符。在大多数操作系统中,每个运行的进程都有一个唯一的标识符,称为进程ID(PID),以及一个指向其父进程的链接,该链接通过父进程的标识符PPID来表示。
当我们多次运行某个程序时,该进程的PID每次都会变化,但是PPID可能不会改变,我们可以通过ps来查看当前父进程是什么,通过下图可以看到当前进程的父进程是bash,bash是外壳程序Shell的一种,也叫做命令行解释器,在命令行中,父进程一般都是命令行解释器。
系统中有一个系统函数叫做getppid,getppid能够让使用getppid的进程获取它父进程的进程ID。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
int i = 0;
while(i <= 100)
{
int pid = getpid();
int ppid = getppid();
printf("我已经是一个进程了, 我的进程ID是:%d, 我的父进程ID是:%d\n",pid,ppid);
i++;
sleep(1);
}
return 0;
}
三、查看进程
除了上面ps指令查看进程的方式,还有另一种方式就是,Linux下的根目录中有一个叫做proc的目录,这个目录是动态的,里面存放了正在运行的的进程,目录里面的子目录的名称是以进程的PID命名的,此外/proc目录还包含了其他与系统运行相关的信息。
现在我想查看这个子目录中存放了什么,如下图,子目录中存放的是该进程在内存中的详细信息和很多的属性信息。
由于里面的很多内容在目前大家的理解不了,这里就讲exe和cwd。
exe指向的是当前进程在磁盘中对应可执行程序的路径,也就是说进程在运行后还是依然能知道它是从哪里来的,如果在进程运行的时候,将它对应的可执行程序在磁盘中删除会怎么样?进程会继续运行,直到进程运行完毕,因为程序已经被拷贝到内存中了,删除磁盘中的程序并不影响进程的继续运行,并且进程能够检测出对应的在磁盘中的程序是否被删除了。
在学习文件管理的时候学到过,当以写的方式打开文件并且文件不存在时,操作系统会在当前目录下创建这个文件,那么这里的当前目录就是cwd,也称为工作目录,默认当前目录就是该进程对应程序所在的目录。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
FILE* fp = fopen("code.txt","w");
if(fp == NULL)
return 0;
fclose(fp);
int i = 0;
while(i <= 100)
{
int pid = getpid();
int ppid = getppid();
printf("我已经是一个进程了, 我的进程ID是:%d, 我的父进程ID是:%d\n",pid,ppid);
i++;
sleep(1);
}
return 0;
}
其实工作路径是可以通过chdir进行修改的,这里我将进程的工作目录修改为当前进程对应程序所在目录的上级目录,通过下面这张图可以发现,cwd变为了我通过chdir函数修改过的路径了,并且文件也是创建在修改后的路径的目录中。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
chdir("/home/chineseperson04/CSDN/Process_Concept");
FILE* fp = fopen("code.txt","w");
if(fp == NULL)
return 0;
fclose(fp);
int i = 0;
while(i <= 100)
{
int pid = getpid();
int ppid = getppid();
printf("我已经是一个进程了, 我的进程ID是:%d, 我的父进程ID是:%d\n",pid,ppid);
i++;
sleep(1);
}
return 0;
}
四、进程的创建
Linux中创建进程的方式
- 命令行直接启动进程
- 通过代码来创建进程
4.1 命令行直接启动进程
启动进程的本质就是创建进程,由于进程启动时操作系统要将程序加载到内存并创建对应的PCB,PCB中有很多进程属性需要赋值,这些属性通常是由以父进程中的属性进行赋值的,所以进程是通过父进程或是以父进程为模版进行创建的。
命令行直接启动进程我们都使用过,使用./程序名
或是基本命令就能启动进程。
4.2 通过代码来创建进程
通过代码来创建进程的方式有很多种,这里我讲解fork函数是如何创建进程进程的。
4.2.1 通过fork函数来创建进程
但是如何通过代码创建进程呢?那就要通过我们下面将要讲到的系统调用fork函数。
fork函数的作用就是创建一个子进程,当一个进程调用fork函数时,fork会以其为模版创建一个子进程,将它的大部分属性赋给子进程。也就是说当我们进程遇到fork函数时,就会从一个执行流变成两个执行流。
下面我使用一段代码对fork函数进行测试,通过运行结果来看,第二个printf函数只调用了一次,但是输出了两次。通过对下图的观察我们可以发现新创建出来的进程的父进程就是最开始运行的进程,这个结果就能证明了fork函数能够创建进程。我们通过ps指令查询最开始运行的进程的父进程发现它的父进程是bash(命令行解释器),通过命令行启动的进程,它的父进程都是命令行解释器,因为bash是用C语言写的,那么这里是不是可以进行一个大胆的猜测,bash的源代码中是否也使用了fork来创建子进程呢?
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("我是一个进程 , PID:%d \n",getpid());
fork();
printf("我是一个进程 , PID:%d , PPID:%d\n",getpid(),getppid());
return 0;
}
通过上面的代码也可以得出一个结论:
只有父进程会执行fork函数之前的代码,fork之后的代码父子进程都需要执行。
上面我们阅读手册看到了fork函数如何使用和基本功能,当我们再阅读收藏看fork函数的返回值时,可以发现当子进程创建失败时,fork只有一个返回值-1,当子进程创建成功时,fork函数竟然有两个返回值,一个是子进程的PID作为父进程的返回值,一个是0作为子进程的返回值,所以我们可以根据fork函数两个返回值来让父子进程执行不同的代码。
通过上面这段代码进行演示,运行结果确实表面fork函数有两个返回值。
问题:
到目前为止,大部分人可能都不知道创建子进程的意义是什么?试用了一下fork函数感觉没什么用啊,难道我们创建子进程就是为了让子进程做和父进程一样的事情吗?
创建子进程的意义是为了子进程配合父进程完成某些任务,这个任务是单进程完成不了的。在上面我们讲到了fork函数有两个返回值,那么就可以通过这两个返回值的不同来让父子进程做不同的事情。
我这里假设这个任务必须通过任务一和任务二配合才能实现。通过下面这段代码的结果来看,我们实现了多进程的运行,并且我们遇到了以前没有遇到的场景,两个死循环同时运行,if/if lese / else
竟然可以同时进入!!
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("我是一个父进程 , PID:%d \n",getpid());
pid_t id = fork();
if(id == -1) return -1;
else if(id == 0)
{
// fork返回值为0,这是子进程
while(1)
{
printf("我是一个子进程 , PID:%d , PPID:%d , fork return:%d , 我正在执行任务二\n",getpid(),getppid(),id);
sleep(1);
}
}
else
{
// fock返回值大于0,这是父进程
while(1)
{
printf("我是一个父进程 , PID:%d , PPID:%d , fork return:%d , 我正在执行任务一\n",getpid(),getppid(),id);
sleep(1);
}
}
return 0;
}
4.2.2 fork函数的原理
通过上面的讲解,大家或许已经学会了fork函数的使用,但是大家大概率不能够理解为什么能这么用,下面通过几个问题来带领大家理解fork函数的原理。
-
fork函数做了什么?
fork函数以父进程为模板创建子进程,系统中多了一个进程,操作系统为了管理它,需要为子进程创建PCB,但是目前来看子进程并没有代码和数据,可以大胆的猜测一下,父进程和子进程是共享代码和数据的(数据并不是完全共享的,第五点会说到),所以说fork函数之后父子进程会执行相同的代码。那么fork函数之前的代码子进程能够看到嘛?如果能看到的话为什么子进程不从头开始执行呢?
子进程是能够看到fork函数之前的代码的。子进程不从头开始执行的原因是,进程中的PC/EIP寄存器能够指向下一条指令(代码编译完后就是指令)的地址,当执行完fork之后,PC/EIP寄存器会指向fork函数之后的指令,而上面又讲到了父子进程共用代码和数据,那么子进程也可以访问PC/EIP寄存器,所以子进程执行fork函数以后的代码。 -
为什么fork函数能够拥有两个返回值?
观察fork函数的整体我们可能看不出什么,但是如果我们查看fork函数的内部或许那个发现些端倪,如果说一个函数已经运行到了return,那么这个函数的核心工作完成了吗?是的,已经完成了,所以在return语句之前就已经完成了对子进程的创建,子进程PCB创建等任务了,并且在上面讲到过fork之后父子进程代码共享,当前面核心工作做完后,还是剩下一个代码段用来进行返回,通过内部某种机制区分了父进程和子进程的执行路径,并在每个路径中return返回了不同的值,所以到父进程被调度的时候就要执行一次return,子进程被调度也要执行一次return,所以fork函数有两个返回值。上面的讲解方便大家理解,实际的实现更复杂,涉及到操作系统内核、寄存器和上下文切换来实现fork函数有两个返回值的。 -
为什么fork函数的两个返回值,会给父进程返回子进程的PID,给子进程返回0?
因为父进程需要知道新创建的子进程的PID,以便它可以对其进行跟踪、管理(比如发送信号)、等待其结束(通过wait()或waitpid()等函数)或进行其他形式的交互。返回0还意味着子进程可以使用返回值来初始化某些变量,而不必担心与父进程中可能存在的任何特定值发生冲突。 -
fork之后,父子进程谁先运行?
不确定,由操作系统决定。创建完子进程只是开始,创建完子进程后,系统中的其他进程和父子进程要被调度,当父子进程的PCB都在运行队列中排队的时候,哪个的PCB先被选择调度,谁的进程就先运行。
-
如何理解同一个变量会有不同的值?
在讲这个问题之前我们要知道的一个概念是:进程之间运行的时候,无论是什么关系,都是互相独立的。进程的独立性表示在每个进程有自己代码、数据和PCB,进程之间不会互相影响,但是有一个特例就是父子进程,子进程只有自己的PCB,并没有自己的代码和数据,而是与父进程进行共享,当进程运行起来后,代码是只读的无法进行修改,但是数据却可以改变,如果说父进程通过一个全局变量来判断是否结束程序,而子进程却可以改变这个全局变量,那么子进程就可以影响父进程,显然是不符合概念的,所以说父子进程必须有自己独一份的数据,但是如果父进程中的数据非常多,但子进程中需要改变的数据只有寥寥几个,那么将父进程中所有的数据全部拷贝一份给子进程就会降低创建子进程的效率,所以这里的拷贝并不是全部拷贝一份,而是通过写时拷贝进行拷贝的,当父子进程中有一个进程需要改变数据,那么就将这份数据拷贝一份给子进程,其余的数据就共享。通过上面进程关系的讲述,我们再来谈同一个变量会有不同的值的问题,我们通过一个变量id来接收fork的返回值本质上就是修改了数据,所以操作系统需要将变量id进行了写时拷贝,最终使得同一个变量会有不同的值。
结尾
如果有什么建议和疑问,或是有什么错误,大家可以在评论区中提出。
希望大家以后也能和我一起进步!!🌹🌹
如果这篇文章对你有用的话,希望大家给一个三连支持一下!!🌹🌹