第三章 进程概念
从第三章起我们将进入Linux系统编程的阶段,我们将从计算机操作系统的角度进一步学习Linux。
冯诺依曼体系结构
在这里又要旧话重提讲一下冯诺依曼体系结构,在冯诺依曼提出计算机的理论组成结构后,现代计算机的组成结构基本都是按照这一套冯诺依曼几十年前提出的体系结构进程设计与制造的。可以说冯诺依曼奠定了现代计算机的硬件体系结构。
冯诺依曼体系结构可以概括为将计算机分为以下几个部分:1、输入设备(键盘);2、输出设备(显示器)3、存储器(内存);4、运算器(cpu);5、控制器(cpu)。
对于冯诺依曼体系结构的理解我们不应该只停留在字面上,我们要深入理解计算机的硬件之间数据流的关系,计算机内部在我们执行各种操作的时候数据流是如何移动的?这些理解会随着对计算机系统的理解强化而更加深入。
操作系统
操作系统可以笼统解释为是一个安装在计算机中的软件,为了让计算机更加方便使用。但实际上,操作系统是统筹合理管理计算机上的软硬件资源的工具。
那么操作系统如何管理计算机呢?其实计算机在进行管理时会将计算机上的一个个模块用类似于结构的东西进行描述以便于信息的管理,之后再利用数据结构进行组织,因此可以描述为六个字:先描述,后组织。
计算机上的安装的硬件往往不能直接进行使用,我们需要通过驱动这个媒介才能进行使用,因此对下计算机则会组织和管理驱动来管理硬件。对上操作系统会将系统调用接口进行封装和管理变成库(lib)之后才会提供给用户进行使用。就像我们在写C语言时使用的系统函数,这里调用的接口不是直接的系统调用接口而是包含在库中的一层封装是为了让我们更加方便的进行系统调用从而控制硬件执行操作。
因此在这里我们可以概括操作系统为:对下管理硬件资源,对上提供良好的执行环境。
进程概念
什么是进程
进程:进行中的程序。那么计算机系统是怎么描述和组织一个个进程的呢?系统通过一个叫进程控制块的东西进行描述和组织一个个进程,简称PCB。而在Linux上PCB是一个结构体,这个结构体中保存着一个个进程的信息。因此对于操作系统来说一个PCB就是一个进程,系统在管理进程实际上就是在管理一个个PCB,这是系统管理进程的媒介。
PCB
那么PCB都包含有哪些信息呢?
1、PID:这是PCB中最为重要的信息,也是我们区分不同的进程的标志。每一个进程都有一个属于自己的ID编号这个编号就存储在PID中。
2、进程状态:任务的状态信息。
3、优先级:相比于其他进程的优先级。
4、程序计数器:由于CPU一次只能同时执行一个进程,因此为了达到看上去可以同时执行多个进程因此CPU会不停的高速在各个进程中进行切换执行,一次执行一点,这个被称为CPU轮循机制。那么在不停切换的时候需要记录各个进程当前执行到的代码,因此就在PCB中进行保存。程序计数器就是记录下一次执行的命令的地址。
5、内存指针:包括程序代码和进程相关数据的指针。
6、上下文数据:进程执行时CPU寄存器中存储的数据。
7、IO状态信息:包括IO请求和分配给进程的IO设备和打开的文件等。
8、记账信息:处理的时间综合,使用时钟综合,时间限制等。
9、其他信息。
进程查看
在Linux中进程的信息都保存在/proc
这个系统文件夹中,我们可以使用ls /proc/
来查看进程信息。同样我们也可以用ps
或top
来查看进程信息。
我们可以首先写一个可以一直执行的程序然后来查看这个进程的信息。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("%d\n", getpid());//getpid()函数可以得到当前进程的PID
sleep(1);
}
}
[misaki@localhost 第三章]$ ./a.out
5517
5517
我们重新打开一个终端查看以下这个进程的信息。
先用查看系统文件的方式查看一下进程信息。
我们确实在系统文件中找到了我们的进程,但是这样的文件信息不太便于我们直接查看进程的信息。因此接下来我们使用ps
命令。
在这个命令执行后我们可以查看到进程的PID以及占用虚拟内存及物理内存的多少和大小以及进程的状态和执行的命令等。
创建进程
我们可以在一个进程中使用系统命令fork()
创建其子进程,我们创建子进程可以更快的处理数量很多的数据。
fork()
是有返回值的,在父进程中fork()
返回子进程的PID,在子进程中返回0,由此我们可以区分父子进程。由于子进程会完全复制父进程的PID因此子进程在创建后会和父进程一起执行创建进程之后的语句直到进程结束。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36/**
*
* fork初使用
* 通过复制调用进程,创建一个新的进程(子进程)
*/
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("parent pid = %d\n", getpid());
pid_t pid = fork();//复制了父进程的PCB,因此程序计数器也会跟父进程一致,因此子进程下一句执行的代码和父进程一致
printf("child1:%d\n", pid);
if(pid < 0)
{
return -1;
}
else if(pid == 0)//fork有返回值,子进程返回0,父进程返回的是子进程的pid
{
printf("child:%d\n", getpid());//得到当前进程的PID
}
else
{
printf("parent:%d\n", getpid());//得到当前进程的PID
}
printf("Misaki\n");
}
[misaki@localhost 第三章]$ ./Creat
parent pid = 6200
child1:6201
parent:6200
Misaki
child1:0
child:6201
Misaki
在这个例子中父进程先执行之后才执行了子进程,并且子进程只执行了fork()
之后的语句。
进程状态
在程序执行后,每个进程都有属于自己的状态,并且我们可以通过ps -aux
或者ps -axj
命令查看到进程的状态。基本状态分为以下几种。
1、运行状态(R):这个状态表明进程要么正在运行要么就在进程队列中。
2、睡眠状态(S):表示进程正在等待某个时间的完成。同时睡眠也分为可中断和不可中断的睡眠。这里的睡眠指可中断睡眠。
3、磁盘睡眠状态(D):这里的睡眠指的即是不可中断睡眠。这个状态的进程往往是在等待IO的结束。
4、停止状态(T):这里的停止状态指的是通过指令让一个进程处于暂停状态,同时我们是随时可以将其恢复继续运行的。
5、死亡状态(X):这个状态并不会在查看进程的时候显示,因为这个状态的进程已经完全停止了,是不可恢复的。
僵尸进程
这是要尤为重点讨论一个进程的状态——僵尸进程。这是一种十分危险的进程状态,是由于子进程在运行中退出但父进程并未读取子进程的退出状态码所导致的,并且此时的子进程为了等待父进程读取退出状态码一直处于僵死状态,由此既产生僵尸进程,直到父进程也退出。以下我将演示僵尸进程的形成。
我首先书写了一个程序达到让创建新进程后让父进程睡眠60秒并且让子进程立刻退出,由此来达到制造一个长达60秒的僵尸进程。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return -1;
}
else if(id > 0)
{
printf("父进程:%d\n", getpid());
sleep(60);
}
else
{
printf("子进程:%d\n", getpid());
_exit(-1);
}
}
之后我们运行程序后,用新的控制台查看查看进程状态即可得到如下图的信息。
可以看到我们的子进程已经变成了僵尸进程。
造成僵尸进程的后果:
1、浪费资源。由于僵尸进程一直不退出,父进程一直在关心自己子进程的退出,但是迟迟不会读取,由此系统需要一直维护僵尸进程,并且如果有大量的僵尸进程也会消耗大量资源进行维护。
2、内存泄漏。由于创造进程需要消耗内存而内存一直无法得到释放,就会造成内存泄漏。
那我们如何关闭僵尸进程呢?也很简单,只需要关闭他的父进程即可。至于如何避免造成僵尸进程,我们需要用到进程等待技术,这一点我们之后再讲。
孤儿进程
孤儿进程也是一种特殊的进程。他是在父进程先退出子进程还在执行的情况下造成的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid = fork();
if(pid < 0)
{
perror("fork error");
return -1;
}
else if(pid == 0)
{
printf("I am child:%d\n", getpid());
sleep(60);
}
else
{
printf("I am parent:%d\n", getpid());
sleep(5);
exit(0);
}
}
[misaki@localhost 第三章]$ ./Guer
I am parent:4646
I am child:4647
以上这个程序我们创造了一个持续一分钟的孤儿进程。我们在另一个终端中查看下父进程退出前后的进程信息。
前:
后:
通过父进程退出前后我们可以看出来子进程的父进程会从原来的父进程变成1号进程——init进程
。这个进程就像是一个巨大的孤儿院,只要你有init进程回收机制,我们的孤儿进程就会被1号进程领养。
进程优先级
PRI + NI
在操作系统下,我们往往是多个进程同时执行,但是往往在不同的进程中拥有着优先级别,系统会将资源优先分配给优先级高的进程,这样重要的进程才不会显得卡顿。我们的进程往往分为交互式进程和批处理进程,交互式进程要求一旦用户操作就优先运行,因此它们的优先级往往要高于批处理进程。我们先用ps -elf
查看一下进程的优先级。
我们主要关注这两行数据,PRI
和NI
,它们共同决定了一个进程的优先级。PRI(优先级) = PRI(初始) + NI(NICE值)
,我们往往无法直接改变一个程序的PRI
优先级,但是我们可以通过修改进程的NI
值来达到修改进程优先级的目的。PRI
值越小则优先级越高,NI
值的范围为-20 ~ 19,一共40个优先级级别。
我们可以使用nice -n 新NICE值 -p PID
来更改一个已有进程的NICE值
,也可以使用nice -n 新NICE值 可执行程序
来用一个NICE
值运行一个程序。不过我们要注意修改NICE值需要root用户。
其他概念
1、并行:cpu资源充足,多个程序同时运行。
2、并发:cpu资源不足,多个程序轮换运行。
3、独立性:多个进程之间相互独立。
4、竞争性:进程多资源少,多个进程间竞争资源。
环境变量
基本概念
环境变量是保存系统运行环境参数的变量。常见环境变量:PATH
,HOME
,SHELL
。
环境变量相关基础指令
1、echo $NAME
:可以查看一个环境变量。
2、env
:查看所有环境变量。
3、export
:声明一个环境变量。
4、unset
:删除一个环境变量。
我们在将一个程序的路径加入PATH
环境变量后即可直接使用程序名执行程序。export PATH=$PATH:程序路径
。
在程序中获取环境变量
通过命令行第三个参数
每个程序在执行时都会接收到操作系统的一张表,这张表是由字符指针数组构成的,每个指针都会指向一个以’\0’结尾的环境变量,并且这张表存储在main函数的第三个参数中。1
2
3
4
5
6
7
8
9#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
在C语言的libc
官方库中,有一个事先定义好的全局变量environ
,这个变量也指向环境变量表,我们可以通过它来找到所有的环境变量。1
2
3
4
5
6
7
8
9
10#include <stdio.h>
int main()
{
extern char** environ;
int i = 0;
for(; environ[i]; i++){
printf("%s\n", environ[i]);
}
return 0;
}
通过系统调用获得环境变量
getenv()
是一个系统调用接口专门用来帮助我们在程序中获取环境变量的值。1
2
3
4
5
6
7#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
程序地址空间
我们之前在学习C语言研究计算机内存时将内存分为几个基础区域,但是我们计算机在存储数据时真的如我们所想将数据就这么随意的散布在内存中么?并且在存储时如果每个进程都这样进行分配岂不是会很难管理?
虚拟地址和物理地址
我们先通过一段代码来看看其中的端倪。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25#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;
}
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
为什么会这样呢,命名地址相同但是值却不同?我们的系统在给每个进程分配内存时都会进行一套处理,所让我们看到的地址都不是真实的地址我们称之为虚拟地址,与之相对的,真实的在内存中的存储方法是不可见的,是由系统进行管理的,我们称这种真正的地址为物理地址。所以在我们上面这个程序中父进程与子进程的变量的地址相同其实是它们的虚拟地址相同,但必然有着不同的物理地址,因此才会有不同的值。
虚拟地址空间
在每个程序执行时系统就会为它分配一块物理内存,纳闷物理内存是如何和虚拟内存关联的呢?那就必然有着一个中间起到连接作用的工具——页表。页表是一用来记录虚拟地址与物理地址它们之间的关系,并且对内存数据进行管理。如下所示。
不过在我们创建子进程时也并不是一旦创建就会直接分配物理内存,这样有点过于浪费内存空间了。得益于写时拷贝技术可以让我们创建完子进程后父子进程公用同一块物理内存,只有在子进程改变变量时才会分配新的物理内存,这样大大减少了对内存的消耗。