进程与并发:从概念到实战全解析
一、进程的概念与定义:
什么是进程?
对于进程有着多种定义:
1、一个正在执行的程序。
2、计算机中正在运行的程序的一个实例。
3、由一个单一顺序线程以及一个当前状态和一组相关系统资源所表征的活动单元。
首先不能混淆的一点是,程序是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。而进程是动态的,是程序的一次执行过程。同一个程序多次进行会对应多个进程。
综上而言,总结一下,进程是什么?
答:进程由三部分组成:
1、一个可执行的程序
2、程序所需要的相关数据。
3、程序的执行上下文(也称为进程状态),对于进程状态,下满会详细讲解,可以直接在目录点击运行状态来查看!
程序是静态的,而进程是动态的。
在linux系统中,操作系统是通过进程去完成一个一个的任务的,进程是管理事务的基本单元。
打开qq三次,在任务管理器中来看,每一次打开都是一个不同的进程,都是qq,那么操作系统是怎么样对这三个进程进行区分的呢?答案是PID,类似于我们的身份证号,进程为PID,而父进程为PPID。这个PID和PPID都是唯一的。
二、并发的概念:
并发,在操作系统中,一个时间段有多个进程都处于已启动进行到运行完毕之前的状态,但一个时刻点上仍只有一个进程在运行。
例如,我们可以在打游戏的同时听歌,那么游戏这个进程和音乐这个进程为什么可以同时进行呢?答案是因为并发。
操作系统具有并发,谈到这里就不得不谈到进程切换了,多个进程在同一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,也就是并发。
如下图所示:
进程切换:
通俗一点讲,进程切换就是从正在运行的进程中,收回CPU的使用权利,交给下一个要运行的进程。因为被切换的进程以后还要继续运行,所以这里我们必须把它的数据保存起来。
进程在切换的时候要对上下文进行保护(保存),当进程恢复运行的时候要进行上下文的恢复!
进程切换是操作系统中的一个关键操作,在这个过程中,寄存器发挥着重要的作用!
1、 保存当前进程状态:寄存器用于存储CPU在执行指令时的各种数据和状态信息,例如程序计数器(PC)记录了下一条要执行的指令地址,通用寄存器存储运算数据等。当进行进程切换时,操作系统需要把当前正在运行进程的寄存器内容保存起来,因为这些信息反映了该进程在CPU上的执行状态。比如将程序计数器的值保存到进程的上下文环境中,以便下次该进程恢复执行时,能从正确的指令处继续运行。
2、 恢复新进程状态:当保存好当前进程的寄存器状态后,操作系统会从即将运行的新进程的上下文环境中,读取之前保存的寄存器值,并将其恢复到CPU的寄存器中。这样,新进程就可以从上次被暂停的地方继续执行,如同从未被打断过一样。
这里需要注意的一点是:寄存器硬件并不等于寄存器内的数据。寄存器里面的数据只属于当前进程!而寄存器就相当于公有的,它里面的数据是当前进程私有的,寄存器被所有进程共享,寄存器内的数据才是上下文数据,它是私有的,这一点要弄清,不要混淆!
此时B进程正在执行,在A中断的同时,所有寄存器中的内容被寄存在上下文环境中,以后操作系统进行进程切换就可以通过上下文来进行了,进程切换的过程包括保存B的上下文数据和恢复进程A的上下文,任何时候整个进程状态都包含在上下文环境中。
理解了进程切换,再谈并发就不那么难理解了。
计算机是如何管理硬件的?答案是先描述起来,再把它们进行组织,提到描述,那么就不得不提到进程控制块了。
三、进程控制块(重要):
PCB(进程控制块),进程信息被放在一个叫做进程控制块的数据结构中,可以理解为它是进程所有属性的一个集合。linux内核的进程控制块是task_struct结构体,提到结构体,我们并不陌生,在c/c++中都有一定的接触。
tast_struct中的内容:
进程的状态,就绪、运行、挂起等状态
进程切换时需要保存和恢复的一些CPU寄存器
描述虚拟地址空间的信息
当前工作目录
usk掩码
优先级
用户id和组id
描述控制终端的信息等等......
后面就不一一列举了。
总结就一句话:进程由程序代码以及相关数据还有进程控制块组成,那么可想而知,进程控制块对于进程来讲,它的重要性。
这里可以采用ulimit -a命令查看当前用户的资源限制设置的命令:
四、进程状态(重要):
首先,进程的状态有很多种,运行、就绪、挂起、阻塞、睡眠、停止、死亡等等。就不一一列举了。
所谓进程的不同状态本质是为了应付各种场景,进程的不同状态实际上是进程在不同的队列之中。
操作系统会维护多个队列来管理进程,每个队列对应一种状态!!!
就绪队列:存放所有就绪态的进程,等待CPU调度。
阻塞队列:存放阻塞态进程,等待特定事件完成。
运行态虽然不算队列,但同一时刻只能一个或多个进程在CPU上云霄,颗粒剂为正在占用CPU资源的临时状态,而进程状态的变化,其实就是从一个队列“移到”另一个队列中。
1、运行状态(Running):
这里的运行状态并不意味着一定在运行中,它表明进程要么在进程中要么在运行队列里,在linux内核里,为了统一管理,把这两种情况都归为R状态。
当进程处于R状态时:
如果进程正在CPU上执行,则属于正在运行的逻辑。
如果进程已就绪但是还没抢到CPU,会被放在runqueue的就绪队列里等待调度。
2、阻塞态:
在进程运行的过程中,可能会请求等待某个事件的发生(如等待系统资源的分配或者等待其他进程的响应),在这个事件发生之前,进程无法继续往下执行,那么如果一直等待这个事件发生,那么一定会浪费时间,所以此时操作系统会让这个进程下CPU,并让它进入“阻塞态”,等这个事件发生了,会让这个进程继续运行。
进程的进程控制块(PCB)以及相关的数据结构仍然保留在内存中,会持续占用一定的内存资源,只是不占用CPU资源了。当等待时间完成,阻塞态会先转换为就绪态等待CPU调度执行。
3、挂起状态:
是操作系统为了满足资源管理、调整系统性能等需求,将进程从内存移动到外存(如硬盘),使进程处于静止状态。挂起的进程暂时不参与CPU调度,即便等待的进程暂时不参与CPU调度,即便等待的事件发生,也不会立即转为就绪态,需要先被唤醒并重新由外存调入到内存。
这里最不好理解的就是挂起状态和阻塞状态,因为很容易混淆这两个状态的概念,通俗一点讲,挂起状态是空间不够了,先把数据放回到磁盘上,节省空间,操作系统就可以把节省出来的空间给别人使用!
下面我将对阻塞状态和挂起状态进行详细解析,避免混淆这两个概念。
首先需要理解的是:阻塞不一定挂起,但是挂起一定阻塞!
阻塞是进程等资源/时间(比如磁盘读写),本质上是逻辑上无法继续执行。
挂起是进程被从内存挪到磁盘,本质上被换出内存。
阻塞状态的逻辑:进程进入阻塞,是因为要等某个事件,阻塞时,进程还在内存里(task_struct/数据都在内存),但因为等资源,无法被CPU调度执行。
那么它会立刻调度吗?答案是并不会,要等待很长时间,阻塞的进程得等事件完成,才能重新被调度。
挂起状态的触发:
当内存不够用时,操作系统会选择一些短期内不会使用的进程,把它们的代码和数据从内存临时存放到磁盘中,这样就可以节省出一些空间让其他进程来使用。
注意!!!task_struct仍然在内存中,系统还需要用他记录“进程被挂到磁盘了”,否则下次没法恢复。
挂起的作用:
本质是内存交换机制!把不常用进程挪去磁盘,腾出内存给更加急需的进程来使用。
对阻塞不一定挂起,但挂起一定阻塞的理解:
比如进程等网路数据时,可能一直待在内存里阻塞(因为此时内存够用, 就不用被挂起!)。
挂起一定阻塞:
进程被挂起的前提是暂时用不上,而暂时用不上最常见的原因就是他在阻塞等事件,短期内不需要CPU,如果进程是活跃的(急需的),那么系统不会把它挂起,否则影响执行。
从图上来观看一下他们之间的关系:
包含单挂起状态的模型:
总结:
阻塞:进程在等东西,逻辑上没法跑,可能在内存里干等!
挂起:进程被挪去磁盘,物理上没内存了,一定是因为它在阻塞或者是长期不用,才会被选中挂起。
阻塞就是逻辑等,而挂起就是物理挪!
这样做的目的就是:平衡资源利用,和提高执行效率。
4、停止状态(T状态):
T状态通常表示进程暂时停止运行,不再参与CPU调度,直到特定信号唤醒。
处于T状态的进程仍存在于内存中,保留所有资源,等待被信号唤醒后,可恢复到就绪状态。
5、睡眠状态:
睡眠状态是阻塞状态的一种,通俗的讲,阻塞状态的范围更大,而睡眠状态只是相当于阻塞状态这个集合里面的一个子集,睡眠状态是阻塞状态的一种具体实现!
6、僵尸进程:
僵尸进程指已经终止运行,但内核仍保留其部分信息,它的标志是Z,本质是已经死亡但是未被彻底清理的进程,不再执行任何代码,也不占用CPU资源,但会保留少量内核数据结构(如上面提到的PCB进程控制块)。
僵尸进程产生的原因:
1、子进程终止:子进程执行完任务后,通过exit()等函数终止,释放用户态资源。
2、内核保留信息:内核不会立即删除子进程的PCB,而是保留其退出码等信息等待父进程查询。
3、父进程为回收:如果父进程未调用wait(),waitpid()等函数读取子进程的退出状态,内核无法释放这部分残余信息,子进程就会称为僵尸进程。
僵尸进程的特点:
1、状态标志:ps命令中状态为Z或者Z+,后面会通过写一个c语言代码给大家看。
2、资源占用:不占用CPU和内存等用户态资源,但是会占用一个进程号(pid)和少量内核存储空间。
3、不可杀死:不能使用kill命令清楚,只能通过回收父进程或者让父进程调用wait()系列函数来处理。
下面将用c语言写一段代码来让大家看一下僵尸进程。
首先先创建一个目录(也可以不创建),使用mkdir newdir ,然后创建一个名为newtest.c的文件,使用vim,在里面写上c语言代码,再进行编译。
这是newtest.c
#include
#include
#include
#include
int main() {
pid_t pid = fork(); // 创建子进程
if (pid < 0) {
perror("fork failed");
exit(1);
} else if (pid == 0) {
// 子进程逻辑
printf("子进程 (PID: %d) 运行,即将退出\n", getpid());
exit(0);
} else {
// 父进程逻辑
printf("父进程 (PID: %d) 运行,不回收子进程状态\n", getpid());
while (1) {
sleep(1); // 父进程持续运行,不调用 wait 系列函数
}
}
return 0;
}
把这个代码写好后,./newtest进行运行,此时会看到父进程和子进程的提示信息,程序会持续运行(因为父进程无限循环sleep)。
再新建一个终端,然后输入 ps aux | grep newtest。
此时,子进程的pid为4842,那么在4842的后面显示状态Z+,这就是一个僵尸进程。
对于上面的代码,首先先使用fork()函数复制一个新进程(子进程),与父进程共享代码,但有独立的PCB,子进程PID为0,父进程中会返回子进程的PID,也就是上面的4842,这是为了方便我们区分谁是父进程谁是子进程。
子进程exit(1),但是虽然退出了,但状态残留,内核的PCB不会立即销毁,而是会保存exit code等信息。此时,子进程进入僵尸进程的等待期,等着父进程用wait/waitpid来读取这些信息。
但是注意!这里父进程一直循环sleep,那么就永远不会调用wait/waitpid了,那么内核无法释放子进程的PCB,只能让它以僵尸进程的形式进行存在。
这就是僵尸进程,僵尸进程的本质是“已经死亡的进程,残留PCB等待父进程回收”。
僵尸进程的“生命周期”与危害
1. 存在条件:
父进程未调用 wait/waitpid,且父进程未退出。
2. 何时消失:
父进程主动调用 wait/waitpid,读取子进程状态 → 僵尸进程被彻底销毁。
父进程退出 → 僵尸进程由 init(或 systemd)接管并回收。
3. 潜在危害:
大量僵尸进程会占用 PID 号(Linux 中 PID 是有限资源),可能导致新进程无法创建。
7、孤儿进程:
孤儿进程是操作系统中一种特殊的进程状态,指的是父进程先于子进程退出后,子进程称为无父进程的孤儿进程,此时系统会将其收养,使其称为特殊的进程。
孤儿进程产生的原因:
父进程通过fork()系统调用创建子进程后,两者是独立运行的,父进程的生命周期与子进程没有强制关联。
父进程意外退出,而子进程仍在运行,此时子进程就会称为孤儿进程。那么孤儿进程都有什么特点呢???
孤儿进程是独立运行的,只是父进程不在了,它的PPID会变为1,因为它原来的父进程退出来,它被系统领养了,所以新的PPID为1,孤儿进程退出后,它的资源会被系统回收,并不会产生僵尸进程。
下面将用一个小例子来看一看孤儿进程。
#include
#include
#include
#include
int main() {
pid_t pid = fork(); // 创建子进程
if (pid < 0) {
// fork 失败处理
perror("fork failed");
exit(1);
} else if (pid == 0) {
// 子进程逻辑:等待父进程退出,成为孤儿进程
printf("子进程 (PID: %d) 运行中,父进程 PID: %d\n",
getpid(), getppid());
// 子进程睡眠 5 秒,确保父进程先退出
sleep(5);
// 5 秒后,子进程的父进程已退出,会被 init 接管
printf("子进程 (PID: %d) 现在的父进程 PID: %d(应为 1)\n",
getpid(), getppid());
printf("子进程退出\n");
exit(0);
} else {
// 父进程逻辑:直接退出,不等待子进程
printf("父进程 (PID: %d) 运行中,即将退出(让子进程成为孤儿)\n",
getpid());
exit(0); // 父进程退出,子进程成为孤儿
}
return 0;
}
这个编译过程我就不详细说了,因为和上面的僵尸进程一致,我就直接给大家看结果了。
总结如下:
孤儿进程是父进程提前退出后遗留的子进程,由系统初始化进程收养并管理,不会像僵尸进程那样占用系统资源,因此通常无需特殊处理。
五、环境变量:
环境变量指在操作系统中用来指定操作系统运行环境的一些参数。
环境变量的本质就是字符串
统一的格式额为:名=值[ :值]
使用形式与命令行参数类似。
环境变量在操作系统中具有全局性,就好比我们在c/c++代码中写的全局变量,有些类似。
使用env命令可以查看系统中所有的环境变量。
有关环境变量常见的命令:
1、env:显示所有环境变量
2、echo:显示单个环境变量的值
3、unset:清楚环境变量
4、export:设置一个新的环境变量
下面我将采用c语言的代码来对环境变量具有全局性进行验证:
#include
#include
#include
#include
int main() {
// 父进程设置环境变量
putenv("TEST=hello");
printf("父进程:设置了 TEST=hello\n");
// 创建子进程
pid_t pid = fork();
if (pid == 0) {
// 子进程读取
printf("子进程:读到 TEST=%s\n", getenv("TEST"));
exit(0);
}
// 父进程等待
wait(NULL);
printf("父进程:验证结束\n");
return 0;
}
对于上述代码,我在父进程里设置了环境变量TEST,那么我子进程是怎么获取到的呢?答案是环境变量具有全局性,即使是在父进程里创建的,我子进程依然可以获取到,环境变量被继承下来了。
接下来,我们看下面的代码:
#include
#include
#include
int main() {
static int num; // 静态变量,地址在数据段,更稳定
void *addr = #
// 创建子进程
pid_t pid = fork();
if (pid == 0) { // 子进程
num = 100; // 子进程给地址赋值100
printf("子进程: 地址=%p, 存的值=%d\n", addr, num);
sleep(10); // 等待父进程查看
} else { // 父进程
num = 200; // 父进程给地址赋值200
printf("父进程: 地址=%p, 存的值=%d\n", addr, num);
wait(NULL); // 等待子进程结束
}
return 0;
}
接着来看一下这段代码的运行结果:
我们可以清晰地看到,地址相同,那么存的值怎么是不同的呢?一个值为100,一个值为200?
对于这个问题如果想真正的解释清楚,那么就需要花费大量的篇幅!
本文就先写到这里,本文篇幅过长,难免会有一些小的瑕疵或错误,可在评论区进行支持,本人会及时进行更改,感谢支持!