Linux 进程概念
- 冯·诺依曼体系结构
- 软件运行与存储分级
- 数据流动的理论过程
- 操作系统
- 操作系统(Operator System) 概念
- 操作系统的功能与作用
- 系统调用和库函数概念
- 进程概念
- 描述进程 - PCB
- task_struct
- 查看进程
- 通过系统调用获取进程标示符 PID
- 通过系统调用 fork 函数创建进程
- 简单使用
- 区分父子进程操作
- 父子进程的写时拷贝
- 进程状态
- 具体的 Linux 内核解释
- 运行、阻塞 和 挂起状态
- 进程如何被转移
- Linux 进程状态
- 僵尸进程与其危害
- 僵尸进程危害
- 孤儿进程
以下代码环境为 Linux Ubuntu 22.04.5 gcc C语言。
冯·诺依曼体系结构
我们生活中的计算机大部分都遵守冯·诺依曼体系,如笔记本、服务器等等。
而计算机都是由一个个的硬件组件组成的:
- 输入设备:包括键盘、鼠标、扫描仪、写板等。
- 存储器:内存。
- 中央处理器(CPU):含有运算器和控制器等。
- 输出设备:显示器、打印机等。
如果再精确一点说明,则有:
- 这里的存储器确切是指内存。
- 不考虑缓存情况,这里的 CPU 只能对内存进行读写,不能访问外设(输入或输出设备)。
- 外部设备(输入或输出设备) 要输入或者输出数据,也只能写入内存或者从内存中读取。
- 可以肯定的是,所有设备都只能直接和内存打交道。
软件运行与存储分级
软件在运行之前,会先存储在磁盘或其它外部存储设备中。当需要运行软件时,会将软件的程序加载到内存当中,然后由 CPU 获取来执行程序,处理程序逻辑,最后由显示器等输出设备显示结果。
考虑到 CPU 的处理速度非常快,这个体系之下还会细分很多的存储设备,目的就是为了尽可能不拖慢 CPU 的速度。
数据流动的理论过程
当我们使用这个体系结构的计算机进行信息交流,就一定会通过 输入设备 -> 载入内存 + CPU运算 -> 输出设备 这个步骤:
操作系统
操作系统(Operator System) 概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。
更广泛上的操作系统包括:
- 内核(包括:进程管理、内存管理、文件管理、驱动管理等)
- 其他程序(例如函数库,shell程序 等等)
操作系统的功能与作用
在整个计算机软硬件架构中,操作系统的功能定位是一款搞 " 管理 " 的软件,它的作用是让应用程序正常执行,具体为:
- 对下,与硬件交互和管理所有的软硬件资源。
- 对上,为用户程序(应用程序)提供一个良好的执行环境。
在上图中可以看到:
-
软硬件体系结构为层状结构,各层也设计成 高内聚低耦合,方便各个部分自己更新迭代。
-
访问操作系统就必须使用系统提供的系统调用接口。
-
若用户程序访问硬件,则一定会贯穿整个软硬件体系结构!
那操作系统如何 " 管理 " 呢?
-
先描述被管理对象:使用 struct 结构体(使用的是 C语言)构建被管理对象的数据。
-
再组织被管理对象:使用高效的数据结构组织被管理对象。
总结起来就是一句话:先描述,再组织。
系统调用和库函数概念
在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以有的开发者对部分系统调用进行适度封装,形成库。有了库,就利于更上层用户或者开发者进行二次开发。
进程概念
从课本概念出发:程序的一个执行实例,正在执行的程序等。
从内核观点出发:担当分配系统资源(CPU时间,内存)的实体。
更具体的说进程:进程 = 内核数据结构元素 + 进程的代码和数据。
描述进程 - PCB
基本概念
-
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
-
概念上称之为 PCB(process control block),在 Linux 操作系统下的 PCB 是:
task_struct
。
task_struct 为 PCB 的一种
-
在 Linux 中描述进程的结构体叫做 task_struct 。
-
task_struct 是 Linux 内核的一种数据结构,它会被装载到 RAM(内存) 里并且包含着进程的属性信息。
task_struct
内容分类
- 标示符:描述本进程的唯一标示符,用来区别其它进程。
- 状态:任务状态,退出代码,退出信号等。
- 优先级:相对于其它进程的优先级。
- 程序计数器:程序中即将被执行的下一条指令的地址。
- 内存指针:包括程序代码和进程相关数据的指针,还有和其它进程共享的内存块的指针。
- 上下文数据:进程执行时处理器的寄存器中的数据。
- I/O 状态信息:包括显示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其它信息…
组织进程
可以在 Linux 的内核源代码里找到它,所有运行在系统里的进程都以 task_struct 链表的形式存在内核里。
查看进程
-
进程的信息可以通过 /proc 系统文件夹查看。如:要获取 PID 为 1 的进程信息,则需要查看 /proc/1 这个文件夹。
-
大多数进程信息同样可以使用
top
和ps
这些用户级工具命令来获取。
另外可以注意到 OS 会给每个登录用户分配一个 bash 进程。
通过系统调用获取进程标示符 PID
通过查看 man 手册可以知道在代码层面进程的 PID 如何获取:
我们可以用代码测试看看:
#include <stdio.h>
#include <unistd.h>int main()
{printf("当前进程的 PID 为 %d\n", getpid());printf("当前进程父进程的 PID 为 %d\n", getppid());return 0;
}
通过系统调用 fork 函数创建进程
简单使用
通过查看 man 手册可以知道在代码层面创建进程的 fork 函数信息:
我们可以测试下面的代码,父进程进入 fork 函数时,会创建子进程,最后两者一起从 fork 函数出来执行 PID 的打印:
#include <stdio.h>
#include <unistd.h>int main()
{printf("父进程 PID 为 %d\n", getpid());fork(); // 父进程进入创建子进程,fork() 调用完后两者同时出来 printf("进程 PID 为 %d\n", getpid()); // 父子都独自打印自己的 PID return 0;
}
区分父子进程操作
这也就意味着,当父进程进入 fork 函数创建子进程时,两者代码一样,执行代码命令一样,在上面的代码中 fork 函数后的执行操作就是一样的。
为了区分父子进程,如果是子进程 fork 的返回值规定为 0,如果是父进程则返回大于 0 的数,小于 0 说明创建子进程失败。
接下来测试返回值是否是这样规定的:
#include <stdio.h>
#include <unistd.h>int main()
{int ret = fork(); // ret 同时接收 fork 返回的两个值if (ret < 0) { perror("fork"); // 小于 0 表示调用失败return 0;} else if (ret == 0) // 0 为子进程{ int child = 2;while (child--){ printf("我是子进程,我的 PID 为 %d\n", getpid());printf("我的父进程 PID 为 %d\n", getppid());}; } else // 大于 0 为父进程{ sleep(3);int parent = 3;while (parent--){ printf("我是父进程,我的 PID 为 %d\n", getpid());} } return 0;
}
可以看到,通过分支语句可以让两个进程共享代码的情况下去执行不同的代码。
这意味着:
-
fork 函数有两个返回值,父进程有一个,子进程有一个。
-
父子进程代码共享,数据各自开辟(写时拷贝节省空间)。
-
fork 调用之后通常需要使用 if 分支语句进行分流。
父子进程的写时拷贝
另外我们注意到 ret 变量接收 fork 函数返回值,竟然出现不一样的 if 语句跳转。事实上,如果修改父子任何一方的数据,OS 会将被修改数据在底层拷贝一份,让目标进程修改这个拷贝,这种做法叫写时拷贝:
#include <stdio.h>
#include <unistd.h>int main()
{int a = 10; int b = 20; int ret = fork();if (ret < 0){ perror("fork");return 0;} else if (ret == 0){ b = 10; printf("我是子进程,我的 PID 为 %d\n", getpid());printf("我的 ret 变量的值为 %d\n", ret); printf("子进程的 a == %d, &a == %p\n", a, &a);printf("子进程的 b == %d, &b == %p\n", b, &b);} else{ sleep(2); // 父进程等待 2 秒,让子进程先修改。 printf("我是父进程,我的 PID 为 %d\n", getpid());printf("我的 ret 变量的值为 %d\n", ret);printf("父进程的 a == %d, &a == %p\n", a, &a);printf("父进程的 b == %d, &b == %p\n", b, &b);} return 0;
}
可以看到,两个进程的 b 地址一样,子进程修改后,父进程的 b 变量竟然没有变!
这说明进程变量的地址不是物理地址,而是虚拟的地址!虽然两者地址一样,但是底层的物理地址一定不一样。
这可以说明接收 fork 函数的 ret 变量在被修改时,两个进程的 ret 已经不一样了(写时拷贝执行),if 判断的值自然不一样。
也就是说,进程具有独立性,在大部分运行情况不受其它进程影响。
但为什么 fork 函数返回的值大于 0 为父进程,等于 0 为子进程呢?
因为大于 0 的值实际是子进程的 PID,一个父进程可以有多个子进程,其 PID 拿给父进程用于管理(上述子进程的 PID 由父进程的 ret 保管)。而子进程可以使用 getpid() 函数拿到自己的 PID,使用 getppid() 函数拿到父进程的 PID。其 ret 拿取没有必要,则规定 0 为子进程。
进程状态
在操作系统的概念上说,进程状态有:创建、就绪、运行、阻塞、挂起、结束等状态。
具体的 Linux 内核解释
运行、阻塞 和 挂起状态
在具体的操作系统也就是 Linux 中,每个 CPU 有一个调度队列 runqueue,只要在 runqueue 调度队列中的进程就算在运行中(也就是包含就绪和运行状态)。
当进程进入阻塞状态,通常是在等待某种设备或资源就绪,如:C语言的 scanf 函数会等待用户在键盘输入内容加回车后再继续向下执行,若用户不输入,则一直处于阻塞状态。
在阻塞状态时,进程会脱离调度队列被分配到其它等待队列:
如果键盘输入数据后,OS 会第一时间知道并将获取数据的进程加入调度队列,让该进程进入运行状态。
那什么是挂起状态呢?当内存空间不够时,为保证 OS 本身正常运行,OS 不得不将部分没有使用的进程的代码和数据部分,临时的放入磁盘交换分区。此时在内存中的进程只有 task_struct,代码和数据却放在了磁盘,这就叫做挂起状态。
如果轮到当前的进程运行、获取数据,OS 又会将对应进程的代码和数据归还给进程。
进程如何被转移
上述中我们注意到一个问题,当进程从 runqueue 分配到 wait queue 时,进程是如何被转移的?
事实上,Linux 进程的 PCB(tast_struct) 使用的是特殊的双链表结构 struct list_task
,这个结构只包含 next 和 prev,用于指向后一个节点(task_struct)和前一个节点。在一个 task_struct 中包含多个 struct list_task
就可以在不同队列转移。
当然,这个结构本身不能获取整个 task_struct 结构的信息,但可以使用 C语言的 offsetof 找到其相对于 task_struct 的偏移值,通过 地址移动 + 强制类型转换,即可在不同的队列中获取 task_struct 完整的结构。
这意味着进程被操作时,删除与插入到其它队列的时间复杂度都是 O(1) 级别,极大的提高了效率。
Linux 进程状态
Linux 对进程的状态具体规定了以下几种(在 Linux 内核里,进程也叫做任务 task)。以下的状态是在 Linux 内核源代码里定义的,可能不适用于其它 OS:
-
R(running)运行状态:并不意味着进程一定在运行中,只表明进程要么是在运行中要么在运行队列(调度队列)里。
-
S(sleeping)睡眠状态:意味着进程在等待事件完成(这里的睡眠有时候也叫做 可中断睡眠(interruptible sleep) )。
-
D(Disk sleep)磁盘休眠状态:有时候也叫不可中断睡眠状态(uninterruptible sleep) ,在这个状态的进程通常会等待 I/O 的结束。
-
T(stopped)停止状态:可以通过发送 SIGSTOP 信号让进程停止下来,再发送 SIGCONT 信号让进程继续运行。
-
t(tracing stop)跟踪停止状态:当进程被调试如 gdb 调试代码程序,在断点处停止时的状态。
-
X(dead)死亡状态:这个状态只是一个返回状态,不能在任务列表里看到这个状态。
-
Z(zombie)僵尸状态:为了获取进程退出信息的临时状态。
我们着重查看僵尸状态,让子进程只打印自己的 PID 就退出,而父进程则执行死循环:
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>int main()
{int ret = fork();if (ret < 0){ perror("fork");return 0;} else if (ret == 0){ printf("我是子进程,我的 PID 为: %d\n", getpid()); // 子进程执行后退出,但父进程没有回收,会一直保持僵尸状态} else { printf("我是父进程,我的 PID 为: %d\n", getpid()); // 父进程执行死循环while (true){ ; } } return 0;
}
可以看到,子进程状态处于 Z
也就是僵尸状态,而父进程使用 Ctrl + Z
快捷键正处于暂停状态,grep 命令也是一个进程,ps 命令查看正好将其打印出来,它此时处于睡眠状态,S+
的 +
号表示是前台进程,没有则表示后台进程。
另外,D 状态下的进程是无法被 OS 杀掉的,这是为保证重要的数据正常处理,不受 OS 在内存不足时的干扰。但这意味着除非它自己的任务完成,然后自己结束,想要手动结束就只能让电脑关机或断电了。
僵尸进程与其危害
上述的僵尸状态(zombie)是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵尸进程。
僵尸进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
僵尸进程危害
进程的退出状态必须被维持下去,因为它要告诉与它联系的进程(父进程)自己任务处理的情况。可父进程一直不读取,那子进程就会一直处于 Z 状态。
维护退出状态本身就是要用数据维护(此时进程的代码和数据已经没有了),也属于进程基本信息,所以保存在 task_struct 中。换句话说,Z 状态一直不退出,task_struct 就一直要维护,这就导致内存被浪费,也就是内存泄漏。
如果一个父进程创建很多子进程,但不读取,自己也不退出,就会造成大量的内存资源浪费。
如何让父进程读取子进程状态?使用 wait() 系统调用(有机会再剖析解释)。
孤儿进程
父进程如果提前退出,而子进程后退出,进入 Z 之后,子进程就称之为 " 孤儿进程 "。
孤儿进程会被 1 号 init 或 systemd 进程领养,此时由 init / systemd 进程处理子进程,防止内存泄漏:
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>int main()
{int ret = fork();if (ret < 0){ perror("fork");return 0;} else if (ret == 0){ printf("我是子进程,我的 PID 为: %d\n", getpid()); // 子进程死循环 while (true){ ; } } else { printf("我是父进程,我的 PID 为: %d\n", getpid()); // 父进程打印退出} return 0;
}
可以看到子进程被 1 号进程 " 领养 ",子进程退出变为僵尸状态,而 1 号进程会定期调用 wait() 回收所有孤儿进程的状态信息,确保其不会长期滞留为僵尸进程。
当然,这种方法只是兜底策略,如果在父进程长期运行的环境下(如服务器),不注意回收子进程的内存泄漏风险依然存在。