CPU只有一个,而进程是有多个的,所以CPU在处理进程的时候势必会有先后顺序,而决定进程获取CPU资源先后顺序的就是优先级。
- 概念:CPU资源分配的先后顺序,就是指进程的优先权。
随便写一段代码,然后让它执行起来,成为一个进程,再使用ps指令来查看该进程的优先级。
- UID:执行该进程的身份
- PID:当前进程的唯一标识符
- PPID:当前进程的父进程的标识符
- PRI:表示优先级,默认值是80
- NI:nice值,用于修改一个进程的优先级。
修改进程的优先级:
- 指令:top。 有时需要使用sudo top指令来提高权限。
- 使用方法:在进入top后,按r,然后输入进程的PID,确认后再输入要修改的nice值。
- 功能:改变进程的优先级。
优先级的计算规制:
新优先级 = 老的优先级(80) + nice值
可以看到,这里的NI值也就是nice值是19,并不是我们设置的100,而此时的优先级是99,也就是80+19,所以说,优先级的设置是有一个范围的,此时我们知道了它的上界是19,下面来看看它的下界。
重新将nice值设置为-50,此时再查看修改后的优先级,发现NI值是-20,优先级是80-20 = 60,并不是我们所设置的值,所以nice值的下界就是-20。
说明:
之所以对优先级的范围进行限制,就是为了防止调度失衡,如果一个进程的优先级可以被设置为无限高或者无限低,此时调度就不再公平,有些进程可能永远也无法获得CPU资源。
- 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
在一般情况下,我们并不需要去设置一个进程的优先级。
进程切换是在多进程并发的时候必然经历的过程。
- 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
一个CPU中只有一套寄存器,例如eax,ebx,eip(PC指针)等等,虽然数量有很多,但是只有一套,而一套寄存器只能供一个进程使用。
当进程1正在被执行的时候,此时CPU中的寄存器中的内容都是和进程1相关的,比如运算产生的临时数据等等。
当CPU从执行进程1变为执行进程2的时候,寄存器中原本属于进程1的内容就需要被保存,因为进程2同样会用到这些寄存器,就会覆盖掉原本的内容。目前可以认为,和进程1相关的寄存器中的内容,被复制到了PCB中。这一过程被叫做,进程切换中的上下文保护。
当CPU再次从执行进程2变为执行进程1的时候,和进程2相关的寄存器中的内容同样会进行保护,并且将进程1的PCB中属于上下文保护的内容再恢复到CPU的寄存器中,覆盖掉属于进程2的内容,接着之前执行到的位置继续执行下去,这一位置由eip(PC指针)寄存器从恢复的数据中读取。这一过程被叫做,进程切换中的上下文恢复。
时间片:
CPU只有一个,进程有很多个,为了能够公平的执行每一个进程,CPU执行每一个进程的时间都是相同的,假设这个时间是10ms。
当进程1被执行了10ms,此时就会将进程1产生的临时数据进行上下文保护,也就是CPU寄存器中的内容都复制到对应的PCB中。再开始执行进程2,当进程2被执行了10ms后,同样会将进程2的上下文进行保护,然后将进程1的上下文进行恢复,继续执行进程1,如此往复,直到俩个进程结束。
CPU中寄存器里的内容,只是属于当前被正在执行的进程。
由于CPU执行的速度很快,一个进程被执行的时间很短,所以多个进程表现出来的结果就是一起被执行下去。而一个进程被执行的时长就是时间片。
此时你是否有一个疑问,CPU为什么不执行完一个进程再去执行下一个进程呢?如果按照这种方式,当被CPU执行的这个进程是一个死循环的情况时,别的进程就永远不会被CPU执行了。
CPU之所以严格按照时间片去执行不同的进程,就是为了保证调度的均衡性,使得每个进程都能够被执行到。
进程的并发就是按照上诉过程进行的。
除了并发,还有几个和进程相关的概念:
- 独立性:多进程运行时。需要独享各种资源,多进程运行期间互不干扰。
比如,父进程崩溃了而不再运行时,子进程并不会受到任何影响,仍然会正常的执行下去。
- 并行:多个进程在多个CPU下分别同时进行。
并行和并发虽然只有一字之差,但它们的意义却完全不同,主要体现在CPU的个数不同。
- 环境变量(environment variables):一般是指在操作系统中用来指定操作系统运行环境的一些参数。
- 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
- 环境变量通常具有某些特殊用途,在系统当中通常具有全局特性。
环境变量在系统运行的时候就会被加载到内存中,供所有进程使用,就类似于全局变量,并且是内存级别的变量。
使用指令env可以看到当前系统中的所有环境变量,如上图所示,这么本喵介绍几个常见的,来给大家解释什么是环境变量。
使用指令echo可以查看环境变量的值。如上图中,下面一串路径就是环境变量PATH的值,不同路径之间用冒号隔开。那么这个环节变量的作用是什么呢?这些路径的表示的意义是什么呢?
在执行一个可执行呈现的时候,需要使用./可执行程序名,其中./就是路径。我们自己写的可执行程序,要想执行就必须加路径才能够执行。
我们知道,指令ll的本质也是一个可执行程序,但是此时就不需要加路径,直接使用程序名就可以执行。
同样的,我们自己的可执行程序也可以不用加路径:
将我们自己写的可执行程序,复制到/user/bin路径下,此时再执行process的时候同样不需要再写路径,直接写可执行程序名就可以执行对应的程序,如上图所示。
但是不建议这样做,因为这样会污染指令池,我们写的这个可执行程序是没有经过检测的,对于/usr/bin/路径中的其他指令文件是不安全的。
在环境变量PATH中加上我们自己所写可执行程序所在的路径,如上图中红色框中所示,此时需要使用到指令export,加上之后再运行我们自己的可执行程序就不用加路径了,只需要输入名字即可。
这是因为,系统会去环境PATH变量中那些路径下去搜索没有带路径的可执行程序。在系统开始运行的时候,就会将PATH变量的值加载到内存中,任何一个进程都能够看到和使用环境PATH中的值。
可以看到,用户不同,环境变量HOME中的值也不同,这也是我们为什么使用指令cd~就可以回到当前目录的家目录的原因,因为会根据环境变量HOME中的值来切换路径。
USER:当前用户名
PWD:当前所处路径
HOSTNAME:主机名
SHELL:当前的shell,通常它的值都是/bin/bash
在系统启动的时候,会把.bash_profile执行一次,把环境变量加载到内存中,供后面各种进程的使用。
- 指令:echo $环境变量名
- 功能:显示环境变量的值
这个指令在上面已经多次使用,本喵这里就不再演示了。
- 指令:env
- 功能:显示所有环境变量
- 可采用env | grep 环境变量名 的组合方式来查看某一个环境变量
- 指令:set
- 功能:显示本地定义的shell变量和环境变量
环境变量现在大概已经了解是什么了,但是本地变量又是什么呢?
我们现在对Linux系统进行的一切操作都是在bash这个shell上进行的,而bash就是一个进程,而且我们可以在这个进程上定义变量,如上图中蓝色框中所示。此时定义的变量就像我们在C/C++代码运行过程中在堆区或者栈区创建变量一样,它是被运行的。
但是此时的变量仅是一个临时的变量,当进程结束以后就会消失,不像环境变量一样是被加载到内存中一直存在的,这种变量叫做本地变量。
可以看到,在环境变量中是无法查找到本地变量的,如上图中红色框,而set查找的不仅是环境变量,还有shell中的本地变量,所以能够查找到我们创建的本地变量,如上图中绿色框。
- 指令:export 变量名
- 功能:创建一个新的环境变量
使用export将原本是本地变量的my_value设置成了环境变量,并且可以在环境变量中查找到。
当然也可以设置一个全新的环境变量:
- 指令:unset 环境变量名
- 功能:清除环境变量
此时就将我们刚刚设置的环境变量清除了,在环境变量中查找不到了。
这是获取环境变量的系统调用,它的需要包含的头文件是stdlib.h,环境变量存在,一字符串的形式返回环境变量的值,也就是char*类型的指针。环境变量不存在,则返回空指针。
如上面代码,使用了系统调用getenv来获取环境变量USER。
可以看到,执行有系统调用的可执行程序的结果,和查看环境变量USER的结果是一样的,因为在程序中使用系统调用getenv获取到了环境变量USER的值。
- 这解释了为什么在执行某些指令的时候,会显示访问被拒绝,权限不足等问题。因为是可以通过系统调用获取当前用户身份的,将结果与设定的用户进行比较,如果不相同就拒绝操作。
比如下面代码:
可以看到我们自己的模拟的pwd和系统中的pwd显示的结果是一样的。
将我们自己写的mycmd的路径加载环境变量PATH中,此时即使改变所处路径,也可以直接使用可执行程序名,实现和mypwd一样的功能,如上图中所示。
不管是我们自己模拟的mycmd还是一直使用的指令pwd,它们为什么能够知道当前所处的路径呢?
系统中有一个环境变量PWD,该变量中的值就是当前所处的路径,而且这个变量是在系统启动的时候就加载到内存中的,并且是由bash来维护的。
环境变量具有全局属性,而我们使用的指令pwd也好,还是自己写的mycmd也好,都属于bash的子进程,全局变量会被子进程继承下去,所以这些子进程都能使用父进程bash维护的环境变量。
其实main函数也是有形参的,只是我们在平时写的时候并不写它的形参。这些形参被叫做命令行参数。
在刚接触Linux的时候,学习了很多指令,比如ls,它可以加各种选项,如上图所示。我们知道,这些指令的实质就是可执行程序,所以我们自己的可执行程序也是可以加各种选项的。
如上图所示,可执行程序的名字和后面的选项会被系统进行处理,处理成多个字符串,可执行程序名是一个,以空格为界,每一个选项是一个字符串。
而这些字符串会被放在一个数组里,这个数组是char*类型的指针数组,如上图绿色框所示。
- int argc表示的字符串的个数。
- char* argv[]表示的存放各个字符串首字母地址的指针数组。
main函数是我们所写程序的入口,而此时的命令行参数是main函数的形参,既然是形参就会接收实参,而这个实参是系统的strrtup函数传递的,也就是说main函数也是被系统调用的。而这俩个命令行参数则在main函数被调用的时候进行了传参。
选项不同,执行的结果不同,入上图所示。
环境变量的组织方式:
而环境变量也是可以通过这种方式获取的。
main函数其实还有一个参数,如上图所示,该变量同样是一个指针数组,里面存放的值是各个环境变量。
环境变量和环境变量的值一起被当作成一个字符串,多个环境变量就有多个字符串,在指针数组env中,每一个字符串的首元素地址占据一个数组元素。
- 该数组的最后一个元素是一个NULL指针
- for循环使用env[i]控制循环结束,因为当i的值为数组env的最后一个元素下标时,此时的env[i]的值就是0,也就是false,此时的for循环就会结束
注意:
在Makefile文件中需要加一个选项,使用c99标准来进行编译,如上图所示,否则就是会编译不过报错。
还有一种方式可以获得环境变量,就是通过外部变量的方式。
如上图所示的环境变量,environ是一个二级指针数组,它执行的数组中,存放着环境变量字符串首元素的地址,这个数组其实就是使用第三个命令行参数的方式中的那个数组。
如上诉代码,使用extern引用外部变量,也就是二级指针变量,如上图中的红色框中所示。
通过上面的分析,可以看到,环境变量是以数组的形式存储的,它的组织方式如下图:
如上图所示,每一个环境变量包括其值都被组成成一个字符串放在数组中。
每个程序在加载到内存中成为进程以后,都会收到操作系给的一张环境表,这个环节表是一个字符指针数组,每一个指针指向一个以’\0’结尾的环境变量字符串。
这个数组中的最后一个元素是一个NULL指针。
这是之前在学习C/C++的时候,经常画的内存,但是真的理解它吗?物理内存中就是这样的吗?其实并不是这样的。
来看一段代码:
#include
#include
#include
int global_value = 10;
int main()
{
pid_t id = fork();
if(id<0)
{
printf("子进程创建失败\n");
return 1;
}
else if(id == 0)
{
int cnt = 0;
while(1)
{
printf("我是子进程。global_value = %d,&global_value = %p\n",global_value,&global_value);
cnt++;
sleep(1);
if(cnt==10)
{
printf("子进程改变了global_value的值\n");
global_value = 100;
}
}
}
else if(id > 0)
{
int cnt = 0;
while(1)
{
printf("我是父进程。global_value = %d,&global_value = %p\n",global_value,&global_value);
cnt++;
sleep(2);
}
}
return 0;
}
上面代码中,创建一个子进程,父子进程同时打印一个全局变量gloabl_value,最开始全局变量是10,在子进程运行10秒钟后将这个全局变量改成了100,然后父子进程继续同时打印这个全局变量。
在子进程改变全局变量之前:
- 父子进程打印全局变量的值都是10。
- 父子进程打印全局变量的地址都是0x60104c。
在子进程改变全局变量之后:
- 子进程打印全局变量的值是100,符进程打印全局变量的值仍然是10。
- 父子进程打印全局变量的地址仍然是0x60104c。
颠覆性的认识出现了,父子进程打印的全局变量是同一个变量,而且地址也是同一个。当子进程改变这个全局变量以后,父子进程打印的全局变量值不一样了,但是打印的这个全局变量的地址还是一样的。
这和我们的认知有很大的差距,同一个物理地址中存放的值只能是唯一的,而此时一个地址中存在了俩个值,说明此时这个地址不是物理地址,也就是不是真实的内存空间。
- 在Linux地址下,这种地址叫做虚拟地址。
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!
- 物理地址,用户一概看不到,由OS统一管理。
在Linux中,虚拟地址也叫做线性地址,有时也被叫做逻辑地址,只需要知道,虚拟地址=线性地址=逻辑地址
虚拟地址必将被操作系统转化成物理地址。
而上面提到的C/C++中的内存空间,在这里我们叫做进程地址空间。
以32位的系统为例,共有4GB的空间,有232个地址,一个地址代表的空间是1Byte大小。
如上图所示,在一个结构体中,有多个变量,这些变量就将这4GB的空间划分了开来。
- 例如,stack_start中放的是栈区的起始地址,stack_start中放的栈区的结束地址。
这样一来,进程地址空间就被描述了出来,也就是被划分了出来,而且可以通过修改结构体变量中的值来调整各个区域的大小。
注意:
在Linux操作系统中,每一个进程都认为自己是独占CPU资源的,而且认为4GB的空间都是属于自己的。因为CPU只有一个,只能通过时间片的方式在一个时刻指执行一个进程,此时这个进程就认为自己拥有所有的资源。
所以当一个可执行程序从硬盘加载到内存中成为一个进程以后,操作系统维护的该进程在PCB中的结构体task_struct也会有一个指向划分地址空间区域结构体的指针。
如上图所示,每个进程的task_struct中的mm指针都会指向一个mm_struct结构体,而这个结构体将4GB的空间划分成了各个区域。
当该进程的栈区以及堆区有数据创建以及释放时,对应的mm_struct中的边界值都会发生变化。
而CPU在取代码以及数据段的数据时,拿到的地址也是mm_struct中对应的地址。
所以说,区域调整的本质就是修改各个区域的end或者start值。
每一个进程的地址空间都是按照这个规则在划分,在变化,而且会认为这些空间以及CPU都是属于自己的。但事实上,进程肯定不只一个,而是有很多个,但是物理空间也就是真实的空间只有4GB,如果真的给每一个进程都分配4GB的地址空间,那么物理空间肯定是不够用的。
- 进程地址空间就是操作系统给每一个进程画的一个大饼,让每一个进程都以为自己有4GB的空间,并且独享CPU资源。
- 而且进程互相之间不是不知道对方的存在的。
进程认为自己有4GB的空间,如果该进程要申请4GB的空间怎么办?此时操作系统肯定不会给它4GB的物理内存空间啊,如果给了那别的进程就没空间用了,所以此时操作系统就告诉这个进程申请失败。
所以每个进程只有申请适当的空间时,操作系统才会给它分配对应大小的物理内存。
此时又有一个问题:
- 每个进程在各自的进程地址空间中,空间的使用情况是不同的,比如代码段数据段的大小。通俗来讲就是,每个进程对操作系统画的这个大饼的完成情况是不一样的。
所以此时的操作系统需要记住个各个进程对这个大饼的完成情况。而方式就是通过维护进程PCB中task_struct中的mm指针来记住的。
此时系统中运行的所有进程的情况都被操作系统所掌握,而每个进程都不知道其他进程的存在,并且认为自己独享4GB的空间以及CPU资源。
这是一段随便的汇编代码,可以看到,每一条汇编代码是有地址的,如上图中的红色框所示,当这个汇编代码经过汇编以后生产的二进制机器码也是有地址的。这个地址就是这些代码在代码段中的地址。
磁盘中的二进制可执行文件(.exe)是源文件经过预处理,编译,汇编,链接四个阶段后形成的,而这过程也是按照进程地址空间的规则进行的,也就是说,编译器也是按照进程地址空间的区域划分规则来编译源文件的。
- 编译后形成可执行程序的地址被叫做逻辑地址,其本质就是虚拟地址,只是因为在磁盘上,所以叫法不同而已。
这样一来,可执行程序中代码的地址就按照进程地址空间的规则分布好了。当可执行程序从硬盘上加载到内存上以后,内存上会给这个可执行程序分配一部分物理空间用来存放代码,但是此时在物理内存中的代码的地址仍然是进程地址空间的地址,这俩个地址是不同的,一个是真实的,一个是虚拟的。
- 加载到内存中的代码有俩个地址:
- 物理地址是真实的地址,是用来存放加载进来的代码的。
- 进程地址空间的地址是代码中每条语句在编译后形成的虚拟地址。
此时就有俩个地址了,而我们通过演示也知道,我们打印以及操作的地址是进程空间的地址,也就是虚拟地址,而物理空间的地址我们是操作不了的,那么它们是怎么对应起来的呢?
如上图所示,操作系统会维护一个叫做页表的东西,如上图中橘色框所示,左边是加载到内存中的的可执行程序,包括代码以及数据。
- 在页表的左边,是进程的虚拟地址
- 在页表的右边,是进程的物理地址
这样一来,虚拟地址和物理地址虽然不一样,但是就一一对应了起来,这个工作是由操作系统进行的。
当进程执行起来以后,堆区和栈区有数据存入的时候,同样会分配虚拟地址,并且在内存中也会有一个真实的物理地址,操作系统再通过页表将虚拟地址和物理地址对应起来。
- 补充知识点:
- 在32为的系统上,物理内存的大小是4GB,每4KB被叫做一页。
- 所以说,物理内存中有4GB/4KB页
编译器在进程编译的时候,还有一种比较老的方式,就是采用相对地址偏移的方式,此时编译出来的可执行程序,只有一个基地址,之后的所有代码都是在这个基地址的基础上做加减法,而加减的值就是偏移量。
这种方式下,页表中的左边,也就是存放虚拟地址的那一列,存放的就是基地址和偏移量,而不是具体的虚拟地址了。
而现在大部分采用的方式都是第一种,就是直接使用具体的虚拟地址,而不使用偏移量。
多个进程的时候,每个进程都有一个进程地址空间,它们的划分规则都是一样的,并且它们都有各自的页表,通过各自的页表,映射到物理内存中去。
如上图中所示,子进程和父进程即使它们各自的进程地址空间完全一样,但是当映射到内存中以后,它们的物理地址是不一样的,各占一块空间。
- CPU在取指令的时候,它读到的地址同样是虚拟地址,因为它是根据PCB中的task_struct中的进程属性找到进程在内存中对应的代码的。
- 而PCB是由操作系统维护的,操作系也遵循着虚拟地址空间的规则,所以它维护的PC指针中的值就是虚拟地址。
- CPU需要拿上虚拟地址通过页表才能找到指令在物理内存中的真正位置。
此时就可以回答最开始那个问题了,为什么同一个全局变量在父子进程中的值不同,但是地址是相同的?
如上图中,有俩个进程,假设它们一个是父进程,一个是子进程,就像本喵上面写的程序那样。
在父子进程的进程地址空间中,相同的地址处都有一个全局变量global_value,通过各自的页表,映射在了物理内存中。
- 在这个全局变量每一发生改变的时候,父子进程中该值是一样的,所以在物理内存中俩个进程只需要映射到一块物理空间即可。
- 当子进程改变了它的全局变量,此时由于俩个进程该变量的值不一样了,所以不能再映射到一块物理空间中了。
- 所以此时操作系统会将原本在物理空间中的全局变量拷贝一份,放在另一块空间中,并且将新物理空间的物理地址更新到子进程的页表中。
- 此时父子进程中,全局变量在各自的进程地址空间中的虚拟地址仍然是相同的,但是各自页表中对应的物理地址已经不同了。
在上诉过程中,当子进程将全局变量改变以后,操作系统将原本物理空间中的值拷贝一份放到新的空间中,这一行为叫做写时拷贝。
写是拷贝,故名思意,只有在写入的时候才会发生的拷贝行为。
进程地址空间的存在保证了系统的安全。如果进程直接访问物理空间,当进程访问的地址是非常重要的空间时,此时就是非法访问。
但是有了虚拟地址空间,进程想访问哪里就访问哪里,当操作系统将进程的虚拟地址和物理地址做映射的时候,系统会发现这是一次非法的方法,就可以直接拒绝。
此时的非法请求从进程发出,还没有到达物理内存就被拒绝了,对物理内存没有任何的影响。
不同的进程即使操作同一块空间,因为虚拟地址空间的存在,操作系统在将不同进程的相同地址和物理空间做映射的时候,就可以在物理内存中分配不同的物理空间供不同的进程使用。
如此一来,即使进程操作的是相同的地址,但是映射到物理内存中后,操作的物理空间就不同了,并不会互相影响,保证了进程的独立性。
进程空间地址的存在,让进程以为自己拥有所有的空间,从而可以随意进行操作,而不用考虑是否会影响到其他的进程。
同样的,编译器在编译不同进程的时候,只需要按照进程地址空间一套规则编译即可,每个进程都一视同仁,不用考虑不同进制之间的影响。
总的来说,进程地址空间的存在,就是让各个进程只做自己的事而不用考虑其他人,编译器在编译的时候也只需要考虑一个进程。
不同进程的虚拟地址最后会由操作系统通过页表与物理内存映射起来。
优先级以及环境变量是一些概念性的东西,理解起来没有难度。而进程地址空间是比较难理解的,要时刻牢记我们操作的地址都是虚拟地址,这个地址是假的,可以随意操作,但是这个地址又必须在物理内存中对应存在,操作系统通过页表将虚拟地址和物理地址映射了起来。
总之,操作系统为了各个进程的正常运行以及系统的正常工作,做了很多的工作。