进程优先级
什么叫进程优先级?
进程优先级是指进程获取某些资源的先后顺序
上文中的task_struct,也叫进程控制块(PCB),本质上是结构体,我们的优先级就被写在结构体里面(内部字段)
int prio = ??
在Linux下,进程的优先级是用数字表示的。数字越小,优先级越高
如何查看进程的优先级?
来,先创建一个进程
#include<stdio.h>
#include<unistd.h>
int main(){
while(1){
printf("i am a process,pid:%d\n",getpid());
sleep(1);
}
return 0;
}
还有该程序对应的makefile
bin = myprocess.exe
src = myprocess.c
$(bin) :$(src)
@gcc -o $@ $^
@echo "compiled $(src) to $(bin)..."
.PHONY:clean
clean :
@rm -f $(bin)
@echo "clean project"
然后我们用之前学的查看进程的命令来查看进程:
ps -l
但是我们发现能查看的进程只有两个,并且没有我们运行的那个进程,这是为什么?
因为我们在运行进程 和查看进程 的时候使用了两个终端 。这个命令只能查看当前终端的进程,所以查看不到另一个终端的进程。此时我们需要使用下面的命令:
ps -al//a是all的意思
PID:25485就是我们的进程
这里的PRI 就是我们表示优先级的标识,NI就是对优先级进行修改的值,可以帮助我们在启动前或者运行时做动态调整(没错,优先级是可以修改的)
UID : 代表执行者的身份
PID : 代表这个进程的代号
PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
PRI :进程可被执行的优先级(默认优先级),其值越小越早被执行
NI:当前进程优先级的修正数据,nice值,新的优先级=优先级+nice,达到对于进程优先级动态修改的过程
在命令行行中输入renice:
然后输入:
top//top命令经常用来监控linux的系统状况,是常用的性能分析工具,能够实时显示系统中各个进程的资源占用情况。
(top也可以用CTRL+C/q(quit)来中断)
来先看看我们的进程在哪?此时NI为0
输入**r,**输入你想要输入的值(我输了100)
为什么呢?
其实是因为nice值不可任意调整,是有范围的(为了保证平衡)
那范围是多少呢?
一般来说,nice值是[-20,19],总共四十个数字,所以刚调成一百实际上是把nice值变成19了(选取最接近的取值范围的数,毕竟100远大于19)
再修改一个
欸,为什么不可以了?
因为名为普通用户是更改进程优先级的次数是有限制的,进程优先级是不支持被频繁修改的。当然超级用户root是不受限制的
关于优先级的调整,我们每次调整的时候都是从80开始的,在80的基础上对数据做修正(虽然并不推荐改优先级)
当只有一个CPU,一套寄存器时,多进程 在特定时间范围内被调度时需要通过上下文切换的方式 保证多个进程的代码得以推进,我们把这个过程叫做并发(CPU运行速度很快,能在很短的时间内将这些东西都进行一遍)(卡顿本质是CPU调度进程操作不过来,所以不要挂太多后台)
并发和并行的区别:
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进
并行:多个进程在多个CPU下同时运行
并发和并行也是可以同时存在的
优先级的特点和意义
我们知道进程访问的资源(CPU)始终都是有限的,通常都是很多进程在争几个或一个CPU
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
操作系统关于调度和优先级的原则:分时 操作系统,保证基本的公平,如果进程长时间不被调度,就会造成饥饿问题
饥饿问题:考虑一台打印机分配的例子,当有多个进程需要打印文件时,系统按照短文件优先的策略排序,该策略具有平均等待时间短的优点,似乎非常合理,但当短文件打印任务源源不断时,长文件的打印任务将被无限期地推迟,导致饥饿以至饿死。
命令行参数
我们的main函数也是带参数的:
#include<stdio.h>
int main(int argc,char* argv[])
{
return 0;
}
不过平时没写,那说明main函数的参数可带可不带
那么这些参数都是干啥的呢?
上图的意思是:后面不加-a这种东西的时候,./process.out就是一个参数,后面的-a等等也是参数
也就是说:main函数的参数是包括程序名称在内及其他参数
什么意思呢?
main函数是我们的主函数,是程序执行的起点,也是终点,有且仅有一个;所以他的参数比较特殊
argv[0]保存自身运行的目录路径和程序名,从argv[1]开始才是指向对应的参数,所以我们的main函数一般都是这么写的:
int main (int argc,char *argv[]){...}//指针数组
int main (int argc,char **argv){...}//二级指针
①
argc(count)
:参数计数器,整型变量 ,表示参数的个数 .②
argv(vector)
:参数数组本身,指向字符串的指针数组,表示存放参数的具体内容.【参数表】
char*指向的是字符串 (比如我们的-a -b...),在进行命令行输入的时候会有一些程序,会帮我们将输入的整体的字符串打散 ,把空格转换成**\0** 就相当于把字符串打散。将字符串指针放到数组 里,以参数 的形式传递给main函数,最终就有了argv ,有几个字符串,argc的个数就是几,但argv最后必须以NULL结尾
怎么验证是以NULL结尾的?我们可以把argv[]当作循环的变量:
#include<stdio.h>
#include<unistd.h>
int main(int argc,char* argv[])
{
int i=0;
printf("argc=%d\n",argc);
for(i=0;argv[i];i++)
{
printf("argv[%d]->%s\n",i,argv[i]);
}
return 0;
}
是不是还是一样的?判断条件是argv[i],当argv[i]到最后的时候也就变为NULL也就是0,也退出循环
这玩意怎么用?
来看:
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main(int argc, char* argv[])
{
if (argc != 2)
{
printf("Usage:%s -[a,b,c,d]\n", argv[0]);
return 1;
}
if (strcmp(argv[1], "-a") == 0)
{
printf("this is function1\n");
}
else if (strcmp(argv[1], "-b") == 0)
{
printf("this is function2\n");
}
else if (strcmp(argv[1], "-c") == 0)
{
printf("this is function3\n");
}
else if (strcmp(argv[1], "-d") == 0)
{
printf("this is function4\n");
}
else
{
printf("no this function!\n");
}
return 0;
}
我们发现配不同的参数,可以选择执行不同的功能执行
概念:命令行参数指的是在运行可执行文件时提供给程序的额外输入信息。它们通常以字符串形式出现,并且紧跟着可执行文件名之后
这就是我们命令行参数的作用:命令行参数的本质 是交给我们不同程序不同的选项,用来定制不同的程序功能(命令中会携带很多的选项)
那么是谁帮我们把字符分割,生成动态数组呢?
#include<stdio.h>
#include<unistd.h>
//#include<string.h>
int g_val = 100000;//定义宏变量
int main()
{
printf("I am father process,pid: %d,ppid: %d ,g_val:%d\n", getpid(),getppid(), g_val);
sleep(5);
pid_t id = fork();
if (id == 0)
{
while (1)
{
printf("I am child process,pid: %d,ppid: %d , g_val: %d\n", getpid(),getppid(), g_val);
sleep(1);
}
}
else
{
while (1)
{
printf("I am father process,pid: %d,ppid: %d , g_val: %d\n", getpid(), getppid(), g_val);
sleep(1);
}
}
return 0;
}
我们的g_val定义在main函数外,也就是说定义的时候还没有子进程,那么子进程的g_val是怎么来的?当然是继承父进程(外面一圈的代码就是父进程的代码)的数据,所以二者的g_val都是100000
我们去掉子进程,来看看到底是谁在背后:
#include<stdio.h>
#include<unistd.h>
int g_val = 100000;
int main()
{
printf("I am father process,pid: %d,ppid: %d ,g_val:%d\n", getpid(),getppid(), g_val);
return 0;
}
可以看到bash一直在后面孜孜不倦的工作
命令行中启动的程序都能变成进程, 而这些都是bash的子进程
所以命令行中输入的数据默认是输入给父进程Bash的(Bash:命令行解释器,来解释你的命令,所以会把输入的参数变成进程参数,定义好一个argc,再创建个表argv[ ],定义完后再由Bash传递给子进程)
总之这一切都是bash(命令解释器)做的
子进程可以直接看见父进程的数据和代码
环境变量
为什么加了参数就不一样了?
是Linux中存在一些全局的设置,表明告诉命令行解释器应该去哪些路径下去寻找可执行程序
我们先来学第一个环境变量:PATH
来打印一下:
系统中的很多配置,在我们登录Linux的时候,就已经加载到了Bash进程中,也就是从磁盘加载到内存中
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
如:我们在编写C/C++代码的时候,在【链接】的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找
我们在使用某个功能的时候不在乎是怎么找到的,而是它怎么能直接用,环境变量就是中间者,在系统当中通常具有全局特性
上图中的**:**作为分隔符,分割路径,也就是Bash内部维护的一堆路径,这是Bash在执行时的搜索路径(Bash在执行命令的时候,需要先找到命令,因为未来要加载),如果找不到命令,就会显示不存在
环境变量不存在的命令:
所以一些命令不用带路径是因为在Bash的搜索路径中(usr/bin)
欸?那我们都知道路径了,是不是可以自己做一个指令,把自己的程序安在bash里?
我们copy到路径中:
cp process.out /usr/bin/
哦,我等普通人不配这么做,不过切换为超级用户还是可以的
但是不建议这么做,会污染指令集
我们的PATH都有自己配置的路径,那我们要是把这个路径改了呢?
PATH=/home/name
啊啊啊啊命令用不了了!那怎么办?
重新连接一下
又可以了?!因为环境变量在登录时被加载到Bash内部,所以改变是没用的
我们也看见刚刚用:分隔的路径,说明环境变量里有很多路径,我们能不能添加一个自己的路径?
PATH=$PATH:路径
这样我们执行就不用输**./**了
但是是写在内存的不是写在磁盘的,重启了也是没有
最开始的环境变量有自己的数据来源,不是在内存中,是在系统对应的配置文件中
在每个用户的家目录下, 会存在两个隐藏文件:.bash_profile,.bashrc打开看看:
bashprofile 会判断配置文件是否存在**.bashrc**
这是在把bashrc的内容导入到Bash进程的上下文里:
. ~/.bashrc
这就是PATH环境变量 :
PATH=$PATH:$HOME/bin
这是.bashrc的作用是把系统的bashrc也导入一遍(除了用户家目录下有配置文件,主系统也有)
看一下系统下的bashrc:
vim /etc/bashrc
里面都是些脚本, 执行的操作就是Bash在登录时把所有的环境变量都导入进来
环境变量默认是在配置文件中的,所以如果想要将自己的路径添加到配置文件且永久保留,就可以把这个:
改成:
然后就永久的可执行了:
但是最好不要瞎改系统内的环境变量
Windows也可以配置环境变量
之前学go的时候配置过
除了PATH还有别的环境变量:env
输入env:
三种环境变量捏:
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash
我们输入pwd可以知道自己当前的路径,是因为系统存在可以随着路径变化而变化的环境变量:
在启动时要创建一个命令行SHELL让它来为我提供命令行解释的服务
怎么知道我哪个shell呢(shell有很多种)?
查查就好:
我们的shell是bash捏
还有个环境变量叫HISTSIZE:
指令history:上下翻可以记录历史指令,1000是能记录的历史指令数
history能查看使用过的历史指令:
定义我们自己的环境变量:
export的语法:
export [-fnp] [变量名]=[变量设置值]
实例:将路径/jome/name添加到环境变量中
prettyprint
$ export PATH=$PATH:/home/name
想取消就这样
unset name
还有种变量:
没有被系统导入到环境变量,但可查,这个叫本地变量
定义的本地变量可以被修改,没在环境变量表中可以导入至环境变量表(env)中
本地变量只在本Bash内部有效,无法被子进程继承下去,导成环境变量才能被获取
本地变量只能使用内建命令才能看到
环境变量和代码有什么关系呢?
怎样通过代码的方式获取环境变量呢?
例如在C中默认给我们提供环境变量:errno
在Linux中系统也默认给我们提供了一个全局变量:environ
来查看一下我们的全局变量:
#include<stdio.h>
#include<unistd.h>
int main()
{
extern char** environ; //声明一哈
int i = 0;
for (i = 0; environ[i]; i++)
{
printf("env[%d]->%s\n", i, environ[i]);
}
return 0;
}
这些环境变量就是刚刚shell内部的环境变量
环境变量默认也是可以被子进程拿到的,不然我们是这么通过程序(也就是bash的子进程)打印出环境变量呢?
磁盘的内容包含了环境变量
.bash_profile
.bashrc
/etc/bashrc
在启动的时候会把磁盘中的内容导入到内存中
环境变量的本质是数据,所以bash也就拿到了这些环境变量
父进程的数据默认能被子进程看到并访问
所以环境变量的内容被子进程看到并不奇怪
那环境变量很多,bash内部如何组织呢?
bash会维护一张表:char* env[ ](也就是char **env)
环境变量本质是字符串,bash进程启动的时候,默认会给子进程形成两张表:
argv[ ]命令行参数表(从用户输入命令行获取)
env[ ]环境变量表(从OS的配置文件表)
bash通过各种方式交给子进程,导入环境变量的本质是把内容添加到表里:env
环境变量具有系统级的全局属性:环境变量也是数据,会被子进程继承
子进程的子进程也可以获取环境变量
getenv:
putenv:
可以这样获取环境变量:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main(int argc,char* argv[],char* env[])
{
char* path = getenv("PATH");
if (path == NULL)
{
return 1;
}
printf("path:%s\n", path);
return 0;
}
这样也可以看环境变量的信息
所以目前已知的环境变量的获取方式:
通过main函数参数
extern char** environ;
getenv("PATH");
但是我们用export创建环境变量的时候:
export love=trust
不会创建新的子进程吗?
这种export和echo叫**内建命令,**在bash里(另一个相对的概念叫外部命令)
不会,因为在命令行中执行的命令有80%都是Bash创建子进程执行的
内建命令是由Bash亲自执行(Bash也是C写的,里面有些C的函数,如果命令是这些函数就不创建新的子进程了)
前面已经证明过它的存在了:PATH改掉后ls(外部)跑不了,echo(内建)接着跑
地址空间
process.c:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int g_val = 100;//一开始是100捏
int main()
{
printf("father is running,pid: %d,ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if (id == 0)
{
//child
int cnt = 0;
while (1)
{
printf("I am child process,pid: %d,ppid: %d\n", getpid(), getppid());
printf("g_val: %d,&g_val: %d\n", g_val, &g_val);
sleep(1);
cnt++;
if (cnt == 5)
{
g_val = 300;//注意这里g_val被改了
printf("I am child process,change:%d -> %d\n", 100, 300);
}
}
}
else
{
//father
while (1)
{
printf("I am father process,pid: %d,ppid: %d\n", getpid(), getppid());
printf("g_val: %d,&g_val: %d\n", g_val, &g_val);
sleep(1);
}
}
return 0;
}
可以看到子进程的g_val从100->300了,但是他们的&g_val是一样的,也就是地址是一样的
为什么呢?
首先我们知道变量内容不一样。所以父子进程输出的变量绝对不是同一个变量;但是地址是一样的,所以该地址一定不是物理地址
这个地址不是物理地址,这种地址在系统层面上被称为虚拟地址。
我们在c/c++语言所看到的地址,全都是虚拟地址,物理地址用户一概看不到,由OS统一管理,OS负责将虚拟地址转化为物理地址
如何理解虚拟地址?
操作系统内部的地址空间长这样:
地址空间是虚拟地址,存在页表将地址空间的虚拟地址 转化到物理内存(建立映射关系)
页表:带有权限属性的放在物理内存中的,用来记录虚拟内存页与物理页映射关系的一张表。
我们的g_val一开始设置在全局变量区,有自己的虚拟地址,并且通过页表与其物理地址映射
创建完子进程后,我们的子进程有自己的task_struct,也有自己的虚拟地址空间,也会有自己对应的页表
那操作系统该怎样管理这么多地址空间和页表呢?
地址空间本质上是内核数据结构(结构体对象)
子进程会把父进程的很多内核数据结构拷贝一份(子进程的地址空间和页表都来自父进程)
所以在映射的时候只会进行浅拷贝(只赋值),由于子进程修改数据可能会对父进程产生影响,进程在运行时具有独立性(设计原则)
事实上子进程尝试对数据进行修改的时候(100->300),操作系统(自主完成,写时拷贝 )会重新开辟一块空间(在物理内存 上),把老数据拷贝到新空间 中,把新地址 放入页表,重新构建映射,地址指向新空间,再进行写入工作(感觉这里很难懂)
那为什么要这样干呢?因为进程具有独立性啊(应该说是因为写时拷贝,所以具有独立性)
那么可不可以把数据在创建子进程的时候,全给子进程拷贝一份?
不这样干是因为,有很多东西紫禁城并不会去修改,比如说一些环境变量;没必要改占据空间还很大,直接全拷贝一份浪费资源
写时拷贝就可以按需申请,就不会过分浪费地址空间
如何理解地址空间
首先要知道划分区域,重新划的过程 是在做区域的调整
上面的场景用计算机语言怎样表述呢?
地址空间本质是内核的一个struct的结构体!(叫struct mm_struct 的一个结构体对象)内部很多的属性都是start,end的范围
操作系统在实际中,给每个子进程划分了一大块进程地址空间。当我们有了对应的空间后就可以进行对应区域的划分(比如很多机器执行整数加法的指令可能要求整数是四字节对齐的。此时编译器为了对齐可能会做padding(补白),使得实际分配的内存大于数据本身占用的内存)
一个对应的范围,可以通过页表将虚拟地址映射到物理内存,这就是地址空间
为什么要有地址空间
实际的物理内存中,代码区,数据区,堆区,栈区,共享区,命令行参数和环境变量是乱序的,但在地址空间的这个虚拟概念这,这些永远有序
所以地址空间存在的第一个意义:让无序变成有序,让进程以统一的视角看待物理内存及自己运行的各个区域
假设代码区已经被执行了1M,还有1M需要执行,但操作系统发现物理内存整体空间不够了,,要把闲置空间释放掉,那检测到了前1M的代码已经执行完,可以被释放掉了。
如果先预设申请堆空间 就不急着申请物理内存,这样内存的使用率就比较高
第二个意义就也很显著啦:进程管理模块和内存管理模块进行解耦,每个内存块可以分开使用(有的执行,有的释放
对进程而言,假设我们今天要访问代码区一部分,或者访问的区域在地址空间本就不存在或者访问越界,查页表也就不存在对应的地址,不存在对应关系,操作系统会直接拦截本次访问请求
第三个意义:拦截非法请求,避免向物理内存中写入废旧数据,对物理内存进行保护
地址空间不会整体使用,一般只会使用有一部分
如何进一步理解页表和写时拷贝?
CPU内有一个简单的工作单元:MMU
当然CPU内还有很多寄存器
CR3的主要功能还是用来存放页目录表物理内存基地址 ,每当进程切换时,Linux就会把下一个将要运行进程的页目录表物理内存基地址等信息存放到CR3寄存器中。
页表还有其他功能,比如进程挂起到外设,页表就会帮助标记
例如:
#include<stdio.h>
int main()
{
char* str = "hello world";
*str = 'H';
return 0;
}
在这段代码中,能执行吗?
不能,连编都编不过去
进行写入的时候会崩溃(如果cpp的话则是直接出现语法错误,因为cpp类型检查较为严格)
那为什么这段代码会崩溃呢?
因为字符串常量区不能被修改,那为什么字符串常量区不能被修改?
因为每一个虚拟的区域都通过页表进行映射,而页表在进行映射时存在权限管理(也就是rwx权限
因为存在不同进程的不同权限管理,导致一个进程爆了不会影响其他进程(因为无法写入,改变不了
在操作系统层面上如何支持我们进行写时拷贝呢?
父进程对于全局变量的权限是rw,当父进程创建子进程,为了支持写时拷贝,操作系统会修改页表中父子进程的权限(rw --> r),这样就不能写入了。当尝试写入时,操作系统会直接识别到错误:
1.判断错因是否是数据不在物理内存(缺页中断)
2.判断数据是否需要写时拷贝(若是则进行写时拷贝)
3.如果都不是,进行异常处理
缺页中断:在执行一条指令时,如果发现他要访问的页没有在内存中,那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从外存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。
如何理解虚拟地址?
我们该怎样理解虚拟地址呢?
尝试联想一下,在最开始的时候,地址空间、页表里的数据从哪来?
程序里本身就有地址(在编译成二进制程序)
反汇编,查看二进制程序:
objdump -S file //后面跟的是可执行程序哦
程序里面的地址就是虚拟地址(逻辑地址)
所以加载的时候是直接从程序 读的(页表,地址空间,虚拟地址,,,),这种编译方式叫平坦模式
平坦内存模式把全部系统内存表示为连续的地址空间。所有指令、数据和堆栈都包含在相同的地址空间中。通过称为线性地址(linear address)的特定地址访问每个内存位置。
其他模式:分段内存模式把系统内存划分为独立段的组,通过位于段寄存器中的指针进行引用。每个段用于包含特定类型的数据。一个段用于包含指令码,另一个段用于包含数据元素,第三个段用于包含程序堆栈。
如果程序使用实地址模式,那么所有段寄存器都指向零线性地址,并且不会被程序改动。所有指令码、数据元素、堆栈元素都是通过它们的线性地址直接访问的。
和我们之前学到fork()返回值为什么可以进入两个while循环是不是串一起了?
当我们fork的时候不论是父进程还是紫禁城都会return,而return的本质就是对id进行写入,当fork()return的时候创建紫禁城,发生写时拷贝(虚拟地址相同,物理内容不一样)