【把Linux“聊”明白】进程的概念与状态

进程
友情专栏:【把Linux“聊”明白】
文章目录
- 进程
- 前言
- 一、基本概念与操作
- 1-1 基本概念
- 1-2 PCB
- 1-3 task_struct
- 1-4 查看进程
- 1-5 通过系统调用获取进程标示符
- 1-6 通过系统调用创建进程
- 1-7 进程具有独立性
- 二、进程状态
- 2-1 运行-阻塞-挂起
- 2-2 Linux内核源代码怎么说
- 2-2 进程状态查看
- 2-3 僵尸进程——Z(zombie)
- 2-4 僵尸进程的危害
- 2-5 孤儿进程
- 总结
前言
有了上篇文章的基础上,我们在学习进程之前,要知道,操作系统是怎么管理进行进程管理的呢?很简单,先把进程描述起来,再把进程组织起来!
通过将每个进程的属性信息封装成数据结构,并以链表等组织形式进行管理,操作系统实现了对进程的创建、调度、终止等全生命周期管理。
一、基本概念与操作
1-1 基本概念
先来看课本与内核对于进程的解释:
课本概念:程序的一个执行实例,正在执行的程序等;
内核观点:担当分配系统资源(CPU时间,内存)的实体。
听起来都太抽象,在这里,我们可以理解为进程 = 内核数据结构对象 + 自己的代码和数据,如下图所示:

可以看到,每个进程都对应着其内核数据结构对象和自己的代码和数据,我们可以把其内核数据结构用类似链表的结构连接起来,那么对于进程的管理,就变成对链表的增删查改了。
1-2 PCB
内核数据结构我们又称为PCB (Process Control Block),即进程控制块。可以理解为进程属性的集合。简单说,它就是描述进程的结构体。
我们通过PCB,就可以直接或者间接的找到进程的所有属性。
在Linux中,具体的PCB是task_struct,PCB是课本的说法。
task_struct是Linux内核的⼀种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
1-3 task_struct
既然task_struct是描述进程的结构体,那么它里面详细有什么呢?简单看一下:
标示符:描述本进程的唯一标示符,用来区别其他进程。
状态:任务状态,退出代码,退出信号等。
优先级:相对于其他进程的优先级。
程序计数器:程序中即将被执行的下⼀条指令的地址。
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
上下文数据:进程执行时处理器的寄存器中的数据。
I∕O状态信息:包括显示的I/O请求,分配给进程的I∕O设备和被进程使用的⽂文件列表。
记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息……
1-4 查看进程
在这里,我们要知道,我们历史上执行的所有指令(内建命令除外)、工具、自己的程序,运行起来,都是进程!
很好理解,现在我们来了解一下如何查看进程呢?
- 通过
/proc系统文件夹查看
如:要获取PID为1的进程信息,你需要查看 /proc/1 这个⽂件夹。
- 使用top和ps这些用户级工具
我们可以先建一个一直循环的程序来进行测试:
#include <stdio.h>
#include <unistd.h>int main()
{while (1) {printf("hello world\n");sleep(1);}return 0;
}

ps ajx | head -1 && ps ajx | grep myproc | grep -v grep
ps ajx:使用特定的格式选项显示进程信息
其它命令组合,就是帮助我们既能清晰看到表头信息,又能准确找到目标进程,排除干扰项所用。
补充:

1-5 通过系统调用获取进程标示符

pid与ppid:
进程id(PID)
父进程id(PPID)
使用一下:
#include <stdio.h>
#include <unistd.h>int main()
{printf("pid:%d\n",getpid());printf("ppid:%d\n",getppid());return 0;
}
输出:

我们可以对ppid进行搜索

哦,bash?
说明:bash(命令行解释器)也是一个进程。
我们要知道,OS会为每一个登录的用户,分配一个bash。
补充: exe和cwd
它们是进程对应的两个属性,我们可以在 /proc 下查看进程对应的属性:

执行命令后可以看到:

解释:
exe :指向启动该进程的可执行文件的完整路径。它告诉你这个进程是由哪个程序文件创建的。
cwd :Current Working Directory 的缩写,代表进程的当前工作目录。像我们在C语言中的fopen函数,以写的形式打开文件…,如果我们没有指定路径的话,就会默认在当前路径,即根据cwd来确定路径。
1-6 通过系统调用创建进程
我们是通过fork函数来进行创建子进程的,我们可以来查看一下man手册:


fork有两个返回值,我们可以这样理解,父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝),现阶段只能这样理解。
使用:
#include <stdio.h>
#include <unistd.h>int main()
{pid_t ret = fork();if(ret == -1){perror("fork");return 1;}else if(ret == 0){printf("我是子进程,我的pid是:%d\n",getpid());}else if(ret > 0){printf("我是父进程,我的pid是:%d\n",getpid());}return 0;
}
输出:

啊,两个判断条件都成立嘛,和我们以前的说法不一样呀,一组if else只能同时成立一个呀!!!
不着急,分析:
上述的说法不是很标准,但是对于我们现阶段理解是足够了,因为我们现在什么都不懂,讲太深直接绕进去了。
所以,在fork之后,父子未来执行不同的代码逻辑。
有了刚才的说明,下面,我们再来对会出现的疑问进行解释:
-
fork为什么会有两个返回值?
在父进程中:fork() 返回子进程的ID(一个大于0的数)
相当于说:“我是爸爸,我得到了儿子的身份证号”
在子进程中:fork() 返回 0
相当于说:“我是儿子,我的身份标识是0”
方便后续管理。 -
两个返回值各自是如何返回给父子?

-
一个变量如何能让if和else if 同时成立这个问题,需要在后面才能解释清楚,我们这里简单澄清几个点
实际上,一个变量不可能同时让 if 和 else if 条件成立,只是表面看起来一样,底层已经发生了很大的变化了;
也就是说,两个独立的进程各自执行自己的代码路径,都有ret这个变量。
目前,只能说这些,后续的虚拟地址空间会给我们揭晓答案的。
1-7 进程具有独立性
看代码:
#include <stdio.h>
#include <unistd.h>int main()
{int cnt = 100;pid_t ret = fork();if(ret == -1){perror("fork");return 1;}else if(ret == 0){cnt+=100;printf("我是子进程,我的pid是:%d,cnt的值为%d\n",getpid(),cnt);}else if(ret > 0){printf("我是父进程,我的pid是:%d,cnt的值为%d\n",getpid(),cnt);}return 0;
}
输出:

可以看出,进程具有独立性。
现阶段可以这样理解:把父子任何一方,进行数据修改,OS会把被修改的数据在底层拷贝一份,让目标进程修改这个拷贝。(写时拷贝)
有了进程的基本概念,我们来看一下进程的状态吧。
二、进程状态
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。
2-1 运行-阻塞-挂起
首先,我们要知道,一个CPU(单核),一个调度队列(运行队列),如下图:

也可以说,只要挂载到调度队列,即处于运行状态。
对于阻塞,我们来想一个在C语言中写的scanf函数,它在等待用户输入的时候,进程就处于阻塞。此时,该进程就会被从运行队列中删除,从而挂载到等待队列(wait_queue)中,等待用户输入。阻塞就是等待某种设备或资源就绪。
但是,在有些时候,内存的空间严重不足,我这个进程又处于阻塞状态,那我们可不可以把此进程对应的代码和数据放到磁盘上呢(唤入),在需要的时候又加载进内存呢(唤出),这就叫做阻塞挂起,此时,进程的task_struct又会被挂载到类似于阻塞挂起的队列。
都挺合理,但是,一个结构体(task_struct)怎么会链接在多个队列中呢?
其实,这种链接形式并不是我们在链表中学的那种,在结构体中直接定义一个结构体指针,而是存在一个struct list_head结构体:
struct list_head
{struct list_head *next, *prev;
}
在进程的task_struct中存在多个struct list_head,从而实现一个进程挂载在多个队列中。类似于:

要通过struct task_struct 中的 struct list_head找struct task_struct,也很简单,利用相关结构体的知识来计算。不做说明了。
可见,进程状态的变化,表现之一,就是进程在不同的队列中流动,本质就是链表的增删查改。
2-2 Linux内核源代码怎么说
在kernel源代码里定义的进程状态:
/**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 *const task_state_array[] = {"R (running)", /*0 */"S (sleeping)", /*1 */"D (disk sleep)", /*2 */"T (stopped)", /*4 */"t (tracing stop)", /*8 */"X (dead)", /*16 */"Z (zombie)", /*32 */};
简单说明:
R运行状态(running):并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
S睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。浅睡眠。
D磁盘休眠状态(Disksleep):有时候也叫不可中断睡眠状态(uninterruptiblesleep),在这个状态的进程通常会等待IO的结束。深度睡眠。
T停止状态(stopped):可以通过发送SIGSTOP信号给进程来停止(T)进程。这个被暂停的进程可以通过发送SIGCONT信号让进程继续运行。
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
Z 僵尸状态 (Zombie):进程已经执行完毕,但其退出状态还没有被父进程读取。(后面会重点说)

2-2 进程状态查看
命令:ps aux / ps ajx
解释:
a:显示一个终端所有的进程,包括其他用户的进程。
x:显示没有控制终端的进程,例如后台运行的守护进程。
j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息。
u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等。
2-3 僵尸进程——Z(zombie)
我们创建子进程的目的,就是为了让子进程完成某种事情的。那子进程完成了吗?对应结果相关的信息,父进程得知道吧。这些信息在哪里呢?子进程对应的task_struct中。
所以,在子进程执行完相关的代码后,子进程对应的代码和数据会被清理,但是,子进程对应的task_struct不会被清理,而是将子进程的状态设置为Z。然后,等待被父进程接受相关退出信息。
所以,
僵死状态(Zombies)是⼀个比较特殊的状态。当子进程退出并且父进程(使用wait()系统调用,后续说)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。
现在,我们来模拟一个僵尸进程。
#include <stdio.h>
#include <unistd.h>int main()
{pid_t ret = fork();if(ret == -1){perror("fork");return 1;}else if(ret == 0){//childint cnt = 5;while(cnt--){printf("我是子进程,我的pid是:%d,父进程pid是%d\n",getpid(),getppid());sleep(1);}}else if(ret > 0){while(1){printf("我是父进程,我的pid是:%d\n",getpid());sleep(1);}}return 0;
}
编译执行


此时的defunct指的就是:进程实体已死亡,但残骸(task_struct)还在。
如果父进程一直不接受呢?——僵尸进程的危害
2-4 僵尸进程的危害
明确几点:
-
进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程进程如果⼀直不读取,那子进程就⼀直处于Z状态?
是的! -
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态⼀直不退出,PCB⼀直都要维护?
是的! -
那⼀个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
-
会造成内存泄漏?
是的!
至于如何避免?到进程等待时会说明。
2-5 孤儿进程
僵尸进程指的是子进程想退出发生的情况,那如果父进程先退出呢?
父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
父进程先退出,子进程就称之为“孤儿进程”
孤儿进程被1号init进程领养,当然要有init进程回收喽。
模拟孤儿进程
#include <stdio.h>
#include <unistd.h>int main()
{pid_t ret = fork();if(ret == -1){perror("fork");return 1;}else if(ret == 0){//childwhile(1){printf("我是子进程,我的pid是:%d,父进程pid是%d\n",getpid(),getppid());sleep(1);}}else if(ret > 0){int cnt = 5;while(cnt--){printf("我是父进程,我的pid是:%d\n",getpid());sleep(1);}}return 0;
}


我们可以用信号来杀。

总结
理解进程状态转换和生命周期管理,是掌握操作系统工作原理的关键基础。后续我们将继续深入进程通信、进程调度等高级话题。
如果本文对您有启发:
✅ 点赞 - 让更多人看到这篇硬核技术解析 !
✅ 收藏 - 实战代码随时复现
✅ 关注 - 获取Linux系列深度更新
您的每一个[三连]都是我们持续创作的动力!✨



