进程概念
基本概念
一般课本上都会这样说:一个可执行程序被执行后就变成了一个进程。
站在操作系统的角度:进程是担当分配系统资源的实体。
如果说给进程一个准确的定义,应该是:
内核数据结构(后面内容会提到) + 该进程对应的代码和数据。
进程的描述-pcb
pcb
操作系统是一个软硬件资源管理的软件,那么相比进程也要被操作系统(OS)管理。
那么操作系统是如何对进程进行管理的呢?答案肯定是:先描述,在组织,对一个进程我们首先要用计算机语言对其进行描述,再利用相关的数据结构将其组织管理起来。
在操作系统的书籍上称描述进程的结构体为pcb,在linux操作系统下这个结构体叫做task_struct,这是在操作系统内核中创建的一种数据结构。但是一台计算机上会同时有多个进程(你可以打开你的任务管理器,看到许多进程正在跑着),操作系统是如何将这么多进程组织起来的呢?
是将各个进程的pcb(process contro block)利用链表这种数据结构对其组织起来。
操作系统也是在内存中的。
task_struct中内容分类
操作系统内核中创建pcb来完成对进程的管理,那么这个结构体里究竟都有什么内容呢?
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。 这里的标示符指的是PID
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/ O状态信息: 包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
这里的部分内容在后面会提到。
查看进程
查看进程的信息
- /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<stdio.h>#include<unistd.h>intmain(){while(1){printf("hello Linux\n");sleep(1);}return0;}
通过系统调用来查看进程的标识符
- PID :进程id
- PPID :父进程的id 我们知道我们自己写的程序是通过bash来创建子进程执行的,所以使用命令行bash启动的程序,其父进程都是bash。getpid/getppid这两个系统调用一个返回的是当前进程的id,一个返回的是当前进程父进程的id。
1 #include <stdio.h>2 #include <unistd.h>3 #include <sys/types.h>45intmain()6{7pid_t id =getpid();8pid_t pid =getppid();9printf("当前进程的pid:%d,父进程的pid:%d\n",id,pid);10return0;11}~
我们通过ps命令查看一下bash的pid与打印出来的是否一置。
可以看到test这个进程的父进程就是bash。
创建进程(fork)
用户可以通过fork这个系统调用来创建子进程的。
可以看到 fork() 这个函数很特殊,成功创建子进程后居然有两个返回值,给父进程返回子进程pid,给子进程返回 0,如果创建失败那么就返回 -1。
至于为什么会这样,在下面的进程地址空间中会讲到。
fork()函数调用后的变化:
- fork之后,执行流会变成两个执行流。
- fork执行之后父子进程,父子进程谁先执行是随机的由调度器决定先调度谁。
- fork之后,fork之后的代码共享,通常我们采用if else 语句来进行分流,父子进程执行不同的代码
- 不同进程之间是相互独立的,父子进程也是如此,这是如何实现的呢? 对于代码:代码是只读的,父子进程公用一份代码谁读谁的代码互不影响。 对于数据:当有一个执行流想要修改数据的时候会发生写时拷贝(下面的进程地址空间会提到),来保证进程的独立性。 使用fork( )创建子进程的例子:
1 #include <stdio.h>2 #include <unistd.h>3 #include <sys/types.h>4 #include <stdlib.h>5intmain()6{7int a =100;8pid_t id =fork();9if(id ==0)10{11//child12while(1)13{14--a;15printf("pid: %d,ppid: %d,a = %d,&a = %p\n",getpid(),getppid(),a,&a);16sleep(1);17}18}19elseif(id >0)20{21//parent22while(1)23{24printf("pid: %d,ppid: %d,a = %d,&a = %p\n",getpid(),getppid(),a,&a);25sleep(1);26}27}28else29{30perror("fork()\n");31exit(1);32}33return0;34}
可以看到对于变量a,子进程中对其进行了修改,由于进程的独立性所以并不会影响到父进程中a变量的值,但是它们的地址确是一样的?这就说明这里的地址不会是物理地址,因为如果是物理地址那么地址一样,值就必然一样。(至于怎么回事?下面进程地址空间中会提到)。
杀掉进程(kill)
上面写的代码是一个死循环的代码,会一直跑下去,我们要如何终止该进程呢?
- ctrl + c
- kill -9 pid
- killall 进程名
进程状态
在Linux操作系统下,进程的状态主要分为一下七种。
- R 运行状态 一个进程处于运行状态,那么就说明此进程的pcb维护在运行队列当中,正在被CPU或等待被CPU调度的状态,那么就是说可以同时存在多个运行状态。
ps -axj 指令查看进程的信息
1 #include <stdio.h>2 #include <unistd.h>3 #include <sys/types.h>45intmain()6{7while(1)8{9;10}1112return0;13}
可以看到这个死循环的进程就处于R状态。
- S 休眠状态 S状态表明进程需要等待某种资源的就绪,处于一种阻塞状态,例如一个进程要访问显示器这个资源但是但是并不只是它这一个进程要访问显示器资源,所以OS会为每个硬件维护一个排队的队列,将等待需要这种资源的进程都维护在这个队列中。一旦进程所需的资源全部都就绪后,那么它就会被维护在CPU的运行队列中。
1 #include <stdio.h>2 #include <unistd.h>3 #include <sys/types.h>45intmain()6{7int n =0;8scanf("%d",&n);9return0;10}
在等待键盘输入的过程中,进程就处于睡眠状态。
- D 不可中断休眠状态 这个状态与S状态不同,这是一种不可中断的休眠状态,S状态的进程可以被OS杀死,但是D状态的进程是不可以被OS杀死的,只能等待其自己苏醒。而D状态通常是在向磁盘中高IO的状态,导致磁盘空间不足导致该进程为D状态,一般情况下是不会出现这种状态的。
- T 停止状态 T状态是处于一种暂停状态,它可以使OS认定该进程当前的行为可能存在危险等,该进程就会被OS暂停,或者用户可以手动对其发送信号对其进行暂停。
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>56intmain()7{8 pid_t id =fork();9if(id ==0)10{11//child12while(1)13{14printf("我是子进程,我的pid :%d,我的ppid: %d\n",getpid(),getppid());15sleep(1);16}17}18elseif(id >0)19{20//parent21while(1)22{23printf("我是父进程,我的pid :%d,我的ppid: %d\n",getpid(),getppid());24sleep(1);25}26}27else28{29perror("fork");30exit(-1);31}32return0;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 指令查看进程相关的信息
- UID:代表执行者的身份
- PID:这个进程的标识符
- PPID:这个进程父进程的标识符
- PRI:这个进程的优先级,值越小优先级越高
- NI:代表这个进程的nice值
PRI与nice
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值
如果是普通用户只能调整nice为正值。
- nice -n指令 进程启动前调整进程nice值
- renice指令 进程运行中调整nice值 普通用户使用renice要进行提权。
环境变量
定义
环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数
在我们安装许多软件的时候,都需要配置环境变量,你是否为此烦恼过,这环境变量就是是个啥东西?
例如你在下载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 路径就在其中。
查看环境变量
- env 指令
通过env指令可以查看所有的环境变量
- environ 全局变量 在进程中都会有environ这个环境变量,其类型是一个指针数组,其中维护了所有的环境变量。其中这个指针数组的最后一个有效字符串的下一个位置存的是NULL。
1 #include <stdio.h>2 #include <stdlib.h>3 #include <unistd.h>4 #include <sys/types.h>5externchar** environ;6intmain()7{89int i =0;10for(;environ[i]; i++)11{12printf("%d -> %s\n",i,environ[i]);13}14return0;15}
- main函数的envp参数 main函数也是可以传参的,应该见过的是main函数这两个参数main(int argc,char*[] argv),其实main函数还可以传环境变量的参数 envp其类型也是指针数组类型,
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>56intmain(int argc,char* argv[],char*envp[])7{8int i =0;9for(;envp[i];i++)10{11printf("%d -> %s\n",i,envp[i]);12}13}
- 通过调用 getenv( ) 获取某个环境变量
参数为环境变量的名称,我们知道环境变量是key - value的结构,key是其名称,value表示其内容。
1 #include <stdio.h>2 #include <stdlib.h>3 #include <unistd.h>4 #include <sys/types.h>567intmain()8{9char* env_path =getenv("PATH");10if(env_path !=NULL)11printf("PATH : %s\n",env_path);12return0;13}
与前面的方法不同,getenv一次只能获取一个环境变量。
设置环境变量(export)
在之前讲到,Linux指令不用加 ./只用其名字就可以直接运行,是因为其路径存在了PATH环境变量中,当运行该程序时系统会自动去PATH环境变量中去找。
那可不可以将自己写的可执行程序的路径添加到PATH中呢?
答案是可以的。
export PATH = $PATH:要添加的路径
$PATH的意思是显示PATH环境变量中的内容,如果不加这句之前的PATH环境变量会被覆盖掉,而我们添加路径其实是追加。
可以看到成功为PATH环境变量添加了一个新的路径,下面来验证一下test目录下自己写的程序可不可以不加./运行。
神奇的一幕发生了,居然是可以的!!!
- 环境变量是一张内存级的表,也就是说虽然你当前修改了PATH环境变量的内容,但是你重启机器的时候PATH环境变量又会变回原来的内容。
重启机器后发现确实之前更改的PATH环境变量又恢复了最初的内容。
环境变量这张表是内存级的,说明在开机的时候会被加载到内存,那么是从哪里加载的呢?
家目录下有关于环境变量的文件 .bashrc .bash_profile
这两个配置文件是 /etc 路径下的。
可以看到这些文件里都是关于一些环境变量的配置,当开机时环境变量就是从这些系统的配置文件中加载到内存的。
本地变量与环境变量
命令行式的定义变量
我们在bash这个命令行解释器下是可以直接定义变量的。
- set指令
set指令会将所有变量打印出来,包括环境变量和本地变量
这里只截取了一部分,可以看到刚刚定义的myval变量也被打印了出来。
set指令与env指令的区别是:
env指令只打印环境变量
set指令打印环境变量与本地变量
环境变量与本地变量的区别
- 环境变量具有全局属性,会被子进程继承
- 环境变量表是维护在shell内部的
- 本地变量只存在于当前shell进程
- 可以通过export 本地变量 将其倒成环境变量
可以看到通过export成功的将myval这个本地变量倒成了环境变量。
- 验证环境变量会被子进程继承 我们写的任何一个程序都是shell的子进程,所以可以通过mytest这个可执行程序,调用getenv获取myval环境变量。
1 #include <stdio.h>2 #include <stdlib.h>3 #include <unistd.h>4 #include <sys/types.h>5678intmain()9{10char* env_myval =getenv("myval");11if(env_myval !=NULL)12printf("myval : %s\n",env_myval);13return0;14}
- 验证本地变量不能被子进程继承 首先定义一个本地变量,通过在mytest这个shell的子进程中调用getenv查看其环境变量表中有无value这个变量。
1 #include <stdio.h>2 #include <stdlib.h>3 #include <unistd.h>4 #include <sys/types.h>567intmain()8{9char* env_value =getenv("value");10if(env_value !=NULL)11printf("value : %s\n",env_value);12else13printf("Not have value\n");14return0;15}
- unset 指令取消本地和环境变量 刚刚我们将myval倒成了环境变量,使用unset myval 可将其取消掉。
同样unset可以将value这个本地变量取消掉。
命令行参数
我们在使用Linux指令的时候,通常一个指令会搭配不同的选项使用,例如ls -a ,ls -l等等,不同的搭配实现的功能也不同。但这是如何实现的呢?
在上面分享环境变量的时候提到main函数的参数,其中有两个参数 int argc,char* argv[ ]
argv是一个表结构,有argc个元素,会将我们输入的不同选项存入其中,从而达到不同的选项实现不同的功能的目的。argv[0]存的是指令的名称,后序存的是指令选项。
下面就运用这个规则,写出一个具有不同选项的程序:
1 #include <stdio.h>2 #include <string.h>3intmain(int argc,char* argv[])4{5if(argc ==2)6{7if(strcmp(argv[1],"-a")==0)8printf("功能1\n");9elseif(strcmp(argv[1],"-b")==0)10printf("功能2\n");11elseif(strcmp(argv[1],"-c")==0)12printf("功能3\n");13}14return0;15}
进程地址空间
简单理解
这是在上面介绍fork函数的时候留下的一个问题,为什么统一个变量地址都是相同的,但内容却不一样,很显然不是物理地址,说明我们使用的地址是虚拟地址。
一个C/C++程序员通常把内存分为堆区,栈区,数据段,代码段等等。
这些其实都不是真正的物理内存,而是在进程控制块pcb中维护的进程地址空间(也就是所说的线性空间或者虚拟空间)。
- 进程地址空间是维护在pcb中一个数据结构mm_struct{}。
- 地址空间是一个线性区域,其空间区域的划分只需要指明起始地址和终止地址。
对于32为机器来说地址空间中存在一个线性范围从 0x00000000 - 0xffffffff这样每一个地址对应一个字节,由于这些地址是线性的故称为线性地址空间 。
而不同的区域其实就是对这些线性地址进行区域的划分。
- 例如堆区栈区在调整区域大小的时候,起始就是修改了边界值。
- 数据和代码只会存在物理内存中,但是进程直接用的是虚拟地址,在虚拟地址和物理地址间有一个页表的结构,完成从虚拟地址到物理地址的映射。
父进程通过fork创建出子进程,子进程的pcb中的大部分内容都是从父进程的pcb中拷贝的,也就是说父进程中的某个变量a的地址,在子进程中地址是不变的。
但是为了保证进程的独立性,父子进程不能互相干扰,由于他们中a变量的地址是相同的,不能因为一个进程同修改了a变量从而影响另一个进程,所以在修改数据的时候会发生写时拷贝。
写时拷贝:当父子某个进程修改数据时,OS会先在物理内存中新开辟一块空间,拷贝一份原来的数据,然后通过页表修改虚拟地址到物理地址的映射关系,从而保证了进程的独立性。
- 那么现在也就能理解fork为什么会有两个返回值? 其实就是发生了写时拷贝,在fork函数return的时候其实已经完成了fork函数的主题内容, 那么此时就已经创建出来了子进程,fork函数之后的代码会共享,那么return也会返回两次,return的本质就是写入,写入就是对原有数据做更改,那么就会发生写时拷贝,从而就形成了父子进程都会接收到返回值,且返回值的内容不同,但是存放返回值变量的地址相同(虚拟地址)。
深入理解
malloc的本质
在说明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单独的为它服务,并不关心真实的物理地址,从而达到了使进程以统一的视角看待自己的代码和数据。
进程的代码和数据会一直在内存中嘛
进程的代码和数据被加载到内存时,并不会一下都加载到内存中,而是虚拟地址中用的部分代码和数据会被加载到内存当中,这样使内存的利用率提高。
版权归原作者 大理寺j 所有, 如有侵权,请联系我们删除。