Linux 进程概念
1. 冯诺依曼体系结构
目前市面上,几乎所有计算机的硬件构成都遵循冯诺依曼体系结构
与最原始的【输入设备->CPU->输出设备】这样简单的结构相比,冯诺依曼体系结构有何好处?
每次我们在键盘输入数据,经过CPU处理,再输出到显示器上,这一整个过程中,数据从键盘流向了CPU,又流向了显示器,由此可以得知,数据是在计算机体系结构中不断流动的,而数据从一个硬件流向另一个硬件,其本质是数据的拷贝,既然有数据的拷贝,就不得不考虑效率的问题
我们都知道,CPU由于其功能的重要性,发展十分迅速,也就导致其运算速度很快,输入/输出设备相较于CPU,速度就比较缓慢,这就导致CPU处理完数据后需要等待输入设备给它数据,因此整台计算机的效率就比较低下;这就好比有两个人计划盖一所房子,一个人负责用砖头盖房子,另一个人负责递砖头给他,那盖整所房子的效率实际是取决于递砖头的那个人,因为就算你盖房子的效率再高,没有砖头你怎么盖呢?所以CPU和设备之间的关系也是这样,这种传统的计算机体系结构效率过于低下
为了解决效率低下的问题,冯诺依曼在CPU和设备之间添加了存储器,也就是内存,由内存去获取数据,交给CPU处理,CPU处理完后再返回给内存,再由内存把数据给输入设备
由此我们可以得出结论:
- 外设的数据没有直接交给CPU处理,而是先放到内存中
- CPU不和输入/输出设备打交道,它只和内存打交道
那么,这样做有什么好处呢?一方面,内存可以预先获取一部分数据作为缓存,当CPU需要数据时,直接交给CPU处理;另一方面,数据在外设与内存之间的拷贝效率要高于外设与CPU的,从而大大提升了计算机的整体效率
计算机中,存储数据有很多硬件,寄存器,内存,硬盘…对于这些硬件,距离CPU越近,效率就越高,但成本也就越大
刚才说到想要提高计算机的效率,既然这样,那为什么不在外设上装满寄存器,这样效率不就能达到最大了吗?
按照目前的科技水平,确实是能造出这样的计算机,但问题是,这样的计算机,哪个平名百姓能用得起?这样的计算机成本太大,太贵了,也就很难普及
因此,冯诺依曼体系结构的意义不仅仅是提高的计算机的整体效率的问题,更重要的意义是它在提升计算机效率的同时,又将计算机的成本控制在了一个合理的范围内,让普通老百姓能接受,这大大提高了计算机的普及,从而让越来越多的人能够用得起计算机,互联网也才会发展的这么迅速,我们才有如今的万物联网!
有了上面的知识,我们也可以理解为什么我们平常使用的程序在运行前都要先加载到内存中
我们平常写的程序是二进制代码文件,既然是代码,它就是数据;而我们知道,这些数据是要被CPU访问的
但CPU只和内存之间传输数据,因此数据首先要加载到内存中
同时,我们也可以思考这样的场景:
你在QQ上,向你的朋友发送了一条信息,解释这条信息在计算机中的整个流动过程
- 想发送数据,首先得登录QQ,于是QQ这个程序被加载到了内存中
- 从键盘获取数据–>加载到内存–>被QQ捕获–>CPU加密处理–>返回给内存–>网卡,网卡通过网络交给你朋友的网卡上,加载到内存–>被QQ捕获–>CPU解密处理–>返回给内存–>输出到你朋友的显示器上
2. 操作系统
2.1 概念
操作系统是一款软硬件资源管理的软件
狭义上,我们所知的操作系统由操作系统内核+操作系统外壳程序(图形化界面/shell外壳程序)构成
2.2 结构示意图
我们先简单点看
可以看到,最底层是基础硬件,这些硬件根据冯诺依曼体系结构,通过主板连接在一起,而光有这些硬件远远不够,需要有人对他们进行管理,这便是操作系统的工作
在操作系统和硬件之间,有个驱动层;操作系统需要管理硬件,同时它本身也应当稳定,不能因为硬件的改变就影响到它自身;如果操作系统直接去管理硬件,那么硬件改变了,操作系统也要随之改变,因此,在操作系统和硬件之间添加了驱动层,每个硬件在驱动层都对应一个驱动程序,这些驱动程序由厂商提供
那么,操作系统为什么要管理好底层的这些硬件呢?
正常来讲,这些硬件当然需要被管理好,不然我的电脑怎么运行呢?事实上也确实如此,但需要知道,这并不是人类创造操作系统真正的目的;操作系统它总归只是一个工具,所有的工具其最总的目的都是为了人类的方便,操作系统也是如此,它一定是为用户提供帮助的
操作系统通过管理好底层的这些硬件(手段),进而为用户提供一个稳定的,高效的,安全的环境(目的)
2.2 理解操作系统
前面我们说过,操作系统是一款软硬件资源管理的软件,重在管理二字,因此我们理解操作系统要从操作系统如何管理出发
生活中,处处都有管理,学校里,校长作为一校之主,自然需要将每个学生管理好;一开学,分配好宿舍,上课时间、地点…给你安排的明明白白的,但奇怪的是,我好像从未和校长见过面,更没有与他讨论过这些东西,它怎么把我的事情全部处理好了?校长是怎么做到的?
我们假设校长是一个程序员,最开始想管理好你们自然需要跟你们交谈,但是随着学校规模的增大,学生人数越来越多,校长每天跑动跑西,感到越来越力不从心,于是他想了个法子,写了一个可以记录学生数据的程序,派辅导员收集各班的学生信息,辅导员又派班长收集学生信息,最总所有的学生信息都统计在了程序中,这样校长就能足不出户,动动电脑就能管理好学生
在上述过程中,校长不需要与学生见面,只要有学生的数据,就能管理好学生;也就是说,管理者不需要直接和被管理者直接见面,数据才是管理的关键,管理的本质是对数据进行管理
那么,面对这么多的学生,校长如何清楚的知道每项数据对应哪个学生呢?校长本身是一个程序员,他做了一个通讯录的程序,在通讯录中,每个学生都有基本属性,知道了一个学生的基本属性就相当于知道了该学生,同时,他又懂一些数据结构,将每个学生通过一定的结构拼接在一起,就完成了整个通讯录
通讯录中记录每个学生的基本属性可以称为描述一个学生,将每个学生通过一定结构拼接在一起称为组织学生
因此,任何管理最总归结为6个字:先描述,再组织
用同样的思想来再理解我们的操作系统,想要管理好底层的这些硬件,不需要操作系统直接去访问这些硬件,只要有这些硬件的数据即可,先要对这些硬件描述,再将它们组织起来,这样操作系统只要管理好这些数据,就相当于管理好了硬件
理解了操作系统对下层的管理,我们再来聊聊操作系统对上层有哪些作用;前面说过,操作系统的最总目的是为了用户的方便,但我们知道,用户有时需要访问底层硬件,比如打开文件,这些文件都在磁盘中,本质是打开了磁盘中的文件,这个过程难道是用户直接访问了磁盘吗?当然不是,既然操作系统是硬件的管理者,那么用户想要访问底层硬件必须先告诉操作系统,必须由操作系统去执行;因此,打开文件等访问底层硬件的操作其实是操作系统帮我们完成的
有时,我们也需要访问操作系统本身,比如我想看看操作系统内的数据,难道操作系统就任由你看了?显然不是这样的,操作系统不相信任何人,万一你直接把操作系统搞崩溃了呢?但是你是用户,操作系统必须满足用户的需求,于是,操作系统向用户提供了一些系统接口,用户想访问操作系统,只能通过这些接口访问
但还是存在一些问题,不同的操作系统提供的接口名字和使用方式不同,但功能可能是类似的,难道用户每换一个平台就要把相同功能的接口重新学一下吗?这对用户来说太麻烦了;于是,将系统接口包装成操作接口,提供给用户;这也就是为什么有的语言是跨平台的,因为它有自己的标准库,不同的平台标准库调用的系统接口不同,但我们用户不需要管
3. 进程
3.1 进程的概念
进程是操作系统分配资源的最小单位
在Windows下打开任务管理器,其中有很多进程,这表明进程在操作系统中可以同时存在很多,操作系统需要对这么多的进程管理,如何管理?先描述—操作系统中一定有描述进程的结构体,再组织—操作系统需要以一定的数据结构将这些结构体组织起来
在操作系统中,描述一个进程的结构体叫做PCB(process control block)
我们都知道,可执行程序被CPU执行前,要先加载到内存中,很多人认为,加载到内存中的代码和数据就是进程,实际上这种定义并不完整
对于每一个加载到内存中的可执行程序,操作系统都会创建一个PCB,PCB中记录着每个可执行程序的属性信息,比如该程序的时间片、CPU何时调度等等,再通过一定的数据结构组织起来,这样,对进程的管理就变成了对该数据结构的增删查改
进程可以简单定义为:进程 = 内核数据结构(PCB) + 代码和数据
为什么要有PCB?这是因为操作系统需要对进程进行管理,就得先描述,再组织,而PCB是用来描述的
PCB是操作系统中的概念,在Linux中,它的PCB叫做task_struct
可执行程序将来是要被CPU调度的,其本质是进程的task_struct处在CPU的调度队列当中,也就是让进程的task_struct在CPU的调度队列中进行排队
未来我们理解进程的动态运行,其本质都是进程的task_struct处在不同的数据结构当中,这样进程就能访问到不同的资源
3.2 进程的pid
task_struct是用来描述进程的,那么一个进程都有哪些属性呢?
Linux中,以./运行一个可执行程序或者直接执行系统指令时,它们的本质都是运行了一个进程,每个进程都有唯一的标识符,叫做pid
如何在进程运行的过程中获取进程的pid呢?由于pid是属于task_struct内部的一个属性,而task_struct又是内核数据结构,用户不能直接访问操作系统,必须使用系统调用才能访问操作系统
#include <sys/types.h>
#include <unistd.h>pid_t getpid(void); // 获取当前进程的pid
pid_t getppid(void); // 获取当前进程的父进程的pid
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{while(1){printf("进程pid: %d, 父进程pid: %d\n", getpid(), getppid());sleep(1);}return 0;
}
这里pid为3881的进程是bash,平时我们运行的进程或系统指令,都是bash的子进程
3.3 进程的创建
通过命令行的方式当然能运行起来一个进程,能不能通过代码的方式呢?
用户不能直接通过操作系统直接创建进程,需要使用系统调用
#include <sys/types.h>
#include <unistd.h>pid_t fork(void);/*
fork的返回值:
如果为-1,创建子进程失败
如果为0,当前进程是子进程
如果>0,当前进程是父进程
/*
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{printf("running, pid:%d, ppid:%d\n", getpid(), getppid());sleep(3);pid_t id = fork();if(id == 0){while(1){printf("child running, pid:%d, ppid:%d\n", getpid(), getppid());sleep(1);}} else{while(1){printf("parent running, pid:%d, ppid:%d\n", getpid(), getppid());sleep(1);}}return 0;
}
我们来理解一下上面的程序,fork函数用于创建一个子进程,创建子进程的本质就是在内核中创建一个task_struct + 程序代码和数据,也就是说子进程在内核中也会有自己的task_struct,但父进程的代码和数据是从文件中加载到内存中的,子进程的代码和数据从哪来呢?
子进程默认会继承父进程的代码和数据,fork之后,父子进程共享代码
但对于上述代码的现象,仍有疑问?
-
同一个变量id,为什么既是==0又是>0?
这与虚拟地址空间和父子写时拷贝技术有关
-
fork()函数为什么会有两个返回值?
fork()是一个函数,CPU会去执行fork()函数内部的相关代码;在fork()函数内部,return之前,子进程已经被创建好了,后续的代码被子进程共享,由此fork()函数有两个返回值
父进程与子进程之间具有独立性,由于子进程会继承父进程的代码和数据,代码是可读的,因此可以共享,但数据父子进程可以都需要修改,因此数据必须分开,同时为了保证效率,父子进程会以写时拷贝的方式各自持有数据
// 多进程的创建
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{int count = 5;for(int i = 0;i < count;i ++){pid_t id = fork();if(id == 0){while(1){printf("child-%d running, pid:%d\n", i + 1, getpid());sleep(1);}}}while(1){printf("parent running, pid:%d\n", getpid());sleep(1);}return 0;
}
Linux中,在/proc
目录下存放着当前所有进程,在进程目录中,有这样两个文件
exe文件记录自身可执行程序所在的路径
cwd文件记录着进程的当前工作目录
cwd文件有何作用呢?使用fopen打开一个文件时,我们只写了文件名,但fopen底层的系统调用会自动加上进程的当前工作路径,所以我们会看见文件就创建在可执行程序所在的路径下了
可以使用系统调用函数修改进程的当前工作路径
#include <unistd.h>int chdir(const char *path);
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>int main()
{chdir("/home/byh/linux");FILE* pf = fopen("log.txt","w");(void)pf;fclose(pf);while(1) {printf("I am a process,pid:%d\n",getpid());sleep(1);}return 0;
}
3.4 进程状态
进程的task_struct中,记录着进程的状态属性
Linux中,一个进程可能有一下几种状态
- R:running
- S:sleeping
- D:disk sleeping
- T:stopped
- t:tracing stop
- X:dead
- Z:zombie
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{while(1){printf("proecss running, pid:%d\n", getpid());}return 0;
}
上述的进程在运行时,会发现它会处于两种状态,‘R’和’S’,对于’R’状态,很好理解,因为该进程本来就是在运行,但进程在运行为什么又会处于’S’状态呢?
我们使用printf函数打印信息,最终是向显示器(外设)打印的,当进程被CPU调度,准备向显示器打印数据时,由于CPU与外设之间天然存在速度上的差距,导致进程有时需要等待显示器就绪,才能获取CPU的调度,打印数据
因此,这里的’S’实际上是一种等待资源就绪的状态;同时,'S’状态也是一种可中断睡眠,我们可以随时使用ctrl + c来终止进程
同样是上面的同一份代码,使用gdb打断点,运行到断点处,进程暂停了,此时进程就处于’t’状态
当进程运行时,给进程发送19号信号SIGSTOP
,发现进程也暂停了,处于’T’状态
'D’状态是Linux特有的一种状态,有这样的一种场景,进程要向磁盘载入数据,由于外设速度慢,进程大部分时间处于等待"资源"就绪的状态,也就是’S’状态;此时内存严重不足,操作系统为了维持系统的稳定,需要强制杀掉一些进程,于是它找啊找,找到了这个在’S’状态的进程,觉得它既然是在睡眠状态,那应该没有要紧的事,就把它杀掉了,因此,磁盘存放数据也就失败了;假设该进程要向磁盘载入的数据十分重要,此时数据弄没了,是谁的责任?为了避免这种情况,进程在这种情况下需要将自身状态改为’D’状态,代表着深度睡眠,不可中断睡眠,进程不可被杀,即使我们向进程发送了SIGKILL
信号,进程也不可被杀
僵尸进程
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{pid_t id = fork();if(id == 0){// childint cnt = 5;while(cnt --){printf("child running, pid:%d\n", getpid());sleep(1);}}else{while(1){printf("parent running, pid:%d\n", getpid());sleep(1);}}return 0;
}
'Z’状态代表着进程处于僵尸状态,当子进程运行完毕后,它会留下退出信息在task_struct中,等待父进程读取
此时子进程已经运行完成,代码和数据已经被释放,只剩下task_struct,这种状态下的进程叫做僵尸进程
**僵尸进程的危害:**虽然子进程的代码和数据被释放了,但它的task_strcut还在内存中,如果父进程没有去获取task_struct中的退出信息然后释放,那么task_struct的空间就造成了内存泄漏
我们之前在命令行启动的进程,为什么从来没有关心它们的僵尸问题?因为在命令行直接启动的进程,它们的父进程是bash,进程运行完毕后,bash会自动帮我们回收这些僵尸进程
孤儿进程
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{pid_t id = fork();if(id == 0){// childwhile(1){printf("child running, pid:%d\n", getpid());sleep(1);}}else{int cnt = 5;while(cnt --){printf("parent running, pid:%d\n", getpid());sleep(1);}}return 0;
}
如果父进程先比子进程先结束,此时子进程就没有了父亲,这种进程叫做孤儿进程;没有了父进程,就代表着当子进程运行完毕后没有人去回收僵尸进程,会造成内存泄漏的问题;Linux中为了避免这种情况,规定父进程比子进程先结束时,这些子进程由1号进程(OS本身)领养
3.5 进程的运行、阻塞和挂起
运行态
前面是Linux系统对于进程状态的具体表现,接下来从理论层面,从操作系统的层面理解进程的状态
所有的进程最终要被CPU调度,每一块CPU都有一个运行队列,真正严格意义上的进程的’R’状态其实是该进程在这个运行队列当中,表示该进程已经准备好了,可以随时被调度
同时,CPU调度进程时,不是一直调度一个进程直到该进程结束的,而是根据每个进程分到的时间片轮转调度的,在进程的task_strcut属性中就会记录着进程的时间片,时间片到了的进程排到运行队列尾
上面所描述的是调度算法的一种,不同的操作系统会有不同的调度算法
- 多个进程以进程切换的方式依次被调度,在一个时间段内得以推进代码的方式,我们把它叫做并发
- 一块CPU不止有一个核心,每个核心都有一个运行队列;任何时刻,真的有多个进程在同时运行的方式,我们把它叫做并行
阻塞态
#include<stdio.h>int main()
{ int i = 0; scanf("%d",&i); printf("%d\n",i); return 0;
}
运行上述代码,进程会停止在scanf处,等待着我们输入数据,此时处于’S’状态
现在我们能够理解,进程实际是在等待资源就绪,我们把进程等待资源就绪的状态叫做阻塞态
操作系统管理软硬件资源,对于进程(软件)的管理,我们以及讲解过了,那么操作系统是如何管理硬件的呢?
先描述,再组织,操作系统内一定存在着描述着各种硬件的结构体,再以某种数据结构组织起来,这样,对硬件的管理,就变成了对该数据结构的管理
而描述硬件的结构体中,记录着硬件的各种属性;还有一个等待队列
进程在CPU的运行队列中,处于’R’状态,执行到scanf函数时,操作系统检查底层键盘数据是否就绪,发现没有就绪,于是将该进程的task_struct从运行队列中移除,同时挂到键盘结构体的等待队列中,此时进程变成了阻塞状态,该进程是没有被调度的
等到键盘数据就绪,进程获取到数据后,再将进程的task_struct挂到到CPU的运行队列中
阻塞状态本质是进程在等待底层硬件“资源”的就绪
阻塞和运行状态的变化,伴随着进程的task_strcut挂到了到不同的队列当中
挂起态
在磁盘中,有一个swap分区,可以用来存放内存中进程的代码和数据
由于操作系统本质上管理的是task_struct,与程序加载到内存中代码和数据无关,在内存十分紧张的场景下,操作系统会将进程代码和数据临时存放到磁盘中的swap分区,以此来换取内存,缓解内存的紧张,更加合理的使用内存资源;此时该进程的状态就叫做挂起状态;待内存紧张问题解决再唤入数据
挂起状态本质是以时间换取空间的做法,但是注意,swap分区的容量不应设置太大,避免操作系统一有内存紧张问题就向swap分区存放数据,频繁的唤出唤入数据会增加系统的IO,必然导致效率的降低
3.6 进程的切换
CPU根据调度算法依次调度进程时,进程之间是怎样切换的?
难道一个进程的时间片到了,下次再运行时就从头运行该进程吗?显然不是这样的,CPU内部有很多寄存器,用来存放当前运行进程的临时数据
当一个进程被调度时,各种数据会加载到寄存器当中,一旦进程的时间片到了,寄存器会把该时刻进程的临时数据存到tack_struct,我们把CPU的寄存器中存放的这些临时数据称为进程的上下文数据
等到下次该进程再次被调度时,会加载这些上下文数据到寄存器当中,这样就不需要重新调度进程,完成了进程的切换
进程在切换的过程中,最重要的是上下文数据的保护和恢复
需要知道,寄存器本身是硬件,CPU内的寄存器只有一套,但它可以有多套进程的上下文数据
3.7 进程的优先级
什么是优先级
进程的优先级是进程的task_struct中的一条属性字段,它代表着该进程获取资源(CPU)的先后顺序
在Linux中,优先级的值越小,代表优先级越高
优先级与权限的关系?权限规定的是能不能访问某种资源,而优先级则是能访问了,规定的是访问资源的顺序
为什么要有优先级
一台计算机中,所有的硬件都只有一个,而进程会有很多,一定会有多个进程都要访问同一个硬件的情况,也就意味着进程要访问的资源始终是有限的,那就必然要有访问的先后顺序
操作系统对于调度和优先级的原则,它会保证最基本的公平,确保每个进程都能被调度,如果一个进程长时间不被调度,就会造成饥饿问题
Linux中的优先级
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{while(1){printf("running, pid:%d\n", getpid());sleep(1);}return 0;
}
PRId
代表该进程当前的优先级,NI
代表该进程的nice值
在 Linux 系统中,进程新的优先级 = 默认优先级(80)+ nice值
其中 nice 值范围在[-20, 19]
,也就意味着一个进程能修改的优先级范围在[60, 99]
可以通过修改nice值来控制一个进程的优先级,但并不建议任意修改一个进程的优先级
4. 命令行参数
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.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("function1\n");else if(strcmp(argv[1], "-b") == 0) printf("function2\n");else if(strcmp(argv[1], "-c") == 0) printf("function3\n");else if(strcmp(argv[1], "-d") == 0) printf("function4\n");else printf("no function\n");return 0;
}
结论:在命令行输入的数据最终会以空格作为分隔符,放入 argv 字符指针数组中,argc代表参数的个数
在命令行输入给进程的参数的叫做命令行参数
- 是谁将命令行参数传递给main函数的?
- 为什么要这样做?
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>int gval = 1000;int main()
{pid_t id = fork();if(id == 0){// childwhile(1){printf("child running, pid:%d, gval:%d\n", getpid(), gval);sleep(1);}}else{while(1){printf("parent running, pid:%d, gval:%d\n", getpid(), gval);sleep(1);}}return 0;
}
通过以上程序想说明的是:子进程能访问父进程的数据
在 Linux 的命令行中启动的进程,都是 bash 的子进程,在命令行输入的参数也自然就是输入给 bash 进程的了,
bash 进程会分析命令行参数,并转化成 argv 表,然后创建子进程执行命令,由于子进程能访问父进程的数据,也就自然能访问 argv 表了
因此,argv 表是由 bash 构建并传递给其子进程的main函数的
至于为什么要这么做,我们可以对比一些命令
可以看到,同一个进程输入不同的选项,得到的执行的结果不同,命令行参数的意义也在于此:为了让进程根据不同的选项完成不同的功能
5. 环境变量
5.1 什么是环境变量
在 Linux 中,各种命令行指令和我们自己写的程序,运行起来后本质都是进程,但是为什么运行我们自己的程序需要加上路径才能运行,而指令可以直接运行?
Linux 中,存在一些全局的设置,告诉命令行解释器应该去哪些路径下寻找程序,我们把这些全局的设置称为环境变量,而命令行解释器默认查找路径的环境变量叫做PATH
当命令行解释器要执行一个程序时,默认会去环境变量PATH的路径下去寻找,如果找到了就执行,找不到就报错
这也是为什么系统的指令可以直接运行,因为系统指令路径添加到PATH环境变量中了,如果我们希望自己的程序也能不加路径直接运行,可以将程序拷贝到系统路径下(不推荐这么做),也可以将程序的绝对路径添加到环境变量中
当然,这种做法只是临时的,当系统重新启动时,PATH 中的内容就会还原
实际上,环境变量是存放在系统的配置文件中,当系统启动时,bash 进程会读取系统配置文件中的内容,将环境变量载入到内存中,因此,上述的环境变量都是内存级的
要想修改环境变量永久生效,需要修改系统配置文件中的环境变量
以 Ubuntu20.04 为例,在/etc/environment
文件中加上程序的绝对路径,重启系统
5.2 其他的环境变量
Linux中,除了 PATH,还有很多其他的环境变量,使用env
指令查看所有环境变量
我们也可以使用export
导入环境变量,使用unset
删除环境变量
直接以上述的方式导入的变量,发现它并不在环境变量中,但却能通过 echo 指令查到该变量的值,这种变量叫做本地变量,本地变量只在 bash 内部有效
5.3 理解环境变量
int main()
{extern char** environ;for(int i = 0;environ[i];i ++){printf("env[%d]->%s\n", i, environ[i]);}return 0;
}
该程序的结果就是我们在命令行使用 env 查看到的环境变量
其原理同命令行参数类似,系统启动时,bash 进程会根据系统配置文件中的环境变量,生成一份环境变量表,由于命令行中运行的大部分进程都是 bash 的子进程,子进程能访问到父进程 bash 的数据,也就能获取到 bash 的所有环境变量了
使用 export 导入环境变量,本质就是在环境变量表末尾添加新的环境变量
也可以将环境变量表作为参数传递给 main,或者使用getenv
系统调用打印某个环境变量
int main(int argc, char* argv[], char* env[])
{for(int i = 0;env[i];i ++){printf("env[%d]->%s\n", i, env[i]);}return 0;
}
#include <stdlib.h>char *getenv(const char *name);
int main()
{char* path = getenv("PATH");printf("PATH=%s\n", path);return 0;
}
总结:Linux 系统启动时,bash进程默认会创建两张表:
- argv[]命令行参数表(通过用户输入的方式获取)
- env[]环境变量表(由系统配置文件加载)
子进程可以通过各种方式访问表中的数据
有三种方式可以在代码中获取环境变量
- char** envrion:全局变量
- char* env[]:环境变量表
- char* getenv(“name”):系统调用
但目前仍有个问题,既然 bash 下执行的程序是 bash 的子进程,使用 export 导入环境变量时,新的环境变量应该是 bash 的子进程 export 的数据,我们只说过子进程能继承访问父进程的数据,为什么这里父进程居然能访问子进程的数据了?
父进程的环境变量具有系统级的全局属性,因为环境变量能被子进程一直继承下去,但子进程的环境变量不应当被父进程访问,因为进程具有独立性
在 Linux 的命令行中,大部分命令 bash 创建子进程执行的,但也有一些特殊的指令,是由 bash 亲自执行的,这样的命令我们叫做内建命令
常见的内建命令有:echo export pwd …
这也是为什么当我们将环境变量 PATH 清空,其他如 ls 等指令不能运行,而echo、export、pwd等指令能正常运行
知道了 export 是内建命令,它是由 bash 亲自执行的,也就能解释上面"子进程访问了父进程数据"的现象了,export 导入的环境变量仍然是由 bash 导入的
同时,也能理解本地变量不能使用 env 查看到,但能使用 echo 输出,因为 echo 是内建命令
6. 进程地址空间
int gval = 100;int main()
{printf("parent running, pid:%d\n", getpid());pid_t id = fork();if(id == 0){int cnt = 0;while(1){printf("child running, pid:%d, gval:%d, &gval:%p\n", getpid(), gval, &gval);sleep(1);cnt ++;if(cnt == 3){printf("change gval:%d->%d\n", gval, gval + 100);gval += 100;}}}else{while(1){printf("parent running, pid:%d, gval:%d, &gval:%p\n", getpid(), gval, &gval);sleep(1);}}return 0;
}
进程 = task_struct + 代码和数据,因为进程要有独立性,所以父进程和子进程都有自己的task_struct
代码是只读的,所以父子进程共享同一份代码不会影响独立性,但是对于数据,父子进程可能需要修改,为了保持独立性,父子进程的数据肯定不会是同一份,上面的代码 g_val 在父子进程中的值不同也验证了这点
但奇怪的是,为什么父子进程中 gval 的地址相同,gval 的值却不同?说明该地址并不是物理地址,它是一个虚拟地址
6.1 地址空间的概念
在 C/C++ 的学习过程中,我们可能会见过这样的内存分布表
结论:可执行程序加载到物理内存时,操作系统不仅会创建内核数据结构 task_struct,还会给该进程创建一份虚拟地址空间,该地址空间上的地址都是虚拟地址
同时,还会构建一张页表,用于将虚拟地址映射到物理地址
当父进程创建子进程时,子进程会继承父进程的地址空间和页表,说明子进程的页表和父进程的页表中的虚拟地址指向同一块物理内存
现在,我们来解释上述的现象是如何产生的
如果子进程不需要修改数据,子进程和父进程共享同一份代码和数据,当子进程要修改数据时,操作系统会在物理内存中将数据拷贝一份,并更新子进程页表中虚拟地址和物理地址的映射关系,最后再修新数据的值
Linux系统中,只在需要修改数据时才将数据拷贝一份,叫做写时拷贝,它的本质是按需申请空间,从而达到节省空间的效果
6.2 理解地址空间
理解区域划分
每一个进程都有一个地址空间,有多个进程就有多个地址空间,OS就需要管理这些地址空间 — 先描述,再组织
内核中描述地址空间的结构体叫做struct mm_struct
,它的本质是对各种区域的划分
从图片上看,地址空间中有很多区域,栈,堆…也就是说地址空间需要进行区域划分
在OS内部,地址空间本质是一个内核结构体对象,里面定义了很多 start ,end 的变量,用来表示各个区域的范围
所谓的区域划分,就是在结构体中定义该区域的起始位置和结束位置
操作系统会给每一个进程赋予一个跟物理内存一样大小的地址空间,进程则根据自身的需求使用地址空间
为什么要有地址空间
每个程序加载到内存中的代码和数据并不是有序的,通常都是乱序的
如果 tack_struct 直接指向物理内存中的代码和数据,会产生以下的问题
- 进程管理太过杂乱
- 内存管理的同时还需要进程管理,两个模块之间依赖度过高
- 不能对物理内存进行有效的保护
有了地址空间,加上页表的映射
-
将无序变成有序,让进程以统一的视角看待物理内存以及自己运行的各个区域
-
内存管理和进程管理解耦
-
拦截非法请求,保护了物理内存
当进行非法写/读数据时,比如越界访问/写入数据,页表没有对应的映射关系,则直接报错,防止了对物理内存的随意修改
6.3 理解页表和写时拷贝
实际上,页表比我们了解到的要复杂很多,页表不仅记录着虚拟地址到物理地址的映射关系,还记录着数据是否在物理内存中,数据是否有 rwx 权限
之前我们说过进程的挂起状态,当内存严重不足时,OS会将部分内存暂时唤出到磁盘上的swap分区,此时页表中的物理地址仍然指向物理内存
页表中会有个选项表示该物理内存的数据在不在内存当中,0表示不在,1表示在,如果要访问的数据不在物理内存中,不需要访问物理内存,直接在地址空间部分将你拦截
char* p = "hello world";
*p = 'H';
如果编译上述代码,编译器会报错,从语言层面,我们知道该字符串是常量字符串,存放在常量区,不可被修改
从操作系统的层面,为什么常量区的数据不可被修改呢?在页表中还有个选项,用来表示该地址的数据是 rwx 的
当进行非法访问数据时,OS识别到错误,进行判断
- 数据在不在物理内存中,如果不在,发生缺页中断
- 需不需要写实拷贝,如果需要,进行写时拷贝
- 如果不是上述要求,则进行异常处理
有了上面的知识,我们也能理解 fork 函数的返回值为什么既是 >0 ,又是 == 0,在fork函数内部,会return一个值给id,其本质就是对id进行写入操作,此时会发生写时拷贝,因此父子进程中id的值不同
7. Linux2.6内核进程调度队列
每个CPU都要有自己的运行队列,下图是Linux2.6内核中进程运行队列的数据结构
其中,queue 数组下标[0,99]的位置我们不管,[100,139]代表着不同优先级的队列,刚好40个,这也是为什么进程的 nice 值的调整范围是[-20,19],刚好也是40个数;active,expired指针分别指向arr[0]和arr[1],分别代表着准备调度的进程(活跃进程)和调度完的进程(过期进程)
首先,bitmap数组一共是160个bit位,queue中每一个下标按顺序都对应bitmap中的一个bit位;bit位为0表示没有该优先级的进程,为1表示有
当一个进程准备调度时,先根据它的优先级放入队列当中;CPU查找进程进行调度时,不是去queue中从头开始寻找进程,而是先去bitmap的160个bit位中寻找第一个bit位为1的位置,再去queue中调度该位置的进程
当一个进程调度完,不是直接插入到原本队列当中,而是插入到过期队列;当一个队列的所有进程调度完,将 active 和 expired 指针交换,就完成了过期队列和活跃队列的交换,再去调度 active 指针中的队列,依次往复
也就是说,Linux中调度时,一共有两个队列,一个负责只进不出,另一个负责只出不进,当只进不出的队列运行完,交换这两个队列,达到了轮换的效果
整个过程查找一个进程进行调度的时间复杂度为O(1),也被叫做大O(1)调度算法