文章目录
进程前的一些话
先了解一些操作系统的总体框架:
操作系统(operator system):任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)简单来说: 操作系统就是一种来管理软硬件的程序
-
为什么要有OS??
更好地去管理协调软硬件资源,给予用户程序一个安全稳定快速的执行环境。
但是操作系统本身是整体的:操作系统不相信任何的用户,但是依然要为用户提供服务,所以就出现了一些接口,用这些接口调用来供上层开发使用,叫做系统调用
。可是由于系统调用在使用上难度较高,对于一般用户很不友好,所以有些开发者对系统进行了适度封装,从而形成了库,优化了用户的使用和开发体验。 -
操作系统对进程的管理:
同样也是先描述(描述为PCB结构)再组织(各种数据结构类型)
进程的基本概念
简单来说一个被载入内存的程序叫进程,或者一个正在运行的程序叫做进程。
进程 = 内核PCB数据结构对象 + 所写代码和数据
ls /proc 查看当前系统中所有的进程
使用命令ps axj也可以显示系统当中存在的进程。
- 计算启动时加载操作系统首先加载到内存中(OS实际上也是一种程序),操作系统通过
先描述,再管理
这样的方式来管理内存。
那么到底什么是描述呢,什么又是管理呢?
举个例子,公司里最上层的管理者想要管理员工,实际上是不需要与员工见面的,上层的管理者只需要知道一个员工的属性,如出勤,项目贡献,活动参与度等等数据。管理者通过整合每一个人的的属性,就可以了解员工的状态来进行管理员工了。
- 所以操作系统对进程的管理和组织也是同理:
当一个进程加载到内存的时候,OS会立刻描述这个进程,集合进程的信息,形成一个进程控制块PCB(Process Control Block)
,进程的所有信息都比放在PCB中。 - PCB可以理解成: 是一个类似C语言中的结构体,或者c++中的类。上面我们说了,包含PCB和代码和数据,那么PCB之中就有记录进程代码的地址。
刚刚说了PCB,那task_struct又是什么呢?
- task_struct其实是PCB的其中一种: task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含进程的信息。
task_struct的基本内容分类
- PID:用来区别进程的标识。(PID是会变化的)
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器(pc): 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
- 其他的各种信息。
进程的查看方式
1.ps axj 查询方式
ps axj | head -1 && ps axj | grep test
使用组合技查询可以现实首行信息标识和目标进程
2.ls /proc方式
方法:ls /proc/对应进程pid
其中红圈中的目录名为数字,这些数字就是进程的PID。这些目录中记录着进程的详细信息
认识第一个系统调用
对于系统调用的使用: getpid(),getppid()
分别获得当前进程的PID和其父进程的PID。
系统调用中的创建子进程->fork( )
fork( )是一个系统调用的接口,可以帮助我们在运行代码时创建子进程。
- fork函数的返回值:
1、如果子进程创建成功,在父进程中返回子进程的PID,而在子进程中返回0。
2、如果子进程创建失败,则在父进程中返回 -1。
例如,我们看下面的代码:
很明显,fork()之后原来的进程创建了一个子进程,但是这里还是会与一些疑问?
- 为什么fork要给父进程返回子进程PID,给子进程返回0呢?
- fork( )具体干了什么操作?
- 一个函数怎么返回了两次值?
这里我们来一一解答:
首先,我们需要明白一个点:父进程的数据和代码是加载到内存当中取用的,那么fork之后创建之后的子进程数据和代码又来自哪里呢?很简单,既然是父子关系,老爸坑定要带一波儿子的,所以子进程的代码和数据与父进程共享
。
了解之后我们来回答第一个问题:为什么针对父进程和子进程返回不同的值?
1 - 返回不同的返回值,是为了区分不同的执行流,执行不同的代码块。(简单说:叫来子进程就是来帮忙的,让父和子执行不同的代码块。)
fork( )具体干了什么操作?
2-
由上图可知:fork()完成了对子进程PCB的构建操作,所以在return 的前一刻已经创建号子进程了。那么return ret就分别被父子进程各调了一次。
在回答第三个问题之前,我们需要明白如下情况:
所以在理解函数能够返回两次的基础上,我们知道了父子数据实际上时分开存储的,所以id有两个值时是不是就可以接受了一些。
提示点 : 对于父子进程这两个进程,谁先运行时不确定的,这个有调度器中的算法具体实现。
一般操作系统的状态
运行状态
-
对于已经载入内存的进程来说(已经形成PCB),在被CPU执行之前,会有一个运行队列,相当于queue一般,头部进入CPU中,然后不断地向尾部遍历进入。接着就是调度器按照自身内部的算法去控制进程进出CPU的过程。
处于运行队列之中的进程就称为运行态(R状态),无论进程是否正在被CPU执行,即可以有同时有多个存在R状态的进程 -
但是对于一个已经放到CPU上的进程,是要一直执行完毕,才自己放下来呢?
当然不是
:日常如果写出了死循环,若是要跑完当前进程才能调度,那么对于用户来说其他的后续进程就会卡住不动。
所以进程有一个叫做时间片的概念:该时间片所限制的一段时间内,保证所有的进程都会被CPU执行,此过程在计算机叫做并发执行
。因此,会存在大量将进程放上CPU和拿下来的过程,这个过程叫做进程切换
阻塞态
对于硬件设备,操作系统对其的管理方式依旧是先描述,再组织
。将硬件的数据属性形成集合PCB来管理。
- 当进程处于等待队列的时候,此时进程的数据正在等待硬件的输入,此时进程就处于阻塞状态!
挂起状态
当内存中资源严重不足的时候,操作系统会把进程的数据和代码换出到磁盘中以缓解内存的压力,此时的进程就被称为挂起状态,后续需要的时候再换入。
Linux中进程的状态
static const char task_state_array[] = {
“R (running)”, / 0*/
“S (sleeping)”, /* 1*/
“D (disk sleep)”, /* 2*/
“T (stopped)”, /* 4*/
“T (tracing stop)”, /* 8*/
“Z (zombie)”, /* 16*/
“X (dead)” /* 32*/
};
通过ps axj可以查看所有的进程信息情况:STAT那一列就是进程的状态显示。
运行状态R
当一个进程处在运行队列里的时候,进程就处于R状态。无论进程当前是否被载入CPU中,都是R状态,所以可能会有多个进程同时处于R的状态。
D深度睡眠不响应任何操作系统请求,所以在D状态的等待期间,该进程无法以任何被杀掉:以确保数据不会丢失。
浅度睡眠状态(S)
一个进程处于浅度睡眠状态,意味着该进程正在等待某件事情的完成,处于浅度睡眠状态的进程随时可以被唤醒,也可以被杀掉(这里的睡眠有时候也可叫做可中断睡眠(interruptible sleep))。
其实可以理解为阻塞状态,等待这键盘等外设写入数据,一但获取到数据就变成R状态。
例如,我们使用sleep(second)的时候,此时查看进程的状态就是阻塞的状态。
处于浅度睡眠状态的进程是可以被杀掉的。kill -选项9
+ 对应进程的PID
深度睡眠状态-D
一个进程处于深度睡眠状态(disk sleep),表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复。该状态有时候也叫不可中断睡眠状态(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束。
例如,某一进程要求对磁盘进行写入操作,那么在磁盘进行写入期间,该进程就处于深度睡眠状态,是不会被杀掉的,因为该进程需要等待磁盘的回复(是否写入成功)以做出相应的应答。(磁盘休眠状态)
暂停状态-T
在Linux当中,我们可以通过发送SIGSTOP信号使进程进入暂停状态(stopped),发送SIGCONT信号可以让处于暂停状态的进程继续运行。
我们再对该进程发送SIGCONT信号,该进程就继续运行了。
僵尸状态-Z
当一个进程结束的时候,操作系统不会立刻释放其PCB的内存占用,而是保留下来,等着父进程或者bash回收结束信息,若是结束信息始终无法被回收,那么进程的一些相关数据会一直保留再内存当中。所以,当一个进程正在等待结束信息被回收,那么此时进程的状态就是僵尸状态。
- 再主函数中,我们一直会写返回0,这个0其实就是返回给操作系统,让其来判断进程代码是否顺利的执行。
- 但是进程结束后的退出信息保存再哪里呢?
进程退出的信息(例如退出码),是暂时被保存在其进程控制块当中的,在Linux操作系统中也就是保存在该进程的task_struct当中。
僵尸进程
当一个进程的子进程结束后,夫进程没有回收子进程的退出信息,那么这个子进程就成为了僵尸进程。
下面的代码时我们自己编写的,父进程创建子进程,此时父进程中没有回收子进程退出信息的操作,所以子进程就会变成Z状态。
#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;
}
僵尸进程的危害
- 僵尸进程的退出信息被保存在task_struct中,僵尸状态一直不退出,那么PCB就一直需要进行维护。
- 若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
- 僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致
内存泄漏
。
孤儿进程
在Linux当中的进程关系大多数是父子关系。若是父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。
若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号init进程领养,此后当孤儿进程进入僵尸状态时就由int进程进行处理回收。
- 为什么要被领养呢?
因为未来子进程也会结束,需要回收信息进行内存的释放。
进程的优先级
是什么:
进程对于资源的访问,谁先访问,谁后访问的权力。
为什么会有优先级?
内存资源时有限的,进程是多个的,所以注定了进程之间要竞争资源。操作系统必须出手保证良性的进程,否则会出现某些进程长时间得不到CPU资源,出现进程的饥饿问题。
ps -l
PRI代表着进程的优先级,整数越小, 优先级越高。
NI代表的是nice值,其表示进程可被执行的优先级的修正数值。
PRI的范围是[60,99),所以NI的范围是[-20,19)共四十个级别
pri(新) = pri(旧) + nice
注意这个pri旧的值始终是80,与上一次调整后的数值无关。即操作系统当中默认的PRI值是80,且每次修改都是从80的基础上去加减。
调度器大体上是如何去通过pri去控制呢?
…待补充。
通过renice命令更改进程的nice值
- 使用renice命令,后面跟上更改后的nice值和进程的PID即可。
- 若是想使用renice命令将NI值调为负值,也需要使用sudo命令提升权限。
进程切换
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
进程切换必须要有进程上下文的保存和回复过程。
若是不保存,那么下次该进程在CPU中就无法接着上回退出时的地方继续运行了。
cpu内部的寄存器里面保存的进程的临时数据—>进程的上下文。
所以,进程在离开CPU的时候,会将当前的寄存器里内部数据保存到自身的PCB(理论理解,但是速度太慢,现实用的硬件方面的操作和软结合):
简单来说就是将自己的数据带走,方便下次运行,达到无缝衔接的进程切换过程
环境变量
什么是环境变量:环境变量是系统提供的一组name = value形式的变量,不同的环境变量有不同的用户,通常具有全局属性
。
认识常见的环境变量:
PATH: 指定命令的搜索路径。
HOME: 指定用户的主工作目录(即用户登录到Linux系统中的默认所处目录)。
SHELL: 当前Shell,它的值通常是/bin/bash。
查看环境变量的方法:
我们可以通过echo命令来查看环境变量,方式如下:
echo $NAME //NAME为待查看的环境变量名称(当环境变量被访问时,前面都要带dollar)
我们知道,系统提供的指令实际上是一个可执行程序,那为什么我输入指令的时候不需要带路劲?
就是由于shell内部维护了一个PATH的环境变量,作为一个输入指令时的一个搜索路劲。
有了PATH环境变量,我们输入对应指令的时候,系统就会自动在PATH内部所给的路劲里面寻找可执行文件的路劲去匹配,找到就停止,找不到提示找不到。
我们有两种不带路径去运行可之心程序的方法:
1.将可执行程序拷贝到环境变量PATH的某一路径下。
sudo cp proc /usr/bin
既然在未指定路径时,系统会根据环境变量PATH当中的路径进行查找对应可执行程序,那我们就可以将我们的可执行程序拷贝到PATH的某一路径下,此后我们的可执行程序不带路径系统也可以找到了
2.将可执行程序所在的目录导入到环境变量PATH当中。
本地变量 && 环境变量
本地变量只在当前bash中有效,不会被其他子进程继承。
esport 本地变量
就可以使得转化为环境变量。
env
:显示所有的环境变量。
unset 环境变量
:删除环境变量
set
:可以查看所有类型的变量(包括本地和环境变量)
环境变量的组织方式:
命令行参数
main函数其实是带有参数的,int main(int argc,char *argv)
int argc:argc字符数组中的有效元素个数。
char *argv[]:字符指针数组,数组当中的第一个字符指针存储的是可执行程序的位置,其余字符指针存储的是所给的若干选项。
但是,其实main函数还有第三个参数:
main函数其实是带有参数的,int main(int argc,char *argv,char* env)
main函数的第三个参数接收的实际上就是环境变量表,我们可以通过main函数的第三个参数来获取系统的环境变量。
环境变量表实际上也就是上面的环境变量的组织方式:
所以我们可以通过第三个参数来获取所有环境变量:
也可以通过我们还可以通过第三方变量environ来获取。
通过系统调用获取环境变量,getenv(); getenv函数可以根据所给环境变量名,在环境变量表当中进行搜索,并返回一个指向相应值的字符串指针。
程序地址空间
再认识这个程序地址空间之前呢,我们先来看一个现象?
这个就是我们学习fork的时候留下的疑问,我们知道id发生了写时拷贝,单独开辟了空间,那为什么这两个id的地址确实一样的呢?
其实,我们见到的地址实质上都是虚拟地址,会通过页表
映射去访问真正的物理地址。状况图如下:
由于子进程继承父进程的进程地址空间信息,所以子进程的进程地址空间也有一个0X40405c
,但是两者映射到的物理地址不同 。100的物理地址是0X114514,200的物理地址是0X514411。
到这里之后,我们虽然知道两个相同地址id为什么会有不同的值了,但是,到底什么是进程地址空间呢?
所谓进程地址空间,本质是一个描述进程可视内存范围的大小的数据结构对象。
地址空间内存在各种区域的划分,即对线性地址设有不同的start和end;
进程地址空间本质上是内存中的一种内核数据结构,在Linux当中
进程地址空间具体由结构体mm_struct
实现,类似PCB,地址空间也是要被操作系统管理的:先描述,再组织。
struct mm_struct的指针存在进程的PCB中。
在结构体mm_struct当中,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于虚拟地址是由0x00000000到0xffffffff线性增长的,所以虚拟地址又叫做线性地址。
对进程的认识更新
所以我们可以更新一下我们对进程的认识了:
进程 = 内核数据结构(task_struct && mm_struct && 页表)+ 数据和程序代码。
所以在创建一个进程的时候,先创建进程的PCB内核数据结构(mm_struct和页表等),其程序代码和数据可以分批地加载进来-------惰性加载(要的时候再拿)。
那么我们为什么要有进程地址空间呢?
- 让进程以统一的视角看待内存。
- 有了进程空间虚拟地址,让我们在访问的时候,增加一个转换的过程,在这个过程中,可以对我们的寻求地址进行审查,所以一旦有异常访问,立刻拦截,该请求不会到达物理内存=》保护物理内存。
我们知道,进程是可以被挂起的,但是操作系统怎么知道代码数据在不在内存呢?
- 其实这个页表的属性有关,页表有专门的一栏来做判断,0和1。若是发现没有数据和代码加载进入,就会出现缺页中断,此时虚拟地址就正在等待映射到物理内存,操作系统会自动去物理内存里面申请一份空间去构建映射关系。 例如:写时拷贝就是一类缺页中断。
- 进程具有独立性,都有单独的进程地址空间和页表地址。进程进出CPU的时候会保存进程的上下文数据,所以进程切换的时候,PCB一变进程地址空间就跟着变动,上下文中保存着页表地址也会变成下一个进程的(页属于进程上下文)。
- 对于物理内存如何加载,加载到哪个位置,对于页表映射关系的填写——>这一整套流程叫做Linux的内存管理模块。