[Linux 进程basic] task_struct&mm_map&查看进程&进程状态&进程优先级&环境变量
![image-20221003202515131](https://i-blog.csdnimg.cn/blog_migrate/b4c32c2e92aad480edee76ec70be61c0.png)
进程的概念
描述进程的概念我们有下面两个方式
课本概念: 程序的一个执行实例,正在执行的程序等。
内核观点: 担当分配系统资源(CPU时间,内存)的实体。
当你的代码进行编译链接后便会生成一个可执行程序,这个可执行程序本质上是一个文件,是放在磁盘上的。当我们双击这个可执行程序将其运行起来时,本质上是将这个程序加载到内存当中了,因为只有加载到内存后,CPU才能对其进行逐行的语句执行,而一旦将这个程序加载到内存后,我们就不应该将这个程序再叫做程序了,严格意义上将应该将其称之为进程。
##理解管理和PCB
管理的本质
管理我们需要理解的是:决策和执行
管理是什么管是在管被管理对象的,理是在理清楚数据的
任何管理本质上都可以转化为先描述后组织
对任意对象的管理就被转化成了一些数据结构去方便管理
PCB
一个操作系统要管理就需要先描述再组织,进程当然也要被管理,那么描述进程的结构体叫做PCB,Linux中具体的PCB就叫做task-struct,会被读入RAM中,然后包含着进程的信息
系统当中可以同时存在大量进程,使用命令ps aux便可以显示系统当中存在的进程。
而当你开机的时候启动的第一个程序就是我们的操作系统(即操作系统是第一个加载到内存的),我们都知道操作系统是做管理工作的,而其中就包括了进程管理。而系统内是存在大量进程的,那么操作系统是如何对进程进行管理的呢?
这时我们就应该想到管理的六字真言:先描述,再组织。操作系统管理进程也是一样的,操作系统作为管理者是不需要直接和被管理者(进程)直接进行沟通的,当一个进程出现时,操作系统就立马对其进行描述,之后对该进程的管理实际上就是对其描述信息的管理。
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,课本上称之为PCB(process control block)。
操作系统将每一个进程都进行描述,形成了一个个的进程控制块(PCB),并将这些PCB以双链表的形式组织起来。操作系统对进程的管理实际上就变成了对该双链表的增、删、查、改等操作。
task_struct
task_ struct内容分类
内容 | 内容描述 |
---|---|
标示符(pid,ppid) | 描述本进程的唯一标示符,用来区别其他进程。状态: 任务状态,退出代码,退出信号等。 |
优先级 | 相对于其他进程的优先级 |
程序计数器(PC) | 程序中即将被执行的下一条指令的地址。 |
内存指针 | 内存指针可以帮我们找到代码和数据 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针 |
上下文数据 | 进程执行时处理器的寄存器中的数据。 |
I/O状态信息 | 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。 |
记账信息 | 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。 |
连接信息 | 数据结构要连起来 |
时间片 | 操作系统进程控制块的时间片轮转算法 |
进程切换
-
CPU只有一套寄存器,计算需要将我们的内存数据移动到寄存器中,形成当前进程的上下文数据
-
进程被切换可能在任何时间点被切换,要么是当前时间片到了,要么被当前更高优先级的进程抢占了
-
如果进程直接走了,下一个进程覆盖了上一个进程,那么上一个进程应该如何回来?
- 类比大学时征兵的例子,引征入伍的时候肯定需要首先保留学籍,为了回来的时后可以继续学习之前的内容
- 对于进程来说只要我们保存寄存器中的上下文数据就可以了,这就是恢复上下文
- 保存上下文数据的方法是保存到进程控制块task_struct中(不是特别准确)
前台进程和后台进程
对于前台进程来说,后台进程指令是无法影响的,我们也可以运行可执行程序,创建一个后台进程
./[name] &
此时的后台进程是不影响后台指令的输入的
后台进程如何删除,单纯Ctrl+c是不能的,那只能结束前台进程
kill -9 [pid]
组织进程
可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核里
CPU找到task_struct之后,CPU开始循环执行取值令,分析指令,执行指令
CPU只需要向指令寄存器eip去拿指令就可以了,所谓的函数跳转,分支判断,循环等,都是通过修改eip完成的
查看进程
通过系统调用获取进程标示符
大多数进程信息同样可以使用top和ps这些用户级工具来获取
ps aux
ps axj
ps命令与grep命令搭配使用,即可只显示某一进程的信息。
proc目录
ls /proc
在根目录下有一个名为proc的系统文件夹。
文件夹当中包含大量进程信息,其中有些子目录的目录名为数字。
这些数字其实是某一进程的PID,对应文件夹当中记录着对应进程的各种信息。跟上的话查看具体进程信息
ls /proc/[pid]
这是我们会发现进程都有一个cwd,就叫做当前进程的当前工作目录
通过系统调用获取进程标示符
进程id(PID)
父进程id(PPID)
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("pid: %d\n", getpid());
printf("ppid: %d\n", getppid());
return 0;
}
通过系统调用创建进程-fork初识
代码级别创建进程
🌮 fork的功能就是创建子进程
fork快速入门
父子进程关系
🍁 fork创建子进程,fork之前的代码有父进程执行,fork之后的代码,默认情况父子都可以执行
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
cout<<"I am running"<<endl;//父进程
int ret = fork();
//下面开始父子进程
printf("hello proc : %d!, ret: %d\n", getpid(), ret);
sleep(1);
return 0;
}
父子进程代码共享,但是数据?
🍁 fork有了两个进程,这两个进程谁先被调度?不确定,要取决于OS的调度算法
两次返回值
fork函数会有两次返回值,给父进程返回子进程的pid,给子进程返回0
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t ret = fork();
printf("hello proc : %d!, ret: %d\n", getpid(), ret);
sleep(1);
return 0;
}
fork用if分流
fork函数创建出来的子进程与其父进程共同使用一份代码,但我们如果真的让父子进程做相同的事情,那么创建子进程就没有什么意义了。
实际上,在fork之后我们通常使用if语句进行分流,即让父进程和子进程做不同的事。
fork函数的返回值:
1、如果子进程创建成功,在父进程中返回子进程的PID,而在子进程中返回0。
2、如果子进程创建失败,则在父进程中返回 -1。
既然父进程和子进程获取到fork函数的返回值不同,那么我们就可以据此来让父子进程执行不同的代码,从而做不同的事。
int main()
{
int ret = fork();
if(ret < 0){
perror("fork");
return 1;
}
else if(ret == 0){ //child
printf("I am child : %d!, ret: %d\n", getpid(), ret);
}else{ //father
printf("I am father : %d!, ret: %d\n", getpid(), ret);
}
sleep(1);
return 0;
}
区别于此前的if分支一次只能够执行一个,现在fork可以同时执行超过一个分支,因此我们可以通过if来实现分流,控制父进程和子进程
fork总结
所以说当前阶段fork的重点就是习惯使用分流父子进程操作
进程状态
一个进程从创建而产生至撤销而消亡的整个生命期间,有时占有处理器执行,有时虽可运行但分不到处理器,有时虽有空闲处理器但因等待某个时间的发生而无法执行,这一切都说明进程和程序不相同,进程是活动的且有状态变化的,于是就有了进程状态这一概念。
新建,运行,就绪,挂起,阻塞等待,退出,这些都是某个进程的一些状态
这些都是操作系统级别的理解,也就是应该符合操作系统的共性,所以我们可以有一个具体的操作系统,下面来学习Linux的进程状态
状态能数据化吗?都是可以数据化的,进程的状态信息就保存在了task_struct中
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
注意:在Linux操作系统当中我们可以通过 ps aux 或 ps axj 命令查看进程的状态。
Running
**R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。**也就是说,可以同时存在多个R状态的进程。
P.S.: 所有处于运行状态,即可被调度的进程,都被放到运行队列当中,当操作系统需要切换进程运行时,就直接在运行队列中选取进程运行。
🍎 如果是1个CPU,可不可以同时存在多个R状态的进程
其实进程是R状态,不代表在运行,代表的是可以调度
操作系统除了会维护一个表示进程信息的task_struct,还会维护一个调度队列
CPU调度的话就可以在调度队列中,进行选择调度,这里启用的就是FIFO的调度算法
Sleeping
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
浅度睡眠的意义在于可以被唤醒也可以被杀掉
我们可以主动的赋予一个进程休眠状态
sleep(1000);
代码当中调用sleep函数进行休眠100秒,在这期间我们若是查看该进程的状态,则会看到该进程处于浅度睡眠状态。
Disk sleep
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
与TASK_INTERRUPTIBLE状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,指的并不是CPU不响应外部硬件的中断,而是指进程不响应异步信号。
绝大多数情况下,进程处在睡眠状态时,总是应该能够响应异步信号的。否则你将惊奇的发现,kill -9竟然杀不死一个正在睡眠的进程了!于是我们也很好理解,为什么ps命令看到的进程几乎不会出现TASK_UNINTERRUPTIBLE状态,而总是TASK_INTERRUPTIBLE状态。
而TASK_UNINTERRUPTIBLE状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。(参见《linux内核异步中断浅析》)
在进程对某些硬件进行操作时(比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用TASK_UNINTERRUPTIBLE状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。这种情况下的TASK_UNINTERRUPTIBLE状态总是非常短暂的,通过ps命令基本上不可能捕捉到。
Stoppped
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
手动暂停状态
kill -SIGSTOP
手动继续执行
kill -SIGCONT
我们如果对T状态的进程操作杀死进程,那么只有在唤醒的时候才会回收资源
后期会学到通过信号发送和键盘发送信号的几种方式来传递停止信号Ctrl+Z
Dead
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
Zombie
僵尸状态快速入门
🍁 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用,后面讲)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
🍁 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
🍁 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
举一个警察赶到犯罪现场的例子
- 封锁现场
- 采集有效信息
- 清理现场
拥有前两个状态的就是僵尸状态,而清理现场最后一个状态的是退出状态,
进程退出,系统层面,曾经申请的资源并不是立即释放,而是暂存一段时间,供OS(父进程)读取,也就是僵尸状态
为什么要有僵尸状态?僵尸状态进程创建的意义
我们先回答下面的一个问题:任务完成的时候,调用方应不应该知道任务完成的怎么样了?
当然是应该知道!!
此时我们想到我们在main函数的返回值,这个退出码其实就是代表了程序运行的结果
int main()
{
...
return 0;
}
我们说在Linux中有一个$?
命令,这个表示的就是在命令行中,最近的一次进程退出时的退出码
echo $? # 我们可以通过这个指令来先是上一次进程退出的退出码
所以说main函数的退出码就是表示任务完成的成果,也就是我们为什么一直需要return 0,这就是退出码
进程退出的信息(退出码)是会暂时保存起来,保存在task_struct
中,如果不读取的话,此时相关的数据就不应该被释放,这种就是僵尸进程
模拟僵尸状态
#include <stdio.h>
#include<stdlib.h>
#include <unistd.h>
int main()
{
printf("I am running ....\n");
pid_t id= fork();
if(id==0){
//child
int count=5;
while(count){
printf("I am a child,pid: %d,ppid: %d\n,count: %d\n",
getpid(),getppid(),--count);
sleep(1);
}
printf("child quit");
exit(1);
}else if(id>0) {
//father
while(1){
printf("I am a father,pid: %d,ppid: %d\n",getpid(),getppid());
sleep(1);
}
}else{
//do nothing
}
return 0;
}
输入以下脚本查看状态
while :; do ps aux | head -1 && ps aux |grep myproc| grep -v grep; echo "################################"; sleep 1; done
可以看到子进程变成了僵尸进程,此时父进程还在执行
总结僵尸进程
僵尸进程是什么?
僵尸进程是已经退出,但是相关资源还没有被回收,为了表示这个状态,我们称之为Z状态
往往是子进程先退出而父进程没有对子进程的退出信息进行读取
为什么要有僵尸进程?
因为我们必须保证这个进程跑完,作为这个进程的父进程,必须知道布置给子进程的任务完成的怎么样了,需要起码知道结果
僵尸进程的危害?
僵尸进程的退出状态必须一直维持下去,因为它要告诉其父进程相应的退出信息。可是父进程一直不读取,那么子进程也就一直处于僵尸状态。
僵尸进程的退出信息被保存在task_struct(PCB)中,僵尸状态一直不退出,那么PCB就一直需要进行维护。
若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致内存泄漏。
孤儿进程
父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
子进程先退出而父进程没有对子进程的退出信息进行读取,称为“僵尸进程”
父进程先退出,子进程就称之为“孤儿进程”
孤儿进程被1号init进程领养,当然要有init进程回收喽。也就是说孤儿进程会被父亲操作系统领养
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 1;
}
else if(id == 0){//child
printf("I am child, pid : %d\n", getpid());
sleep(10);
}else{//parent
printf("I am parent, pid: %d\n", getpid());
sleep(3);
exit(0);
}
return 0;
}
进程优先级
🍁 cpu资源分配的先后顺序,就是指进程的优先权(priority)。
🍁 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
🍁 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
⚠️ 虽然我们是可以改的,但是最好不要改变操作系统所控制的优先级
什么是优先级
优先级本质上是谁先占有资源和谁后占有资源的问题
权限是能不能获得,而优先级值得是谁先谁后
###为什么要有优先级
资源有限的,进程是可以有多个的,通过标优先级是可以提高系统性能的(其实改善的是我们想要的性能,因为改善这个,那个就会收到影响,对于操作系统而言似乎是没有什么性能提升的)
进程需要优先级设置,进行排序
排队
进程需要排队,如何理解进程需要排队
进程排队:PCB去排,PCB属性信息放到CPU里面计算
查看系统进程
在linux或者unix系统中,用ps –l命令则会类似输出以下几个内容:
ps -l
信息 | 描述 |
---|---|
UID | 代表执行者的身份 |
PID | 代表这个进程的代号 |
PPID | 代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号 |
PRI | 代表这个进程可被执行的优先级,其值越小越早被执行 |
NI | 代表这个进程的nice值 |
PRI and NI
🌿 PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
🌿 NI是nice值,其表示进程可被执行的优先级的修正数值,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。可以理解nice值是进程优先级的修正修正数据
🌿 PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
,这里的old默认为80
🌿 当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
🌿 调整进程优先级,在Linux下,就是调整进程nice值
🌿 nice其取值范围是-20至19,一共40个级别。
用top和renice命令更改已存在进程的nice
top
进入top后按“r”–>输入进程PID–>输入nice值
另外还有renice操作
若是想将NI值调为负值,也就是将进程的优先级调高,需要使用sudo命令提升权限。
多进程概念
🌸 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
🌸 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
🌸 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
🌸 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
环境变量
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
使用场景:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
为什么要有环境变量?
来确认当前所处的状态,比如说,我当前是谁,当前终端是谁…
理解命令和环境变量
命令本身其实是一个可执行程序,诸如ls的命令可以响应,但是自己随便生成的一个可执行程序是无法随便响应的,只能在当前目录响应
这是因为自己的程序没有添加 环境变量
常见环境变量
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
PATH
查找方式
echo $NAME //NAME:你的环境变量名称
这里我们来找到环境变量
echo $PATH
这时如果我们要我们的某一个自己写的可执行程序也能在任意位置执行怎么操作?
方法一
我们找一下ls的位置
所以说如果我们把我们的可执行文件放到usr/bin中的时候我们可以在任意位置执行这个可执行程序了
sudo cp -f [filename] /usr/bin
然而这个方法是不推荐的,因为这会污染环境变量
方法二
推荐的方法是在PATH中更新环境变量
export PATH=$PATH:[路径]
然而这个PATH按照如上操作的话是每次登录的时候都会更新的
HOME
echo $HOME
SHELL
bash其实就是系统当中的命令,该命令跑起来之后形成一个线程进行解释我们的命令
环境变量相关命令
- echo: 显示某个环境变量值
- export: 设置一个新的环境变量
- env: 显示所有环境变量
- unset: 清除环境变量
- set: 显示本地定义的shell变量和环境变量
env
echo "hello linux" > /dev/pts/0
这个就是终端设备输出,奇怪我这里没有SSH_TTY显示环境变量
实际上我们可以用who来看
如何让我们自己的命令也能够像可执行程序执行
方式一:将可执行程序拷贝到环境变量PATH的某一路径下。
sudo cp [dirname] /usr/bin
方式二:将可执行程序所在的目录导入到环境变量PATH当中。
export PATH=$PATH:/绝对路径
和环境变量相关的命令
1、echo:显示某个环境变量的值。
2、export:设置一个新的环境变量。
3、env:显示所有的环境变量。
4、set:显示本地定义的shell变量和环境变量。
5、unset:清除环境变量。
有关环境变量
环境变量名称 | 表示内容 |
---|---|
PATH | 命令的搜索路径 |
HOME | 用户的主工作目录 |
SHELL | 当前Shell |
HOSTNAME | 主机名 |
TERM | 终端类型 |
HISTSIZE | 记录历史命令的条数 |
SSH_TTY | 当前终端文件 |
USER | 当前用户 |
邮箱 | |
PWD | 当前所处路径 |
LANG | 编码格式 |
LOGNAME | 登录用户名 |
环境变量组织
系统当中环境变量的组织是这样的
每个程序都会收到一张环境变量表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串,最后一个字符指针为空。
代码获取环境变量
我们说main函数其实是可以有参数的
int main(int argc, char *argv[], char *envp[])
- char *argv[]
其中这个argv值得是命令行参数数组,接收的是命令行参数,其实我们可以理解ls
是一个C语言写的程序,然后接收这个
-a、
-l`等选项的呢,本质即使main函数是接收的这个命令行参数数组
main函数的前两个参数,main函数的第二个参数是一个字符指针数组,数组当中的第一个字符指针存储的是可执行程序的位置,其余字符指针存储的是所给的若干选项,最后一个字符指针为空,而main函数的第一个参数代表的就是字符指针数组当中的有效元素个数。
int main(int argc, char *argv[], char *envp[])
{
if(argc == 2){
if(strcmp(argv[1], "-a") == 0){
printf("hello allen\n");
}
else if(strcmp(argv[1], "-b") == 0){
printf("hello world!\n");
}
else{
printf("hello default!\n");
}
}
printf("argc : %d\n", argc);
for(int i=0; i < argc; i++){
printf("argv[%d]: %s\n", i, argv[i]);
}
}
- char *envp[]
这个就是环境变量信息,envp存储结构就是命令行参数
系统调用这个程序的时候传入的环境变量,
环境变量访问方式
代码获取
命令行参数
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0;
for(; env[i]; i++){
printf("%s\n", env[i]);
}
return 0;
}
通过第三方变量environ获取
二级指针指向环境变量指针
由于libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明。
#include <stdio.h>
int main(int argc, char *argv[])
{
extern char **environ;
int i = 0;
for(; environ[i]; i++){
printf("%s\n", environ[i]);
}
return 0;
}
系统调用获取
getenv
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
常用getenv和putenv函数来访问特定的环境变量。
环境变量通全局属性
环境变量通常具有全局属性,可以被子进程继承下去
#include <stdio.h>
#include <stdlib.h>
int main()
{
char * env = getenv("MYENV");
if(env){
printf("%s\n", env);
}
return 0;
}
直接查看,发现没有结果,说明该环境变量根本不存在
导出环境变量
export MYENV="hello world"
再次运行程序,发现结果有了!说明:环境变量是可以被子进程继承下去
进程地址空间
进程地址空间是内存中的内核数据结构mm_struct
其实这个图就叫做进程地址空间
注意该图显示的所有内容都不是内存,或者和内存没有直接关系
我们说当又有一个进程被创建出来的时候,系统就会生成一个mm_struct和一个task_struct,这些东西是存在内存中的
Linux中的进程地址空间排布规则
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include<malloc.h>
#include <unistd.h>
int main(int argc, char *argv[], char *envp[])
{
printf("code addr: %p\n", main);
char *str = "hello world";
printf("read only addr: %p\n", str);
printf("init addr: %p\n", &g_val);
printf("uninit addr: %p\n", &g_unval);
int *p = malloc(10);
printf("heap addr: %p\n", p);
printf("stack add: %p\n", &str);
printf("stack add: %p\n", &p);
for (int i = 0; i < argc; i++)
{
printf("args addr: %p\n", argv[i]); // ls -a -l
}
int i = 0;
while (envp[i])
{
printf("env addr: %p\n", envp[i]);
i++;
}
return 0;
}
这样的排布是符合上图的
进程独立性
用例子证明
为了演示进程之间的独立性和解决上述的进程地址空间和内存有没有关系,我们可以做下面的测试
如果我们让父子进程打印输出一下这个全局变量的值和地址我们会发现输出出来的变量值和地址是一模一样的,因为子进程按照父进程为模版,而父子并没有对变量进行进行任何修改
int g_val = 100;
int main(int argc, char *argv[], char *envp[])
{
pid_t id = fork();
//int id = fork();
if(id == 0){
//child
printf("child: pid: %d, ppid : %d, g_val: %d, &g_val: %p\n",
getpid(), getppid(), g_val, &g_val);
}
else{
//father
sleep(2);
printf("father: pid: %d, ppid : %d, g_val: %d, &g_val: %p\n",
getpid(), getppid(), g_val, &g_val);
}
sleep(1);
}
接下来我们修改一下代码,我们让子进程把全局变量修改一下
我们的问题是g_val
的地址是否变化,其次是如果子进程先运行,然后修改了g_val
,然后观察父进程来打印g_val
,看看是修改前的还是修改后的
int g_val = 100;
int main(int argc, char *argv[], char *envp[])
{
pid_t id = fork();
//int id = fork();
if(id == 0){
//child
g_val=200;
printf("child: pid: %d, ppid : %d, g_val: %d, &g_val: %p\n",
getpid(), getppid(), g_val, &g_val);
}
else{
//father
sleep(2);
printf("father: pid: %d, ppid : %d, g_val: %d, &g_val: %p\n",
getpid(), getppid(), g_val, &g_val);
}
sleep(1);
}
结果是父进程虽然和子进程仍然是一个地址,但是值确是独立的
所以通过这个测试我们可以了解到进程之间是有独立性的
同时还有下面几点特性 :
🍁 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
🍁 地址值是一样的,说明,该地址绝对不是物理地址,是虚拟地址
🍁 在Linux地址下,这种地址叫做虚拟地址
🍁 我们在用C/C++语言所看到的地址,全部都是虚拟地址!
🍁 物理地址,用户一概看不到,由OS统一管理,OS必须负责将虚拟地址转化成物理地址。
进程的写时拷贝技术
我们说之前我们曾在string的模拟实现中有提到一个写时拷贝技术,也就是通过计数+写时拷贝来解决浅拷贝的析构多次问题,那么这里的进程独立性是怎么解决的呢?其实用到的也就是写时拷贝技术
而当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
例如,子进程需要将全局变量g_val改为200,那么此时就在内存的某处存储g_val的新值,并且改变子进程当中g_val的虚拟地址通过页表映射后得到的物理地址即可。
虚拟地址映射
所以我们说进程地址空间的内容是被转化成了虚拟地址空间,映射在内存中
![image-20220628112848913](https://i-blog.csdnimg.cn/blog_migrate/b636026ef1f31df238920c446d5bf676.png)
虚拟地址空间,如果是32位的话就是4GB
注意这232次的空间的大小其实和物理空间大小本身是没有什么关系的,而进程地址空间本身其实和物理内存没有关系
下面我们来看一下mm_struct:
每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建。而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的是mm_struct的地址。
例如,父进程有自己的task_struct和mm_struct,该父进程创建的子进程也有属于其自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置,如下图:
如果我们想要寻找某一个地址的话只需要基地址+偏移量
如果我们想要扩大这个某个空间的划分的时候其实只要修改某一个end指针就可以了
为什么要有地址空间?
此时我们再来回答这个问题:为什么要有地址空间?
把地址编上了“刻度”,方便存储查找数据
这样也不会存在系统级的越界问题(错误的访问物理内存)了,因为
空间访问如果越界的话,如果查看页表没有找到映射,那么就判定为野指针,页表的其他区域(不属于你的空间)不会允许你访问
地址空间进程独立性小结
🐟 为什么数据要进行写时拷贝?
进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
🐟 为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。
🐟 代码会不会进行写时拷贝?
90%的情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进行进程替换的时候,则需要进行代码的写时拷贝。
🐟 地址空间的特点
地址空间功能
虚拟地址空间+页表本质功能就是保护内存
相同的进程结构
每个进程都认为看到的是相同的空间范围(构成,顺序)
独占内存
每个进程都认为自己在独占内存,更好的完成进程独立性和合理使用空间
这样的好处就是可以将进程进行调度,内存管理进行解耦或者分离,可以延迟加载(提高效率),可以按照需要,动态地加载入内存
执行文件的分段(补充)
同时我们的exe文件也是如此,是分段的,为了方便操作系统来针对性的解析
进程调度队列(极简)
一个CPU拥有一个runqueue
如果有多个CPU就要考虑进程个数的父子均衡问题
优先级
普通优先级:100~139(我们的操作都是普通的优先级,因为nice值的取值范围限制)
实时优先级:0~99(不是重点)
活动队列
🌿 时间片还没有结束的所有进程都按照优先级放在该队列
🌿 nr_active: 总共有多少个运行状态的进程
🌿 queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下标就是优先级!
🌿 从该结构中,选择一个最合适的进程的过程
- 从0下表开始遍历queue[140]
- 找到第一个非空队列,该队列必定为优先级最高的队列
- 拿到选中队列的第一个进程,开始运行,调度完成!
- 遍历queue[140]时间复杂度是常数!但还是太低效了!
🌿 bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个比特位表示队列是否为空,这样,便可以大大提高查找效率!
过期队列
过期队列和活动队列结构一模一样
过期队列上放置的进程,都是时间片耗尽的进程
当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算
active指针和expired指针
🍒 active指针永远指向活动队列
🍒 expired指针永远指向过期队列
🍸 活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的。
🍸 在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了一批新的活动进程
进程调度算法
在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法
总结
对于操作系统来说,进程就是正在运行中的程序,而对于内存来说,进程只是一块地址空间。
**为什么需要抽象出进程?**这是因为我们需要同时运行多个程序提高效率
参考资料:https://blog.csdn.net/chenlong_cxy/article/details/120193456