了解进程调度,Linux进程优先级,理解进程竞争性与独立性,理解并行与并发
Linux的调度与切换,了解Linux2.6内核中进程队列的数据结构
理解环境变量,熟悉常见环境变量及相关指令, getenv/setenv函数
理解C内存空间分配规律,了解进程内存映像和应用程序区别, 认识地址空间。
前提:进程要访问某种资源,进程进行通过一定的方式(排队),确认享受资源的先后顺序。
提问:优先级 vs 权限
权限绝对的是能不能的问题,而优先级决定的是你先还是我先的问题,说白了,只要到了优先级,大概率一定是你拥有享受这个资源的权限了,只不过需要通过优先级确定先后的问题。
为什么要有优先级呢?
我们可以想一想,如果我们在食堂打饭的时候,打饭的人比学生多,那么学生就不用排队,可是现在打饭的人明显少于学习,所以学生就要排队,进程也是如此,因为系统的资源过少!但是这是一个相对概念,只有在中午去食堂买饭就需要排队,而在下午三四点去买饭就不用排队!这也就解释了CPU为什么有运行队列,因为CPU只有一个,资源过少,每个进程想获取CPU就必须要排对,进程需要通过队列确认优先级去获得CPU的访问,如果我们电脑的CPU很多,那就不需要队列了,直接运行进程就行啦。
我们先写一份上面的代码,然后执行一下,同时输入在右侧输入:ps -al
ps -al
是一个用于显示进程信息的命令,常用于Linux。下面是对该命令的解释:
ps: 这是进程状态的缩写,是一个用于报告当前进程状态的命令。
-a: 显示所有终端上的进程,而不仅仅是与当前用户相关的进程。
-l: 以长格式显示输出。长格式包括更多的列,提供更详细的信息。
我们很容易注意到其中的几个重要信息,有下:
- UID : 代表执行者的身份
- PID : 代表这个进程的代号
- PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行
- NI :代表这个进程的nice值
这里我就要插几句话啦!不要把优先级想象的特别复杂,它在sturck task_struct里面就是一个整型变量,而且上面我们观察到Linux下默认优先级是80,但是Linux的优先级是可以被修改的,但是Linux优先级是有区间的,其范围是[60,99],一共40个。Linux优先级本质就是整型变量数字,数字越小,优先级越高!
- PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小 进程的优先级别越高
- 那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
- PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
- 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
- 所以,调整进程优先级,在Linux下,就是调整进程nice值
- nice其取值范围是-20至19,一共40个级别。
- 需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。
- 可以理解nice值是进程优先级的修正修正数据
Linux系统允许用户调整优先级,但是不能直接让你修改PRI,而是通过修改nice值,它不是优先级,而是进程优先级的修正数据!现在我们来演示一下优先级的修改。
结果展示:
根据这个公式:PRI(new)=PRI(old)+nice,我们再恢复出原来的优先级
咦!这是什么情况!!!
nice值是通过覆盖式的写入的,但是我们的PRI不应该是80 = 90 + (-10)嘛,为什么是70呢?其实这是正常的,你想想如果我们的PRI是80,那我们的nice就是-10了,而其他进程nice值是0,这样系统的数据就不一致了,所以PRI在每一次设置的时候,PRI(old)都是80。
我们再来测试一下nice其取值范围
首先我们将nice值干到100。
然后我们将nice值干到-100。
总结:nice其取值范围是-20至19,一共40个级别,Linux优先级是有区间的,其范围是[60,99],一共40个,两个相互对应。
提问:Linux为什么调整优先级是受收限制的?
如果允许任意进程调整优先级,用户可能会将自己的进程提升到高优先级,自己的进程抢占系统资源,使其他进程无法正常运行,无法享受到CPU等其他资源,此时其他进程很难得到资源,此时就被称为进程饥饿问题。任何得分时操作系统,调度上,都要具有较为公平的进行调度,所以就要求优先级是受收限制的。
概念准备:
- 进程在运行的时候,放在CPU上,必须直接把代码跑完,才行吗???不行,这很明显,如果我们写一个死循环的代码,那么CPU永远都跑不完,其他进程也就永远得不到调度,那么此时系统就卡死了。所以现代操作系统都是基于时间片轮转进行轮转执行的!!!
- 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
- 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
- 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为 并行
- 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为 并发
我们可以在struct task_struct查看到这些硬件上下文。
所以进程切换的过程就是:进程在CPU上运行 -> 时间片用完,保存硬件上下文 -> 运行队列进程阻塞 ->时间片轮转到该进程,恢复硬件上下文。这也就是一个进程在CPU一整个周期的情况。
前提:Linux实现进程调度算法需要考虑优先级,考虑饥饿,还要考虑效率。
上图是Linux2.6内核中进程队列的数据结构,我们先不看,后面我会基于上面的画一个。
1. 从0下表开始遍历queue[140]
2. 找到第一个非空队列,该队列必定为优先级最高的队列
3. 拿到选中队列的第一个进程,开始运行,调度完成!
4. 遍历queue[140]时间复杂度是常数!但还是太低效了!
上图是Linux2.6内核中进程队列的数据结构,之间关系也已经给大家画出来,方便大家理解。
在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增 加,我们称之为进程调度O(1)算法! 补充:参考文档
我们知道main函数是有形参的,只不过我们一直没有使用过,今天我们来看一下main函数的参数,看看main函数的参数是什么意思?它和密命令行参数有什么关系?
首先我们知道argv是一个指针数组,数组元素有argc个,它里面的元素类型都是char*的,而我们知道char*指向的是字符串的首地址,我们来看一下argv里面的每个char*都指向了那个字符串?
我们发现随之我们的程序执行,当我们以空格作为分隔符传入-a,-b,-c,程序按照空格为分隔符,依次传入一个一个子串到argv这个指针数组里面,同时每传入一个字串,argc的个数就会加一。现在我再来解释一下输出内容。
所以我们在命令行上输入以空格为分隔符输入的字符串,会被bash解析一个一个子串,维护成指针数组argv,同时还维护了一个计数器argc,只要是函数它就会被调用,main函数也不例外,main函数由系统调用,bash会把参数传入给main函数,所以我们的main函数就拿到了命令行上输入以空格为分隔符输入的字符串和计数器argc,然后就输出了上述的结果。整个传入main函数的参数我们可以称之为一张表,我们称之为命令行参数表,把这张表就传给当前进程,而且整张表必须以NULL为结尾。那么我们的程序还可以这样写:
当i++后,此时argv[i]就访问到了NULL,NULL对应的就是0,此时程序就会退出。通过上面我们知道main函数的两个参数是从命令行输入的,然后再由bash创建、维护并传参的,可是我们为什么要这么做呢???下面我们来写一个demo,来说明我们为什么要这么做。demo的功能是输入./myprocess -[number](1-3)可以实现不同的功能,只能传入两个参数。
我们来看一下程序的运行结果:
通过上面的demo,我们发现我们可以通过不同的选项,让我们的同一个程序执行它内部不同的功能!为什么呢?我们看一下下面这张图片。
我们发现我们平时使用的指令,后面跟着的都是我们的指令选项,这不就是和我们上面demo一样嘛,这些指令大部分也都是使用c语言去写的,选项的本质也就是命令行参数,所以我们平时使用的选项,最终都是以命令行参数的形式传递给我们main函数,然后我们可以根据选项去实现不同的指令功能。命令行参数,是Linux指令选项的基础!
我们先来见见环境变量,我们运行我们的程序要运行就需要./myprocess或者找到这个可执行程序的绝对路径运行这个可执行程序,如果我们不以上面的两个方式,此时就会出现command not found的错误信息,这句话的潜在意思就是我们系统在执行myprocess的时候,系统没有找到这个可执行程序。
这里有一个问题,为什么执行我们的程序要./,而执行系统自带的指令就不用呢?比如ls,没见到我们用./ls呀!这是因为系统存在一个全局的环境变量PATH,我们可以通过echo $PATH打印出这个环境变量里面的内容。
所以上面的问题就能解决了,因为ls的执行路径默认就在/usr/bin/这个路径下,所以ls指令执行的时候就可以找到执行路径,而myprocess的路径是/home/whb/110/110/lesson12,没有被添加到环境变量PATH里,所以我们输入myprocess找不到执行路径,也就会给我们输出command not found的错误信息。PATH环境变量里面保存的是我们Lixnu系统搜索可执行程序时所对应的搜索路径。如果我们要想我们自己的程序也和ls一样直接执行,我们由两种方法:
1.将我们的mprocess拷贝到/usr/bin/这个路径下:
2.把自己的可执行程序添加到环境变量PATH里
此时我们再通过which就可以查到我们自己的可执行程序了
如果我们把之前的环境变量给覆盖了怎么办?很明显此时就只能我们自己的程序能够运行,系统提供的那些指令都不能使用了。
那怎么办?所有的基本的指令都不能使用了。不用担心此时,我们现在修改的环境变量它仅仅是在内存当中,由系统开辟的内存级的变量,此时我们重新打开Linux系统即可。我们再来看一下环境变量,当我们执行pwd的时候,它是怎么知道我们当前所处的路径的呢?是因为我们系统存在一个PWD的环境变量:
这个环境变量会时时刻刻观察当前用户的路径的变化,当用户切换了路径,PWD的环境变量也会相应的做出变化,进行同步更新。
我们再来看一下环境变量,当我们执行whoami的时候,它是怎么知道我们当前用户呢?是因为我们系统存在一个USER的环境变量:
当用户登录系统的时候,USER的环境变量会记录首次登录的用户是谁,然后将用户名记录在USER的环境变量中。还有其他环境变量我就不一一介绍了,我们可以通过env查看系统中已经存在的环境变量。
那这里我们就可以粗略的理解一下环境变量,环境变量是由bash提前在给每一个人在登录的时候,提前在内存里面开辟一块空间,开辟好之后,把这个人的基本信息写入到这块空间里,所以这个用户在使用Linux系统的过程中,系统直接可以通过环境变量认识用户,就好比入职公司之前,公司会提前把我们的工牌做好,此时领导或者公司同事就可以通过工牌认识你。所以预先给用户准备的一批变量就是我们的环境变量,也就是我们进入了Linux这个系统环境而提前准备的变量。
常见环境变量
和环境变量相关的命令
查看环境变量方法:
echo $NAME //NAME:你的环境变量名称
那我们想要自己定义一个环境变量呢?export: 设置一个新的环境变量
上面我们知道main函数有两个参数,实际上main函数还有一个参数char* env[];这个env和我们的argv一样,env也是一个数组指针,内部每个指针指向的就是环境变量。
环境变量的组织方式:
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。
运行结果:
此时我们就会发现系统会把环境变量以main函数的参数的形式传递给我们的进程.myprocess。然后我们再来改一下我们的代码。
运行结果:
通过上面的结论我们可以知道环境变量是默认是可以被子进程继承的,那我们的孙子进程呢?我们再来修改一下我们的代码。
这里的运行结果也显示了孙子进程,它也被我们的孙子进程所继承。因为我们创建子进程的时候,这个环境变量的数据本来也应该属于子进程,因为创建子进程的时候代码共享,数据以写时拷贝的方式各自私有一份。所以环境变量具有全局属性,环境变量会被所有的子进程包括孙子进程进行继承!!!也就意味着我们在bash命令行添加的任何环境变量,将来在我们的子进程都可以看到。
环境变量基本概念
除了上面的命令行第三个参数获得所有环境变量,我们还可以通过函数getenv去获得一个环境变量。
#include
#include
int main()
{
const char* username = getenv("USER");
if(username)
printf("username:%s\n", username);
return 0;
}
运行结果:
此时我们就获得了环境变量。那么环境变量到底有什么用呢?
运行结果:
此时系统通过环境变量USER知道当前用户是whb,就让该用户运行核心的代码,当我们有另外一个用户root登录的时候,操作系统此时就会形成该用户的环境变量,USER就是root。然后以root的身份去执行代码,此时就没有权限了。
此时我们就可以通过不同的用户根据自己的环境变量执行自己相应的代码。比如通过环境变量做一些身份识别的功能 - 比如优酷的普通用户和VIP用户。除了通过上面的命令行第三个参数获取环境变量,还可以通过第三方变量environ获取。我们可以先来看看eviron是什么,我们可以通过手册查看:man eviron
现在我们来写一个代码来验证一下
#include
#include
int main()
{
extern char** environ;
int i = 0;
for(;environ[i];++i)
{
printf("environ[%d]:%s\n",i,environ[i]);
}
return 0;
}
第三方变量environ是一个二级指针,它在这里也指向一个char*的数组。
注意:libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明,我们只能对它进行声明,不能进行定义。我们上面有一个结论:环境变量具有全局属性,那么现在我们在bash进程导入一个环境变量:export myenv=aaaaaaaaaaaaaaaaaaaaaa;当我们再次运行上面的程序时,此时这个程序是在bash运行的,那么父进程也就是bash,而我们上面的程序就是bash的子进程,同时子进程也会继承父进程的环境变量,所以当我们运行之后,我们也可以在子进程中看到刚刚设置的环境变量了。
除了环境变量,程序中还可以使用本地变量,我们来看一下
通过上面的图片我们可以知道设置本地变量不用加export,同时我们无法通过env查到本地变量,那我们该怎么查询本地变量呢?我们可以使用set查看,set会把bash所有的变量全部展示出来,包括环境变量和本地变量。
我们可以通过getenv获取一个变量。
运行结果:
如果我们获取本地变量呢?const char* myenv=getenv("hello");
我们在bash命令行输入echo $hello可以看到本地变量hello,但是当运行上面的程序就无法看到。所以我们就可以得出一个结论:本地变量只在bash内部有效,不能被子进程继承下去,即本地变量不是环境变量,本地变量只在bash内部有效。
设置变量有了,那怎么移除变量呢?使用unset + 变量名
此时我们就看不到我们刚刚设置的环境变量myenv和本地变量hello了。还有一个问题,我们之前提到了如果我们修改了系统的某一个环境变量,我们只需要关掉我们的Xshell,然后再次启动,此时环境变量就被恢复了目前我们见到的环境变量都是内存级别的,所以bash进程退出了,那么这个环境变量也就没有了,那么下次启动bash环境变量是从哪里来的呢?答案是磁盘里文件当中。当你启动bash时,它会按照特定的顺序读取和执行这些文件,从而设置相应的环境变量。这确保了在每个用户登录时和每个Shell启动时都能够获得正确的环境变量。那我们来看看这个文件
bash_profile:这是一个Bash shell的配置文件,它在用户登录时执行一次。如果存在,通常位于用户的主目录下,文件名为.bash_profile
。
那我们想自己导入一个环境变量呢?export MYVAL=youcanseeme
然后我们退出我们的Xshell,看看再次启动的时候是否有刚刚设置的环境变量。
此时我们就可以刚刚设置的环境变量。除了从bash_profile中导入我们的环境变量,其实更多的是从basrc中导入的。
而里面又是etc/bashrc中导入的,我们可以打开观看一下。由于里面都是脚本文件,我们这里就不多多介绍了。
总结:是什么为什么怎么办?
- 1.环境变量是有系统提供的一组变量,每一个环境变量都有它的用途
- 2.在不同的场景下,执行某些对应的工作或者任务时,是需要知道它的更多属性的,比如平时创建的文件的时候,它就知道此时文件的拥有者是谁。
- 3.指令操作、代码操作、三种获取环境变量的方式和环境变量的特性。
由于64位下的程序地址空间比较复杂,我们下面展示的都是kernel 2.6.32内核32位平台下
上面的图我们以及不陌生了,在前面的c语言专栏这张图就已经出现过了,今天我们再来看这张图,来验证一下程序地址空间的特性。
运行结果:
从上面的结果我们可以发现地址是和我们上面的程序地址空间图变化一致的。我们发现栈地址和堆地址之间相差非常大,可以确定它们之间出现非常大的镂空。下面我再来验证其他的特性:堆区向上(高地址)增长,而栈区(低地址)增长。我们来看一段代码看一下他们的增长方向。
我们来看一下运行结果:
此时就能验证上面的结果,即堆栈相对而生。我们看到程序地址空间图还要环境变量和命令行参数,我们再来看一下他们
这里我们给命令行带上-a -b -c参数,然后再来看一下结果:
可以得出命令行参数表(每个表表向的地址)和环境变量表(每个表表向的地址)都比栈大,且环境变量表最大。那如果我们这样写呢?
运行结果:
这里我们就有一个结论了,无论是表,还是表指向的项目,都在栈上。
同时根据上面的程序地址空间图,已初始化和未初始化是我们的全局变量,会在我们的进程运行期间,一直运行!如果我们再定义一个变量,然后将其用static修饰会怎样呢?
我们发现被static修饰的变量的地址会居于已初始化和未初始化之间,为什么呢?因为static修饰的变量会自动初始化成0,这也就是为什么static修饰的变量不会随着局部函数调用而销毁,因为此时在语言上已经变成全局的了。问题又来了,上面的程序地址空间图是我们的内存吗?我们来看一下代码
运行结果:
我们发现,输出出来的变量值和地址是一模一样的,很好理解呀,因为子进程按照父进程为模版,数据本来就是被父子进程所共享的,父子并没有对变量进行进行任何修改。可是将代码稍加改动:
运行结果:
我们子进程将我们的数据修改之后,父子进程输出不同的值,这也很合理,因为此时发生了写时拷贝,父子进程是两个进程,两个进程之间相互独立,他们之间不能发生数据干扰,对于父子进程而言他们应该是访问的是不同的变量,但是此时变量的地址确实一样的。我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做 虚拟地址
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
- OS必须负责将 虚拟地址 转化成 物理地址 。
所以就可以得出结论:上面的程序地址空间图不是我们的物理内存,而是我们的进程地址空间。
所以之前说‘程序的地址空间’是不准确的,准确的应该说成 进程地址空间 ,那该如何理解呢?看图:
在多进程编程中,当创建子进程时,操作系统会使用写时复制(Copy-on-Write,COW)技术来优化内存的使用。写时复制是一种延迟内存拷贝的策略,它允许父子进程共享相同的物理内存页,直到其中一个进程尝试修改该页的内容时,才会真正进行拷贝。
当在子进程中修改数据时,由于发生了写操作,操作系统会复制相应的内存页,确保父子进程不再共享相同的物理内存页。因此,尽管父子进程最初访问相同的变量地址,但在写操作之后,它们实际上拥有各自的拷贝,此时只有相同的虚拟地址,而真正的物理地址是不相同的。
这就是为什么观察到父子进程输出的地址相同但变量内容不同的原因。这种行为确保了父子进程在修改数据时互不干扰,因为它们实际上操作的是各自私有的内存拷贝。
上面的图就足矣说名问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!
现在我们再来看看什么是进程地址空间?
进程地址空间是指操作系统为每个运行中的进程分配的内存空间。每个进程都有自己独立的地址空间,这使得不同进程能够独立运行,而不会相互干扰。这也就是上面的富豪给自己每个孩子独立画出的大饼,使得每个孩子都认真学习,每个孩子将来都会继承亿万遗产。这样每个进程有自己的地址空间,这样做的好处是,每个进程都认为它是系统中唯一的运行程序,都能享有4G的大空间,它可以独立地访问和修改自己的内存空间,而不会影响其他进程的内存。
进程地址空间本质其实就是pcb中的数据结构,那如何理解上面进程地址空间的各个区域呢?
在操作系统中,进程地址空间的属性通常会通过一些整型变量描述。这些信息可以存储在进程控制块(PCB)数据结构中,被一个struct mmstruct所管理。我们来看一下源码。
区域划分的本质就是区域内的各个地址都可以使用!!!但是我们的地址空间,不具备对我们的代码和数据的保存能力!代码和数据没有存放在地址空间,而是在物理内存中存放的!此时就需要将地址空间的虚拟地址转化到物理内存中!此时操作系统就给我们提供了一张表 - 页表!虚拟地址是给进程的,进程是你的,所以这个虚拟地址是给用户的。
存放页表的地址通常是物理地址,而不是虚拟地址。页表是操作系统用来进行虚拟地址到物理地址映射的关键数据结构之一。在典型的虚拟内存系统中,每个进程都有自己的页表,这个页表负责将进程中的虚拟地址映射到物理地址。
因为页表本身是由操作系统管理的数据结构,需要在物理内存中保留,以确保对它的快速访问。如果页表本身存放在虚拟地址空间中,那么就需要在访问页表时进行额外的虚拟地址到物理地址的映射,这会导致循环依赖问题,因为访问页表的过程本身需要页表。
在多进程的环境中,不同进程的地址空间是相互独立的,互不干扰。这也是为什么在多进程编程中,父子进程可以拥有相同的变量地址,但在写时复制的过程中,它们的地址空间会逐渐分离,以确保彼此之间的数据不会相互影响。
1.将物理内存从无序变成有序:
2.解耦合进程管理和内存管理:
3.保护内存的重要手段:
malloc
函数是用于在C语言中动态分配内存的函数。当调用 malloc
时,它会返回一个指向分配的内存块的指针。这个内存块在逻辑上被视为可用的,但在物理上可能并没有被实际分配。
具体来说,malloc
会在进程的虚拟地址空间中分配一块指定大小的内存,并返回这块内存的起始地址。但是,这个时候并没有真正分配物理内存。物理内存的分配通常是在程序试图访问这块内存时进行的,这就是所谓的"延迟分配"。
当程序开始使用 malloc
返回的指针来写入数据时,操作系统会在需要的时候分配物理内存,并将虚拟地址空间中的页面与实际的物理内存页关联起来。这个过程被称为""缺页中断"。操作系统会负责将相应的页面加载到物理内存中,并将适当的页表项设置为指向这个物理内存页。
所以,malloc
返回的内存块在逻辑上是立即可用的,但在物理上并不一定立即分配。分配的物理内存是在程序首次访问这块内存时动态完成的。这种延迟分配的机制可以帮助操作系统更高效地管理内存,只为程序实际需要的部分分配物理内存,充分提高内存的使用率。