【Linux】学习之详谈进程概念——关于进程概念,看这一篇就够了!
基本概念
课本概念: 程序的一个执行实例,正在执行的程序等。
内核观点: 担当分配系统资源(CPU时间,内存)的实体。
进程说白了就是正在进行中的程序,而一个可执行程序想要使他处于进行中的状态,也就是要运行这个程序。实际上,运行一个程序的本质便是将程序加载到内存中,也就是说,进程实际上就是加载到内存中的程序。但一个程序(包括代码和数据)加载到内存中后直接就变成了进程吗?答案是否定的。
描述进程-PCB
在上一篇浅谈操作系统我们已经学习到了操作系统是要管理软硬件的,而进程管理就是管理软件的一部分,那么操作系统要如何管理进程呢?还是那六个字——先描述,再组织。站在校长的角度,想要描述好一个学生的属性信息,在C++中用类,而在C语言中用结构体。那么站在操作系统的角度,由于Linux是用C语言写的,所以想要管理进程,就得先描述进程,也就是用结构体描述进程,再组织,再用数据结构将所有进程组织起来,从而达到管理的目的。
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
在Linux操作系统上,所谓的进程控制块PCB就是进程属性的集合,也就是结构体task_struct。是Linux中用于描述进程的结构体。
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
这同样也解释了为什么进程控制块PCB要存在,便是为了操作系统管理进程
进程与程序
不知你对上述知识点是否感到紊乱,下面让我们再来梳理一下,进程与程序的关系:
首先,我们写完代码之后,通过编译器进行编译链接,会形成一个可执行程序。此时,可执行程序本质上是一个文件,它是放在磁盘上的,而当我们去运行此可执行程序是,本质上就是将文件内的代码和数据加载到内存当中,而同时,由于数据量的庞大和杂乱,操作系统要想管理好这些代码和数据,就必须将这些代码和数据聚合在一起以便管理,于是操作系统会对这些代码和数据先进行描述,也就是创建结构体task_struct,里面存放此程序的属性信息,这个结构体称之为进程控制块PCB,此时进程控制块PCB与加载进内存的代码和数据我们统称为进程,也就是进程包括进程控制块与程序。
❗️❗️❗️现在,我们已经解释完了3个问题:❗️❗️❗️
- 😕什么是进程?——>程序相关内容以及进程控制块之和
- :happy:什么是PCB?——>描述进程属性的集合
- 😩为什么要有PCB?——>为了操作系统能够进行进程管理
task_struct内容分类
由上我们得知了操作系统需要聚合数据,那么需要聚合一些什么数据呢?也就是PCB里有什么内容呢?让我们来看看😍
-
标示符: 描述本进程的唯一标示符,用来区别其他进程。
-
状态: 任务状态,退出代码,退出信号等。
-
优先级: 相对于其他进程的优先级。
-
程序计数器(pc): 程序中即将被执行的下一条指令的地址。
-
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
-
上下文数据: 进程执行时处理器的寄存器中的数据。
-
I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
-
记账信息: 可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
-
其他信息。
🤔下面让我们来一一探讨这里面的内容❗️❗️
如何查看进程
在看内容时,首先我们得知道如何查看进程
有两种方法:
在目录下查看
用命令查看
top指令
1. 在目录下查看
在我们的根目录下,有一个proc文件:
我们进入文件后,能看到很多名为数字的子目录。这些数字其实就是进程id—标识符PID,若我们要获取PID为1的信息,我们只需进入/proc/1这个文件夹,就可以查看到1这个进程的信息
2.用命令查看
我们还可以通过命令ps来查看所有进程信息,也可以和grep命令搭配使用,用来筛选特定进程,显示特定进程信息。
ps aux / ps ajx
a:显示终端上的所有进程,包括其他用户的进程
u:显示进程的详细信息
x:显示没有控制终端的进程
j:列出与作业控制相关的信息
ps ajx | head -1 是取头标题
grep -v 是与grep相反的用法,也就是不显示含有该字符串的语句。
3.top指令
在Windows系统下,我们可以打开任务管理器,其中也能观察到前台和后台的进程:
而Linux系统下也有属于它的任务管理器,也就是在命令行输入指令top,就能打开Linux下的任务管理器:
task_struct内容—标示符
现在我们知道了如何查看进程,我们来学习PCB内的第一个内容:标示符
就像我们有身份证一样,每个进程也有自己的编号,我们称为进程id,也叫PID
就像我们有父亲一样,每个进程也有自己的父亲,我们称为父进程id,也叫PPID
进程id和父进程id就是标示符。
标示符和身份证一样。用来区别其他进程
我们可以通过系统调用获取进程标示符:getpid()和getppid()函数
在命令行上运行的命令,基本上父进程都是bash——防止恶意程序破坏内核的运行。
注意:程序虽然是唯一的,但每一次运行程序的进程是不唯一的,即每次启动同一个程序时进程的PID是不同的
拓展—fork()创建子进程
我们知道进程间是存在父子关系的,在一个进程中我们可以创建子进程,让其执行别的任务。我们可以通过系统调用来创建进程
先通过man fork 认识fork()
是一个无参数的系统调用函数,返回值类型为pid_t
用代码来验证创建的子进程存在!
运行结果如下:
❗️❗️❗️❗️❗️
结论:bash通过可执行程序的运行创建了子进程,子进程通过系统调用fork()创建了子进程。
❗️❗️❗️❗️❗️
我们已经理解了操作系统中,bash作为父进程为加载进内存中的程序创建的子进程,那如何理解fork()创建的子进程呢?
其实,在操作系统的角度上,创建进程的方式是没有差别的,无非就是将代码和数据加载进内存时再用PCB进程控制块描述此程序从而形成进程。那问题又来了:子进程对应的代码和数据从哪来?
❗️其实答案已经显而易见了:
- 在默认情况下,通过fork()创建的子进程的代码和数据会继承父进程的代码和数据
- 内核数据结构task _struct也会以父进程为模板,初始化子进程的task_struct
也就是说:父子进程的代码其实是共享的❗️
但有一点需要注意:代码是共享的,但代码是写死的,是不会被修改的
那数据呢?数据是共享的,但数据是要考虑修改的情况的!!!
进程与进程之间,不管父子关系,其数据都是必须要保证独立的,换个角度来想,我们创建子进程,难道是为了和父进程干同样的事吗?肯定不是的❗️ 因为这样是没有意义的❗️
考虑到数据会被修改的情况,子进程会采用写时拷贝来完成进程数据的独立性
(数据各自开辟空间,互不影响,相互独立)
由于有写时拷贝的存在,fork()函数会返回两个返回值:
1、如果子进程创建成功,在父进程中返回子进程的PID,而在子进程中返回0。
2、如果子进程创建失败,则在父进程中返回 -1。
我们来用代码来验证fork()的两个返回值(间接验证了写时拷贝的存在,一方数据修改不影响另一方)
明显两个返回值的结论得证。
这样,我们可以利用分流,让父子进程各自执行不同的任务,使其变得有意义。
背景知识之—CPU中时间片的概念
在讲PCB其他内容之前,我们先来认识一下CPU中时间片的概念。
本质上,我们使用计算机,就是为了让计算机帮助我们执行任务。也就是说,进程被创造出来,就是为了让CPU更好的去执行相应的代码。但在计算机中,CPU相比进程的数量来说,简直是天方夜谭,一般电脑都只是配有一个CPU,但要面对很多的进程的时候,该怎么办呢?
我们来回顾一下,操作系统是如何管理进程的?——>先描述,再组织!
实际上,组织的过程,也就是用数据结构将大量的PCB组织起来的过程,操作系统自己维护了一个运行队列,当CPU空闲时,操作系统会把进程的PCB链接到队列的后面,(虽然CPU接触的是以PCB为结点的队列,但最终还是会逐句执行PCB对应的代码和数据,后面会提到)但是进程的代码不是说很短时间内就可以运行完的,而我们用户平时使用计算机时,我们却并没有感受到一个进程在运行而其他进程没有运行,我们感受到的进程都同时在执行的,而这就是时间片的功劳!
时间片即CPU分配给各个程序的时间,即该进程允许运行的时间,使得各个程序表面上看就是同时运行的!
如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。
如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。而不会造成CPU资源浪费。
比如:操作系统规定每个进程单次运行的时间片为10ms,若一个程序已经运行了10ms,不管此程序是否被运行完毕,都会被放置队列的尾部重新排队。又若程序运行不到10ms就运行完毕了,CPU就会当即切换到下一个进程。
所以,我们所看到多个进程同时进行,本质是通过CPU的快速切换完成的!
我们还可以得到一个结论: 敲重点啦!!!
❗️❗️❗️进程是可以被调度的,是可以被CPU切换的❗️❗️❗️
task_struct内容—状态
在进程属性的集合中,还存有进程的状态!
所谓的进程,在运行的时候,有可能因为运行的需要处在操作系统维护的不同的队列里,而在不同的队列里所处的状态是不同的!!!
我们先认识进程状态为什么要存在,方便后续理解!
进程状态存在的意义:方便操作系统快速判断,完成特定的功能(比如调度),本质上是对进程的分类!
在Linux内核中,对进程状态有如下的定义:
/*
* 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 */
};
R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
t追踪状态(tracing stop):
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态
Z僵尸状态(zombie)
同样的,想要查看进程的状态,我们用ps ajx或ps aux查看:
下面我们一一介绍各种状态并演示:
R——运行状态(running)
运行状态并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
在操作系统维护的运行队列中,每一个处于运行队列的进程随时都有可能被CPU调度,此时的进程就是处于运行状态,所以并不是处于运行状态就意味着此进程就在被CPU占用,有可能是正在等待CPU占用!
S——浅度睡眠状态(sleeping)
意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
我们先用一段代码演示一下S状态:
注意:处于S状态下是可以立即被终止的!浅度睡眠也叫做可中断睡眠
举个小小的栗子~:在Windows中,我们有时候打开浏览器时会一直转圈圈,显示正在加载中,有可能这个进程就是一个正在等待网卡数据就绪的S状态进程!
我们把处于运行队列中的运行状态的task_struct放到等待队列中,就叫做挂起等待(阻塞)
而把处于等待队列中的等待状态的task_struct放到运行队列中,意味着被CPU调度了,叫做唤醒进程!
在这样的代码中,我们观察到进程既有R状态又有S状态,且S状态居多
其实,进行打印的时候也就是打印到我们的显示屏上,这其实就是需要等待外设的,而外设相对于CPU来说太慢了,我们看似一直在运行,实际上从微观层面上看是一直处于切换的,等待是就是休眠态,就绪时就是运行态,所以大部分的时间都是休眠态。因为IO等待外设就绪是需要花时间的!
D——深度睡眠状态(Disk sleep)
有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
D状态其实也是一种S状态,只不过S状态是可以被操作系统杀掉的,而D状态是为了防止被操作系统误杀而产生的状态,通常是在等待IO时会被列入的状态,因为若此时进程一旦被操作系统误杀,则外设对进程的回复一旦完成却找不到进程了,麻烦就大了!
T——暂停状态(stopped)
可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
SIGSTOP和SIGCONT信号:
在我们的kill命令中,就包含了这两个信号的选项,我们可以通过 kill -l 命令来查看:
我们前面杀死进程用的就是 -9 选项SIGKILL信号!
下面演示一下如何发送信号暂停和继续一个进程:
利用进程的PID,向目标进程发送信号,进程就会做出对应的响应!
我们发现进程状态从S+ 发送暂停信号后变成了T状态,再发送继续信号后变成了S状态,这个+号代表什么呢?
其实+号就代表此时进程是在前台运行的,想要终止进程我们可以通过CTRL+C的方式杀掉进程或者向进程发送终止信号。而+号一旦没有代表进程是处于后台运行,因为已经在后台了,只能通过发送信号来杀掉进程。
我们在运行程序时也可以之间让程序直接在后台运行:
只需在后面加一个符号:&
Z——僵尸状态(zombie)
僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
tip:我们通常写的程序中,main函数有返回值,其实这个返回值就是进程退出时的退出码,会存在进程的进程控制块当中,并且通过系统该进程的父进程会拿到此退出码。
也就是说,当一个进程退出时,他所含的退出信息仍以数据的形式存储在task_struct中,系统和父进程会读取此退出信息,若此时父进程一直没有读取子进程的退出信息,此时子进程就处于僵尸状态,此时该进程也被称为僵尸进程。读取信息的过程也叫父进程检测和回收子进程。
下面我们用监控命令行脚本来演示一下僵尸进程。
首先我们编写一个c程序:
#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 child...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);
sleep(1);
count--;
}
printf("child quit...\n");
exit(1);//直接退出,无返回值,也就是父进程读取不到退出信息
}
else if(id > 0){ //father
while(1){
printf("I am father...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
}
else{ //fork error
}
return 0;
}
编译此程序并在另一个终端下监控此程序,我们需要用一段命令行脚本:
[ccn@VM-12-5-centos test_PCB]$ while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep; sleep 1; echo "######################"; done
准备就绪后,我们开始测试:
僵尸进程的危害
进程的退出状态必须被维持下去,因为他需要给父进程他的退出信息,但父进程一直不读取,那么子进程就会一直处于僵尸状态直到被读取。
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,那么PCB就一直要维护。
那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间
若僵尸进程中有申请空间资源,但父进程一直不读取退出信息,这这些申请的资源就一直无法被回收,那么随着积累量的增长,会造成空间资源浪费,也就是内存泄漏问题!
X——死亡状态(dead)
这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
孤儿进程
与僵尸进程相反,如果父进程先退出,子进程还在运行,此时子进程就是一个孤儿进程,会被操作系统领养
因为父进程退出了,因此子进程要退出时,其退出信息没有父进程来进行读取,所以此时僵尸进程会一直占着资源,所以一般孤儿进程会由操作系统领养,这样该进程退出时就由操作系统来读取其退出信息,使其正常退出。
下面我们用代码来演示一下孤儿进程:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("I am running...\n");
pid_t id = fork();
if(id == 0){ //child
while(1){
printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid(), count);
sleep(1);
}
}
else if(id > 0){ //father
int count = 5;
while(count){
printf("I am father...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);
sleep(1);
count--;
}
printf("father quit...\n");
exit(0);//父进程5s后就退出,此时子进程就是一个孤儿进程
}
else{ //fork error
}
return 0;
}
我们通过观察PID和PPID的变化来判断是否为孤儿进程:
而此时1号进程其实就是操作系统。
到此,关于task_struct内的第二大内容进程状态就已经讲完了
task_struct内容—进程优先级
什么是优先级?
- cpu资源分配的先后顺序,就是指进程的优先权(priority)。
优先级有什么用吗?
-
优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
-
还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能
在Linux下,我们在命令行输入命令:ps -l 会有以下几个重要的信息:
UID : 代表执行者的身份
PID : 代表这个进程的代号
PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
PRI :代表这个进程可被执行的优先级,其值越小越早被执行
NI :代表这个进程的nice值
PRI 与 NI
PRI
-
PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高。
NI
那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
所以,调整进程优先级,在Linux下,就是调整进程nice值
nice其取值范围是 -20至19,一共40个级别。
注意:
- 在Linux操作系统中,每次重新设置PRI时,这个值都是从80开始的,即PRI默认80,PRI(new)=PRI(old)(80)+ NI
- nice值不是进程的优先级,只是nice值是进程优先级的修正数据,会影响进程的优先级变化
top命令更改进程nice值
之前我们提到过,top命令相当于Linux系统下的任务管理器, 配合选项,就可以对进程的nice进行修改:
进入top后,在键盘上敲 “r” ——>接着输入进程的PID ——> 输入nice值
进入top之后按“r”:
光标亮的地方提示我们输入PID,我们只需要输入目标进程PID然后按回车:
敲下回车后光标亮的地方提示我们输入要更改的nice值,我们只需输入nice值后再按下回车即可:
我输入的nice值为10,此时也观察到nice值已经变成了10并且PRI变成了90(默认80),修改完成!
关于优先级的其他概念
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
注意:关于task_struct我们需要掌握的重点内存已经介绍完毕,下面的内容只做了解,因此这里只浅谈,不细究!
task_struct内容—程序计数器
概念:程序中即将被执行的下一条语句的地址
在一个程序中,我们知道有循环语句,判断语句等等,当然也有最基本的顺序结构,那么CPU是如何知道现在正在执行哪一行语句,并且这一行的下一行是谁呢?答案就是程序计数器了。
在我们的代码被写入内存时,CPU内的PC寄存器(理解成pc指针)会帮助我们实现顺序结构,pc指针既有下一条语句的地址,也有当前语句的地址。当执行完一条语句,pc指针会自动++,指向下一条语句。
task_struct内容—内存指针
概念:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
可以通过内存指针直接找到被联系对象的实体,通过PCB可以找到这个进程对应的代码和数据
task_struct内容—上下文数据
概念:进程执行时处理器的寄存器中的数据
保护上下文与恢复上下文,在一个时间片结束后,进程会被弹出到队列后,此时寄存器会保护此进程加载进来的数据,后面再一次到次进程时又会恢复这些数据。
好比学生休学时,学校会保留档案,等回来时学校还是照样有你的信息。
task_struct内容— I/O 状态信息
概念:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表
IO状态就是这个进程要进行IO,根据冯诺依曼,IO的本质其实是进程前身要从输入设备读到内存或写到输出设备,进程本身就在内存里,因此进程中要进行很多IO操作,于是就会产生很多的IO状态的信息
task_struct内容—记账信息
概念:可能包括处理器时间总和,使用的时钟数总和,时间限制,记帐号等
OS调度模块,较为均衡的调度每个进程,公平的获得CPU资源(进程被寄存器调度的一个重要的参考标准,结合优先级)
到此,关于PCB的主要内容就介绍完毕,下面介绍另一个概念——环境变量
环境变量
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但
是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
再举个简单的例子,我们自己写的可执行程序时,我们通常需要用 ./+文件名运行,目的是为了指明路径,但运行命令时,为什么就不用呢?命令也是一个可执行程序呀?其实这也是环境变量影响的。我们在Windows中,下载安装软件时,安装这一步骤其实就是将软件的路径添加到环境变量里去。
常见环境变量
- PATH: 指定命令的搜索路径。
- HOME: 指定用户的主工作目录(即用户登录到Linux系统中的默认所处目录)。
- SHELL: 当前Shell,它的值通常是/bin/bash。
查看环境变量的方法
echo $NAME //NAME:你的环境变量名称
我们用命令的方式来查看环境变量:
测试PATH
在前面我们已经查看了环境变量PATH,我们发现当中有多条路径,并且路径都由冒号隔开,这些已经添加进PATH的路径就成了我们使用命令行命令时,例如:ls , echo (这些可执行程序就位于PATH的某一个路径之下)这些命令时不需要在前面添加路径的原因,系统会默认前往环境变量中从左至右依次在各个路径当中进行查找:
所以,想要使用我们自己编写的可执行程序而又不想带 ./ 路径时,我们也可以将路径添加到环境变量当中:
输入命令:export PATH=$PATH: 程序所在路径
再次查看也能发现该路径已在环境变量PATH下:
这种方法的缺陷是:这种添加环境变量的方式,是内存级别的环境变量,在你退出此终端后下一次登录时,就会被删除了,也就是只能维持你本次登录
那么还有别的方法吗?
当然有,还可以通过配置环境变量的方式,但一般不这么干,若操作不当容易出事,因此这里不做演示。
还有另一种方法:
将可执行程序文件拷贝一份到环境变量中存在的路径下:
这样也可以成功,但为了管理文件,一般也不这么干。
测试HOME
在每个用户登录时,起始默认的家目录是不一样的,根本原因也是HOME环境变量的存在:
和环境变量相关的命令
echo
显示某个环境变量值,例如: echo $PATH
export
设置一个新的环境变量
env
显示所有的环境变量
set
显示本地定义的shell变量和环境变量
unset
清除环境变量
系统上还存在一种变量,是与本次登录有关的变量,叫本地变量(只在本次登录有效)
环境变量的组织方式
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串
通过代码来获取环境变量
我们再重新捋一下思路,每一个可执行程序执行时需要带上路径,当若将路径添加到环境变量当中,就可以直接用可执行程序的名字来运行此程序,而添加了环境变量的程序是通过接收一张环境表——字符指针数组的方式来与环境变量关联,
那么此环境表会在程序当中的哪个部分呢?我们又如何来通过代码来获取这个隐藏的环境表呢?
实际上,我们使用的main函数是带参数的。
我们先回答上面的问题:
实际上!main函数的第三个参数,就是一个用来接收环境表的字符指针数组!
我们可以通过main函数的第三个参数来获取系统环境变量!
- 通过参数获取
运行程序后就可以得到各个环境变量的值
- 通过第三方变量获取
运行后也能获取到环境变量,
注意:libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern声明。
- 通过系统调用获取
上述两种方法虽然都可以获取但是都不常用,因为打印出来的都是一批字符串,看着很难受,而我们常用的是通过系统调用来获取环境变量:
getenc函数可以根据所给环境变量名在环境表中搜索并返回相应路径的字符串指针:
运行结果:
环境变量具有全局属性的根本原因
环境变量具有全局属性的根本原因是环境变量是可以被子进程继承的
子进程中的环境变量一般是系统给的,而命令行上启动的进程的父进程都是bash命令行解释器,也就是说环境变量其实是父进程给的,也就是bash,当bash登录时,bash就把系统的环境变量导到了进程的上下文数据中,而bash是从系统读的,系统是从配置文件中获取的。
拓展——main函数的前两个参数
我们前面提到过main函数的第三个参数是用来接收环境表的,能够利用第三个参数来获取环境变量,那么还有两个参数有什么用呢?
通过参数解析我们可以看到这两个参数其实跟command line有关,也就是命令参数有关
实际上,我们使用命令时 , 例如 ls ,我们所带的选项 -l -a 等其实对应的就是第二个参数argv这个字符串指针数组的内容,通过程序来控制各个选项所表现的功能,也就有了命令的可用性,而第一个参数agrc,就代表的是数组的元素个数,若命令行参数中只有一个程序名,那么数组也就只有一个元素。
下面我们通过代码来查看:
由于我们在运行时没有带任何选项,因此数组中只有一个元素:程序名
带选项运行:
其实第一个参数agrc的最大作用是可以用来指定一个程序必须带有几个参数,起到限定参数的作用:
下面我们演示一下如何用代码利用选项参数:
程序地址空间
在学习C语言中,我们都见过这样一张图:
在我们的认识中,这就是我们程序的地址空间的布局图
我们通过一段代码来验证并且更加深刻的理解它:
tip:对于全局变量,实际上若没有初始化,他的值在编译程序时是默认没有给他开辟空间的,只有加载的时候才有,被加载进一个区域,该区域会被设置成不用保存数据只知道有个变量需要定义的区域,本质是节省空间,被设置成多少跟编译器有关系。
我们可以观察到与布局图是吻合的。
回忆完内存布局图后,我们来看这样一段代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
//全局变量
int g_val = 0;
int main()
{
//接收返回值,分别让父子进程做不同的事
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
else{ //parent
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
运行结果:
我们发现,输出出来的变量值和地址是一模一样的,很好理解呀,因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改。可是将代码稍加改动:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取
g_val=100;//子进程修改全局变量的值
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //parent
//父进程先休眠,等子进程打印完后再打印
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
运行结果:
怎么样,是不是对结果大为震惊!相同的地址,却有不同的值!
因此我们能得到以下结论!:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 地址值一样,说明该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做 虚拟地址
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
OS需要负责将虚拟地址转化为物理地址!
因此,我们之前所认为的:程序的地址空间的布局图,其实是一个错误的说法!准确的来说应该是进程的地址空间!如何理解呢?
进程地址空间
实际上!每个进程都有一个自己的地址空间,在Linux内核中,进程地址空间是一个数据结构类型,定义具体进程的地址空间变量
也就是说,进程在创建之初,除了描述进程的进程控制块PCB:task_struct,还有进程地址空间:mm_struct要创建,而mm_struct内的内容就是用来帮助进程先规划出区域来,但还没真正放到物理内存中去,因此才得名虚拟空间!
如何进行区域划分呢?
这与mm_strcut内的内容有关:
这样做的意义我们后面再谈。
现在我们认识到了,每个进程被创建出来时,除了PCB还有进程地址空间,进程规划好自己的空间后,操作系统会通过页表和MMU协调工作将虚拟地址映射转化成物理地址。
页表:操作系统给每个进程维护的一张映射表。
MMU:集成在CPU的查页表的硬件。
现在我们可以解释为什么父子进程打印出来的全局变量是同一个地址:由于子进程是以父进程为模板创建出来的,因此进程地址空间以及PCB也是继承父进程的,因此有着相同的mm_struct以及页表,所以当然打印出来了相同的地址!
但为什么值不同呢?
在前面的模块(fork()创建子进程)我们提到过写时拷贝的存在:
进程是具有独立性的,进程与进程之间互不干扰,因此,一旦父子进程有一方发生修改,为了防止干扰就会发生写时拷贝。
原本的父子进程没有发生修改时,操作系统只会维护一份只读数据,那样成本是最低的,但一旦子进程的值被修改,为了防止干扰,操作系统会给子进程重新开辟物理空间(物理空间会重新申请新空间并把子进程的修改值拷贝进去),也就是说,写时拷贝,其实是直接在物理空间上面拷贝!
于是这个一开始大为震撼的结果也就得到了解释:父子进程有相同的地址空间和页表,子进程发生了修改,操作系统在物理空间上写时拷贝了另一份数据,使得子进程页表建立了另一个映射,因此打印出来的虚拟地址仍是一样的,但由于通过了不同的映射,因此打印出来变量的值不同!
进程地址空间存在的意义
进程地址空间存在有什么意义呢?为什么不直接把代码和数据加载到物理空间上呢?那样还更加方便,不用去进行复杂的映射呀?
-
为了保护物理内存以及各个进程的数据安全!
通过添加了一层软件层,完成有效的对操作系统内存进行风险管理(权限管理)
页表中其实也有权限管理的,举个例子:
为什么我们字符常量区的常量不可修改?本质就是操作系统在页表上只给了你只读的权限!
所以意义之一就是有利于做管理,监控你对数据操作的做法合不合适,如果没用中间软件层,也就是没有进程地址空间直接管的话可能会访问到其他进程的代码和数据!
-
将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和OS进行内存管理操作,进行软件上面的分离。
申请空间时不会立马从物理内存开辟空间,而是在地址空间中把区域调大后拿着地址空间多调大的区域的空间,等到要使用要读写时才在物理内存中开辟空间建立映射(调大就是理解成边界按照线性增长了,所在区域变大了,(start,end)的模变大了)
这怎么理解呢?举个例子:
我们现在申请1000个字节的空间,但我们立马就能使用吗?其实不然,可能会存在暂时不会全部使用或者暂时不使用的情况,也就是说,在操作系统的角度,你申请了这部分空间,但是你闲置着不用,但其实别的进程申请空间时,这部分空间是可以立马拿得出手并给别的进程使用的,所以你申请空间是操作系统不会立马给你映射,而是在虚拟空间中调大对应区域,等你要使用时才给你映射,这样就不会白白占用物理空间,让别人也可以使用。
这样一来,若有个进程申请空间时物理内存满了,可以执行内存管理算法。照样可以有空间。
-
帮助CPU开始执行代码
不同进程起始入口不一样,那么CPU如何知道进程的第一条代码从哪开始呢?CPU如何快速的找到每个进程的起始地址?其实还是进程地址空间的功劳!
操作系统让CPU每次都从同一个地址执行代码,通过映射再找到对应进程的物理内存的代码,
每个进程都有页表,每个进程都有地址空间 ,映射时,虚拟地址全部都是同一个地址,(让CPU都从同一个地址开始)再映射到不同的区域(物理内存)
这样CPU不会乱,也快, 只要是不同进程的页表,就可以映射进不同的代码的起始地址。
-
每个进程都有同样的视角去进行各种区域管理
我们已经知道,每个进程都会创建出进程地址空间,也就是说,每个进程都认为自己在独占系统资源,站在CPU和应用层的角度,进程统一可以看做统一使用4GB的空间,而且每个空间区域的相对位置都是比较确定的!
这样统一化后,每个进程自己规划自己的代码和数据放的区域,规划好后再映射到物理内存,而程序的代码和数据其实是可以被加载到物理内存的任意位置的,也就是有了页表后,虚拟地址可以是连续的但是在物理内存上是可以随便放的,这样很多个进程同时加载时就不会杂乱,统一化之后更简单,只需要操作系统维护好页表就行!
拓展—Linux2.6内核进程调度队列
上图是Linux2.6内核中进程队列的数据结构。
前面的学习我们知道一个CPU是只维护了一个运行队列(runqueue)的
因此如果有多个CPU,就要考虑进程个数的负载均衡问题。
进程还有别的队列:
活动队列
-
时间片还没有结束的所有进程都按照优先级放在该队列
-
nr_active: 总共有多少个运行状态的进程
-
queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下标就是优先级!
从该结构中,选择一个最合适的进程,过程是怎么的呢?
从0下表开始遍历queue[140]
找到第一个非空队列,该队列必定为优先级最高的队列
拿到选中队列的第一个进程,开始运行,调度完成!
遍历queue[140]时间复杂度是常数!但还是太低效了!
- bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个比特位表示队列是否为空,这样,便可以大大提高查找效率
过期队列
- 过期队列和活动队列结构一模一样
- 过期队列上放置的进程,都是时间片耗尽的进程
- 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算
active指针和expired指针
active指针永远指向活动队列
expired指针永远指向过期队列
可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的。没关系,在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了一批新的活动进程!
到此,本篇结束,制作不易,如果内容对你有帮助,关注我,我会努力学习争取做出更优质的博文!
如果内容有误,请及时批评指正!