Linux——进程概念
进程概念精析
目录
1. 基本概念
这里直接从内核的角度给出“进程”的概念:担当分配系统资源(CPU时间,内存)的实体。
我们在写完代码后,将代码编译链接形成可执行程序,那么可执行程序是如何运行起来的呢?
答案是要将它加载到内存中去,这个时候系统就会为我们创建进程PCB,那么什么是PCB呢?

我们使用的指令、工具、自己的程序,只要运行起来,都是进程!
2. 描述进程-PCB
系统当中可以同时存在大量进程,使用命令ps aux便可以显示系统当中存在的进程。

那么我们看到系统中存在很多进程,那么有这么多的进程,系统要不要将它们管理起来呢?
答案的肯定的,那么该如何管理呢?先描述,再组织!
操作系统管理进程也是一样的,操作系统作为管理者是不需要直接和被管理者(进程)直接进行沟通的,当一个进程出现时,操作系统就立马对其进行描述,之后对该进程的管理实际上就是对其描述信息的管理。
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,称之为PCB(process control block)。进程的所有属性,都可以在PCB中直接或间接找到。
操作系统将每一个进程都进行描述,形成了一个个的进程控制块(PCB),并将这些PCB以双链表的形式组织起来。

这样一来,对进程的管理就变成了对链表的增删查改了。
由此,这里需要输出一个结论:进程=PCB+自己的代码和数据。
创建一个进程实际上就是先将该进程的代码和数据加载到内存,紧接着操作系统对该进程进行描述形成对应的PCB,并将这个PCB插入到该双链表当中。
而退出一个进程实际上就是先将该进程的PCB从该双链表当中删除,然后操作系统再将内存当中属于该进程的代码和数据进行释放或是置为无效。
2.1 task_struct-PCB的一种
Linux是用C语言写的,那么大家可以思考一下,C语言中是用什么来描述对象的,答案是结构体,所以task_struct是一个结构体。
- PCB实际上是对进程控制块的统称,在Linux中描述进程的结构体叫做task_struct。
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含进程的信息。
2.2 task_struct内容分类

3. 查看进程
3.1 通过系统目录查看
在根目录下有一个名为proc的系统文件夹。

文件夹当中包含大量进程信息,其中有些子目录的目录名为数字。

3.2 通过ps命令查看
这个就是上面提到的,我们可以使用“ps aux”命令来查看进程信息。

ps命令与grep命令搭配使用,即可只显示某一进程的信息。

4. 通过系统调用获取进程的PID和PPID
通过使用系统调用函数,getpid和getppid即可分别获取进程的PID和PPID。
我们可以通过代码来进行测试:


这里大家可以看到我们循环打印的PID和PPID。说明一下,这里包括下面的的代码都是在VScode上写的,VScode上也可以调出终端(Ctrl+~)。
这里需要来补充一个知识,大家先来看下面的现象:

还是上面那一段代码,我这次多运行了两次,大家发现一个现象,进程的PID每次都在变化,这个是正常的,但是重点在于它们的父进程的ID都是一样的,那这里就说明一个问题,这些进程的父进程都是同一个进程,那么这个父进程的是谁呢?

大家注意上面标红的部分,可以证明上面提到的父进程就是bash!
我们历史上使用过的命令,本质上都是进程,它们的父进程都是bash。
关于bash,前面我们说过,它是命令行解释器,实际上它自己也是一个进程。我们每次登录云服务器,OS都会为我们分配一个bash。
5. 通过系统调用来创建子进程
5.1 fork函数
fork是一个系统调用级别的函数,其功能就是创建一个子进程。


大家仔细观察上面的代码,结果循环打印了两条语句,其中第一条是proc.exe这个可执行程序运行起来后产生的进程信息,第二条是fork创建的子进程的信息。这里大家注意proc进程的PID和fork的PPID是一样的,而我们知道PID是自己的,PPID是当前进程的父进程的,这里就说明了它们之间的父子关系。
每出现一个进程,操作系统就会为其创建PCB,fork函数创建的进程也不例外。

我们知道加载到内存当中的代码和数据是属于父进程的,那么fork函数创建的子进程的代码和数据又从何而来呢?
大家来看下面的代码


通过上面的运行结果,我们可以得到结论:
使用fork函数创建子进程,在fork函数之前的代码被父进程执行,而fork函数之后的代码,则默认情况下父子进程都可以执行。子进程与父进程共享代码,子进程没有自己的独立的代码和数据。
需要注意的是,父子进程虽然代码共享,但是父子进程的数据各自开辟空间(采用写时拷贝)。
5.2 使用if进行分流
上面我们说到,fork之后的代码父子都可以执行,那么父子进程都做一样的事,子进程就失去了意义,我们需要让父子进程各干各的事,互不干扰。
那么一般情况下,我们就需要使用if来进行分流。
fork函数的返回值:
1、如果子进程创建成功,在父进程中返回子进程的PID,而在子进程中返回0。
2、如果子进程创建失败,则在父进程中返回 -1。
既然父进程和子进程获取到fork函数的返回值不同,那么我们就可以据此来让父子进程执行不同的代码,从而做不同的事。
#include<stdio.h>
#include<unistd.h>
#include <sys/types.h>
int main()
{
printf("I am running!\n");
pid_t id=fork();
if(id==0)
{
//子进程
while(1)
{
printf("I am child\n");
sleep(1);
}
}
else if(id>0)
{
//父进程
while(1)
{
printf("I am father\n");
sleep(1);
}
}
else
{
printf("fork fail");
}
return 0;
}
大家可以看到,父子进程在执行各自的代码,这样就实现了分流。
这里想必大家心里会有疑问,上面的代码与我们之前学过的代码有些差异;
1. 为什么fork函数给父子返回不同的返回值?
这是因为在Linux中,父进程:子进程=1:n,这就意味着一个父进程会有很多个子进程,那么父进程就需要标识每个子进程的唯一性,所以fork函数给父进程返回的是其子进程的PID,未来方便对子进程进行管理;而子进程只需要知道它是否被成功创建即可,不需要标识父进程的唯一性,父进程就一个。
2. 为什么一个函数会返回两次?
这里其实我们可以来看一下fork函数干了什么。fork函数被执行后,会给子进程申请新的pcb空间,然后将父进程的pcb拷贝给子进程,然后再将子进程放入调度队列中,所以在返回之前,子进程已经被调度了,那么return也是一句代码,代码共享,各自返回各自的,所以就会返回两次。
3. 为什么一个变量既大于0,又等于0?导致if else都成立?
这里其实就和写时拷贝有关了,当return返回id时,就是在修改id,父子进程谁先返回谁就发生写时拷贝,父子进程拿着同一个变量的虚拟地址,然后通过页表映射到不同的物理地址,进而拿到不同的id值。
6. Linux进程状态

上面这张图呢,描述的是操作系统普遍的一些状态,这里想给大家介绍3个状态,分别是运行、阻塞、挂起。
那么我们先来介绍运行状态,这里提前给大家渗透一下,后面会详细介绍,一个CPU,拥有一个runqueue,这个runqueue叫作运行队列,CPU将进程放在队列中,然后按照先进先出的规则去进行调用,那么如果一个进程在运行队列中,我们就说这个进行处于运行状态;

那什么叫作阻塞状态呢?简单来说,其实就是进程等待某种设备和资源就绪;这里举个例子,我们之前在使用scanf函数时,程序运行会显示光标,等待我们从键盘上输入数据,此时我们启动的这个进程就处于阻塞状态。那么它的原理是什么呢?大家来看下面的简图;

实际上在OS中,系统会为我们创造类似于struct device这样的描述设备的结构体,因为操作系统是硬件的管理者,那怎么管理呢,就是先描述再组织,这里与进程的PCB是一个道理;在描述设备的结构体中,存在一个叫作等待队列的东西,当系统发现我们的进程要进行scanf读取操作,这个时候操作系统就会去键盘的struct中检查状态(结构体中的属性之一),发现此时键盘并没有按下,那么此时操作系统就会将对应的进程PCB从运行队列中拿出来,并将其链入到键盘的等待队列中,此时这个进程的状态我们就称为阻塞状态。
那么我们是怎么将数据从键盘输入到内存中的呢?
当我们按下键盘时,此时硬件就绪了,那么OS会第一时间知道,然后将键盘的status设置为“活跃”,再去检查键盘的等待队列,发现有进程在阻塞等待,此时就会将这个进程的状态改成运行状态并将其重新链回到运行队列的末尾,等待调度。
所以运行状态和阻塞状态的切换,就是在不同的队列中流动,本质上就是数据结构是增删查改。
最后我们再来聊聊挂起,这是一种比较极端的状态。在等待队列中,可能不止存在一个进程处于阻塞状态,那么这个时候如果内存资源严重不足了,此时OS就会采取措施,处于阻塞状态的进程,其代码和数据不是立马要使用的,因为它们在等待硬件就绪,这个时候操作系统就会将这些阻塞的进程的代码和数据交换到磁盘中的交换分区中,只保留它们的PCB在内存中,那么此时这些进程的状态我们就称为阻塞挂起。本质上挂起就是将进程的数据挂到外设上。
当我们按下键盘,硬件就绪后,OS就会将磁盘中的数据换入,将阻塞挂起的进程重新改成运行状态,再链入到运行队列中等待调度。

挂起本身就是一种比较极端的状态,那么如果操作系统将阻塞的进程的代码数据交换走后,内存空间还是不够用,此时OS就要打运行队列中进程的主意了,操作系统会将运行队列中的进程的代码和数据也交换到磁盘中去,等到调度的时候再将数据换回来,此时那些被拿走代码数据的运行队列中的进程就处于运行挂起状态。
上面我们介绍完了三种状态,我们再来谈谈上面提到的内核链表。
这里大家会有疑问,为啥PCB一会儿在运行队列里,一会儿又属于全局的双链表中,其实这取决于这个双链表和我们之前学的双链表有所差异,大家来看下图。

这是我们以前学过的双链表结构,下面我们来看内核的双链表结构;

我们可以看到,内核中的双链表和我们传统的链表不一样,我们可以通过偏移量去访问PCB中是任意属性,那么为什么要这样设计呢?

我们可以有一个links,那在PCB中也可以有多个links,那么此时一个PCB就可以隶属于不同的数据结构了,只要这个数据结构可以通过节点去进行连接,那么PCB就可以放进去,所以我们才看到PCB一会儿在运行队列里,一会儿又在全局的双链表中,它还可以存在于其他是数据结构中!
下面我们具体谈一下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*/
};
tip: 进程的当前状态是保存到自己的进程控制块(PCB)当中的,在Linux操作系统当中也就是保存在task_struct当中的。 实际上进程状态就是一个整数(宏定义)。
在Linux操作系统当中我们可以通过 ps aux 或 ps axj 命令查看进程的状态。


6.1 运行状态——R
一个进程处于运行状态(running),并不意味着进程一定处于运行当中,运行状态表明一个进程要么在运行中,要么在运行队列里。也就是说,可以同时存在多个R状态的进程。


tip: 所有处于运行状态,即可被调度的进程,都被放到运行队列当中,当操作系统需要切换进程运行时,就直接在运行队列中选取进程运行。
6.2 浅度睡眠状态——S
一个进程处于浅度睡眠状态(sleeping),意味着该进程正在等待某件事情的完成,处于浅度睡眠状态的进程随时可以被唤醒,也可以被杀掉(这里的睡眠有时候也可叫做可中断睡眠(interruptible sleep))。

代码当中调用sleep函数进行休眠100秒,在这期间我们若是查看该进程的状态,则会看到该进程处于浅度睡眠状态。
cp@hcss-ecs-348a:~$ ps aux | head -1 && ps aux | grep proc.exe | grep -v grep

而处于浅度睡眠状态的进程是可以被杀掉的,我们可以使用kill命令,发送9号信号,将该进程杀掉。


这里我是用这段代码为大家演示S状态,当然还有其他的代码可以演示,例如我们在代码中写scanf函数,进程就会阻塞,来等我们从键盘输入数据,此时进程的状态也是S,也叫作可中断睡眠状态。
6.3 深度睡眠状态——D
一个进程处于深度睡眠状态(disk sleep),表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复。该状态有时候也叫不可中断睡眠状态(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束。
例如,某一进程要求对磁盘进行写入操作,那么在磁盘进行写入期间,该进程就处于深度睡眠状态,是不会被杀掉的,因为该进程需要等待磁盘的回复(是否写入成功)以做出相应的应答。(磁盘休眠状态)。
在Linux中,S和D都属于阻塞状态。
6.4 暂停状态——T
在Linux当中,我们可以通过发送SIGSTOP信号使进程进入暂停状态(stopped),发送SIGCONT信号可以让处于暂停状态的进程继续运行。
例如,我们对一个进程发送SIGSTOP信号,该进程就进入到了暂停状态。


我们再对该进程发送SIGCONT信号,该进程就继续运行了。

tip: 使用kill命令可以列出当前系统所支持的信号集。

6.5 僵尸状态——Z
当一个进程将要退出的时候,在系统层面,该进程曾经申请的资源并不是立即被释放,而是要暂时存储一段时间,以供操作系统或是其父进程进行读取,如果退出信息一直未被读取,则相关数据是不会被释放掉的,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态(zombie)。
首先,僵尸状态的存在是必要的,因为进程被创建的目的就是完成某项任务,那么当任务完成的时候,调用方是应该知道任务的完成情况的,所以必须存在僵尸状态,使得调用方得知任务的完成情况,以便进行相应的后续操作。
例如,我们写代码时都在主函数最后返回0。

实际上这个0就是返回给操作系统的,告诉操作系统代码顺利执行结束。
在Linux操作系统当中,我们可以通过使用“echo $?”命令获取最近一次进程退出时的退出码。

tip: 进程退出的信息(例如退出码),是暂时被保存在其进程控制块当中的,在Linux操作系统中也就是保存在该进程的task_struct当中。
6.6 死亡状态——X
死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,所以你不会在任务列表当中看到死亡状态(dead)。
7. 僵尸进程
前面说到,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态。而处于僵尸状态的进程,我们就称之为僵尸进程。
大家来看下面一段代码;
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include <sys/types.h>
int main()
{
printf("I am running\n");
pid_t id=fork();
if(id==0)
{
//子进程
int count=5;
while(count--)
{
printf("I am child——PID:%d,PPID:%d,count:%d\n",getpid(),getppid(),count);
sleep(1);
}
printf("child quit\n");
exit(1);
}
else if(id>0)
{
//父进程
while(1)
{
printf("I am father——PID:%d,PPID:%d\n",getpid(),getppid());
sleep(1);
}
}
else
{
printf("fork fail");
}
return 0;
}

对于以上代码,fork函数创建的子进程在打印5次信息后会退出,而父进程会一直打印信息。也就是说,子进程退出了,父进程还在运行,但父进程没有读取子进程的退出信息,那么此时子进程就进入了僵尸状态。
我们可以打开监控脚本来查看进程的状态:
while :; do ps axj | head -1 && ps axj | grep proc.exe | grep -v grep;echo "######################";sleep 1;done

检测后即可发现,当子进程退出后,子进程的状态就变成了僵尸状态。
8. 僵尸进程的危害
1.僵尸进程的退出状态必须一直维持下去,因为它要告诉其父进程相应的退出信息。可是父进程一直不读取,那么子进程也就一直处于僵尸状态。
2.僵尸进程的退出信息被保存在task_struct(PCB)中,僵尸状态一直不退出,那么PCB就一直需要进行维护。
3.若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
4.僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致内存泄漏。
9. 孤儿进程
在Linux当中的进程关系大多数是父子关系,若子进程先退出而父进程没有对子进程的退出信息进行读取,那么我们称该进程为僵尸进程。但若是父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。
若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号进程领养,此后当孤儿进程进入僵尸状态时就由1号进程进行处理回收。
例如,对于以下代码,fork函数创建的子进程会一直打印信息,而父进程在打印5次信息后会退出,此时该子进程就变成了孤儿进程。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include <sys/types.h>
int main()
{
printf("I am running\n");
pid_t id=fork();
if(id>0)
{
//父进程
int count=5;
while(count--)
{
printf("I am father——PID:%d,PPID:%d,count:%d\n",getpid(),getppid(),count);
sleep(1);
}
printf("father quit\n");
exit(1);
}
else if(id==0)
{
//子进程
while(1)
{
printf("I am child——PID:%d,PPID:%d\n",getpid(),getppid());
sleep(1);
}
}
else
{
printf("fork fail");
}
return 0;
}

大家看上面代码的运行结果,当父进程没退出的时候,子进程的PPID与父进程的PID是一样的;当父进程退出的时候,我们可以看到子进程的PPID变成了1,也就是被1号进程领养了。
10. 进程优先级
10.1 基本概念
什么是优先级?
优先级实际上就是获取某种资源的先后顺序,而进程优先级实际上就是进程获取CPU资源分配的先后顺序,就是指进程的优先权(priority),优先权高的进程有优先执行的权力。
优先级存在的原因?
优先级存在的主要原因就是资源是有限的,而存在进程优先级的主要原因就是CPU资源是有限的,一个CPU一次只能跑一个进程,而进程是可以有多个的,所以需要存在进程优先级,来确定进程获取CPU资源的先后顺序。
10.2 查看系统进程
在Linux或者Unix操作系统中,用ps -l命令会类似输出以下几个内容:

列出的信息当中有几个重要的信息,如下:
UID:代表执行者的身份。

这里拓展一下,其实我们看见的用户名是系统给我们用户看的,在系统内部,它是通过UID去识别的;我们之前学文件权限的时候,系统是怎么知道我们是拥有者、所属组还是others的,其实就是通过对比UID做到的。
PID:代表这个进程的代号。
PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号。
PRI:代表这个进程可被执行的优先级,其值越小越早被执行。
NI:代表这个进程的nice值,进程优先级的修正值。
10.3 PRI和NI
PRI代表进程的优先级(priority),通俗点说就是进程被CPU执行的先后顺序,该值越小进程的优先级别越高。
NI代表的是nice值,其表示进程可被执行的优先级的修正数值。
PRI值越小越快被执行,当加入nice值后,将会使得PRI变为:PRI(new) = PRI(old) + NI。
若NI值为负值,那么该进程的PRI将变小,即其优先级会变高。
调整进程优先级,在Linux下,就是调整进程的nice值。
NI的取值范围是-20至19,一共40个级别。
注意: 在Linux操作系统当中,PRI(old)默认为80,即PRI = 80 + NI。
10.4 查看进程优先级信息
当我们创建一个进程后,我们可以使用ps -al命令查看该进程优先级的信息。

注意: 在Linux操作系统中,初始进程一般优先级PRI默认为80,NI默认为0。
10.5 通过top命令修改进程的nice值
top命令就相当于Windows操作系统中的任务管理器,它能够动态实时的显示系统当中进程的资源占用情况。

使用top命令后按“r”键,会要求你输入待调整nice值的进程的PID。
输入进程PID并回车后,会要求你输入调整后的nice值。

输入nice值后回车,再按“q”即可退出,如果我们这里输入的nice值为10,那么此时我们再用ps命令查看进程的优先级信息,即可发现进程的NI变成了10,PRI变成了90(80+NI)。

注意: 若是想将NI值调为负值,也就是将进程的优先级调高,需要使用sudo命令提升权限。
10.6 通过renice命令修改进程的nice值
使用renice命令,后面跟上更改后的nice值和进程的PID即可。

注意: 若是想使用renice命令将NI值调为负值,也需要使用sudo命令提升权限。
10.7 四个重要概念
1、竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便有了优先级。
2、独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
3、并行: 多个进程在多个CPU下分别同时进行运行,这称之为并行。
4、并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
11. 环境变量
11.1 基本概念
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
环境变量通常具有某些特殊用途,并且在系统当中通常具有全局特性。
怎么来理解全局特性呢?其实就一句话,环境变量可以被子进程继承。
11.2 常见的环境变量
PATH:指定命令的搜索路径。
HOME:指定用户的主工作目录(即用户登录到Linux系统中的默认所处目录)。
SHELL:当前Shell,它的值通常是/bin/bash。
11.3 查看环境变量
echo $NAME //NAME为待查看的环境变量名称
例如,查看环境变量PATH。
![]()
11.4 测试PATH
大家有没有想过这样一个问题:为什么执行ls命令的时候不用带./就可以执行,而我们自己生成的可执行程序必须要在前面带上./才可以执行?

容易理解的是,要执行一个可执行程序必须要先找到它在哪里,既然不带./就可以执行ls命令,说明系统能够通过ls名称找到ls的位置,而系统是无法找到我们自己的可执行程序的,所以我们必须带上./,以此告诉系统该可执行程序位于当前目录下。
而系统就是通过环境变量PATH来找到ls命令的,查看环境变量PATH我们可以看到如下内容:
![]()
可以看到环境变量PATH当中有多条路径,这些路径由冒号隔开,当你使用ls命令时,系统就会查看环境变量PATH,然后默认从左到右依次在各个路径当中进行查找。
而ls命令实际就位于PATH当中的某一个路径下,所以就算ls命令不带路径执行,系统也是能够找到的。

那既然这样,我们可不可以让我们自己的可执行程序也想命令一样不带路径就可以执行呢?
答案当然是OK的;
方式一:将可执行程序拷贝到环境变量PATH的某一路径下。
sudo cp proc /usr/bin
既然在未指定路径的情况下系统会根据环境变量PATH当中的路径进行查找,那我们就可以将我们的可执行程序拷贝到PATH的某一路径下,此后我们的可执行程序不带路径系统也可以找到了。

方式二:将可执行程序所在的目录导入到环境变量PATH当中。
将可执行程序所在的目录导入到环境变量PATH当中,这样一来,没有指定路径时系统就会来到该目录下进行查找了。
export PATH=$PATH:/home/cp/test1

将可执行程序所在的目录导入到环境变量PATH当中后,位于该目录下的可执行程序也就可以在不带路径的情况下执行了。
11.5 测试HOME
任何一个用户在运行系统登录时都有自己的主工作目录(家目录),环境变量HOME当中即保存的该用户的主工作目录。

上面展示了普通用户和超级用户下,我们对应的工作目录。
11.6 测试Shell
我们在Linux操作系统当中所敲的各种命令,实际上需要由命令行解释器进行解释,而在Linux当中有许多种命令行解释器(例如bash、sh),我们可以通过查看环境变量SHELL来知道自己当前所用的命令行解释器的种类。

而该命令行解释器实际上是系统当中的一条命令,当这个命令运行起来变成进程后就可以为我们进行命令行解释。

11.7 和环境变量相关的命令
1、echo:显示某个环境变量的值。
2、export:设置一个新的环境变量。

这里操作很简单,但是有一个问题,我们之前说过,我们使用的命令实际上都是进程,并且它们是bash的子进程,这里的export也是进程,问题在于为什么它可以向父进程bash中导入环境变量?
这里原因在于export命令和普通命令不一样,它叫作内建命令,内建命令在执行时不需要创建子进程,由bash亲自执行。
3、env:显示所有的环境变量。

部分环境变量说明:

4、set:显示本地定义的shell变量和环境变量。


这里的x就是本地变量,它不会被继承,只在bash内部被使用。
5、unset:清除环境变量。

11.8 环境变量的组织方式

每个程序都会收到一张环境变量表,环境变量表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串,最后一个字符指针为空。
11.9 通过代码获取环境变量
大家有没有思考过main函数有没有参数?
main函数其实有三个参数,只是我们平时基本不用它们,所以一般情况下都没有写出来。
我们可以在Windows下的编译器进行验证,当我们调试代码的时候,若是一直使用逐步调试,那么最终会来到调用main函数的地方。

这里我们先来讨论main函数的前两个参数。


main函数的第二个参数是一个字符指针数组,数组当中的第一个字符指针存储的是可执行程序的位置,其余字符指针存储的是所给的若干选项,最后一个字符指针为空,而main函数的第一个参数代表的就是字符指针数组当中的有效元素个数。

下面我们来写一个测试demo;
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[], char* envp[])
{
if(argc > 1)
{
if(strcmp(argv[1], "-a") == 0)
{
printf("you used -a option...\n");
}
else if(strcmp(argv[1], "-b") == 0)
{
printf("you used -b option...\n");
}
else
{
printf("you used unrecognizable option...\n");
}
}
else
{
printf("you did not use any option...\n");
}
return 0;
}

大家可以看到,我们输入不同的选项,可以执行不同的语句(功能)。
下面我们再来谈谈main函数的第三个参数。
main函数的第三个参数接收的实际上就是环境变量表,我们可以通过main函数的第三个参数来获取系统的环境变量。


大家可以看到代码的运行结果就是各个环境变量的值。
除了使用main函数的第三个参数来获取环境变量以外,我们还可以通过第三方变量environ来获取。


这样也可以得到环境变量的值。
注意: libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern进行声明。
11.10 通过系统调用来获取环境变量
使用getenv函数获取环境变量PATH的值。推荐大家用这种方法获得环境变量,因为系统调用可以获取具体的环境变量。
getenv函数可以根据所给环境变量名,在环境变量表当中进行搜索,并返回一个指向相应值的字符串指针。


12. 程序地址空间

这张图相信大家肯定不陌生,在Linux操作系统中,我们可以通过以下代码对该布局图进行验证:
#include <stdio.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char* argv[], char* env[])
{
const char* str = "helloworld";
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
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); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
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]);
}
return 0;
}

大家可以看到打印的结果和图上描述的是一致的。
下面我们来看一段的代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.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=10;
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;
}
下面我们来看运行结果:

大家重点关注它们的地址,发现地址是一样的。
代码当中用fork函数创建了一个子进程,其中让子进程想将全局变量g_val该从0改为10后打印,而父进程先休眠1秒钟,然后再打印全局变量的值。
按道理来说子进程打印的全局变量的值为10,而父进程是在子进程将全局变量改后再打印的全局变量,那么也应该是10,但是大家可以看到我们打印的结果是不一样的,父进程打印的还是0,子进程打印的10。
但是它们地址又都是一样的,那这里就比较奇怪了,同一个地址,为啥值还不一样。
如果说我们是在同一个物理地址处获取的值,那必定是相同的,而现在在同一个地址处获取到的值却不同,这只能说明我们打印出来的地址绝对不是物理地址!!!
实际上,我们在语言层面上打印出来的地址都不是物理地址,而是虚拟地址。物理地址用户一概是看不到的,是由操作系统统一进行管理的。
所以就算父子进程当中打印出来的全局变量的地址(虚拟地址)相同,但是两个进程当中全局变量的值却是不同的。

注意: 虚拟地址和物理地址之间的转化由操作系统完成。这个后面我们会知道,是利用页表进行地址转化的。
13. 进程地址空间
我们之前将那张布局图称为程序地址空间实际上是不准确的,那张布局图实际上应该叫做进程地址空间,进程地址空间本质上是内存中的一种内核数据结构,在Linux当中进程地址空间具体由结构体mm_struct实现。
进程地址空间就类似于一把尺子,尺子的刻度由0x00000000到0xffffffff,尺子按照刻度被划分为各个区域,例如代码区、堆区、栈区等。而在结构体mm_struct当中,便记录了各个边界刻度,例如代码区的开始刻度与结束刻度,如下图所示:


在结构体mm_struct当中,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于虚拟地址是由0x00000000到0xffffffff线性增长的,所以虚拟地址又叫做线性地址。
每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建。
而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的是mm_struct的地址。

而当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。
只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
例如,子进程需要将全局变量g_val改为200,那么此时就在内存的某处存储g_val的新值,并且改变子进程当中g_val的虚拟地址通过页表映射后得到的物理地址即可。

这种在需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。
下面我们来介绍一下写时拷贝:
1. 为什么要存在写时拷贝?
进程具有独立性,多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
2. 为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。
3. 代码会不会进行写时拷贝?
大多数情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进行进程替换的时候,则需要进行代码的写时拷贝。
那么为什么要有进程地址空间呢?
1、有了进程地址空间后,保护了物理内存中的所有合法数据。凡是想使用地址空间和页表进行映射, 也⼀定要在OS的监管之下来进行访问!!
2、因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的 虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可以是有序的。
3、有了进程地址空间后,每个进程都认为自己在独占内存,这样能更好的完成进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),我们的物理内存中可以对未来的数据进行任意位置的加载,将进程调度与内存管理进行解耦或分离。
因为有地址空间的存在,所以我们在C、C++语⾔上new,malloc空间的时候,其实是在虚拟地址空间上申请的,物理内存可以甚至⼀个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全0感知!!
这里总结一下:当我们创建进程时,系统就会为我们自动创建task_struct,mm_struct,页表。
下面再补充几个问题;
1. 我们可以不加载代码和数据,只有task_struct,mm_struct,页表。这里涉及到缺页中断的知识,后面我们会学到。
2. 创建进程的时候,先有task_struct、mm_struct等,还是先加载代码和数据?
这个很明显,肯定是先有task_struct这些数据结构,然后再加载代码和数据。
3. 堆区不止一个吧?那么按照上面的划分,不能解决问题。那么大家来看下面的图。

在mm_struct中,其实还存在一张链表,可以说,mm_struct结构是对整个用户空间的描述,linux内核使用 vm_area_struct 结构来表示⼀个独立的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此⼀个进程使⽤多个vm_area_struct结构来分别表示不同类型 的虚拟内存区域。
14. 进程切换

这里大家首先要区分寄存器和寄存器中的数据,寄存器就是一块儿空间,而其中的内容是变化的。
进程切换的本质就是保存和恢复当前进程的硬件上下文数据。实际上当前进程会将自己的上下文数据保存到task_struct中。
15. Linux2.6内核进程调度队列

一个CPU拥有一个runqueue
15.1 优先级
queue下标说明:
普通优先级:100~139。
实时优先级:0~99。
我们进程的都是普通的优先级,前面说到nice值的取值范围是-20~19,共40个级别,依次对应queue当中普通优先级的下标100~139。这里的queue[140]的类型是struct tsak_struct* [140],它是一个结构体指针数组。
注意: 实时优先级对应实时进程,实时进程是指先将一个进程执行完毕再执行下一个进程,现在基本不存在这种机器了,所以对于queue当中下标为0~99的元素我们不关心。
15.2 活动队列
时间片还没有结束的所有进程都按照优先级放在活动队列当中,其中nr_active代表总共有多少个运行状态的进程,而queue[140]数组当中的每一个元素就是一个进程队列,相同优先级的进程按照先进先出规则进行排队调度。
调度过程如下:
从0下标开始遍历queue[140]。
找到第一个非空队列,该队列必定为优先级最高的队列。
拿到选中队列的第一个进程,开始运行,调度完成。
接着拿到选中队列的第二个进程进行调度,直到选中进程队列当中的所有进程都被调度。
继续向后遍历queue[140],寻找下一个非空队列。
bitmap[5]:queue数组当中一共有140个元素,即140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5×32个比特位表示队列是否为空,这样一来便可以大大提高查找效率。
15.3 过期队列
• 过期队列和活动队列结构⼀模⼀样。
• 过期队列上放置的进程,都是时间片耗尽的进程。
• 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算。
15.4 active指针和expired指针
• active指针永远指向活动队列
• expired指针永远指向过期队列
• 可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时⼀直 都存在的。
• 没关系,在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了⼀批 新的活动进程!
总结: 在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,我们本质上进行的都是数组的增删差改,不会随着进程增多而导致时间成本增加,我们称之为进程调度的O(1)算法。
更多推荐

所有评论(0)