进程是一个运行的程序,是所有计算机的基础。这个过程与计算机代码不一样,尽管它们非常相似。程序通常被认为是 “被动的” 实体,而进程则是 “主动的” 实体。硬件状态、RAM、CPU和其它属性都是进程持有的属性。下面我们就来了解更多关于进程的知识。
文章题目:
- 什么是进程?
- 进程控制块 - PCB(Process Control Block)
- 如何查看进程
- 进程相关概念
- 获取进程标示符
- 创建进程 - fork
- OS进程状态
- 僵尸进程 - zombie
- 孤儿进程 - orphan
- 进程优先级
- 查看系统进程
- PRI and NI
- 更改进程优先级
什么是进程?
进程本质上就是运行的软件、任何进程的执行都必须按照特定的顺序进行。进程是指帮助表示必须在任何系统中实现的基本工作单元的实体。🎯
课内概念:程序的一个执行实例,正在执行的程序等。
内核观点:担当分配系统资源(CPU时间,内存)的实体。
进程的组成:
🎯在电脑开机开始运行时,就已经有很多进程就绪,当我们打开网页进行浏览或者打开QQ,这些都是进程。在系统中有许多的进程,而在我们运行程序时,都需要将其加载到内存中,面对着众多的进程,操作系统应该如何来进行进程的管理呢?因此操作系统为了更好的管理进程,将每一个进程描述起来,这就是描述进程 - PCB。
进程控制块 - PCB(Process Control Block)
🧩每个进程都有一个进程控制块,进程信息就被放在进程控制块的数据结构中,这是一个由操作系统管理的数据结构。
🧩课文中将其称为 PCB(Process Control Block),而在 Linux 操作系统下的 PCB 是:task_struct。
🧩task_struct 是 Linux 内核的一种数据结构,它会被装载到 RAM(内存) 里且包含着进程的信息。
一个进程的进程控制块(PCB)是这样的:
每一个进程都有自己对应的 PCB 来进行标识,也被称为进程的上下文。
使用命令 ps aux 可查看当前计算机中存在的进程:
操作系统对这些进程进行管理时:先描述,再组织。操作系统不是直接对进程进行管理的,而是当一个进程启动时,操作系统先将其进行描述,然后将其描述的进程信息放入进程控制块的数据结构中管理起来,实际上,操作系统对进程的管理是对进程描述信息的管理(即对 PCB 进行管理)。
操作系统将计算机中运行的每一个进程都使用 PCB 进行了描述,然后将这些 PCB 增加到双链表中进行管理起来:
这样以来,操作系统需要对每一个进程进行相关操作时,只需要在链表中找到其对应的 PCB,就可找到进程对其进程操作了。PCB 和 数据结构 对进程的描述和组织使操作系统更便于管理进程,使得操作系统对进程的控制科学、有效。
关于 PCB:
- 每个进程的 PCB 都存储在主存储器中。
- 每个进程只有一个与之相关的 PCB。
- 所有进程的 PCB 都列在一个链表中。
task_struct 内容分类:
- 标示符:描述进程的唯一标示符,用来区别其它进程。
- 状态:任务状态、退出代码、退出信号等。
- 优先级:相对于其它进程的优先级。
- 程序计数器:程序中即将被执行的下一条指令的地址。
- 内存指针:包括程序代码和进程相关数据的指针,还有和其它进行共享的内存块的指针。
- 上下文数据:进程执行时处理器的寄存器中的数据。
- I/O状态信息:包括显示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- other。
如何查看进程
通过 /proc 查看系统文件中的进程信息。首先进入根目录,然后进入名为 proc 的目录,里面包含了许多进程信息,例如下面我们进入1号进程查看其信息。
使用 ps ajx
可查看当前所有的进程,配合 grep 命令使用我们可以查看到我们所需要进程信息
进程相关概念
竞争性:系统进程数目众多,而 CPU 资源是有限的,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
独立性:多个进程运行,需要独享各种资源,多进程运行期间互不干扰。
并行:多个进程在多个 CPU 下分别,同时进行运行,这称之为并行。
并发:多个进程在一个 CPU 下采用进程切换的方式,在一段时间内,让多个进程得以推进,称之为并发。
获取进程标示符
进程id(PID) - getpid()
父进程id(PPID) - getppid()
下面通过一个简单的程序来查看以下进程的 PID 和 PPID。当程序重新运行时,进程的 PID 会变化,但是 PPID 是一样的。通过具体查看,发现 PPID 所对应的进程是 bash。在操作系统执行指令时,为了防止因为程序的崩溃而影响到系统,因此,shell 并不会亲自执行指令,而是派生出子进程去执行指令。所以每个进程的 PPID 都是一样的。
创建进程 - fork
fork 系统调用用于创建一个新的进程,称为子进程,它与进行 fork() 调用的进程并发运行。在创建了新的进程之后,两个进程都将执行 fork() 系统调用之后的下一条指令,子进程使用和父进程相同的程序计数器、相同的 CPU 寄存器,相同的打开文件。下面就来简单的认识一个 fork() 系统调用。
fork() 不接受参数,返回一个整数值,下面为 fork() 返回的不同值:
负数:子进程创建失败
0:返回到新创建的子进程
正数:返回给父进程或者调用它的地方,取值为新创建的子进程的进程 ID
如下所示,使用 fork() 创建子进程:
从运行结果可以看出打印函数执行了两次,且不是同一个进程。而其中一个进程的 PPID 是另一个进程的 PID ,由此可以说明其中一个进程是当前进程(父进程)的子进程。
当前程序的代码和数据是属于父进程的,那 fork 创建出的子进程的数据和代码在哪呢?
上图所示,fork() 调用之后的代码运行了两次,由此可以看出,fork 完成之后,父子进程共享 fork 之后的代码,而父子进程的数据各自开辟空间,独有一份(采用写时拷贝)。
fork 之后用 if 进行分流
当一个进程创建一个新进程时,有两种可能的执行出口:
- 父进程继续与子进程并发执行。
- 父进程一直等待,知道部分或全部子进程终止。
如下演示 fork 之后使用 if 进行分流:
在上面的代码中,使用 fork() 创建了一个子进程。fork() 在子进程中返回0,在父进程中返回正整数,而父进程中的正整数正是子进程的 PID 。这里有两个输出,因为父进程和子进程是并发运行的。而先执行父进程还是子进程是由操作系统决定的。父进程和子进程执行同一个程序,并不意味着它们完全相同。操作系统为这两个进程分配不同的数据和状态,这些进程的控制流可能不同。
总结:
- fork 系统调用创建一个新的进程,称为 ”子进程“,该进程与父进程并发运行。
- 父进程和子进程是通过 fork() 调用的返回值来区分开的。
- 在语法层面上,fork() 不接受任何的参数,其返回值为 pid_t。
- 子进程与父进程使用相同的 CPU 寄存器、相同的打开文件和相同的程序计数器。
- 实际上,我们可以用 fork() 系统调用来创建一些唯一的代码,因为每个子进程都会给出唯一的值。
OS进程状态
正在执行的程序称为进程。在进程执行期间,进程在整个生命周期中会经历不同的状态,这些状态称为进程状态。所有与进程相关的信息都会存储在进程控制块(PCB)中,进程状态也如此。
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 * 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 */
};
上述代码描述了进程的许多状态。在不同的操作系统中,进程可能存在不同的状态。进程的状态存储在进程控制块(PCB)中。下面我们来详细了解一个每个进程状态。
运行状态 - R(running)
只要从就绪队列中将 CPU 分配给进程,进程状态就会变成运行态。运行状态并不意味着进程一定在运行,它表明进程要么在运行中,要么在运行队列中。
如下所示的程序处于运行状态中:
睡眠状态 - S(sleeping)
意味着该进程正在等待某件事情完成,这里的睡眠状态有时候也可以叫做可中断睡眠(interruptible sleep)。处于此状态的进程随时可以被唤醒和杀掉。
为什么程序一直在执行,而进程状态确为睡眠状态呢?🎯
在我们使用指令 ps ajx
查看进程状态时,我们查看到的是指令执行时进程所处的状态,而不是随着进程的变化而进行动态变化的状态。CPU 的速度远远大于其它外设的速度,因此 CPU 处理很快,大多数的时间都是在等待其它硬件调配资源,执行程序的时间很短,因此查看到的进程处于 S+ 状态。
处于浅度睡眠的进程,我们可以对其唤醒或者杀掉,例如使用 kill -9
命令将进程杀掉:
磁盘休眠状态 - D(Disk sleep)
一个进程处于深度睡眠状态,则该进程不会被杀掉。磁盘休眠状态有时候叫做不可中断睡眠状态(uninterruptible sleep),这个状态的进程通常会等待 IO 结束。进程为什么会被置为 uninterruptible sleep 状态呢?当某一进程需要对磁盘进行写入,在对磁盘写入数据期间,该进程就处于深度睡眠状态,不会被操作系统杀掉,该进程需要等待磁盘给出是否写的数据成功的信号。当得到相应的条件时才能响应信号。
停止状态 - T(stopped)
可以通过发送 SIGSTOP 信号开停止进程,这个被暂停的进程可以通过发送 SIGCONT 信号来让进程继续运行。
如下,向进程发送 SIGSTOP 命令,使进程进入暂停状态,发送 SIGCONT 信号进入运行状态:
在上述程序中,进程开始运行时的状态为 R+ ,而暂停之后继续再次运行时进程状态变为 R。进程状态后面的 + 号表示该进程是一个前台进程,没有加号则表明该进程是一个后台进程。前台进程可以通过 Ctrl + c 或者 kill -9 进行终止。没有 + 号表示该进程是后台进程,只能用 kill -9 指令将其杀死。
死亡状态 - X(dead)
死亡状态只是一个返回状态,不会在任务列表看到这个状态。
僵尸进程 - zombie
僵尸进程也被称为 “死亡进程”。理想情况下,当一个进程执行完成时,他在进程表中的条目应该被删除,但是在僵尸进程情况下,这并不会发生。
🎲僵尸状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用 wait() 系统调用)没有读取到子进程退出的返回信息时就会产生僵尸进程。
🎲僵尸进程会以终止状态保持在进程列表中,并且会一直等待父进程读取退出状态信号。
🎲子进程退出,父进程还在运行,父进程没有读取子进程状态,则子进程进入 Z 状态。
如下我们来演示一下僵尸进程,fork() 创建子进程并在其中打印3次信息后退出,父进程一直循环执行代码不退出。子进程退出之后,父进程依旧运行,因此子进程等待父进程读取自己的退出信息,那么等待时就进入了僵尸状态:
#include <stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int count=3;
while(count)
{
printf("I am a child process! PID : %d,PPID : %d,count = %d\n",getpid(),getppid(),count--);
sleep(1);
}
printf("child process quit!\n");
exit(1);
}
else if(id>0)
{
while(1)
{
printf("I am a parent process! PID : %d,PPID : %d\n",getpid(),getppid());
sleep(1);
}
}
else
{
perror("fork false");
return 1;
}
return 0;
}
程序运行后,我们使用监控脚本 while :; do ps axj | head -1 && ps axj | grep process | grep -v grep | grep -v Ssl;echo "---------------------------------";sleep 1;done
来实时查看进程的状态。如下所示,当子进程退出以后,父进程没有退出,子进程就会进入僵尸状态。
僵尸进程的危害:
- 操作系统有一个有限大小的进程列表,大量的僵死进程将产生一个完整的进程列表,进程列表慢了意味着操作系统无法在需要时创建新进程。
- 操作系统中的僵尸进程没有用,因为进程已经死亡,但它的条目占用了内存中的空间。
- 操作系统中的 PID 是有限的,当所有的 PID 都被僵尸进程耗尽后,就无法创建新的进程了。
- 一个父进程创建了许多子进程却不回收,就会造成资源的浪费。
僵尸进程清理:
- 僵尸进程不会被操作系统强制结束。长时间运行的僵尸进程是错误或者资源泄漏的结果。它们在进程表中占用大量的空间,因此,需要避免过多的僵尸进程聚集。
- 进程变成僵尸进程,它将失去所有内存页、打开的文件句柄、信号量锁等。当进程终止时,操作系统会释放几乎所有系统资源。
- 可以通过在子进程结束后立即调用等待来防止这种情况,尽快从进程表中清理它。(后续说)
孤儿进程 - orphan
什么是孤儿进程?所谓的孤儿,我们可以用现实生活中的例子来理解。在现实中,孤儿指的是父母的孩子。类似的,在 os 中,正在执行但父进程已经终止的进程称为孤儿进程。
孤儿进程将会获得一个新的父进程。当内核检测到一个进程成为孤儿进程时,会为孤儿进程提供一个新的父进程。在大多数情况下,新的父进程的 PID 是为 1 的 INIT 进程。新的父进程等待孤儿进程完成之后,会要求内核清理孤儿进程的 PCB。这样孤儿进程就被回收掉了。🎯
以下代码模拟孤儿进程,fork 之后,父进程循环打印3次后退出,子进程一直执行,当父进程退出之后,子进程就变成孤儿进程了。将被 PID=1 的进程领养。
#include <stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
while(1)
{
printf("I am a child process! PID : %d,PPID : %d\n",getpid(),getppid());
sleep(1);
}
}
else if(id>0)
{
int count=3;
while(count)
{
printf("I am a parent process! PID : %d,PPID : %d,count = %d\n",getpid(),getppid(),count--);
sleep(1);
}
printf("parent process quit!\n");
exit(0);
}
else
{
perror("fork false");
return 1;
}
return 0;
}
运行结果如下:当父进程退出之后,子进程的 PPID 变为 1.
❓太多的孤儿进程对系统有什么危害?
- 操作系统中的孤儿进程在系统中占用资源。
- 大量的孤儿进程会使 INIT 进程过载并挂起系统。
进程优先级
在优先调度队列中,每个进程都有一个优先级编号。在 Linux 等系统中,优先级编号的数字越低,则优先级越高。在可用进程中具有较高优先级的进程被给到 CPU。目前存在两种优先级调度算法:抢占式优先级调度、非抢占式优先级调度。
✔️CPU资源分配的先后顺序,就是指进程的优先级(priority)。
✔️优先级高的进程有优先执行的权力。配置进程优先级对多任务环境的 Linux 很有用,可以改善系系统性能。
查看系统进程
在 Linux 或者 Unix 系统中,用 ps -l
命令会输出以下内容:
以上列出几个重要信息:
- UID :代表执行者的身份
- PID :代表这个进程的编号
- PPID :标记了该进程的父进程,即由那个进程发展衍生而来
- PRI :代表这个进程可被执行的优先级,其值越小优先级越高
- NI :该进程的 nice 值
PRI and NI
- PRI 就是进程的优先级,即被 CPU 执行的先后顺序,PRI 值越小,则进程的优先级越高。
- NI 就是我们所说的 nice 值,其表示进程可被执行的优先级的修正数值。
- PRI 值越小越快被执行,加入 nice 值之后,将会使 PRI 变为:PRI(new)= PRI(old)+ nice。
- 当 nice 值为负值时,那么该程序会将优先级值变小,及其优先级会变高,其越快被执行。
- 因此,调整进程的优先级,在 Linux 下,是调整进程的 nice 值。
- nce 值得取值范围是 -20 ~ 19,一个 40 个级别。
更改进程优先级
用 top 命令更改已存在进程的 nice 值:
- 输入
top
命令 - 进入 top 后按 “r” -> 输入进程PID -> 输入 nice 值
如下所示,调整一个进程的优先级:
在 Linux 操作系统中,初始进程的默认 PRI 为80,NI 默认为0。