【Linux】进程

一、冯·诺依曼体系结构

进程是由操作系统分配和调度的,而在谈操作系统之前,我们需要了解计算机是如何组成的

现代计算机发展所遵循的基本结构形式是冯·诺依曼体系结构,采用二进制逻辑,由五部分(运算器、控制器、存储器、输入设备、输出设备)组成

其中,输入设备是向计算机输入数据和信息的设备,例如鼠标、键盘、摄像头、磁盘等

输出设备是计算机硬件系统的终端设备,用于接收计算机的输出内容,例如显示器、网卡、音响等

有的设备也可以既是输入设备又是输出设备,例如磁盘、网卡

运算器和控制器集成在CPU中,一个用于对数据进行算术运算或逻辑运算,一个用于对计算流程进行一定的控制

存储器指的是计算机中的内存,其在冯·诺依曼体系结构中处于核心地位,是外存与CPU进行沟通的桥梁,提供了硬件级别的缓存空间

这五大部分都是独立的个体,各个单元之间需要用系统总线和IO总线连接起来才能共同工作

为什么内存如此之重要?首先我们需要理解数据是如何在硬件之间流动的

数据流分为两种:输入流和输出流

输入流指数据从外存中输入到系统中进行计算,输出流则是数据从系统中输出到外部

而数据流的效率是遵循木桶效应的,我们通过下面的金字塔图可以了解到不同存储设备的区别,从快到慢分别是寄存器、三级缓存、内存、固态硬盘和机械硬盘

如果我们让数据流直接在外存与CPU之间流动,因为外存的速度很慢,所以即使CPU的速度再快,整体的速度也会受限于外存。

那为什么我们不用更快的设备替代外存呢?我们当然可以直接用一大堆寄存器来作为计算机的存储设备,但是这样子成本太高,所以我们需要寻找一个折中的方法

因此我们在外存和CPU之间增加了内存,它的价格相比寄存器便宜许多,速度相比硬盘又高了许多,能够提高数据流的效率。


二、操作系统

2.1 概念

操作系统(Operating System)是计算机内置的一款进行软硬件资源管理的软件,通过进行进程管理、内存管理、文件管理、驱动管理等来为用户/应用程序提供一个良好的执行环境

2.2 结构

操作系统的组织结构大体分为三个部分:用户部分、系统软件部分和硬件部分。操作系统就处于系统软件部分的中间层,它对上为用户提供系统操作接口(system call),对下通过管理驱动程序来调度底层的硬件。

我们是不能直接对操作系统进行操作的,只能使用操作系统提供的系统调用接口,这也是操作系统的自我保护机制。就像银行只会为来取钱的客户开一个窗口,而不会直接把金库打开让客户取钱一样。

操作系统对外表现为一个整体,只会暴露出部分接口供开发者使用,而开发者们可以对这些系统调用接口进行封装,就成为了编程语言中的各种函数。例如printf这种库函数就是对系统调用接口进行封装而来的。

对内,操作系统需要对底层硬件进行统筹管理,具体是如何做到的呢?

我们可以把操作系统看成校长,底层硬件看成学生,校长不需要直接与学生进行接触,只需要得到所有学生的一切基本信息,通过管理学生的信息就可以做出决策,然后让辅导员(驱动程序)执行决策即可。

如何管理学生的信息呢?例如我们可以用链表来维护一个学生信息管理系统,将学生的基本信息存储到节点中的结构体,通过对链表的增删查改就可以完成对学生的管理工作,这一过程可以简化为:描述学生的基本信息并进行组织

所以管理的本质,实际上就是先描述,再组织的过程。操作系统可以通过驱动程序拿到底层硬件的各项数据,并将这些数据用一个结构体描述出来,然后对各个硬件的数据进行组织。

而在组织的过程中,我们需要将描述出的信息结构体用各种数据结构来组织,所以操作系统中注定存在大量的数据结构

通过上面的学习我们可以得出,操作系统在管理进程的时候,也是遵循先描述再组织的!


三、进程

3.1 概念

进程是程序的实体。程序要运行,首先得加载到内存中,而正在运行的程序就叫做一个进程

当我们在Windows下打开任务管理器时,其中显示的就是一行行的进程

可以看到,操作系统中会同时运行很多个进程 。

前面说过,操作系统在进行管理进程的时候也是先描述再组织的,具体是如何实现的呢?

3.2 进程管理

我们在生活当中是如何认识一个事物或对象的?实际上都是通过该事物的属性认识的。当属性够多,我们就可以把这堆属性的集合当作目标对象。

所以当我们在创建进程并管理进程时,首先需要掌握进程的所有属性,因此出现了描述进程的结构体对象——PCB(Process Control Block),它就是进程属性的集合。有了PCB,我们就成功的描述了进程,接下来就是对进程的组织了。

在Linux下的PCB叫做task_struct,它的内部包含了进程的所有信息,包括进程的编号、状态、优先级、程序计数器、上下文数据等等

所以一个完整的进程需要包含描述自身属性的内核PCB结构体+自己的代码和数据,我们可以通过双链表把所有进程的PCB全部链接起来,然后就可以将对进程的管理转化为对这个链表进行增删查改了。

3.3 查看进程

要在Linux下查看正在运行的进程信息,我们可以用top或者ps命令

其中,PID是自己的进程标识符,每个进程都有一个唯一的标识符用于区分。

PR是优先级,NI是nice值,S是状态,这些我们在后面都会提到。

我们可以试着写一个无限循环的程序,然后对其进行观察

可以看到,这个进程就是我们当前正在运行的程序,它的PID是23094,PPID是父进程的标识符

我们知道Linux下一切皆文件,进程的信息都存放在名为/proc的系统文件夹下、以自己PID为名的文件夹中,我们可以用ls命令查看其内容

3.4 获取进程标识符

接下来我们将会认识到两个系统调用接口getpid()和getppid(),它们的功能分别是获取自身的进程标识符和父进程的标识符

这两个接口在使用时需要带上头文件<unistd.h>和<sys/types.h>

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
 
int main()
{
    while(1)
    {
        printf("进程运行中\n");
        printf("pid: %d\n", getpid());
        printf("ppid: %d\n", getppid());
        sleep(1);                                                         
    }
    return 0;
}

运行该程序,并用ps命令观察,结果展现如下:

可以看到PID和PPID是一致的

3.5 fork方法创建子进程

现在我们已经对进程有了基本的了解,包括什么是进程、进程是如何被管理的、进程包含哪些信息、如何获取进程的标识符等,接下来我们学习如何创建子进程

Linux系统中有一个系统调用接口叫做fork,我们可以在man手册中来初步了解它

从中我们可以得到以下信息:

  • fork是一个用于创建子进程的接口,包含在头文件<unistd.h>中
  • fork会返回一个类型为pid_t的值,我们猜测它会返回一个PID
  • 创建出的子进程不会继承父进程的一些属性,是独立于父进程的

实际上,在创建出子进程后,fork函数会返回两次在子进程中fork的返回值为0,在父进程中fork的返回值为子进程的PID。如果fork创建进程失败,则会返回-1

如何验证?我们可以写一段代码看看:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    printf("程序开始运行, pid: %d, ppid: %d\n", getpid(), getppid());
    pid_t id = fork();
    if(id == 0)
    {
        //子进程
        while(1)
       {
            printf("我是子进程, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    else if(id > 0)
    {
        //父进程
        while(1)
        {
            printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }                                                                                                  
    return 0;
}

运行程序,现象如下:

可以看到此时有两个名为test的进程,通过对比PID和PPID可以看到子进程已经成功创建并开始运行了

现在我们验证了,fork函数的确会返回两次,所以才能够分别进入两个分支并同时进行无限循环

而且,子进程可以成功进入循环,说明它是与父进程共享代码和数据的!一般而言子进程创建出来后就会与父进程共享后面的代码和数据,包括return语句,所以会返回两次。返回不同的值,也是为了区分父子进程,让不同的执行流来执行不同的代码块。

但是进程是有独立性的。子进程和父进程共享代码可以理解,因为程序运行中代码是不能被修改的,但是数据是可以被修改的,如果父子进程共享数据的话,那么一个进程修改数据之后不会影响到另一个进程吗?

这里牵扯到另一个概念,即子进程的写时拷贝。父子进程最初共享同一份数据,如果此时子进程要修改父进程的数据,那么就会发生写时拷贝,即给子进程分配一块新的空间并拷贝原数据,然后进行修改。相比一开始就把全部数据拷贝一份,这种方法能够很好的减少空间浪费。

所以这也解释了为什么变量id能够同时有两个不同的值。

但是如果你有兴趣的话可以在父子进程中分别打印一下变量id的地址,会发现两个进程中id的地址是相同的,但是竟然有不同的值。这里牵扯到进程地址空间的相关知识,暂时不在本文中细讲。

总结:在fork函数中创建子进程时先创建子进程的PCB,再填充PCB对应的内容,然后让父子进程共享同一份代码和数据,最后对父子进程返回不同的值。子进程在修改数据时会发生写时拷贝。


四、进程状态

进程状态可以反应进程在执行过程中的变化,在操作系统中进程状态可以分为五个基本状态,即新建状态、运行状态、就绪状态、阻塞状态、终止状态

其中最主要的是运行态、就绪态和阻塞态。当一个进程在CPU中被执行时处于运行态,但是操作系统中有很多个进程需要运行,不能让一个进程占用CPU过长时间,于是需要按照时间片来排队执行。当一个进程的运行时间片到了,就需要退出运行态,重新等待CPU的调度,变成就绪态。如果一个进程在执行期间需要等待某个事件,例如进程要访问外设,但是外设没有输入时,进程就要进入外设的等待队列,此时就会进入阻塞态,等待事件的完成。当外设输入后,进程重新回到CPU的运行队列。

实际上还有挂起状态,当操作系统内部的内存资源严重不足时,操作系统就会将一些进程的代码和数据换出到磁盘中,此时进程就处于挂起状态。当需要运行时再将代码和数据从磁盘换入到内存中

4.1 Linux中的进程状态

操作系统大致遵循以上的模型,不过Linux中的进程状态与上面说的有微小的差别,我们可以看看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 */
};

可以看到在Linux中,运行状态细分为了七种:

  • R(running):运行状态,进程在被CPU调度或处于运行队列中
  • S(sleeping):睡眠状态,进程在等待时间完成,也叫做可中断睡眠状态
  • D(disk sleep):磁盘休眠状态,也叫不可中断睡眠状态,该状态下的进程会等待IO的结束
  • T(stopped):停止状态,可以通过发送信号让进程停止
  • t(tracing stop):追踪状态,程序在调试时遇到断点停下的状态
  • X(dead):死亡状态,进程死亡后被回收的状态
  • Z(zombie):僵尸状态,进程死亡后未被回收时保持的状态

接下来我们一个个观察这些状态

(1)R状态

我们运行一个无限循环的程序,循环内部不作任何语句,就可以观察到该状态了

#include <stdio.h>  
#include <unistd.h>  
#include <sys/types.h>  
  
int main()  
{
    while(1)
    {
        ;                                                                                                  
    }
    return 0;
}

运行程序,结果如下:

可以看到此时进程的状态就是R+,这个+代表进程此时在前台运行,如果我们想让进程在后台运行的话,只需要在运行程序时在命令的后面加上 & 即可,此时就可以在运行程序的同时输入其他命令了

此时进程就正在后台运行中,如果我们要杀死该进程,只需要输入kill -9 +进程PID即可

有些人可能会好奇下面的grep --color=auto test这个进程是什么,实际上这个就是我们输入的命令,因为Linux中的命令也是程序,在输入命令后bash就会创建一个命令子进程

(2)S状态

要观察S状态,我们可以在程序中写一个scanf函数,但是运行程序后不进行输入。

可以看到此时进程状态就是S+,进程正在等待外设的输入。

(3)D状态

在操作系统的内存处于危急存亡的时刻,将进程挂起也不管用了,这时候操作系统就会开始杀进程

但是假如一个进程正在向磁盘中写入一些十分重要的数据,在等待磁盘写入完毕的期间会处于阻塞态,如果此时被操作系统杀掉了,就会导致数据丢失进而产生严重的后果。因此针对这种情况设计了深度睡眠状态即D状态,位于该状态的进程无法被操作系统杀掉

实际当中我们很难观察到D状态的出现,所以这里不作演示

(4)T状态

前面提到我们可以通过发送信号让程序终止,具体信号可以用kill -l查看

可以看到18号信号SIGCONT是继续进程,19号信号SIGSTOP是暂停进程。

获取到进程的PID后,我们就可以输入kill -19 +进程PID来暂停一个进程了,例如:

可以看到,此时进程处于暂停状态,输入kill -18 +进程PID即可恢复运行,但是会变为在后台运行,所以我们需要用kill -9来杀死该进程

(5)t状态

我们写一个程序并用gdb调试一下,就可以观察到t状态了

可以看到,我们在程序中打了一个断点并开始运行程序,当运行到断点处时程序暂停,此时观察进程状态就会发现该进程正处于t状态

(6)X状态

进程死亡后的状态,这个状态持续的事件十分短暂,我们几乎无法观察到

(7)Z状态

实际上,进程死亡后并不是立即就变为X状态了,而是需要先经过Z状态即僵尸状态

就像一个人突然在大街上挂了,并不是马上就埋了,而是先要让110、120和法医等进行现场的维护和死亡原因的判断等工作,家属料理后事,然后才入土。

进程也是这个理,当一个进程死亡后,需要维护自己的相关资源等待父进程回收并提交进程的死亡信息。如果父进程迟迟不读取子进程的状态,子进程就会一直保持Z状态不能被释放。

我们可以通过运行一段代码来观察Z状态:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        int cnt = 5;
        while(cnt)
        {
            printf("子进程运行中...\n");
            sleep(1);
            cnt--;
        }
        printf("子进程结束!\n");
    }
    else if(id > 0)
    {
        while(1)
        {
            printf("父进程运行中...\n");
            sleep(1);
        }                                                                        
    }  
    return 0;
}      

这段代码中,子进程循环5次后就会结束,而父进程一直保持运行,所以在预想中子进程结束后会一直保持Z状态。我们运行代码,结果如下:

可以看到结果符合我们的预期,子进程结束后父进程还在运行,所以子进程一直保持Z状态

因为进程的维护需要占用内存资源,如果存在过多的僵尸进程而迟迟不回收,就会造成内存泄漏

4.2 孤儿进程

前面提到,子进程先结束而父进程迟迟不结束,子进程会保持僵尸状态

如果父进程提前结束,而子进程还在运行,那么子进程就变成了孤儿进程。当子进程结束之后该由谁来回收呢?

我们可以将上面的代码修改一下,让父进程先结束而子进程一直运行,然后进行观察:

可以看到,父进程退出后子进程的PPID变为了1,即我们的操作系统。

所以当父进程比子进程先结束时,子进程就会被操作系统领养,最后由操作系统负责回收


五、进程优先级

在学习进程优先级之前,我们需要了解几个概念:

  • 并行:多个进程在多个CPU下分别同时运行
  • 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间内让多个进程都得以推进

很明显,我们大部分人的计算机中都是只有一个CPU的,进程在我们的计算机内采用并发运行

既然CPU只有一个,而进程有很多个,那么进程之间必然存在竞争关系,如果一个进程长时间得不到CPU资源,该进程的代码就无法得到推进,造成进程饥饿。

CPU需要保证进程之间良性竞争,所以就需要确定进程的优先级

输入ps -l命令,输出的内容如下:

其中的PRI就是进程优先级,代表程序被CPU执行的先后顺序,PRI值越小则进程优先级越高,默认进程的PRI值为80

NI是nice值,用于调整进程的优先级,进程的实际PRI值等于默认PRI值+nice值

我们可以通过调整nice值来调整进程的优先级,其取值范围为-20~19,不能超过这个范围。所以进程优先级的范围就是60~99

但是CPU具体是如何针对优先级来调度进程的呢?

在CPU的运行队列中可以维护一个task_struct*指针数组(活跃队列),其中前100个下标属于操作系统,100-139的下标代表40个优先级

每个task_struct*指针都指向一个当前优先级的进程队列,所有的R状态进程就根据优先级的不同打散到代表不同优先级的运行队列中,所以CPU按照下标的顺序就可以按优先级顺序来运行进程,类似于哈希桶的结构。

判断活跃队列是否运行完毕,如果每次都遍历一遍的话效率就有点低了。这里我们使用位图(即图中的bitmap)来进行判断,5个int类型元素总共160个bit位,每个bit位映射1个优先级,通过某一bit位的值就可以判断该优先级的队列是否为空了。这就是Linux的O(1)调度器的核心算法

但是当进程的时间片到了后需要重新排队,并且同时也可能会有别的进程入队,如果活跃队列既要进行进程的调度,又要维护后加入的进程,就会浪费CPU资源。这时CPU会把这些进程插入到另一个task_struct*指针数组(过期队列)中,当活跃队列为空时,只需要交换一下指向两个队列的指针,就可以继续运行新一批的进程了。

完.

  • 53
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 27
    评论
评论 27
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值