文章目录
- 1.基本概念与基本操作
- 1.1 描述进程-PCB
- 1.2 task_struct-PCB的一种
- 1.3 task_struct内容分类
- 1.4 查看进程
- 1.5 通过系统调用获取进程的PID和PPID
- 1.6 PPID(Parent Process ID)
- 1.7 通过系统调⽤创建进程-fork初识
- fork创建子进程
- 使用if进行分流
- 2.进程状态
- 2.1 运行状态-R
- 2.2 浅度睡眠状态-S
- 2.3 深度睡眠状态-D
- 2.4 暂停状态-T(t)
- 2.5 死亡状态-X
- 2.6 僵尸状态-Z
- 3.僵尸进程
- 3.1 僵尸进程示例
- 3.2 僵尸进程的危害
- 4.孤儿进程
- 5.进程优先级
- 5.1 进程优先级的基本概念
- 5.2 查看系统进程
- 5.3 PRI and NI
- 5.4 进程优先级的操作
- 查看进程优先级信息
- 通过top命令更改进程的nice值
- 通过renice命令更改进程的nice值
- 四个重要概念
- 6. Linux2.6内核进程O(1)调度队列
- 6.1 时间片的概念
- 6.2 一个CPU拥有一个runqueue
- 6.3 优先级
- 6.4 活跃队列
- 6.5 过期队列
- 6.6 active指针和expired指针
- 7. 环境变量
- 7.1常见环境变量
- 7.2 查看环境变量的方法
- 7.3 测试PATH
- 7.4 测试HOME
- 7.5 测试SHELL
- 7.7 和环境变量相关的命令
- 7.8 环境变量的组织方式
- 7.9 获取环境变量
- main函数的参数
- 获取环境变量
- 8. 程序地址空间
- 9. 进程地址空间
1.基本概念与基本操作
- 课本概念:程序的⼀个执⾏实例,正在执⾏的程序等
- 内核观点:担当分配系统资源(CPU时间,内存)的实体。
只要写过代码的都知道,当你的代码进行编译链接后便会生成一个可执行程序,这个可执行程序本质上是一个文件,是放在磁盘上的。当我们双击这个可执行程序将其运行起来时,本质上是将这个程序加载到内存当中了,因为只有加载到内存后,CPU才能对其进行逐行的语句执行,而一旦将这个程序加载到内存后,我们就不应该将这个程序再叫做程序了,严格意义上将应该将其称之为进程。
1.1 描述进程-PCB
- 进程信息被放在⼀个叫做 进程控制块 \color{red}{进程控制块} 进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是:task_struct。上图当中的结构体就是这个PCB;
- task_struct是Linux内核的⼀种数据结构,它会被装载到RAM(内存)⾥并且包含着进程的信息。
当你开机的时候启动的第一个程序就是我们的操作系统(即操作系统是第一个加载到内存的),我们都知道操作系统是做管理工作的,而其中就包括了进程管理。而系统内是存在大量进程的,那么操作系统是如何对进程进行管理的呢?
这时我们就应该想到管理的六字真言:先描述,再组织。操作系统管理进程也是一样的,操作系统作为管理者是不需要直接和被管理者(进程)直接进行沟通的,当一个进程出现时,操作系统就立马对其进行描述,之后对该进程的管理实际上就是对其描述信息的管理。
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,也就是这个PCB(process control block)。
操作系统将每一个进程都进行描述,形成了一个个的进程控制块(PCB),并将这些PCB以双链表的形式组织起来。
这样一来,操作系统只要拿到这个双链表的头指针,便可以访问到所有的PCB。此后,操作系统对各个进程的管理就变成了对这条双链表的一系列操作。
例如创建一个进程实际上就是先将该进程的代码和数据加载到内存,紧接着操作系统对该进程进行描述形成对应的PCB,并将这个PCB插入到该双链表当中。而退出一个进程实际上就是先将该进程的PCB从该双链表当中删除,然后操作系统再将内存当中属于该进程的代码和数据进行释放或是置为无效。
总的来说,操作系统对进程的管理实际上就变成了对该双链表的增、删、查、改等操作。
1.2 task_struct-PCB的一种
-
进程控制块(PCB)是描述进程的,在C++当中我们称之为面向对象,而在C语言当中我们称之为结构体,既然Linux操作系统是用C语言进行编写的,那么Linux当中的进程控制块必定是用结构体来实现的。
-
PCB实际上是对进程控制块的统称,在Linux中描述进程的结构体叫做task_struct。
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含进程的信息。
1.3 task_struct内容分类
- task_struct就是Linux当中的进程控制块,task_struct当中主要包含以下信息:
- 标示符: 描述本进程的唯一标示符(Process ID,简称PID),用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器(pc): 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
其他信息。
1.4 查看进程
- 进程的信息可以通过/proc 系统⽂件夹查看
文件夹当中包含大量进程信息,其中有些子目录的目录名为数字。
这些数字其实是某一进程的PID,对应文件夹当中记录着对应进程的各种信息。我们若想查看PID为1的进程的进程信息,则查看名字为1的文件夹即可。
2. ⼤多数进程信息同样可以使⽤top和ps这些⽤⼾级⼯具来获取
ps命令与grep命令搭配使用,即可只显示某一进程的信息。
注意:grep本身也是一个进程,当你在命令行中运行 grep 命令时,操作系统会创建一个新的进程来执行这个命令。因此,在进程列表中你会看到 grep 进程。
1.5 通过系统调用获取进程的PID和PPID
通过使用系统调用函数,getpid和getppid即可分别获取进程的PID和PPID。
我们可以通过一段代码来进行测试。
当运行该代码生成的可执行程序后,便可循环打印该进程的PID和PPID。
我们可以通过ps命令查看该进程的信息,即可发现通过ps命令得到的进程的PID和PPID与使用系统调用函数getpid和getppid所获取的值相同。
我们用 ls /proc/PID -dl命令可以查看该目录的详细信息,那么Linux下,万物皆文件,我们也可以查看进程的信息。
如果用Ctrl+c强行终止进程,那么我们再次查询这个PID是查不到.
小贴士:kill -9 进程ID # 也可以强制杀死进程,-9 表示强制杀死进程(SIGKILL信号)示例:kill -9 1234 # 强制杀死进程ID为1234的进程
接下来我们进去一个PID里面瞅瞅里面有什么东西
其他暂时先不看,先看这两个文件。
这个exe是进程对应的可执行文件,cwd是进程自己的工作路径。
如果我们这里把这个exe文件删掉,进程依然不会结束,这是因为我们删掉的只是磁盘上的文件,内存里面正在执行还有一份拷贝。
当进行文件操作的时候,我们默认的文件路径都是当前文件路径,那么这个当前文件路径其实就是这个cwd。
以下代码是进行一个简单的文件操作。
如果没有这个文件,则会创建一个文件在当前目录下,而这个目录工作路径是由进程记录的,看下图:
更改进程路径:
#include <unistd.h>int chdir(const char *path);
在.c文件中添加一行代码
那么我们再次查看进程路径可以发现就被更改了。
hello.txt的新建文件路径也会被更改
1.6 PPID(Parent Process ID)
PPID也就是父进程,每一次PID都会变,但父进程PPID是不变的。
查看一下PPID的信息
这个bash本身是一个命令行解释器,当你在 Bash 中运行一个命令或脚本时,Bash 会创建一个新的子进程来执行该命令。在这个情况下,Bash 是子进程的父进程。例如:
.maprocess
当我们每次登录Linux操作系统时,都会给用户分配一个Bash.
这里就类似于C语言中的printf和scanf,打印一个文本,然后用scanf输入,把命令行输入给bash,那么每一个命令行都是进程,那它们的父进程就是这个Bash。
1.7 通过系统调⽤创建进程-fork初识
fork创建子进程
通过man手册查看,可以知道fork的作用就是创建一个子进程
运行以下代码:
#include <stdio.h>2 #include <unistd.h>3 #include <sys/types.h>4 int main() 5 {6 printf("父进程开始运行,ppid:%d\n",getppid());7 fork();//创建一个子进程8 printf("进程开始运行:pid:%d\,ppid:%d\nn",getpid(),getppid());9 return 0;10 }
如果没有加入fork函数,那么我们的运行结果应该就是两个进程的ID。加了fork,运行结果如下:
第二行进程是fork创建的进程,通过输出结果可以发现,fork创建的进程的ppid是myprocess的进程pid,也就是说fork创建的进程和myprocess的进程是父子关系。
每出现一个进程,操作系统就会为其创建PCB,fork函数创建的进程也不例外。
我们知道加载到内存当中的代码和数据是属于父进程的,那么fork函数创建的子进程的代码和数据又从何而来呢?
我们看看以下代码的运行结果:
#include <stdio.h>2 #include <unistd.h>3 #include <sys/types.h>4 int main()5 {6 printf("hello,world\n"); 7 fork(); 8 while(1) 9 { 10 sleep(1); 10 printf("进程开始运行:pid:%d,ppid:%d\n",getpid(),getppid()); 11 } 12 return 0;13 }
运行结果:
实际上,使用fork函数创建子进程,在fork函数被调用之前的代码被父进程执行,而fork函数之后的代码,则默认情况下父子进程都可以执行。需要注意的是,父子进程虽然代码共享,但是父子进程的数据各自开辟空间(采用写时拷贝)。
具体说明:
- 内存副本:在 fork 调用时,操作系统会为子进程创建一个新的内存空间,并将父进程的内存内容(包括代码、数据、堆、栈等)复制到子进程的内存空间中。
- 独立修改:子进程对自己的内存进行修改时,这些修改仅影响子进程自己的内存,不会影响父进程的内存。
- 父子进程的同步:父进程和子进程在 fork后是独立执行的,它们可以同时运行,各自维护自己的内存状态。
使用if进行分流
上面说到,fork函数创建出来的子进程与其父进程共同使用一份代码,但我们如果真的让父子进程做相同的事情,那么创建子进程就没有什么意义了。
实际上,在fork之后我们通常使用if语句进行分流,即让父进程和子进程做不同的事。
fork函数的返回值机制:
既然父进程和子进程获取到fork函数的返回值不同,那么我们就可以据此来让父子进程执行不同的代码,从而做不同的事。
例如,以下代码:
1 #include <stdio.h>2 #include <unistd.h>3 #include <sys/types.h>4 5 int gval = 100;6 7 int main()8 {9 printf("父进程开始运行,pid: %d\n", getpid());10 pid_t id = fork();11 if(id < 0)12 {13 perror("fork");14 return 1;15 }16 else if(id == 0)17 {18 printf("我是一个子进程 !, 我的pid: %d, 我的父进程id: %d, gval: %d\n", getpid(), getppid(), gval);19 sleep(5);20 // child21 while(1)22 {23 sleep(1);24 printf("子进程修改变量: %d->%d", gval, gval+10);25 gval+=10; // 修改 26 printf("我是一个子进程 !, 我的pid: %d, 我的父进程id: %d\n", getpid(), getppid());27 }28 }29 else 30 {31 //father32 while(1)33 {34 sleep(1);35 printf("我是一个父进程 !, 我的pid: %d, 我的父进程id: %d, gval: %d\n", getpid(), getppid(), gval);36 }37 }38 printf("进程开始运行,pid: %d\n", getpid());39 return 0;40 }
运行结果如下:
这个输出结果是不是非常的神奇,else if和else的输出结果居然可以同时出现。
这里先回答一部分问题:
1. 为什么 fork 有两个返回值?
当 fork 被调用时,它会在父进程中创建一个子进程。fork 的返回值在父进程和子进程中是不同的:
- 在父进程中:fork 返回子进程的进程 ID(PID)。这允许父进程知道它刚刚创建的子进程的 PID,从而可以对其进行管理(例如,等待子进程结束)。
- 在子进程中:fork 返回 0。这是一个约定,子进程可以通过这个返回值知道自己是子进程,并执行相应的逻辑。
这种设计的底层逻辑是基于操作系统的进程调度和资源管理机制。操作系统需要知道哪个进程是父进程,哪个是子进程,以便正确地分配资源和管理进程的生命周期。
从底层逻辑上来讲:
- 每个进程在操作系统中都有自己的独立地址空间。这意味着父进程和子进程各自拥有自己的内存空间,尽管它们在 fork 时有相同的初始内容。这种隔离确保了进程之间的稳定性,一个进程的崩溃不会直接影响其他进程。
- 操作系统需要为每个进程分配资源,如 CPU 时间、内存等。通过 fork 的返回值,操作系统可以区分父进程和子进程,从而正确地分配资源。
- 操作系统需要知道哪个进程是父进程,哪个是子进程,以便正确地调度它们。父进程可能需要等待子进程完成,或者子进程可能需要通知父进程它已经完成。
2.进程状态
一个进程从创建而产生至撤销而消亡的整个生命期间,有时占有处理器执行,有时虽可运行但分不到处理器,有时虽有空闲处理器但因等待某个时间的发生而无法执行,这一切都说明进程和程序不相同,进程是活动的且有状态变化的,于是就有了进程状态这一概念。
这里我们具体谈一下Linux操作系统中的进程状态,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 *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*/
};
小贴士: 进程的当前状态是保存到自己的进程控制块(PCB)当中的,在Linux操作系统当中也就是保存在task_struct当中的。
在Linux操作系统当中我们可以通过 ps aux 或 ps axj 命令查看进程的状态。
2.1 运行状态-R
一个进程处于运行状态(running),并不意味着进程一定处于运行当中,运行状态表明一个进程要么在运行中,要么在运行队列里。也就是说,可以同时存在多个R状态的进程。
小贴士: 所有处于运行状态,即可被调度的进程,都被放到运行队列当中,当操作系统需要切换进程运行时,就直接在运行队列中选取进程运行。
2.2 浅度睡眠状态-S
上面的代码我们把所有的IO操作都注释掉了,才会出现R,否则为S,因为S表示该进程属于睡眠状态,这通常意味着进程在等待某个事件的发生,比如上面就是在等待I/O操作的完成。
看以下代码:
进程状态:
注意:处于浅度睡眠状态的进程是可以被杀掉的,我们可以使用kill命令将该进程杀掉。
2.3 深度睡眠状态-D
一个进程处于深度睡眠状态(disk sleep),表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复。该状态有时候也叫不可中断睡眠状态(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束。
例如,某一进程要求对磁盘进行写入操作,那么在磁盘进行写入期间,该进程就处于深度睡眠状态,是不会被杀掉的,因为该进程需要等待磁盘的回复(是否写入成功)以做出相应的应答。(磁盘休眠状态)
2.4 暂停状态-T(t)
这里有t和T的区分
t:
例如,我们对一个进程发送SIGSTOP信号,该进程就进入到了暂停状态。
我们对刚才的代码进行调试,打一个断点,然后运行到断点处,然后再看一下进程,gdb确实是已经跑起来了,但我们myprocess进程确是t的状态。t也被称为追踪状态,因为一个进程要被进行debug,这个断点使进程暂停了,所以为暂停状态。
T:
我们让刚才的代码从新跑起来,然后一个Ctrl+z暂停进程,这时查看我们的myprocess进程:
出现这种情况一般是:
用户手动暂停:
使用 Ctrl+Z 快捷键暂停正在运行的前台进程。
接收到停止信号:
进程接收到 SIGSTOP、SIGTSTP、SIGTTIN 或 SIGTTOU 信号。
使用kill命令可以列出当前系统所支持的信号集。
kill -l
2.5 死亡状态-X
死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,所以你不会在任务列表当中看到死亡状态(dead)。
2.6 僵尸状态-Z
当一个进程将要退出的时候,在系统层面,该进程曾经申请的资源并不是立即被释放,而是要暂时存储一段时间,以供操作系统或是其父进程进行读取,如果退出信息一直未被读取,则相关数据是不会被释放掉的,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态(zombie)。
3.僵尸进程
3.1 僵尸进程示例
前面说到,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态。而处于僵尸状态的进程,我们就称之为僵尸进程。
例如,对于以下代码,fork函数创建的子进程在打印5次信息后会退出,而父进程会一直打印信息。也就是说,子进程退出了,父进程还在运行,但父进程没有读取子进程的退出信息,那么此时子进程就进入了僵尸状态。
可通过以下脚本进行检测:
while :; do ps ajx | head -1; ps ajx | grep myprocess; sleep 1; done
3.2 僵尸进程的危害
- 僵尸进程的退出状态必须一直维持下去,因为它要告诉其父进程相应的退出信息。可是父进程一直不读取,那么子进程也就一直处于僵尸状态。
- 僵尸进程的退出信息被保存在task_struct(PCB)中,僵尸状态一直不退出,那么PCB就一直需要进行维护。
- 若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
- 僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致内存泄漏。
- 虽然僵尸进程不会占用 CPU 资源,但它们会占用系统内存和进程表中的位置,影响系统性能和稳定性。
- 如果系统中存在大量僵尸进程,可能导致进程表耗尽,从而无法创建新的进程。
4.孤儿进程
在Linux当中的进程关系大多数是父子关系,若子进程先退出而父进程没有对子进程的退出信息进行读取,那么我们称该进程为僵尸进程。但若是父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。
若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号systemd进程领养,此后当孤儿进程进入僵尸状态时就由systemd进程进行处理回收。
例如,对于以下代码,fork函数创建的子进程会一直打印信息,而父进程在打印5次信息后会退出,此时该子进程就变成了孤儿进程。
1 #include<stdio.h> 2 #include <sys/types.h>3 #include <unistd.h>4 int main()5 {6 pid_t id = fork();7 if(id == 0)8 {9 while(1)10 {11 printf("我 是 一 个 子 进 程:pid: %d,ppid: %d\n",getpid(),getppid());12 sleep(1);13 }14 }15 16 else17 {18 int cnt = 5;19 while(cnt)20 {21 22 printf("我 是 一 个 父 进 程:pid: %d,ppid: %d\n",getpid(),getppid());23 cnt--;24 sleep(1);25 }26 }27 }
通过该命令观察进程:
while :; do ps axj | head -1 && ps axj | grep myprocess;sleep 1; done
观察代码运行结果,在父进程未退出时,子进程的PPID就是父进程的PID,而当父进程退出后,子进程的PPID就变成了1,即子进程被1号进程领养了。
注意:一但一个进程变成孤儿进程,就会被系统进程systemd领养,那么就会变成后台进程,通过Ctel+c就无法终止进程,只能通过(kill -9 <进程id>)命令杀死进程。
5.进程优先级
5.1 进程优先级的基本概念
- cpu资源分配的先后顺序,就是指进程的优先权(priority)。
- 优先权⾼的进程有优先执⾏权利。配置进程优先权对多任务环境的linux很有⽤,可以改善系统性能。
- 还可以把进程运⾏到指定的CPU上,这样⼀来,把不重要的进程安排到某个CPU,可以⼤⼤改善系统整体性能。
5.2 查看系统进程
在Linux或者Unix操作系统中,用ps -l命令会类似输出以下几个内容:
我们很容易注意到其中的⼏个重要信息,有下:
- UID:代表执⾏者的⾝份
- PID:代表这个进程的代号
- PPID:代表这个进程是由哪个进程发展衍⽣⽽来的,亦即⽗进程的代号
- PRI:代表这个进程可被执⾏的优先级,其值越⼩越早被执⾏
- NI:代表这个进程的nice值
5.3 PRI and NI
- PRI也还是⽐较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执⾏的先后顺序,此值越⼩进程的优先级别越⾼
- 那NI呢?就是我们所要说的nice值了,其表⽰进程可被执⾏的优先级的修正数值 PRI值越⼩越快被执⾏,那么加⼊nice值后,将会使得PRI变为:PRI(new)=PRI(默认值:80)+nice
- 这样,当nice值为负值的时候,那么该程序将会优先级值将变⼩,即其优先级会变⾼,则其越快被执⾏
- 所以,调整进程优先级,在Linux下,就是调整进程nice值
- nice其取值范围是-20⾄19,⼀共40个级别。
需要强调⼀点的是,进程的nice值不是进程的优先级,他们不是⼀个概念,但是进程nice值会影响到进程的优先级变化。
可以理解nice值是进程优先级的修正修正数据
5.4 进程优先级的操作
查看进程优先级信息
我们创建一个进程后,我们可以使用ps -al命令查看该进程优先级的信息。
注意: 在Linux操作系统中,初始进程一般优先级PRI默认为80,NI默认为0。
通过top命令更改进程的nice值
top命令就相当于Windows操作系统中的任务管理器,它能够动态实时的显示系统当中进程的资源占用情况。
通过renice命令更改进程的nice值
然后输入r就是renice
输入进程PID,然后点击回车,然后就是输入你想更改的nice值。
我这里输入的是10,更改之后。
也可以通过 renice命令直接更改,不需要进入top
注意:如果输入-10,PRI则是70,而不是80,因为RPI = RPI(默认值:80)+NI。Linux不允许频繁更改优先级,如果再次输入负数进行修改,需要sudo 进行提权。
在 Linux 系统中,进程优先级(PRI)的范围通常是 [60, 99],而 nice 值的范围是 [-20, 19]。这样的设置是有原因的,主要是为了在保证系统整体性能的同时,提供一定的灵活性来调整进程的执行顺序。如果优先级设置不合理,确实可能导致低优先级的进程长时间得不到 CPU 资源,进而出现进程饥饿的现象。。
四个重要概念
-
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便有了优先级。
-
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
-
并行: 多个进程在多个CPU下分别同时进行运行,这称之为并行。
-
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
6. Linux2.6内核进程O(1)调度队列
6.1 时间片的概念
在Linux系统中,时间片是进程调度中的一个重要概念,它决定了一个进程在被调度执行时可以占用CPU的最大时间。时间片的引入是为了确保所有进程都能公平地获得CPU资源,并防止某个进程长时间独占CPU导致其他进程得不到执行。
- 定义:
时间片是一个固定的时间间隔,通常以毫秒为单位。在Linux中,时间片的长度可以通过内核参数进行配置,但通常默认值为100毫秒左右。 - 作用:
公平性:确保每个进程都能在一定时间内获得CPU资源,避免某个进程因占用CPU时间过长而导致其他进程长时间等待。
响应性:通过限制单个进程的执行时间,可以提高系统的整体响应速度,使系统看起来更加流畅。 - 工作原理:
当一个进程被调度程序选中并开始执行时,系统会为其分配一个时间片。
进程在执行过程中,如果时间片用完,调度程序会将该进程挂起,并将CPU分配给下一个就绪状态的进程。
如果进程在时间片用完前完成了任务或进入阻塞状态,剩余的时间片会被保留,以便下次被调度时继续使用。
6.2 一个CPU拥有一个runqueue
如果有多个CPU就要考虑进程个数的父子均衡问题。
6.3 优先级
queue下标说明:
普通优先级:100~139。
实时优先级:0~99。
我们进程的都是普通的优先级,前面说到nice值的取值范围是-20~19,共40个级别,依次对应queue当中普通优先级的下标100~139。
注意: 实时优先级对应实时进程,实时进程是指先将一个进程执行完毕再执行下一个进程。Linux 本身是一个分时操作系统,但通过实时补丁和调度算法的调整,可以实现较好的实时性能。
6.4 活跃队列
时间片还没有结束的所有进程都按照优先级放在活动队列当中,其中nr_active代表总共有多少个运行状态的进程,而queue[140]数组当中的一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进程排队调度。
调度过程如下:
1.从0下标开始遍历queue[140]。
2.找到第一个非空队列,该队列必定为优先级最高的队列。
3.拿到选中队列的第一个进程,开始运行,调度完成。
4.接着拿到选中队列的第二个进程进行调度,直到选中进程队列当中的所有进程都被调度。
5.继续向后遍历queue[140],寻找下一个非空队列。
这个活跃队列底层类似于一个哈希表,每一个块空间里面都存放了task_struct*,哈希表里面,操作一个桶的时间复杂度基本是O(1),但是如果我们要调用的进度在最后一个桶,那一个桶一个桶遍历过去,就有点浪费了,这时我们可以看到活跃进程里面还有一个bitmap[5],这是一个位图,5*32 = 160>140,通过位图来查阅进程队列(哈希桶)就快很多了。
总结: 在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不会随着进程增多而导致时间成本增加,我们称之为进程调度的O(1)算法。
6.5 过期队列
- 过期队列和活动队列的结构相同。
- 过期队列上放置的进程都是时间片耗尽的进程。
- 当活动队列上的进程被处理完毕之后,对过期队列的进程进行时间片重新计算。
注意:在 O(1) 调度器中,新进程一般会被插入到活跃队列中
6.6 active指针和expired指针
- active指针永远指向活动队列。
- expired指针永远指向过期队列。
由于活动队列上时间片未到期的进程会越来越少,而过期队列上的进程数量会越来越多(新创建的进程都会被放到过期队列上),那么总会出现活动队列上的全部进程的时间片都到期的情况,这时将active指针和expired指针的内容交换,就相当于让过期队列变成活动队列,活动队列变成过期队列,就相当于又具有了一批新的活动进程,如此循环进行即可。
7. 环境变量
环境变量(environmentvariables)⼀般是指在操作系统中⽤来指定操作系统运⾏环境的⼀些参数
如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪⾥,但是照样可以链接成功,⽣成可执⾏程序,原因就是有相关环境变量帮助编译器进⾏查找。
环境变量通常具有某些特殊⽤途,还有在系统当中通常具有全局特
7.1常见环境变量
- PATH: 指定命令的搜索路径。
- HOME: 指定用户的主工作目录(即用户登录到Linux系统中的默认所处目录)。
- SHELL: 当前Shell,它的值通常是/bin/bash。
7.2 查看环境变量的方法
我们可以通过echo命令来查看环境变量,方式如下:
例如,查看环境变量PATH:
7.3 测试PATH
大家有没有想过这样一个问题:为什么执行ls命令的时候不用带./就可以执行,而我们自己生成的可执行程序必须要在前面带上./才可以执行?
容易理解的是,要执行一个可执行程序必须要先找到它在哪里,既然不带./就可以执行ls命令,说明系统能够通过ls名称找到ls的位置,而系统是无法找到我们自己的可执行程序的,所以我们必须带上./,以此告诉系统该可执行程序位于当前目录下。
而系统就是通过环境变量PATH来找到ls命令的,查看环境变量PATH我们可以看到如下内容:
可以看到环境变量PATH当中有多条路径,这些路径由冒号隔开,当你使用ls命令时,系统就会查看环境变量PATH,然后默认从左到右依次在各个路径当中进行查找。
而ls命令实际就位于PATH当中的某一个路径下,所以就算ls命令不带路径执行,系统也是能够找到的。
那么是谁去找这个命令呢?
是bash,系统中很多的配置,在我们登录Linux系统的时候,已经被加载到了bash进程中(内存)。
bash在执行命令的时候,需要先找到路径(默认去PATH中找),因为未来要加载。
用vim打开.bashrc,这就是系统的环境变量对应的值
同样操作打开 .bash_profile,这是系统登录的时候判断家目录下的.bashrc在不在。
然后我们使用 vim /etc/bashrc命令打开,可以找到这里有一个配置文件。
那可不可以让我们自己的可执行程序也不用带路径就可以执行呢?
方式一:将可执行程序拷贝到环境变量PATH的某一路径下。
既然在未指定路径的情况下系统会根据环境变量PATH当中的路径进行查找,那我们就可以将我们的可执行程序拷贝到PATH的某一路径下,此后我们的可执行程序不带路径系统也可以找到了。
方式二:将可执行程序所在的目录导入到环境变量PATH当中。
将可执行程序所在的目录导入到环境变量PATH当中,这样一来,没有指定路径时系统就会来到该目录下进行查找了。
以上方法,如果机器重启,那么就会失效,这是因为系统文件没有进行配置。
方式三:在.bash_profile中进行环境配置
使用vim打开.bash_file,然后添加自己的文件路径,然后关掉机器重启即可。
看效果:
总结:从以上操作可以知道,环境变量是从系统的配置文件中来的,PATH是系统变量中搜索指定命令的一个环境变量值,当我登录的时候,bash就会读取配置文件,形成环境变量表,然后从配置文件当中,把一个一个的环境变量填写到配置文件表当中,同时,bash还可以在未来接受用户的指令。
7.4 测试HOME
任何一个用户在运行系统登录时都有自己的主工作目录(家目录),环境变量HOME当中即保存的该用户的主工作目录。
使用env命令会列出所有当前环境变量,我们查看一下。就可以在一堆环境变量中找到这个。
7.5 测试SHELL
我们在Linux操作系统当中所敲的各种命令,实际上需要由命令行解释器进行解释,而在Linux当中有许多种命令行解释器(例如bash、sh),我们可以通过查看环境变量SHELL来知道自己当前所用的命令行解释器的种类。
而该命令行解释器实际上是系统当中的一条命令,当这个命令运行起来变成进程后就可以为我们进行命令行解释。
7.7 和环境变量相关的命令
- echo:显⽰某个环境变量值
- export: 设置⼀个新的环境变量
- env: 显⽰所有环境变量
- unset: 清除环境变量
- set: 显⽰本地定义的shell变量和环境变量
部分环境变量说明:
环境变量名称 | 表示内容 |
---|---|
PATH | 命令的搜索路径 |
HOME | 用户的主工作目录 |
SHELL | 当前Shell |
HOSTNAME | 主机名 |
TERM | 终端类型 |
HISTSIZE | 记录历史命令的条数 |
SSH_TTY | 当前终端文件 |
USER | 当前用户 |
邮箱 | |
PWD | 当前所处路径 |
LANG | 编码格式 |
LOGNAME | 登录用户名 |
7.8 环境变量的组织方式
在系统当中,环境变量的组织方式如下:
每个程序都会收到一张环境变量表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串,最后一个字符指针为空。
7.9 获取环境变量
main函数的参数
你知道main函数其实是有参数的吗?
main函数其实有三个参数,只是我们平时基本不用它们,所以一般情况下都没有写出来。
我们可以在Windows下的编译器进行验证,当我们调试代码的时候,若是一直使用逐步调试,那么最终会来到调用main函数的地方。
在这里我们可以看到,调用main函数时给main函数传递了三个参数。
先看看前两个参数:
在Linux操作系统下,编写以下代码,生成可执行程序并运行。
运行结果:
现在我们来说说main函数的前两个参数,main函数的第二个参数是一个字符指针数组,数组当中的第一个字符指针存储的是可执行程序的位置,其余字符指针存储的是所给的若干选项,最后一个字符指针为空,而main函数的第一个参数代表的就是字符指针数组当中的有效元素个数。
获取环境变量
mian函数的第三个参数
main函数的第三个参数接收的实际上就是环境变量表,我们可以通过main函数的第三个参数来获取系统的环境变量。
例如,编写以下代码,生成可执行程序并运行:
运行结果就是各个环境变量的值:
除了使用main函数的第三个参数来获取环境变量以外,我们还可以通过第三方变量environ来获取。
这里是一个二级指针,因为环境变量表是一个char*的,用二级指针指向一级指针,合理。
运行以下代码:
运行该代码生成的可执行程序,我们同样可以获得环境变量的值:
通过系统调用获取环境变量
以上两种方法都是不太推荐的,因为那依然需要查询环境变量列表,而通过系统调用getenv函数可以直接获取环境变量。
getenv函数可以根据所给环境变量名,在环境变量表当中进行搜索,并返回一个指向相应值的字符串指针。
例如,使用getenv函数获取环境变量PATH的值。
运行结果:
8. 程序地址空间
在Linux操作系统中,我们可以通过以下代码对该布局图进行验证:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>// 全局未初始化变量,其值是未定义的,位于数据段的BSS区
int g_unval;
// 全局已初始化变量,位于数据段的已初始化数据区
int g_val = 100;// main函数入口
int main(int argc, char *argv[], char *env[])
{const char *str = "helloworld";// 打印main函数的地址,位于代码段printf("code addr: %p\n", main);// 打印全局已初始化变量的地址,位于数据段printf("init global addr: %p\n", &g_val);// 打印全局未初始化变量的地址,位于数据段的BSS区printf("uninit global addr: %p\n", &g_unval);// 静态局部变量,位于数据段的BSS区static int test = 10;// 动态分配的堆内存,位于堆区char *heap_mem = (char *)malloc(10);char *heap_mem1 = (char *)malloc(10);char *heap_mem2 = (char *)malloc(10);char *heap_mem3 = (char *)malloc(10);// 打印堆内存地址,位于堆区printf("heap addr: %p\n", heap_mem);printf("heap addr: %p\n", heap_mem1);printf("heap addr: %p\n", heap_mem2);printf("heap addr: %p\n", heap_mem3);// 打印静态局部变量的地址,位于数据段printf("test static addr: %p\n", &test);// 打印局部变量指针的地址,位于栈区printf("stack addr: %p\n", &heap_mem);printf("stack addr: %p\n", &heap_mem1);printf("stack addr: %p\n", &heap_mem2);printf("stack addr: %p\n", &heap_mem3);// 打印只读字符串常量的地址,位于只读数据段printf("read only string addr: %p\n", str);// 打印命令行参数的地址,位于栈区for (int i = 0; i < argc; i++){printf("argv[%d]: %p\n", i, argv[i]);}// 打印环境变量的地址,位于栈区for (int i = 0; env[i]; i++){printf("env[%d]: %p\n", i, env[i]);}// 释放之前分配的堆内存free(heap_mem);free(heap_mem1);free(heap_mem2);free(heap_mem3);return 0;
}
运行结果:地址从高到低,与布局图吻合。
前面讲过进程具有独立性,看下面的代码:
运行结果:
我们知道进程具有独立性,那么即使是全局变量,子进程更改,父进程的gval不更改,勉强可以理解,那么为什么地址是同一块呢?
如果说我们是在同一个物理地址处获取的值,那必定是相同的,而现在在同一个地址处获取到的值却不同,这只能说明我们打印出来的地址绝对不是物理地址!!!
实际上,我们在语言层面上打印出来的地址都不是物理地址,而是虚拟地址。物理地址用户一概是看不到的,是由操作系统统一进行管理的。
所以就算父子进程当中打印出来的全局变量的地址(虚拟地址)相同,但是两个进程当中全局变量的值却是不同的。
注意: 虚拟地址和物理地址之间的转化由操作系统完成。
9. 进程地址空间
我们之前将那张布局图称为程序地址空间实际上是不准确的,那张布局图实际上应该叫做进程地址空间,进程地址空间本质上是内存中的一种内核数据结构,在Linux当中进程地址空间具体由结构体mm_struct实现。
进程地址空间就类似于一把尺子,尺子的刻度由0x00000000到0xffffffff,尺子按照刻度被划分为各个区域,例如代码区、堆区、栈区等。而在结构体mm_struct当中,便记录了各个边界刻度,例如代码区的开始刻度与结束刻度,如下图所示:
在结构体mm_struct当中,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于虚拟地址是由0x00000000到0xffffffff线性增长的,所以虚拟地址又叫做线性地址。
扩展知识:
1、堆向上增长以及栈向下增长实际就是改变mm_struct当中堆和栈的边界刻度。
2、我们生成的可执行程序实际上也被分为了各个区域,例如初始化区、未初始化区等。当该可执行程序运行起来时,操作系统则将对应的数据加载到对应内存当中即可,大大提高了操作系统的工作效率。而进行可执行程序的“分区”操作的实际上就算编译器,所以说代码的优化级别实际上是编译器说了算。
每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建。而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的是mm_struct的地址。
例如,父进程有自己的task_struct和mm_struct,该父进程创建的子进程也有属于其自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置,如下图:
而当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,为了保证进程的独立性,才将父进程的数据在内存当中拷贝一份,这种拷贝被称为写时拷贝,拷贝后再进行修改。
问答:
1.为什么要写时拷贝呢?写时拷贝的效率会不会很低呢?
通过调整拷贝的时间顺序,达到有效节省空间的效果。
写时拷贝的效率并不会很低,因为如果不写时拷贝,需要将父进程的所以数据拷贝一份,而写时拷贝只需要将需要修改的数据拷贝一份,最坏情况也是跟不写时拷贝的效率一样。
2.可不可以直接将父进程的数据全部拷贝到新的空间呢?
可以,但是没有必要这么做。
因为子进程是能够访问父进程的数据的,大部分情况下,是不需要进行全部拷贝过来,那样太浪费空间了;我们通常是要进行写入的时候,OS才会要写入的变量复制一份,重新开一个大小一样的空间,在新开的空间内写入数据,再将新空间的地址交给页表。这是按需申请。通过调整拷贝的时间顺序,达到节省空间的目的。
3.为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。
4.为什么要有进程地址空间?
- 有了进程地址空间后,就不会有任何系统级别的越界问题存在了。例如进程1不会错误的访问到进程2的物理地址空间,因为你对某一地址空间进行操作之前需要先通过页表映射到物理内存,而页表只会映射属于你的物理内存。总的来说,虚拟地址和页表的配合使用,本质功能就是包含内存。
- 有了进程地址空间后,每个进程都认为看得到都是相同的空间范围,包括进程地址空间的构成和内部区域的划分顺序等都是相同的,这样一来我们在编写程序的时候就只需关注虚拟地址,而无需关注数据在物理内存当中实际的存储位置。
- 有了进程地址空间后,每个进程都认为自己在独占内存,这样能更好的完成进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),并能将进程调度与内存管理进行解耦或分离。
虚拟空间:
那既然每⼀个进程都会有⾃⼰独⽴的mm_struct,操作系统肯定是要将这么多进程的mm_struct组织起来的!虚拟空间的组织⽅式有两种:
1.当虚拟区较少时采取单链表,由mmap指针指向这个链表;
2.当虚拟区间多时采取红⿊树进⾏管理,由mm_rb指向这棵树。
部分源代码:
struct vm_area_struct {unsigned long vm_start; //虚存区起始unsigned long vm_end; //虚存区结束struct vm_area_struct *vm_next, *vm_prev; //前后指针struct rb_node vm_rb; //红⿊树中的位置unsigned long rb_subtree_gap;
}
所以我们可以对上图在进⾏更细致的描述,如下图所⽰:
- task_struct 表示进程,通过 mm 关联到 mm_struct。
- mm_struct 描述进程的内存管理信息,通过 mmap 关联到虚拟内存区域链表。
- vm_area_struct 描述虚拟内存中的一个连续区域,链表中的每个节点对应进程虚拟内存中的一个区域。
- 进程的虚拟内存空间被划分为多个区域,每个区域有明确的用途和属性。
对于创建进程的现阶段理解:
一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建。
本篇博客到此结束,有问题,欢迎评论区留言~