一般课本上都会这样说:一个可执行程序被执行后就变成了一个进程。
站在操作系统的角度:进程是担当分配系统资源的实体。
如果说给进程一个准确的定义,应该是:
内核数据结构(后面内容会提到) + 该进程对应的代码和数据。
操作系统是一个软硬件资源管理的软件,那么相比进程也要被操作系统(OS)管理。
那么操作系统是如何对进程进行管理的呢?答案肯定是:先描述,在组织,对一个进程我们首先要用计算机语言对其进行描述,再利用相关的数据结构将其组织管理起来。
在操作系统的书籍上称描述进程的结构体为pcb,在linux操作系统下这个结构体叫做task_struct,这是在操作系统内核中创建的一种数据结构。但是一台计算机上会同时有多个进程(你可以打开你的任务管理器,看到许多进程正在跑着),操作系统是如何将这么多进程组织起来的呢?
是将各个进程的pcb(process contro block)利用链表这种数据结构对其组织起来。
操作系统内核中创建pcb来完成对进程的管理,那么这个结构体里究竟都有什么内容呢?
这里的部分内容在后面会提到。
/proc系统文件夹里查看
这是一个内存级的文件目录,也就是启动机器后被加载到内存上的。
可以看到,这些蓝色数字(进程的PID)标示的文件夹,就是关于每个进程的信息。
进入到15290文件夹中,可以看到这里面的内容就是关于进程PID为15290的进程的。
ps -axj 命令查看
由于很多进程都在运行着,用ps -axj命令查看时非常难以分辨,所以通常配合 grep 指令来查看进程的信息。
ps -axj | head -1 && ps -axj | grep bash | grep -v grep
我们知道我们的命令行解释器就是一个一直在运行的进程,上面命令就是查询它的信息,为了去掉关于grep 这个进程的信息可以使用,grep -v选项将其去掉
可以看到如果不用grep -v将grep这个进程的信息也会被显示出来。
我们自己写的程序当被 ./ 被执行的时候也会变成进程,那么操作系统也会为其创建pcb,那么也应该能通过此命令查询到。
#include
#include
int main()
{
while(1)
{
printf("hello Linux\n");
sleep(1);
}
return 0;
}
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <sys/types.h>
4
5 int main()
6 {
7 pid_t id = getpid();
8 pid_t pid = getppid();
9 printf("当前进程的pid:%d,父进程的pid:%d\n",id,pid);
10 return 0;
11 }
~
我们通过ps命令查看一下bash的pid与打印出来的是否一置。
可以看到test这个进程的父进程就是bash。
用户可以通过fork这个系统调用来创建子进程的。
可以看到 fork() 这个函数很特殊,成功创建子进程后居然有两个返回值,给父进程返回子进程pid,给子进程返回 0,如果创建失败那么就返回 -1。
至于为什么会这样,在下面的进程地址空间中会讲到。
fork()函数调用后的变化:
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <sys/types.h>
4 #include <stdlib.h>
5 int main()
6 {
7 int a = 100;
8 pid_t id = fork();
9 if(id == 0)
10 {
11 //child
12 while(1)
13 {
14 --a;
15 printf("pid: %d,ppid: %d,a = %d,&a = %p\n",getpid(),getppid(),a,&a);
16 sleep(1);
17 }
18 }
19 else if(id > 0)
20 {
21 //parent
22 while(1)
23 {
24 printf("pid: %d,ppid: %d,a = %d,&a = %p\n",getpid(),getppid(),a,&a);
25 sleep(1);
26 }
27 }
28 else
29 {
30 perror("fork()\n");
31 exit(1);
32 }
33 return 0;
34 }
可以看到对于变量a,子进程中对其进行了修改,由于进程的独立性所以并不会影响到父进程中a变量的值,但是它们的地址确是一样的?这就说明这里的地址不会是物理地址,因为如果是物理地址那么地址一样,值就必然一样。(至于怎么回事?下面进程地址空间中会提到)。
上面写的代码是一个死循环的代码,会一直跑下去,我们要如何终止该进程呢?
在Linux操作系统下,进程的状态主要分为一下七种。
ps -axj 指令查看进程的信息
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <sys/types.h>
4
5 int main()
6 {
7 while(1)
8 {
9 ;
10 }
11
12 return 0;
13 }
1 #include <stdio.h>
2 #include <unistd.h>
3 #include <sys/types.h>
4
5 int main()
6 {
7 int n = 0;
8 scanf("%d",&n);
9 return 0;
10 }
kill -l 指令 列出所有控制进程的信号
向运行的进程发送SIGSTOP信号,就会让该进程暂停处于T状态。(在T状态时发送SIGCONT指令可以让其继续运行)
kill -19 PID
kill -18 PID 使暂停的进程继续运行
t 追踪状态
t状态使一种追踪暂停状态(tracing stop)这种状态的进程最常见的就是我们在使用gdb调试时,打一个断点如果不进行其他操作那么这个进程就会停在断点处。
X 死亡状态
当一个进程死亡一瞬间的时候就是死亡状态,但是它是一瞬间的,可能会观察不到,因为一个进程死亡后就会变成僵尸进程。
Z 僵尸状态
进程死亡后的状态。一个进程死亡后,会处于僵尸状态如果其父进程不为其“收尸”的话,那么其会一直占用资源,造成内存泄漏。
特殊的进程主要分为两种一种是僵尸进程,另一种是孤儿进程。
一个进程死亡后,会处于僵尸状态其进程控制块pcb会一直占用操作系统内核资源,所以某个进程死亡后,其父进程要对其资源进行释放也就是进程等待(后面会分享)。
下面演示一下僵尸进程:
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5
6 int main()
7 {
8 pid_t id = fork();
9 if(id == 0)
10 {
11 //child
12 while(1)
13 {
14 printf("我是子进程,我的pid :%d,我的ppid: %d\n",getpid(),getppid());
15 sleep(1);
16 }
17 }
18 else if(id > 0)
19 {
20 //parent
21 while(1)
22 {
23 printf("我是父进程,我的pid :%d,我的ppid: %d\n",getpid(),getppid());
24 sleep(1);
25 }
26 }
27 else
28 {
29 perror("fork");
30 exit(-1);
31 }
32 return 0;
33 }
这里有的人会有疑惑,明明两个进程都在运行状态,为什么查出来回事休眠状态呢?
这是由于CPU的速度太快了,因为这两个进程都会访问显示器资源,他们的大部分时间都会在显示器的等待队列中排队,而被CPU调度的时间是非常短的,因为CPU的速度极快。可以打个比方在100秒的时间内,这个进程的pcb有99秒都在显示器的等待队列中,只有1秒在CPU的运行队列中,而我们这样查询很难查询到其正处于被调度的状态,所以查出来的结果就是休眠状态,这是很正常的。
kill - 9杀死子进程
可以看到子进程就处于了Z(僵尸)状态。
但是我们也有办法,父进程通过进程等待的方式会为子进程”收尸“。
当父进程先于子进程结束的时候,子进程会成为孤儿进程,但是会被PID为1的进程收养,这个PID为1的进程是操作系统。如果操作系统不对其收养,那么其结束的时候,没有人给其“收尸”,这样会造成内存泄漏。
通过kill -9 杀死父进程,我们发现子进程的状态变为了S,与之前相比少了一个+,没有+表示此进程在后台运行无法通过ctrl C 终止,能通过kill 的方式将其杀死。
可以看到父进程先于子进程退出后,子进程会被操作系统收养(PID为1的进程)。子进程死亡后,操作系统会释放其相关资源,避免内存泄漏。
之前提到进程运行就是其pcb维护在CPU的运行队列当中,但在这个队列当中是否存在优先级呢?先调度谁后调度谁。
ps -la 指令查看进程相关的信息
PRI使进程的优先级,值越小优先级越高,nice值使对进程优先级的修正值,nice的取值范围是 -20 -19。
(new)PRI = (old)PRI + nice,所以nice取负值的时候,会提高进程的优先级。
注意: old PRI 是指从80开始的。
更改进程的优先级是修改该进程的nice值。
top 指令修改已经存在进程的nice值
1,top指令
2,输入r
3,输入要修改的进程的PID
4,输入新的nice值
环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数
在我们安装许多软件的时候,都需要配置环境变量,你是否为此烦恼过,这环境变量就是是个啥东西?
例如你在下载Vs Code 的时候,给其配置gcc编译器的时候,就需要将gcc编译器在磁盘中的路径添加到环境变量表中,这其中的原理是什么呢?
我们发现bash中自带的ls指令直接敲ls就能执行,但是为什么我们自己写的mytest程序,直接敲mytest却会提示not found,必须加上 ./mytest 才能运行,这是为什么呢?
首先说明一下,Linux的指令与我们写的程序没有什么不同,Linux操作系统就是由C语言编写的,其中的大部分指令都是C程序,但是bash自带的指令例如ls这个可执行程序,它的地址储存在了环境变量PATH中,我们输入ls时,bash会自动去环境变量中去找ls的路径,并执行。而我们自己写的可执行程序,必须加./来指明其路径。
可以看到ls的路径是在 /usr/bin 目录下,为了验证上述说法,查看一下PATH环境变量看看其是否包含了此路径。
可以看到PATH环境变量中包含了许多路径,它们是以:分隔的。可以看到 /usr/bin 路径就在其中。
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5 extern char** environ;
6 int main()
7 {
8
9 int i = 0;
10 for(;environ[i]; i++)
11 {
12 printf("%d -> %s\n",i,environ[i]);
13 }
14 return 0;
15 }
int main(int argc,char* argv[ ] ,char * envp[ ] );
envp中与environ全局变量的结构一致,其最后一个有效元素的下一个位置为NULL。
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5
6 int main(int argc,char* argv[],char *envp[])
7 {
8 int i = 0;
9 for(;envp[i];i++)
10 {
11 printf("%d -> %s\n",i,envp[i]);
12 }
13 }
参数为环境变量的名称,我们知道环境变量是key - value的结构,key是其名称,value表示其内容。
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5
6
7 int main()
8 {
9 char* env_path = getenv("PATH");
10 if(env_path != NULL)
11 printf("PATH : %s\n",env_path);
12 return 0;
13 }
与前面的方法不同,getenv一次只能获取一个环境变量。
在之前讲到,Linux指令不用加 ./只用其名字就可以直接运行,是因为其路径存在了PATH环境变量中,当运行该程序时系统会自动去PATH环境变量中去找。
那可不可以将自己写的可执行程序的路径添加到PATH中呢?
答案是可以的。
export PATH = $PATH:要添加的路径
$PATH的意思是显示PATH环境变量中的内容,如果不加这句之前的PATH环境变量会被覆盖掉,而我们添加路径其实是追加。
可以看到成功为PATH环境变量添加了一个新的路径,下面来验证一下test目录下自己写的程序可不可以不加./运行。
神奇的一幕发生了,居然是可以的!!!
重启机器后发现确实之前更改的PATH环境变量又恢复了最初的内容。
环境变量这张表是内存级的,说明在开机的时候会被加载到内存,那么是从哪里加载的呢?
家目录下有关于环境变量的文件 .bashrc .bash_profile
这两个配置文件是 /etc 路径下的。
可以看到这些文件里都是关于一些环境变量的配置,当开机时环境变量就是从这些系统的配置文件中加载到内存的。
set指令会将所有变量打印出来,包括环境变量和本地变量
这里只截取了一部分,可以看到刚刚定义的myval变量也被打印了出来。
set指令与env指令的区别是:
env指令只打印环境变量
set指令打印环境变量与本地变量
可以看到通过export成功的将myval这个本地变量倒成了环境变量。
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5
6
7
8 int main()
9 {
10 char* env_myval = getenv("myval");
11 if(env_myval != NULL)
12 printf("myval : %s\n",env_myval);
13 return 0;
14 }
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5
6
7 int main()
8 {
9 char* env_value = getenv("value");
10 if(env_value != NULL)
11 printf("value : %s\n",env_value);
12 else
13 printf("Not have value\n");
14 return 0;
15 }
我们在使用Linux指令的时候,通常一个指令会搭配不同的选项使用,例如ls -a ,ls -l等等,不同的搭配实现的功能也不同。但这是如何实现的呢?
在上面分享环境变量的时候提到main函数的参数,其中有两个参数 int argc,char* argv[ ]
argv是一个表结构,有argc个元素,会将我们输入的不同选项存入其中,从而达到不同的选项实现不同的功能的目的。argv[0]存的是指令的名称,后序存的是指令选项。
下面就运用这个规则,写出一个具有不同选项的程序:
1 #include <stdio.h>
2 #include <string.h>
3 int main(int argc,char* argv[])
4 {
5 if(argc == 2)
6 {
7 if(strcmp(argv[1],"-a") == 0)
8 printf("功能1\n");
9 else if(strcmp(argv[1],"-b") == 0)
10 printf("功能2\n");
11 else if(strcmp(argv[1],"-c") == 0)
12 printf("功能3\n");
13 }
14 return 0;
15 }
这是在上面介绍fork函数的时候留下的一个问题,为什么统一个变量地址都是相同的,但内容却不一样,很显然不是物理地址,说明我们使用的地址是虚拟地址。
一个C/C++程序员通常把内存分为堆区,栈区,数据段,代码段等等。
这些其实都不是真正的物理内存,而是在进程控制块pcb中维护的进程地址空间(也就是所说的线性空间或者虚拟空间)。
对于32为机器来说地址空间中存在一个线性范围从 0x00000000 - 0xffffffff这样每一个地址对应一个字节,由于这些地址是线性的故称为线性地址空间 。
而不同的区域其实就是对这些线性地址进行区域的划分。
父进程通过fork创建出子进程,子进程的pcb中的大部分内容都是从父进程的pcb中拷贝的,也就是说父进程中的某个变量a的地址,在子进程中地址是不变的。
但是为了保证进程的独立性,父子进程不能互相干扰,由于他们中a变量的地址是相同的,不能因为一个进程同修改了a变量从而影响另一个进程,所以在修改数据的时候会发生写时拷贝。
写时拷贝:当父子某个进程修改数据时,OS会先在物理内存中新开辟一块空间,拷贝一份原来的数据,然后通过页表修改虚拟地址到物理地址的映射关系,从而保证了进程的独立性。
在说明malloc本质之前首先,要明确三点:
1,进程在向OS申请内存空间的时候,OS不会立马将空间的使用权交给进程,而是在进程需要使用的时候在为其分配内存。
2,OS是不允许出现任何不高效的行为的。
3,如果进程申请了内存,但是其没有立马使用,在进程申请之后与使用之前这段时间内,进程没有使用该空间,别的进程也不能使用该空间,这无疑就是一种不高效的行为,OS是不会允许这样的行为出现的。
malloc的实现过程:
进程中需要申请内存的时候,首先OS会在虚拟地址空间上为其分配内存,当进程需要用到这块空间的时候,OS会为其在物理内存上开辟,并将使用权交给进程(这种操作叫做缺页中断),这无疑大大提高了OS的效率。
编译器在编译代码的时候,其内部有这样的虚拟地址嘛? 答案是有的。
objdump -S a.out
上面的指令会对可执行程序进行反汇编,在反汇编中我们会发现就形成了虚拟地址。
也就是说在编译的阶段就有了数据段代码段这样的概念,在被加载到内存的时候,是整块加载的。
在CPU执行代码的时候,首先会通过某种操作找到第一条语句的物理地址,执行玩这条语句的时候,会获取下一条语句的地址,而CPU获取的都是虚拟地址,然后通过页表的映射找到物理地址,这样就形成了一个闭环。
1, 防止地址随意访问,保护物理内存和其他进程
假设没有虚拟地址空间,那么CPU执行代码的时候直接与物理内存交互,如果你的代码有问题出现了野指针的问题,那么CPU对野指针指向的物理空间做相关操作的时候,如果这个地址是其他进程的地址,那么这就会破坏了其他的进程,这是非常不安全的。
而有了虚拟地址空间以及页表的存在,相当于增加了一层软件层做保护,CPU得到的地址都是虚拟地址,在通过页表做映射的时候,页表会对地址做相关的检查,避免出现上面的哪种问题。
2,进程的管理与内存管理解耦
在管理进程中使用的地址都是虚拟地址,CPU并不关心其真实的物理地址,这样进程的管理和内存的管理就达到了解耦,两个互相不受各自的影响。
3,可以让进程以统一的视角看待自己的代码和数据
对于每个进程而言,他能看到的都是其虚拟地址空间,并且会认为CPU单独的为它服务,并不关心真实的物理地址,从而达到了使进程以统一的视角看待自己的代码和数据。
进程的代码和数据被加载到内存时,并不会一下都加载到内存中,而是虚拟地址中用的部分代码和数据会被加载到内存当中,这样使内存的利用率提高。