深入解析 Linux 进程管理
文章目录
- 1. 基本概念
- 2. 进程描述符 PCB
- 3. 查看进程
- 4. 通过系统调用获取进程标示符
- 5. 通过系统调用创建进程
- 6. 进程状态
- 什么叫运行?
- 什么叫阻塞?
- 什么叫挂起?
- 内核源代码
- 进程状态查看
- 僵尸进程
- 僵尸进程危害
- 孤儿进程
- 7. 进程优先级
- 8. 进程切换
- 基本概念
- 进程切换的核心机制
- 进程切换的步骤
- 9. 环境变量
- 和环境变量相关的命令
- 通过系统调用获取环境变量
- 通过代码获取环境变量
- 环境变量的组织方式
- 思考
- 10. 总结
1. 基本概念
- 课本概念:程序的一个执行实例,正在执行的程序等。
- 内核观点:担当分配系统资源(CPU时间,内存)的实体
我们知道程序的本质就是文件,在磁盘放着,那么进程和程序相比,进程具有动态属性。
如何理解呢?
根据冯诺依曼,我要把这个 test.exe
软件运行起来,那么必须得先把该软件从磁盘加载到内存当中,这个软件里面包含了各种二进制成员、代码和数据了,就在内存里了,然后CPU是不是就可以去读取它了呢?当然不是!
现在有太多加载进来的程序,操作系统要管理这些加载进来的多个程序,那么怎么管理呢?
很简单,六个字:先描述,再组织。
在 Linux 内核中用结构体 struct task_struct
来描述的,它叫做进程控制块(PCB),里面包含了一个进程的所有属性。
首先,磁盘当中的 .exe
文件(代码和数据)被加载进来放在内存里了,其次加载进来的每一个进程操作系统会为我们创建一个 PCB 对象,比如:struct task_struct *p1 = malloc(struct task_struct)
;那么 p1
里面包含了进程的各种属性、代码和数据地址等等,此时,就初始化完了一个叫做 task_struct
结构体,这些结构体对象(结构体变量)都会去指向自己的代码。
接着,操作系统把所有的进程的结构体对象(PCB)链接起来(串成链表)。那么此时对于 CPU 来讲我要调度某一个进程(执行某个进程的代码),会先遍历对应的进程的 PCB,然后找到优先级最高的某一个进程,比如 test.exe
,然后把它的代码交到 CPU 里面,CPU 就可以执行对应这个进程的代码。
如果现在进程 proc.exe
想退出了,那么我们只需要去确定 proc.exe
这个 PCB 属性里面有没有一个状态是死亡的,如果是死亡的,那么操作系统遍历链表,把状态是死亡的这个节点找到,然后把它对应的代码和数据释放掉,然后再把PCB释放掉,此时这个进程也就被释放了。
所以,对进程进行管理,本质上不是对进程的可执行程序做管理,而是对 进程对应的 PCB 进行相关的管理。说白了,对进程的管理工作就转化成了对链表的增删查改。
那么换句话说,系统里新增了一个进程加载到内存,把你的代码放进来,操作系统同时也要给这个进程创建 PCB,把属性填入 task_struct
里面,填完之后把这个进程链入到链表里,此时我们就有个新进程了。
所谓的要查找一个进程,其实就是遍历对应的链表找到进程,那么它的代码也就找到了。所谓的删除一个进程,其实就是遍历链表找到进程的 PCB 属性,里面进程是死亡的,把它的 PCB 和对应的进程控制块 free
掉,那么此时这个进程就已经被释放了。
总结:struct task_struct
是操作系统给我们提供的用来描述进程的一个内核结构体,当你加载进程的时候,它要给你创建对应的内核对象(也就是内核变量),并且将对应的结构和代码与数据关联起来,从而完成了 先描述再组织 的工作。
所以:进程 = 内核数据结构(task_struct
)+ 进程对应的磁盘代码。
思考一下:为什么会有 PCB(struct task_struct
)结构体呢???
管理的核心思路:是对数据做管理。
对数据做管理,必须得先拿到数据。可是拿到的数据没有规律,而且杂乱无章,另外数据量可能很大,所以我们需要对数据做归类,按照我们对应的面向对象的思路,把对应的被管理对象需要的属性值抽象出来。
然后呢?那么当我们加载到内存之后,操作系统为其创建对应的PCB的本质就是:因为管理的理念叫做先描述再组织。
操作系统对进程要素的管理,它就必须遵守曾经自己设计的管理的理念:先描述再组织。
所以必须得有 PCB。
2. 进程描述符 PCB
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
它称之为 PCB(process control block),Linux 中进程控制块 PCB —— task_struct
结构体结构下,它是用来控制管理进程。
可以在内核源代码里找到它,所有运行在系统里的进程都以 task_struct
链表的形式存在内核里。
在 Linux 中描述进程的结构体叫做 task_struct
,task_struct
是 Linux 内核的一种数据结构,它会被装载到 RAM(内存)里并且包含着进程的信息。
task_ struct
内容分类:
- 标示符:描述本进程的唯一标示符,用来区别其他进程。
- 状态:任务状态,退出代码,退出信号等。
- 优先级:相对于其他进程的优先级。
- 程序计数器:程序中即将被执行的下一条指令的地址。
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据:进程执行时处理器的寄存器中的数据。
- I/O 状态信息:包括显示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
- 记账信息:可能包括处理器时间总和、使用的时钟数总和、时间限制、记账号等。
3. 查看进程
大多数进程信息同样可以使用 top
和 ps
这些用户级工具来获取。
我们先编写下面这段代码:
#include <cstdio>
#include <iostream>
#include <unistd.h>using namespace std;int main()
{while (1){printf("我是一个进程\n");sleep(1);}return 0;
}
然后我们可以通过 ps
命令来查看上述代码运行以后的进程:
可以看到,程序运行以后,进程的 PID
是 1872,要获取 PID 为 1872 的进程信息,你需要查看 /proc/1872
这个文件夹。
如果我想终止掉这个进程,只需要输入命令:
kill -9 1872
4. 通过系统调用获取进程标示符
- 进程 id(PID)
getpid()
函数返回调用进程的进程标识符。(这通常被用于生成唯一临时文件名的例程中。)
- 父进程 id(PPID)
getppid()
函数返回调用进程的父进程的进程标识符。
代码示例:
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
using namespace std;int main()
{while (1){printf("我是一个进程, 我的id是: %d, 父进程pid是: %d\n", getpid(), getppid());sleep(1);}return 0;
}
可以看到这个进程的 id 是 1989,它的父进程 id 是 1557。
我们把进程 kill 掉以后,再运行一遍,会惊奇的发现,进程 id 已经变成了 2000,父进程 id 还是 1557。
那么这个 1557 是谁呢?我们还是用 ps 命令查看下会发现它叫做 bash。
所以,命令行上启动的进程,一般它的父进程没有特殊情况的话,都是 bash。
换句话说就是,我要执行一条命令。./test
它就是一个任务,这个任务就相当于是交给子进程去执行了,那么父进程 bash 就稳坐钓鱼台,什么也不做。
5. 通过系统调用创建进程
- fork - 创建子进程
fork()
函数会通过复制调用进程来创建一个新的进程。这个新进程被称为 “子进程”,与调用进程完全相同。
代码示例:
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
using namespace std;int main()
{fork();printf("我是一个进程, 我的pid是: %d, 我的ppid是: %d\n", getpid(), getppid());sleep(2);return 0;
}
fork 之前其实是只有一个执行流的,叫做父进程。fork之后会有两个进程,父和子都开始执行后续代码了。换句话说,printf
这条消息被打印了两次。
可以看到,子进程的 id 是 2279,父进程的 id 是 2278,而爷爷进程(父进程的父进程)id 是1557,这个 1557 也就是前面所说的 bash。
- fork 之后通常要用
if
进行分流
fork 有两个返回值。
成功时,子进程的进程标识符会返回给父进程,而子进程则返回 0 。
在失败情况下,父进程会返回 -1 值,不会创建子进程,并且会正确设置 errno 值。
代码示例:
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
using namespace std;int main()
{// 创建子进程pid_t id = fork();if (id == 0) {// 子进程while (1){printf("我是子进程, 我的pid是: %d, 我的ppid是: %d, 我的id是: %d\n", getpid(), getppid(), id);sleep(1);}}else if (id > 0){ // 父进程while (1){printf("我是父进程, 我的pid是: %d, 我的ppid是: %d, 我的id是: %d\n", getpid(), getppid(), id);sleep(2);}}else {perror("fork");return 1;}sleep(2);return 0;
}
可以看到,fork()
之后,会有父进程 + 子进程两个进程在执行后续代码,也就是说 fork 后续的代码被父子进程共享,数据各自开辟空间,私有一份(采用写时拷贝),并且通过返回值不同,让父子进程执行后续共享的代码的一部分。
代码示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>// 定义func1函数,用于计算两个数的和
int func1(int a, int b) {return a + b;
}// 定义func2函数,用于计算两个数的乘积
int func2(int a, int b) {return a * b;
}int main() {int a = 10, b = 20;pid_t id = fork();if (id == 0) {// 子进程int ret = func1(a, b);printf("子进程: a + b = %d\n", ret);} else if (id > 0) {// 父进程int ret = func2(a, b);printf("父进程: a * b = %d\n", ret);} else {perror("fork");}return 0;
}
当 fork()
被调用时,会创建出一个子进程,该子进程是父进程的副本。父进程会返回子进程的 PID(一个大于 0 的值),子进程返回的是 0,之后,父进程和子进程会并行执行后续代码,不过具体的执行顺序要由操作系统调度器来安排。
6. 进程状态
进程为什么会有这么多状态呢?本质都是为了满足不同的运行场景。
什么叫运行?
我们现在有一个进程,在开机之后启动起来了,那么该进程一定要被加载到内存,也就是操作系统内部,并且操作系统为了管理它,必须先描述再组织。
如果这个进程它当前想在 CPU 上去运行起来,CPU 在内核里面必须得给我们去维护一个叫做运行队列,这个队列说白了,就是进行我们对应进程的运行的管理。
一个 CPU 配上一个运行队列,这个运行队列是给 CPU 准备的,而且是内核设计好的。让进程入队列,本质是将该进程的 task_struct
结构体对象放入运行队列中。
所以我们要让进程去排队,根本上不是让磁盘下的可执行程序 test.exe
去排队,而是我们让这个进程的 PCB 结构体去排队。所以 CPU 要调度,说白了就是只要找到自己的运行队列,再根据自己的 head
指针,找到其中某一个进程的 PCB,然后再执行该进程的代码。
因为 CPU 很快,所以这些进程随时随地都要把自己准备好,让别人随时随地可以来调度运行,那么此时在运行队列里的,这样的一个个进程,就叫做运行状态。
也就是说,进程 PCB 在运行队列中,就是运行状态(R),不是这个进程正在运行,才是运行状态。这里所谓的状态,在 PCB 里面它就是一个简单的整数,这个整数是几,就意味着进程的状态是几。
不要只以为你的进程只会等待(占用)CPU 资源,那么它也可能随时随地要使用外设资源。
什么叫阻塞?
进程 A 本来是在运行队列里,我们把它从队列当中拿出来放到磁盘里。但是磁盘很忙,没有空闲的空间,所以把该进程链入到磁盘的等待队列里。而此时 CPU 继续调度运行队列里的另一个进程 B,所以在进程 A 等待期间,CPU 是继续在跑其他程序的。
所以对于 CPU 来讲,它永远都在执行运行的进程,一旦你的代码里有任何需要访问外设的时候(需要我等的时候),我就把你放在我们对应硬件的等待队列里。
我们知道,进程 B 在 CPU 的这个运行队列里时,叫做 R 状态。
而进程 A 在等待外设的这个状态叫做什么呢?其实就相当于把进程 A 从运行队列里剥离下来,放在了某个硬件的结构队列当中,等待着磁盘就绪。所以在把进程 A 剥离下来的同时,把其状态修改为阻塞状态。
而其中所谓的等待,就是把你的进程一会儿放到对应的 CPU 的等待队列里,一会儿把你放在外设的阻塞队列里,这些都是对 struct
对象进行操作,说白了就是把你的 PCB 结构体对象,放到不同的队列中。
所谓不同进程的状态,本质是进程在不同的队列当中等待某种资源。
什么叫挂起?
如果有 3 个进程,大家都在阻塞,那么即便磁盘准备好了,这 3 个货其实根本就不会被直接运行,而是先要把它们再重新投入到运行队列里。其次,它们不但不会被立即调度,更重要的是它们未来要等待很长时间。
那么问题来了,你这个进程的代码和数据依旧在内存里加载着,那么此时万一内存空间不够了,怎么办?换句话说:你占着我的空间,你又不用,那不就是浪费空间吗?
既然你短期不用,那么我们把你的代码和数据暂时保存到磁盘上,此时就节省了一部分空间,那么我们的操作系统就可以继续去运行其他程序使用这部分内存了。我们把这种:一个进程暂时把它的代码和数据换出到磁盘的这种进程,我们叫做该进程被挂起了。
挂起进程不等于释放这个进程,你可以理解成这个进程的内核数据结构还在操作系统里,但是你曾经的代码和数据暂时又不用,所以我把你置换出去,此时我们就节省了空间。之后呢,操作系统就可以继续把这部分节省出来的内存给别人使用,这就叫做挂起状态。
那要是我资源准备好了,我又想回来,怎么办?操作系统要调度之前先做的第一件事情,把你这个进程的代码数据再重新加到内存,然后把你这个 PCB 再改为 R 状态,放到运行队列里,然后再重新运行。
其中把你的这个代码和数据放到磁盘,或者重新加载回来,这个过程叫做内存数据的换入换出。
所以阻塞不一定挂起,但挂起一定是阻塞的!
内核源代码
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在
Linux 内核里,进程有时候也叫做任务)
下面的状态在 kernel 源代码里定义:
它们分别指的是:
- R 运行状态(running):并不意味着进程一定在运行中,它表明进程要么是在运行中,要么在运行队列里。
- S 睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))
- D 磁盘休眠状态(Disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待 IO 的结束。
- T 停止状态(stopped):可以通过发送
SIGSTOP
信号给进程来停止当前进程。这个被暂停的进程可以通过发送SIGCONT
信号让进程继续运行。 - X 死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
代码示例:
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
using namespace std;int main()
{int cnt = 0;int a = 0;while (1){a = 1 + 1;}return 0;
}
可以看到此时该进程的状态为 R 运行状态:
我们再对代码进行修改:
int main()
{int cnt = 0;int a = 0;while (1){a = 1 + 1;printf("当前a的值是: %d, running flag: %d\n", a, cnt++);}return 0;
}
运行起来以后,我们再查看该进程的状态:
咦,我们的期望结果难道不应该是 R 状态吗?怎么变成了 S 状态呢?
因为我们的 printf
代码本身是访问显示器,而显示器是外设,它就比较慢。实际上把数据打印到显示器上是很快的就打印出来了,但是等待显示器就绪要花比较长的时间。(要以 CPU 为参照点)
其实你看起来我们不断在被刷新,实际上,这个进程它有相当大的一部分时间,比如说我们的代码里可能有 99% 的时间都是在等 IO 就绪,换句话说就是等我们的显示器准备好。但是只有 1% 的时间,可能在执行我们对应的打印代码。
所以我们查到的就是叫做 S 状态。如果没有加 printf
的话,就是 R 状态。
所以你可以想象一下,我们这份代码在运行的时候,在系统内部一定会充满大量的切换,就是将你这个进程从运行队列放到阻塞队列,在阻塞队列准备好了,再把它放到运行队列,来回在这些队列中进行切换。
大部分进程一旦有访问外设的行为,基本上你查到的都是 S 状态。所以 S 就是 Linux 操作系统当中的阻塞状态。
此时,我使用 kill -19 [PID]
命令把上面运行的进程给暂停掉,然后再查看其状态,就变成了 T 暂停状态了:
这个暂停是属于阻塞还是挂起呢?当前进程被暂停了,那么这个进程就可以被挂起,但它也属于阻塞的一种,因为它当前没有代码再运行了。
那么我们想把它继续运行,使用 kill -18 [PID]
,你会发现此时变成了 R,而不是 R+ 了:
带 +
号表示前台进程,可以用 Ctrl+c
终止;不带 +
号的是后台进程,需要用 kill -9 [PID]
命令才能终止:
最后还有一个 D 深度睡眠状态,在该状态下的进程,无法被操作系统杀掉,那么只能通过断电,或者进程自己醒来。必须得在高 IO 的情况下,才可能出现 D 状态进程。
另外还有一个 tracing stop 表示当前进程正在被追踪,同样,我们可以用下面的代码来测试:
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>using namespace std;int main()
{printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");return 0;
}
然后使用 gdb
来调试当前进程,在第 15 行打上断点:
此时,我们查询当前进程的状态就会看到变成了 t 状态:
所以你也就理解了:为什么一个进程被调试的时候能在断点处停下来了,停下来之后等待你继续运行,等待你查看当前进程运行的上下文数据。
进程状态查看
现在我们理解了操作系统中最重要的三个状态:运行、挂起、阻塞。其实在 Linux 系统中还有如下这些状态:
我们可以用 ps aux
或者 ps axj
来查看进程的状态:
僵尸进程
我们知道进程被创建出来,其实本质上是想让它帮我们去完成某种任务的。那么为什么会有僵尸状态?
僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时,就会产生僵尸进程,也就是进入到一种僵尸状态。
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入 Z
状态。
来一个僵死进程例子:创建子进程,让父进程不要退出,而且什么都不做,让子进程正常退出。
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>using namespace std;int main()
{pid_t id = fork();if (id == 0){//childprintf("我是子进程, pid: %d, ppid: %d\n", getpid(), getppid());sleep(5);exit(1); //让该进程直接终止}else {//fatherwhile (1){printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);}}return 0;
}
编译并在另一个终端下启动监控脚本:while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep; sleep 1; done
,然后开始测试:
一个进程退出了,它不会立即被回收,而是要等一等,让操作系统或者是父进程来获取它的退出结果。退出之后,然后将它的状态再由 Z 状态变成我们的 X 状态,供我们的系统去回收。
而 defunct
是失效的,味着此时这个进程它已经死掉了,只不过当前没有人回收它罢了,不回收僵尸进程会有内存泄露的问题。
在操作系统中,当一个进程处于运行状态时,实际上它是由两部分构成的,一部分是内核数据结构对象,另一部分则是与之对应的代码以及相关数据。
而当子进程结束运行退出后,其对应的代码和数据便不再会被执行了,此时操作系统可以将它们释放掉,以回收相应的内存空间。不过,需要注意的是,在该进程运行期间,操作系统为了能够对其进行有效的维护和管理,专门为它创建了进程控制块(PCB)结构,这个结构在子进程退出后是依然要被保留下来的。
这就好比我们平时用 C++ 语言编程时,通过 “new” 操作符创建了一个对象,在使用完这个对象之后,还需要我们手动使用 “free” 操作符去释放它所占用的内存空间一样,PCB 结构同样是占据内存的。
正因如此,每当操作系统去加载一个进程的时候,除了要为该进程本身分配相应的资源外,往往还需要额外创建更多用于管理这个进程的相关资源。
僵尸进程危害
- 进程的退出状态必须被维持下去,因为它要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就会一直处于Z状态。
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在
task_struct(PCB)
中,换句话说,Z 状态一直不退出,PCB一直都要进行维护。 - 如果一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费。因为数据结构
对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间。
孤儿进程
思考一个问题:父进程如果先退出,那么子进程后退出,进入 Z 状态之后,该如何处理呢?
先说结论:父进程先退出,子进程就称之为孤儿进程,孤儿进程被 1 号 init
进程领养,当然要由 init
进程回收。
代码示例:
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>using namespace std;int main()
{pid_t id = fork();if (id == 0){//childwhile (1){printf("我是子进程, pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);//exit(1); //让该进程直接终止}}else {//fatherwhile (1){printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);}}return 0;
}
代码运行起来以后,我们把父进程和子进程都在执行各自的代码:
然后我们把父进程给 kill
掉:
在上面僵尸进程的代码中,我们让子进程退出时,子进程会进入到 Z 状态,那么我们为什么没有见到父进程的僵尸状态呢?
因为你的父进程也有父进程,也就是 bash,所以在父进程被 kill
掉的时候,bash 把对应的父进程的资源回收了,所以你没看到父进程的僵尸状态。
那么为什么上面那个例子看到了子进程的 Z 状态呢?很简单,因为该子进程的父进程没有做资源回收。
我们可以发现当前这个子进程它的父进程从 1102 直接变成了 1,这个 1 号进程就是我们所对应的操作系统,即子进程被 1 号进程所领养了,这个子进程此时被称为孤儿进程。
总结:父进程先退出,这种现象是一定存在的,子进程会被操作系统也就是 1 号进程领养,为什么要这么干?如果不领养,那么子进程退出的时候,对应的僵尸,便没人能回收了。被领养的进程叫孤儿进程。
如果是前台进程创建的子进程变成了孤儿进程,此时会自动变成后台进程,只能被 kill
掉。
7. 进程优先级
什么叫做优先级?权限是能做还是不能做的问题,而优先级是先做还是后做的问题。说白了就是,先还是后去获得某种资源的能力。也就是 CPU 资源分配的先后顺序,就是指进程的优先级(priority)
优先级高的进程有优先执行权利,配置进程优先权对多任务环境的 Linux 很有用,可以改善系统性能。还可以把进程运行到指定的 CPU 上,这样一来,把不重要的进程安排到某个 CPU,可以大大改善系统整体性能。
在 Linux 系统中,用 ps -l
命令则会类似输出以下几个内容:
其中的几个重要信息有下:
- UID:代表执行者的身份
- PID:代表这个进程的代号
- PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- PRI:代表这个进程可被执行的优先级,其值越小越早被执行
- NI:代表这个进程的 nice 值
PRI 也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被 CPU 执行的先后顺序,此值越小进程的优先级别越高。那 NI 呢?就是我们所要说的 nice 值了,其表示进程可被执行的优先级的修正数值。
PRI 值越小越快被执行,那么加入 nice 值后,将会使得 PRI 变为:PRI(new) = PRI(old) + nice
,这样,当 nice 值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。
所以,调整进程优先级,在 Linux 下,就是调整进程 nice 值,n其取值范围是 -20
至 19
,一共 40 个级别.需要强调一点的是,进程的 nice 值不是进程的优先级,他们不是一个概念,但是进程 nice 值会影响到进程的优先级变化。可以理解 nice 值是进程优先级的修正数据。
我们先来写一段测试代码:
#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>using namespace std;int main()
{while (1) {printf("pid: %d\n", getpid());sleep(1);}return 0;
}
我们先查看一下当前进程的优先级:
然后使用 top 命令更改当前进程的 nice 值,进入 top 后按 r
→ 输入进程 PID
:
输入你想要更改的 nice 值:
然后再使用 ps -al
命令可以看到当前进程的优先级已经被修改了:
总结:在 Linux 系统中,一个进程最终优先级 = 老的优先级 + nice 值,只要你尝试去设置优先级,老的优先级每一次都是从 80 开始的。
8. 进程切换
在 Linux 系统里,进程切换(Context Switch)是内核实现多任务调度的关键机制。它让多个进程能够在同一个 CPU 上 “同时” 运行,营造出并行执行的效果。
基本概念
定义:进程切换指的是内核暂停当前正在执行的进程(即上下文保存),并恢复之前某个进程的执行状态(即上下文恢复)的过程。这里的 “上下文” 涵盖了 CPU 寄存器的值、程序计数器以及进程空间等状态信息。
为什么需要进程切换:
- 实现多任务:通过进程切换,系统能够在多个进程之间共享 CPU 资源,从而实现并发执行。
- 响应外部事件:当 I/O 操作完成或者中断发生时,需要切换到相应的处理进程。
- 资源分配:保证每个进程都能公平地获得 CPU 时间,避免某个进程长时间占用 CPU。
进程切换的核心机制
上下文(Context): 上下文包含了进程执行所需的所有状态信息。
- 硬件上下文:
- 通用寄存器:像 EAX、EBX 等寄存器存储着临时数据。
- 控制寄存器:例如 EIP(指令指针)和 EFLAGS(标志寄存器)。
- 段寄存器:包含 CS、DS 等,用于内存寻址。
- 软件上下文:
- 进程描述符(task_struct):这是进程在 Linux 内核中的表示,其中包含了进程状态、优先级、内存指针等信息。
- 内核栈:用于保存函数调用的上下文和局部变量。
进程控制块(PCB): 在 Linux 中,进程控制块就是 task_struct
结构体,它存储在内存的内核空间里。
进程切换的步骤
第一步,保存当前进程的上下文:
- 把 CPU 寄存器的值保存到当前进程的
task_struct
中。 - 更新进程状态(例如从 RUNNING 变为 READY 或 BLOCKED)
第二步,选择下一个要执行的进程:
- 调度器(如 CFS)根据进程的优先级和调度策略,从就绪队列中挑选出一个进程。
第三步,恢复下一个进程的上下文:
- 从目标进程的
task_struct
中恢复 CPU 寄存器的值。 - 切换到目标进程的地址空间(修改页表)。
第四步,执行切换:
- 通过修改 EIP 寄存器,跳转到目标进程的下一条指令处继续执行。
如下图所示:
进程在切换的时候,要进行进程的上下文保护;当进程在恢复运行的时候,要进行上下文的恢复。
在任何时刻,CPU 中寄存器里面的数据看起来是存放在寄存器上的,但是,寄存器内的数据只属于当前运行的进程。寄存器被所有进程共享,但寄存器内的数据是每个进程各自私有的上下文数据。
9. 环境变量
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
比如我们在编写 C/C++ 代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
常见环境变量:
- PATH:指定命令的搜索路径
- HOME:指定用户的主工作目录(即用户登陆到 Linux 系统中时,默认的目录)
- SHELL:当前 Shell,它的值通常是
/bin/bash
查看环境变量方法:
先来写一段测试代码:
#include <iostream>
#include <cstdio>using namespace std;int main()
{printf("hello edison!\n");printf("hello edison!\n");printf("hello edison!\n");return 0;
}
然后使用 ./proc
运行起来:
我自己写的可执行程序 proc
和系统里面的可执行程序 ls/pwd/ps
都属于可执行程序,那么凭什么我自己执行 Linux 指令的时候不用带 ./
路径?而我要运行我的 proc
这样的程序必须得带 ./
路径呢?
根据报错可知,要执行一个程序(指令),先找到这个程序,也就是为什么运行写好的 cpp 文件需要用 ./proc
,相当于是告诉系统:运行在当前路径下的一个 proc
程序。
那么如果我想运行这个 proc
程序并且不想让他带任何路径,怎么办呢?
第一种方法:拷贝
- 把我们的
proc
程序拷贝到/usr/bin/
路径下,也就是拷贝到系统安装指令的路径当中。接下来再运行proc
就不用带我们所对应的路径了。
sudo cp proc /usr/bin/
sudo rm /usr/bin/proc
但是不推荐这种方法,因为你写的这个 proc
程序没有经过测试,可能会污染系统当中的指令池。
为什么这种方法可以呢?原理是什么?
操作系统在启动的时候,会在我们 shell 的上下文当中,给我们定义的一个叫做 path 的环境变量,这个变量是系统内全局有效的。(通过
echo $PATH
命令可以查看)
那么我们的系统在执行指令时,它会默认在每一个冒号:
作为间隔的每一条路径当中,去帮我们去检索对应的指令,如果这个指令存在的话,那么它就找到并执行。搜索完所有的这些路径之后,那么发现你的指令不存在,那么它打印-bash: proc: command not found
这样的报错了。
换言之,我们的系统当中的指令能执行,是因为系统的指令在存放在usr/bin
这个路径下,所以就可以被我们的系统找到。
第二种方法:把可执行程序 proc
的路径添加到环境变量里。
此时直接执行 proc
指令即可。
另外,使用 ls -al
命令查看会存在两个文件:.bashrc
和 .bash_profile
:
当你登录自己的系统时,系统会默认让当前的 shell 进程将 .bash_profile
里的相关内容执行一遍,其作用就是把对应的环境变量导入到你正在使用的这个 shell 当中。
而对于我们来说,一旦登录启动成功,在内存里系统会为我们维护一个名为 $PATH
的变量。这个变量是存在于内存层面的,即便你不小心将它覆盖了也没关系,因为下次你关闭系统重新登录的时候,bash 会再次去读取相应的配置文件,并重新把环境变量导入进来。
和环境变量相关的命令
- echo:显示某个环境变量值
- export:设置一个新的环境变量
- env:显示所有环境变量
- unset:清除环境变量
- set:显示本地定义的 shell 变量和环境变量
通过系统调用获取环境变量
在 Linux 系统中我们可以用 getenv()
这个函数在获取环境变量的值。
getenv()
函数搜索环境列表以查找环境变量名,并返回指向相应值字符串的指针。
代码示例:
#include <iostream>
#include <cstdio>
#include <stdlib.h>
#include <string.h>using namespace std;#define USER "USER"int main()
{char *who = getenv(USER);if (strcmp(who, "root") == 0){printf("user: %s\n", who);printf("user: %s\n", who);printf("user: %s\n", who);printf("user: %s\n", who);printf("user: %s\n", who);printf("user: %s\n", who);}else {printf("权限不足!\n");}return 0;
}
当我们运行以后发现会提示权限不足,但是我们切换到 root 用户下再去运行就能成功了:
它怎么知道我没有这个权限去访问呢?
因为很多指令它都会做对应的身份或权限认证,其中有一个重要的环就是通过 USER 来认证的,也就是看这个文件的所有者和所属组。
我们还可以写一个 PWD
的测试代码:
再来看一个例子:
#include <iostream>
#include <cstdio>
#include <stdlib.h>
#include <string.h>using namespace std;#define MY_ENV "myval"int main()
{ char* myenv = getenv(MY_ENV);if (NULL == myenv){printf("%s, not found\n", MY_ENV);return 1;}printf("%s=%s\n", MY_ENV, myenv);return 0;
}
然后再设置一个本地的环境变量 myval,此时运行程序会发现没有结果,说明该环境变量根本不存在:
然后,我们把 myval 导入成全局的环境变量,再次运行程序,发现结果有了!说明:环境变量是可以被子进程继承下去的!
为什么会出现这种现象呢?
环境变量具备全局属性,它能够被所有的子进程所继承,也正因如此,才说它具有全局属性。在 shell 命令行当中,除了存在这种具有全局属性的环境变量之外,还有一类变量,那就是本地变量。本地变量仅在 bash 内部是有效的,没办法将其指定为可被继承的,也就是说它不会被子进程所继承。
通过代码获取环境变量
先来介绍一下 main 函数的前两个参数:int argc
和 char* argv[]
。
- argc(参数计数):是一个
int
类型的值,表示命令行参数的数量(包括程序名本身)。 - argv(参数向量):是一个指向字符串数组的指针,每个字符串是一个命令行参数。
argv[0]
:程序的名称(可执行文件路径)。argv[1]
到argv[argc-1]
:实际传递的命令行参数。argv[argc]
:始终为 NULL,作为数组结束的标志。
下面实现一个基于命令行参数执行不同功能的程序:
#include <cstdio>
#include <cstring>int main(int argc, char* argv[])
{ if (argc != 2){printf("Usage: \n\t%s [-a/-b/-c/-ab/-bc/-ac/-abc]\n", argv[0]);return 1;}const char* option = argv[1];if (strcmp("-a", option) == 0){printf("功能a\n");}else if (strcmp("-b", option) == 0){printf("功能b\n");}else if (strcmp("-c", option) == 0){printf("功能c\n");}else if (strcmp("-ab", option) == 0){printf("功能ab\n");}else if (strcmp("-bc", option) == 0){printf("功能bc\n");}else if (strcmp("-ac", option) == 0){printf("功能ac\n");}else if (strcmp("-abc", option) == 0){printf("功能abc\n");}else{printf("错误:未知选项 '%s'\n", option);return 1;}return 0;
}
运行起来,可以发现同一个程序它最后执行获取到的结果打印出来内容是不一样的,这是通过它的命令行选项控制的。
就比如在 Windows系统中 shutdown -a
,其中 shutdown
就是一个程序,-a
就是选项,它可以根据不同的选项,让我们的程序执行不同的功能。
再来看一下 main 函数的第三个参数: char *env[]
。
含义:指向环境变量数组的指针,每个元素是一个格式为
NAME=VALUE
的字符串。
我们写一个程序用于打印当前进程的所有环境变量:
#include <iostream>
#include <cstdio>
#include <stdlib.h>
#include <string.h>using namespace std;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;
}
这个程序会按顺序打印所有环境变量,每行一个,格式为 env[索引]: 变量名=值
,并且可以看到上面添加的 myval 变量也在其中:
因为 myval 这个环境变量是被导给你的 shell 的,而你运行程序的时候是需要创建子进程,并且把你 shell 的环境变量交给这个子进程的。怎么教呢?你可以理解成就是通过命令行参数 env
教的。
我们还可以使用 extern char **environ
打印当前进程的所有环境变量:
environ
是一个全局变量,指向系统环境变量列表- 格式为
char **
是一个字符串数组(每个字符串是"变量名 = 值"
的形式) - extern 声明表示该变量在其他地方定义(由系统提供)
代码示例:
#include <iostream>
#include <cstdio>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>using namespace std;int main()
{// 先声明一下extern char **environ;//打印环境变量for (int i = 0; environ[i]; i++){printf("%d: %s\n", i, environ[i]);} return 0;
}
environ
没有包含在任何头文件中,所以在使用时要用 extern
声明。
可以看到照样把我们所有的环境变量打印出来了:
环境变量的组织方式
在操作系统中,每个程序都会接收到一张环境表。而这张环境表是通过名为 environ
的指针来指向的。
当 main
函数被调用时,如果其定义包含了第三个参数 char *env[]
,那么实际上就是将这张环境表传递进去了。这个参数所指向的内容,正是一个个合法的环境变量。
从数据结构角度来看,环境表本质上是一个字符指针数组,数组中的每一个指针,都会指向一个以 '\0'
结尾的环境字符串,这些字符串就承载着各式各样的环境相关信息,例如系统配置信息、程序运行的上下文相关设定等内容。
环境变量的用途:
- 常见环境变量:PATH(命令搜索路径)、HOME(用户主目录)、USER(当前用户名)
- 程序可以通过环境变量获取配置信息,例如数据库连接字符串、API 密钥等
思考
我们知道 echo 是一个命令,也就是说它是一个子进程,当我们在 Linux 下执行命令后,hello 是一个本地变量,本来不应该被子进程继承,那么为什么能够打印本地变量的值呢?
在 Linux 系统中,echo 确实是一个外部命令(通常位于 /bin/echo
),执行时会创建子进程。但本地变量(如 hello=8888
)能被 echo 打印的原因是:命令行在执行外部命令前,会先展开变量。
具体过程如下:
- 变量赋值:
hello=8888
创建了一个 shell 本地变量(未导出,不影响子进程)。 - 命令解析:当执行
echo $hello
时,shell 先解析命令行,将$hello
替换为其值8888
,最终实际执行的命令是echo 8888
。 - 子进程执行:shell 创建子进程执行
echo
命令,并将8888
作为参数传递给echo
,而非让子进程读取环境变量。
关键点在于:变量展开发生在 shell 执行子进程之前,子进程只是接收了展开后的参数,而非继承了变量本身。因此,即使 hello
是本地变量,也能通过参数传递的方式被 echo
打印。
如果要验证子进程是否真正继承了变量,可以尝试在子 shell 中访问该变量:
10. 总结
当我们启动 Linux 操作系统时,背后其实发生了一连串复杂且重要的操作。就以启动一个程序为例,系统首先要在众多文件中精准定位到该程序。这就好比在一个大型图书馆里寻找一本特定的书籍,需要按照一定的索引规则进行查找。
一旦找到程序后,接下来就进入到加载阶段。加载的过程,本质上是把程序相关的数据从磁盘这个 “存储仓库” 传输到内存这个 “工作舞台” 上。想象一下,就像是把仓库里的货物搬运到车间,以便工人能够更高效地进行加工处理。
在加载的同时,系统还会为程序进行一系列的配置工作。这其中包括设置环境变量,环境变量就像是程序运行时的 “背景参数”,为程序提供必要的运行环境信息;传入相应的环境变量,确保程序能够获取到所需的外部信息;以及设置命令行参数,这些参数就像是程序运行的 “指令”,告诉程序应该如何执行特定的任务。
当所有这些准备工作都有条不紊地完成后,系统才会依次调用 startup 函数和 main 函数,并将相应的参数传递给它们。就好比一场演出,只有当舞台布置完毕、演员准备就绪、道具摆放妥当后,演出才会正式开始。此时,你编写的代码才真正开始执行,展现出它的功能和价值。
由此可见,在你启动自己的程序之前,Linux 操作系统已经默默地为你完成了大量细致而繁琐的工作,为程序的顺利运行奠定了坚实的基础。