【Linux&操作系统】3. 进程

77 阅读43分钟

3 进程

3.1 进程的概念

  • 我们知道,一个程序被打开,就意味着本质上它被拷贝进了内存中

  • 但我们知道,一台计算机中被执行的可执行程序可以有非常多,就势必需要对这些程序进行管理

  • 一旦牵扯到管理,就意味着它仍然可以套用进"先描述,再组织"的管理模型中

  • 比方说一个程序在内存中,需要有自己的地址,包括起始地址和结束地址,以及优先级,id,对应磁盘位置,可执行程序目录等等,这一点我们可以打开windows的任务管理器,在任务管理器中可以找到很多已经在运行的程序的属性

  • 于是,我们可以效仿管理底层硬件管理的模式,也搞一个类用来存放这些属性,然后搞一个容器方便管理这些程序

  • 问题是,这些类存放在哪里???

  • 事实是,操作系统也是软件,也需要被加载进内存中,我们每次开机等待的时间里,就有一部分是拷贝硬盘资源到内存中

  • 然后为内存开一块专属于操作系统的空间,这些类和容器就存放在专属于操作系统的空间里

  • 所以,本质上,我们想执行一个可执行程序中的代码,就需要先找到这个类,然后通过这个类中程序在内存中的位置,找到内存中的程序,再执行对应部分的代码

  • 我们称这个类为PCB(Process Control Block),即"进程管理块"

  • 我们称 "PCB + 加载进内存的对应代码和数据"称为进程

  • 同时,传统CPU是单核CPU,意味着CPU每次只能处理一个进程的任务,所以这么多进程之间势必会有先后顺序的问题

  • 所以我们可以搞一个类似于队列的结构出来,队列里不需要存放可执行程序的代码,只需要放PCB就行,然后根据队列来调度先后顺序(实际处理不一定是按队列来进行的,而是根据优先级来进行的,核心使用的是双向带头节点链表)

image-8.png

  • 在linux中,这个PCB有了具体的名字,称为task_struct,task_struct是linux中的一种数据结构类型,用于存放进程的属性,并装载进内存中

  • 所以,实际上管理内存中的进程,本质上并不是直接对拷贝进来的程序进行管理,而是对PCB(task_struct)进行管理,换句话说,是对以PCB为Node的链表进行增删查改

3.2 tast_struct的主要内容

  1. 标识符:用于代表当前进程的唯一性的代号
  2. 状态:任务状态,退出代码,退出信号等等
  3. 优先级:字面意思
  4. 程序计数器:记录要执行的下一条指令
  5. 内存指针:指向实际代码的内存地址
  6. 上下文数据:后面会提
  7. I/O状态信息:后面会提
  8. 记账信息:后面会提
  9. 其他信息:后面会提
  • 事实上,哪怕是比较老版本的linux,task_struct也包含上百种进程的属性,这里我们只抓重点

3.3 进程实操演示

  • 使用getpid()可以查看得到当前程序的进程标识符(标识符就是pid)(注意!getpid是一个系统调用接口!!!)

  • 所以我们可以写一个简单的小程序

#include<stdio.h> // printf()
#include<unistd.h> // getpid() & sleep()
#include<sys/types.h> // getpid()

int main()
{
    while(1)
    {
        sleep(1);
        printf("当前进程的pid:%d\n", getpid());
    }

    return 0;
}
  • 运行起来后,程序会死循环运行,并且打印此程序的pid
3.3.1 查询
  • 现在我们开另一个shell

  • 使用ps可以查看当前进程,不过以我们现在的阶段,需要带三个选项,选项的问题后面还会提到的

ps ajx
  • 此时会返回一个表格,但他会打印所有进程,一般进程会非常多,所以我们需要用grep过滤
$ ps axj | grep "test"

# 返回
24255 26415 26415 24255 pts/0    26415 S+    1001   0:00 ./test
  • 我们发现没有表头,所以我们需要把表头也打印一下
  • 如果需要连续执行两条命令,可以使用&&或者;
[command1];[command2]
[command1] && [command2]
  • 这两种形式没有本质区别

  • 所以可以这么玩

$ ps axj | head -1 && ps axj | grep "test"

 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
24255 26415 26415 24255 pts/0    26415 S+    1001   0:00 ./test
26154 26529 26528 26154 pts/1    26528 S+    1001   0:00 grep --color=auto test
  • 此时就可以查询到进程了,对比两边的shell发现pid是相同的

  • 26154 26529 26528 26154 pts/1 26528 S+ 1001 0:00 grep --color=auto test是什么鬼??

  • 实际上,grep命令也是一个程序,所以他也会有进程,所以这个进程实际上是grep

  • 我们知道,ctrl + c可以强制终止一个程序,本质上就和windows的alt + f4是一个道理,都是强制终止程序的进程,但仅限于终止前台程序(前后台程序应该也会在后面的小节中提到)

  • 有没有方式像windows一样,能在某个地方,或者用某条指令,能杀死前台抑或是后台的程序呢??

  • 当然是有的

kill -9 [pidnumber]   # -9是信号编号,后面会提到,该指令用于结束pid为pidnumber的进程
  • 同时,"进程"也可以被称作"任务","task"的翻译就是"任务",所以为什么windows中管理进程的工具会被称为任务管理器

  • 当然,除了使用ps查询进程,还可以用ls查进程

  • 什么?你说用ls??

  • 事实上,进程被创建时,也会被抽象化成一个个文件,这些文件全都在/proc的目录下,事实上,这些文件全都存在内存当中而不是磁盘,因此不会占用磁盘空间,本质上他们并不是文件,只是因为方便使用,统一调用方式,而把他们全都抽象成了文件,于是有/proc是伪文件系统的定义

  • /proc目录下,大部分文件仅为可读,少数文件才为可读写

  • 当我们创建上一个进程时,系统会在/proc下新开一个名字和进程pid一样的目录,里面放的正是进程的相关属性

  • 在目录/proc/[pid]下,有两个文件需要我们关注

    1. exe记录当前进程的可执行程序在磁盘中的位置,如果当前进程还没退出就删掉磁盘中的可执行程序,那么这个exe会直接报红
    2. cwd记录了可执行程序在系统中的绝对路径,这个cwd的作用非常大,就是因为它,我们在编写程序的时候,才能够写相对路径,因为我们写的相对路径会被cwd补充成为绝对路径,当然你也可以用chdir([path])来修改cwd的值
lrwxrwxrwx 1 oldking oldking 0 Dec 10 10:20 cwd -> /home/oldking/newdir
lrwxrwxrwx 1 oldking oldking 0 Dec 10 10:20 exe -> /home/oldking/newdir/test
3.3.2 ppid与父子进程
  • 如果我们仔细检查ps命令的表头,会发现第一个参数是一个叫做ppid的东西,这个东西是不是跟pid有什么关联??
  • 答案是肯定的,我们呢也可以用ps查询这个ppid
  • PS:因为shell重新启动过了,所以pidppid肯定会有变化,会被系统重新分配,这里无伤大雅的
$ ps ajx | head -1 && ps ajx | grep "9067"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 9008  9067  9067  9008 pts/0     9067 S+    1001   0:00 ./test
 9040  9177  9176  9040 pts/1     9176 S+    1001   0:00 grep --color=auto 9067
  • 我们对testppid再查询一次试试
$ ps ajx | head -1 && ps ajx | grep "9008" 
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 9007  9008  9008  9008 pts/0     9067 Ss    1001   0:00 -bash
 9008  9067  9067  9008 pts/0     9067 S+    1001   0:00 ./test
 9040  9206  9205  9040 pts/1     9205 S+    1001   0:00 grep --color=auto 9008
  • 结果非常惊人,这个pid9008的进程竟然叫-bash,也就是命令行解释器

  • 我们知道它负责转交命令给一个叫子进程的东西,问题是我们有见到过这个-bash吗?

  • 事实上我们一打开终端,就已经见到它了,它会默认在命令行开头输出[oldking@iZwz9b2bj2gor4d8h3rlx0Z ~]$,然后等待用户输入文本

  • PS:bash前的-代表这个bash是被分配给一个远端的终端的

  • 并且,每一个用户都会被分配一个独立的bash,更严格的说,应该是每个终端都会被分配一个bash

  • 我们可以打开两个终端并连接服务器测试一下

$ ps ajx | head -1 && ps ajx | grep "bash" 
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 9007  9008  9008  9008 pts/0     9067 Ss    1001   0:00 -bash
 9039  9040  9040  9040 pts/1     9357 Ss    1001   0:00 -bash
 9040  9358  9357  9040 pts/1     9357 S+    1001   0:00 grep --color=auto bash
  • 不难发现它就有两个-bash

  • 事实上,ppid就是代表着当前进程的父进程的pid,意味着test的父进程是-bash,-bash的子进程中有test

  • 这也就解释了为什么我们说-bash会把命令转交给子进程,因为每个命令都是一个程序,所以每个命令的进程的父进程都会是-bash

  • 这也意味着父进程可以创建子进程

  • 我们能不能写一个程序手动创建子进程呢?当然可以

  • 首先我们需要再学习一个系统调用接口:fork()

  • 该接口用于创建一个子进程,同时,这个接口会返回两个返回值,你没听错,两个返回值!!我们也可以通过man查询该接口的返回值

RETURN VALUE
       On  success,  the PID of the child process is returned in the parent, and 0 is
       returned in the child.  On failure, -1 is returned in  the  parent,  no  child
       process is created, and errno is set appropriately.
  • 即,如果子进程创建成功,则在父进程中返回子进程的pid,在子进程中返回0,如果子进程创建失败,则会在父进程返回-1

  • 于是我们可以这么写代码

#include<stdio.h> //printf()
#include<unistd.h> //fork() & sleep()
#include<sys/types.h> //getpid() & getppid()
// getppid的使用方法和getpid相同,只不过前者用于查询父进程的pid罢了

int main()
{
    sleep(1);
    printf("当前进程的pid:%d\n", getpid());

    pid_t id = fork();

    if(id < 0)
    {
        perror("fork error!");
        return 1;
    }
    else if(id == 0)
    {
        //child
        while(1)
        {
            sleep(1);
            printf("子进程的pid: %d, ppid: %d \n", getpid(), getppid()); 
        }
    }
    else
    {
        //parent
        while(1)
        {
            sleep(1);
            printf("父进程的pid: %d, ppid: %d \n", getpid(), getppid()); 
        }
    }
    return 0;
}
  • 编译运行后返回
当前进程的pid:12084
父进程的pid: 12084, ppid: 9545 
子进程的pid: 12085, ppid: 12084 
...
  • 接下来我们详细讲一下基本原理(点到为止,以后会深究)

  • 一旦子进程被创建,OS就会将父进程的PCB拷贝一份给子进程,并修改少数几个属性,例如pidppid就会被修改

  • 但同时,子进程会与父进程共享一份代码和数据,并与父进程一起运行(因为CPU处理数据太快了,所以看上去是一起运行,实际上还是有先后顺序的)

  • 但因为子进程中fork()的返回值和父进程中fork()的返回值不同,所以在if else逻辑中,他们会进入不同的判断逻辑

  • 为什么fork()的返回值需要有两个,而且父子的返回值不同?

  • 因为一个父进程不仅仅可以创建一个子进程,而是可以创建多个子进程,所以父进程需要获得子进程的pid,方便区分和管理不同的子进程

  • 为什么函数fork()可以返回两次?

  • 我们知道,如果一个函数已经开始返回值了,就意味着函数的本职工作已经做完了,接下来需要开始返回结果了,这也就意味着,fork()在返回值返回之前,函数的本职工作就已经做完了,也就代表在返回值返回之前,子进程就已经被创建完毕了,也就意味着这里子进程和父进程已经可以执行不同的代码逻辑了,所以返回值就可以有两个且返回两次

  • 既然子进程和父进程共享一份代码和数据,如果子进程修改数据,会影响到父进程吗?

  • 我们知道,进程和进程之间是相互独立的,具有独立性,例如我如果杀死了vscode的进程,也不会影响edge的进程,也就意味着我如果修改了子进程的数据,父进程也不会改变

  • 但问题是父进程和子进程共享同一份代码和数据,如何做到互不影响呢?

  • 如果子进程需要修改数据,那么需要被修改的数据被修改之前,OS会为这个数据开一个新空间,意味着这个数据会有两个空间,然后子进程会修改新的空间的值并指向它,父进程仍然指向老空间并保持不变,我们称这个过程为"写时拷贝"

  • 注意!父进程和子进程已经是两个进程了!父进程和子进程相互独立了!它俩怎么运行都不能影响到对方!只是公用代码和数据而已!

image-9.png

  • 我么来验证一下
  • 首先先改一改测试代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>

int flag = 0;


int main()
{
    sleep(1);
    printf("当前进程的pid:%d\n", getpid());

    pid_t id = fork();

    if(id < 0)
    {
        perror("fork error!");
        return 1;
    }
    else if(id == 0)
    {
        //child
        while(1)
        {
            sleep(1);
            printf("子进程修改flag的值 %d -> %d \n", flag, flag + 10);
            flag += 10;
            printf("子进程的pid: %d, ppid: %d \n", getpid(), getppid()); 
        }
    }
    else
    {
        //parent
        while(1)
        {
            sleep(1);
            printf("父进程修改flag的值 %d -> %d \n", flag, flag - 10);
            flag -= 10;
            printf("父进程的pid: %d, ppid: %d \n", getpid(), getppid()); 
        }
    }
    return 0;
}
  • 编译执行后
当前进程的pid:13911
父进程修改flag的值 0 -> -10 
父进程的pid: 13911, ppid: 13391 
子进程修改flag的值 0 -> 10 
子进程的pid: 13912, ppid: 13911 
父进程修改flag的值 -10 -> -20 
父进程的pid: 13911, ppid: 13391 
子进程修改flag的值 10 -> 20 
子进程的pid: 13912, ppid: 13911 
父进程修改flag的值 -20 -> -30 
父进程的pid: 13911, ppid: 13391 
子进程修改flag的值 20 -> 30 
子进程的pid: 13912, ppid: 13911 
...

3.4 进程状态

3.4.1 进程状态相关名词的了解
  1. 创建状态
  2. 就绪状态
  3. 运行状态
  4. 阻塞状态
  5. 就绪挂起状态
  6. 阻塞挂起状态
  • 这些名词我们会在接下来的例子中详细了解到
3.4.2 初识进程调度
  • 我们从前面的小节可以知道,管理软件是一个"先描述,再组织"的过程,我们会拿一个链表存放这些进程的PCB,用于管理这些PCB,然后再在链表外套一层队列,以先进先出的策略,队列头的进程可以先到CPU处理

  • 当前进程在CPU处理完了之后,OS会把该进程丢到队列末尾,直到再排到该进程,就再次进行处理

  • 我们称这个队列为工作队列,work_queue

  • 但并不是所有的进程都会一直保持运行,有些进程可能会等待用户进行操作之后再运行,比方说我们在linux中的bash,或者说外壳程序,他通常会等待用户在键盘上输入数据,此时我们称为等待键盘就绪,即此时处于等待状态,如果用户输入了内容,就处于就绪状态

  • 自此,我们就不得不扯另一个东西了,即操作系统对硬件的管理

  • 前面咱有提到过,操作系统对硬件的管理也是"先描述,再组织"的过程,所以此时底层硬件也会一个个被维护起来,也会有一个类似于PCB的东西,我们现在暂时称为device_struct,其中也会维护硬件的相关信息,以及下一个device_struct的指针,同时也会拿一个链表串起来,同时,这个device_struct里也会维护指针用于指向自定义的空间,用于访问硬件的个性化数据,最最关键的,device_struct还维护了一个队列,我们暂时称为wait_queue

  • 此时我们的bash因为需要用户输入数据才能运行,于是OS会把它连接到键盘的wait_queue上,于是work_queue里的bash的进程就会被踢到键盘的wait_queue

  • 一旦键盘输入了内容,此时OS会首先知道,然后把键盘的wait_queue的队头的进程转交给work_queue,等到进程处理到bash的时候,再从键盘的属性中拿字符出来,和进程一起交给CPU处理

  • 自此,我们称:

    1. 进程在work_queue等待被处理的状态,以及进程正在被CPU处理的状态,称为运行状态
    2. 进程在wait_queue中等待硬件就绪的状态称为阻塞状态
  • 此时会有一个问题哈,就是如果说,内存不够了,用户需要运行的进程怎么办??

  • 我们引入一个新的概念,磁盘中会有一片空间会被OS维护,称为swap区

  • 如果空间足够的话,按理讲所有的进程的代码和数据全都应该会存放在内存中,但如果空间不够的话,就会拿一部分代码和数据存在swap区中,只留一个PCB在内存里,等到要用的时候再取回来

  • 不难发现,此时拿wait_queue的代码和数据比较合适,毕竟咱也不知道硬件什么时候会就绪对吧

  • 如果所有硬件的所有wait_queue的所有进程的所有代码和数据都放进了swap区还不够呢?

  • 此时OS就会去拿work_queue的代码和数据了

  • 此时,我们称:

    1. wait_queue/work_queue拿出来暂存在swap区的动作为唤出
    2. swap区拿出来放回到wait_queue/work_queue的动作为唤入
    3. wait_queue唤出到swap区后的状态为阻塞挂起状态
    4. work_queue唤出到swap区后的状态为运行挂起状态

image-10.png

  • 注意:这里的运行状态和就绪状态非常复杂,我们只能暂时这么理解

  • 自此我们就已经把除了创建状态的所有状态全部了解完毕了,当然,要是还不太明白的话,我在下面还举了个例子,仅供参考

  • 假设你是一个即将建设的景区的某个管理部门的主管,现在你的景区需要建一个厕所,你怎么安排?

  • 首先我得搞一个厕所调度员(OS),保证游客(进程)能有序排队

  • 比方说我们建的每一个厕所,只有一个蹲坑(单核CPU),一个人进厕所,如果发现这个坑上正好有一个人,就一定会产生队列(work_queue),每一个人都需要进到包间里解决问题(让CPU执行代码),此时就会产生两种状态,即"正在拉"和"等待别人拉完"(运行状态)

  • 但有的游客没有带纸,于是你干脆在厕所里专门放一个售货机(硬件)用于专门卖纸

  • 于是就会产生一个状态叫"等纸"(阻塞状态)

  • 但问题是,如果遇上五一黄金周或者国庆,景区的人势必会非常非常多,往往这个售货机会产生供不上货的问题,需要等待一个供货员专门供货(即硬件处于等待状态),此时人很多,于是大家自发在售货机前也排好了队(wait_queue),供货员(用户)先供的纸(输入的内容)会被队列最前面的人拿走,然后在在坑前排队

  • 同时,人多也会带来一些问题,即厕所会很容易就挤不下了,于是你想了个法子,给每个游客的入园门票(PCB)上写好游客身份(属性),于是,在厕所人很多的时候,厕所调度员就负责把一些人(唤出)暂时请出厕所,让他们在厕所外面(磁盘)等,让他们把自己的门票留下,调度员来帮他们排队

  • 于是,人很多的情况下,最先应该被请出去的人就应该是售货机前的人,反正还没拿到纸呢,想拉都不行,等纸到了,调度员会第一个知道,然后告诉游客有纸了,叫他们进来(唤入),队头的先来拿

  • 但此时,哪怕售货机前的人都被请出去了,厕所里还是很挤,于是调度员只能把坑前面排队的人也请出去一部分,留下门票,等人少了在叫外面的人进来

  • 于是又会产生两种状态,即"因为人太多和没纸而在外面等"(阻塞挂起状态)和"因为人太多和等前面的拉完而在外面等"(运行挂起状态)

3.4.3 解决PCB可以存放在多种不同数据结构的疑问
  • 在上面几个小节中,其实我们也产生了PCB可以存放在多种不同数据结构的疑问,即:为什么PCB既可以放在work_queue中,又可以放在wait_queue中???

  • 我们知道,常规的链表,每个List_NodeList数据结构都是高度契合的,不存在说List_Node可以放在其他数据结构的可能性

  • 想要搞懂这个问题,我们首先得了解Linux内核的task_struct是怎样写的

  • 在Linux中,内存的全局还会存在一个list,用于链接所有的PCB

  • 但这个list的链接方式区别于任何一个链表,常规链表的指针域的类型都是List_Node的指针,指向上一个或者下一个List_Node,头节点除了指针域之外其他都为空

image-11.png

  • 但Linux内核中控制进程的全局的list则完全不同,设计者专门设计了一个一个结构体,我们暂且将他叫做pointer_area,头节点的类型不为List_Node,而是pointer_area,每一个List_Node都包含pointer_area,每一个pointer_area只能指向上一个节点的pointer_area部分以及下一个节点的pointer_area部分

  • 类似于这样

image-12.png

  • 你肯定满脸问号,这咋玩啊???这咋访问List_Node的其他数据呢?难道用偏移量吗???

  • 哎,猜对了!但没有完全对!

  • 我们得先看一串代码

(&(((struct List_Node*) 0)->pointer_area));
// 这串代码的含义需要剥洋葱式的层层剖析
// `(struct List_Node*) 0`代表将0强转成`struct List_Node*`
// `->pointer_area`可以取到这个只临时存在的`struct List_Node*``pointer_area`
// 然后对其取地址`&()`
// 你发现了么,此时无论`pointer_area``struct List_Node`中是怎么规范的,都可以直接通过上面的代码获得其偏移量
// 此时如果我想访问下一个节点的其他数据,就可以这么玩
(struct List_Node*)(next - (&(((struct List_Node*) 0)->pointer_area)))->other;
// 我们只需要获得下一个节点的地址`next`,通过偏移量得到`struct List_Node`的起始地址,然后强转就可以访问其他数据了
  • 自此,这个奇怪的链表就可以正常实现遍历和数据访问了

  • 但似乎,还是没有解决PCB可以存放在多种不同数据结构的疑问的疑问啊!

  • 其实不然,有没有一种可能,PCB可以存放多个不同的数据结构的pointer_area呢?

  • 一旦这样实现,就可以使PCB同时存在在各种数据结构中,比方说PCB被OS踢出work_queue之前,它属于work_queue,同时也属于全局的list,PCB被OS踢出work_queue之后,会被转入到wait_queue,但此时PCB依旧也属于全局的list

  • 所以实际上,进程在Linux中是以网状结构存在的

3.5 Linux中的进程状态

  • 首先我们得先翻一下源码(这里使用2.6.18的内核版本)

  • 文件以及其路径是linux-2.6.18\fs\proc\array.c

/*
 * 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 *task_state_array[] = {
	"R (running)",		/*  0 */
	"S (sleeping)",		/*  1 */
	"D (disk sleep)",	/*  2 */
	"T (stopped)",		/*  4 */
	"T (tracing stop)",	/*  8 */
	"Z (zombie)",		/* 16 */
	"X (dead)"		/* 32 */
};
  • 在进程状态的设计中,我们使用了整形来代表其进程状态,并且,如果我们从二进制的角度看,不同状态的形式,都是以若干0和若干1组成的,这样做的好处是:让状态切换时,提高修改和判断状态的名称的效率,这里修改和判断其名称只需要用位运算就行(再次凸显出了设计和完善Linux的人都不是一般人)
  1. 位与操作 (&):检查某个特定状态是否被设置。
  2. 位或操作 (|):设置某个状态。
  3. 位非操作 (~):清除某个状态。
  4. 位左移/右移 (<< / >>):更改状态的位位置。
  • 所以说,实际在状态名称的运用中,不会真的用这些字符串,而是用整形代表,除非用户想看状态,才会根据整形,从数组里面找到对应字符串,并输出到屏幕上
3.5.1 Linux中的运行状态和阻塞状态
  • 我们现在可以写一个简单的死循环程序,跑起来并看一下该进程的状态
#include<stdio.h>

int main()
{
    while(1)
    {
        printf("hello world!\n");
    }

    return 0;
}
  • 我们可以打开另一个shell执行以下代码查询该进程的状态
$ ps ajx | head -1 ; ps ajx | grep "test_exe"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 8251  9686  9686  8251 pts/0     9686 S+    1001   0:05 ./test_exe
 9832  9916  9915  9832 pts/1     9915 S+    1001   0:00 grep --color=auto test_exe
  • 此时就出现了一个问题,明明我们的程序一直在跑,为什么我们查询的状态却是"sleeping?"

  • 事实上,相较于CPU处理指令的速度,显示器相应的速度可是慢多了,或者说硬件的响应速度慢多了

  • 所以其实这个进程的很大一部分时间,都是在wait_queue里等待,真正在work_queue和交给CPU处理的时间太太太短了,根本无法查询到

  • 所以,意味着只要这个程序没有进行硬件调用,该进程的PCB就永远不会跑到wait_queue里,我们就可以观察到其"running"的状态了

  • 现在修改代码并且编译

#include<stdio.h>

int main()
{
    while(1)
    {
        //printf("hello world!\n");
    }

    return 0;
}
  • 执行相同的查询步骤
$ ps ajx | head -1 ; ps ajx | grep "test_exe"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 8251 10067 10067  8251 pts/0    10067 R+    1001   0:07 ./test_exe
 9832 10072 10071  9832 pts/1    10071 R+    1001   0:00 grep --color=auto test_exe
  • 此时我们就会发现其处于了运行状态了

  • 还有个问题,这个状态后面的+号是什么意思?

  • +就代表他是个前台进程,不带+就代表他是个后台进程

  • 我们运行一个程序的时候,可以在指令后面加一个&让它跑在后台,跑在前台会占用-bash,跑在后台就没有这个问题

& ./test_exe &
  • 此时我们可以再查询一下
$ ps ajx | head -1 ; ps ajx | grep "test_exe"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 8251 10109 10109  8251 pts/0     8251 R     1001   0:28 ./test_exe
 9832 10119 10118  9832 pts/1    10118 R+    1001   0:00 grep --color=auto test_exe
  • 此时+就不见了

  • 我们知道杀掉前台进程可以用^c,而杀掉后台就需要我们之前提到的kill指令

& kill -9 [pid]
  • 综上,"sleeping"状态,其实就是操作系统学科中的阻塞状态

  • 换句话说,scanf()在等待用户输入的时候,此时进程所处的状态也一定是阻塞状态

3.5.2 Linux中的暂停状态
  • 暂停状态是Linux独有的状态,该状态是一种不属于计算机系统学科中的状态

  • 简单来说,如果一个程序被debug的时候,进程因为断点而停止运行的时候

  • 即,以下步骤

(gdb) l
1	#include<stdio.h>
2	
3	int main()
4	{
5	    while(1)
6	    {
7	        printf("hello world!\n");
8	    }
9	
10	    return 0;
(gdb) b 7
Breakpoint 1 at 0x400531: file test.c, line 7.
(gdb) r
Starting program: /home/oldking/test_exe 

Breakpoint 1, main () at test.c:7
7	        printf("hello world!\n");
Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.3.x86_64
  • 现在我再在另一个shell里查询进程状态
$ ps ajx | head -1 ; ps ajx | grep "test_exe"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
30699 30729 30729 30699 pts/0    30729 S+    1001   0:00 gdb test_exe
30729 30731 30731 30699 pts/0    30729 t     1001   0:00 /home/oldking/test_exe
30740 30768 30767 30740 pts/1    30767 S+    1001   0:00 grep --color=auto test_exe
  • 所以我们说,"t",即"tracing stop"状态,是程序因为断点而暂停运行的状态

  • 那"T"状态又是什么呢?

  • 如果一个进程正在运行,我们按"ctrl+z",就可以让当前进程暂停运行,这种暂停运行的方式,会让进程进入到"T"状态,即"stopped"状态

  • 暂停进程

hello world!
hello world!
hello world!
hello world!
hello world!^Z
[1]+  Stopped                 ./test_exe
  • 查询状态
$ ps ajx | head -1 ; ps ajx | grep "test_exe"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
30699 30821 30821 30699 pts/0    30699 T     1001   0:00 ./test_exe
30740 30835 30834 30740 pts/1    30834 S+    1001   0:00 grep --color=auto test_exe
  • 实际情况下的暂停状态,可能会出现在:因为进程不满足某些条件,或者说进程做了某些不安全不恰当的错误,被操作系统给暂停了的时候,操作系统认为这个进程虽然有问题,但不构成威胁,就会试图暂停进程,然后转交由用户决定是否继续运行该进程
3.5.3 Linux中进程的深睡眠和浅睡眠
  • 浅睡眠其实我们之前就已经说过了,进程在等待硬件就绪,此时进程的PCB会转到wait_queue,并进入"sleeping"状态,即浅睡眠,注意:此时该进程是可以被杀掉的!

  • 而深睡眠,即"D"状态,全称为"disk sleep"状态,这个状态无法被杀死!

  • 为什么处于这个状态的进程无法被杀死?

  • 首先,一旦进程进入睡眠状态,就意味着它在等待硬件就绪,但有一个硬件非常特殊,即磁盘!

  • 这个硬件就特殊在,因为磁盘很容易调度失败,所以在硬件被调度之前和被调度之后,该硬件都会沟通进程,第一次由进程告诉磁盘:我这里有数据传给你,磁盘负责接收和调整.第二次由磁盘告诉进程:数据是否成功写入,写入成功就万事大吉,没写入成功就需要由进程告诉用户

  • 而在写入数据的过程中,该进程会因为等待硬件就绪,而进入睡眠状态

  • 注意:此时该进程不能被杀死,因为进程不知道数据是否成功写入,一旦被杀死,数据还没有写入成功,因为磁盘和进程的第二次沟通无法进行,那么这些数据就会直接丢失

  • 所以进程需要进入深度睡眠状态,以防止被OS杀掉(极端情况下,即内存空间严重不足的情况下,OS甚至会自己杀掉进程而不听从用户的指令)

  • 一般来说,如果在不是"高强度I/O"的情况下,仍然还有一部分甚至大量进程处于"D"状态下,可能意味着磁盘已经老化飘红了,带宽和性能严重下降,此时就需要更换磁盘了

  • "D"状态也是阻塞状态,只不过是一种特殊的阻塞状态

3.5.4 死亡状态和僵尸状态
  • "X",即"dead"状态,死亡状态

  • "Z",即"zombie"状态,僵尸状态

  • 死亡状态还是很好理解的,进程被杀死或者自己结束之后就是死亡状态了

  • 但僵尸状态就没那么好理解了

  • 我们可以把僵尸状态理解为介于活着和死亡两种状态之间的状态,或者称为"半死不活状态"

  • 该状态意味着该进程应该死了,但临死之前OS还得获取一下这个进程的退出信息,而在OS获取退出信息这段时间里,该进程就处于僵尸状态,有点像现实生活中的法医尸检,获取死亡信息

  • 细节上,进程一旦准备退出了,此时该进程的代码和数据部分都可以被释放了,但PCB的部分还不太行,因为退出信息都保存在PCB里面

  • 我们知道,我们现阶段所知道的进程都是由父进程创建的,意味着该进程是因为父进程需要办某件事而创建的,所以子进程退出之前一定需要把相关情况告诉给父进程

  • 所以在进程代码数据都被释放之后,只剩下PCB等待父进程获取信息的这段时间,加上父进程正在获取PCB中信息的这段时间,这两段时间合在一起,一旦进程处于这两段时间之一,则说明进程处于僵尸状态

  • 进程处于僵尸状态下,进程的PCB部分一定不会被释放

  • 值得注意的是:

    1. 进程如果处于僵尸状态,在进程表中仍然可显示,直到被父进程清理
    2. 如果一个进程一直处于僵尸状态,此时它的父进程退出了,那这个进程也会退出
    3. 你可以通过重启计算机清理处于僵尸状态的进程,虽然不建议这样做就是了
    4. "X"状态不可见,因为都退出了,所以肯定是不可见的
  • 综上,我们可以写个程序,手动整一个僵尸进程

  • test.c

#include<stdio.h>
#include<unistd.h> //sleep fork
#include<sys/types.h> //getpid

int main()
{
    pid_t pid = fork();        
    
    if(pid < 0)
    {
        perror("fork error!");
        return 1;
    }
    else if(pid == 0)
    {
        //child
        int count = 10;
        while(count)
        {
            printf("this is child, count is %d\n", count--);
            sleep(1);
        }
    }
    else
    {   
        //parent
        while(1)
        {
            printf("this parent\n");
            sleep(1);
        }
    }
    return 0;
}
  • 现在让它编译,跑起来
$ ./test_exe
this parent
this is child, count is 10
this parent
this is child, count is 9
this parent
this is child, count is 8
this parent
this is child, count is 7
this parent
this is child, count is 6
this parent
this is child, count is 5
this parent
this is child, count is 4
this parent
this is child, count is 3
this is child, count is 2
this parent
this is child, count is 1
this parent
this parent
this parent
this parent
this parent
this parent
this parent
  • child计数还在之前,我们可以一查一下进程状态
$ ps ajx | head -1 ; ps ajx | grep "test_exe"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
30699  4094  4094 30699 pts/0     4094 S+    1001   0:00 ./test_exe
 4094  4095  4094 30699 pts/0     4094 S+    1001   0:00 ./test_exe
30740  4101  4100 30740 pts/1     4100 S+    1001   0:00 grep --color=auto test_exe
  • 不难发现他俩都是浅睡眠,都是处于等待I/O的状态

  • 我们在计数之后再查一遍

$ ps ajx | head -1 ; ps ajx | grep "test_exe"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
30699  4094  4094 30699 pts/0     4094 S+    1001   0:00 ./test_exe
 4094  4095  4094 30699 pts/0     4094 Z+    1001   0:00 [test_exe] <defunct>
30740  4119  4118 30740 pts/1     4118 S+    1001   0:00 grep --color=auto test_exe
  • 不难发现,此时子进程处于僵尸状态,即子进程的代码和资源全都释放了,就剩个task_struct了,后面还贴心地标注了<defunct>,即代表该进程失效了

  • 如果一个进程长期处于僵尸状态,其实就造成了内存泄漏,所以我们就需要尽量避免让进程长期处于僵尸状态

  • 这里引入两个问题: 1.

    • Q:进程中,因为代码部分造成资源部分出现内存泄漏的问题,能不能让进程退出以避免内存泄漏?
    • A:可以,因为进程退出就会直接释放代码部分和资源部份,所以常驻进程一定要尽可能避免出现内存泄露的问题,否则内存泄漏只会越来越严重!
    • Q:父进程获取完子进程的退出信息之后,OS真的会释放掉子进程的task_struct吗?
    • A:不一定,因为本质上,task_struct也是向内存申请来的,申请空间就一定会造成性能损耗,所以OS可能会把废弃的test_struct扔到类似于一个回收站的链表里,这个链表即是"缓存",等待又有新的进程需要分配一个task_struct的时候,只需要从"缓存"里拿一个给它,并且初始化以此就行
3.5.5 孤儿进程
  • 前面我们聊过,一旦子进程变成了僵尸进程,我们可以通过直接结束父进程的方式,一并解决子进程是僵尸进程的问题

  • 但如果说,子进程不是僵尸进程,而是在正常运行,此时结束父进程,就会出现很大的问题,我们称这种进程为孤儿进程

  • 我们可以来试验一下

  • 写一个test.c

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

int main()
{
    pid_t id = fork();

    if(id < 0)
    {
        perror("fork error");
        return 1;
    }
    else if(id == 0)
    {
        //child
        while(1)
        {
            printf("i am a child, pid is %d, ppid is %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    else
    {
        //parent
        int count = 7;
        while(count)
        {
            printf("i am a parent, pid is %d, my count is %d\n", getpid(), count--);
            sleep(1);
        }
    }

    return 0;
}
  • 然后编译,跑起来
$ ./test
i am a parent, pid is 9343, my count is 7
i am a child, pid is 9344, ppid is 9343
i am a parent, pid is 9343, my count is 6
i am a child, pid is 9344, ppid is 9343
i am a parent, pid is 9343, my count is 5
i am a child, pid is 9344, ppid is 9343
i am a parent, pid is 9343, my count is 4
i am a child, pid is 9344, ppid is 9343
i am a parent, pid is 9343, my count is 3
i am a child, pid is 9344, ppid is 9343
i am a parent, pid is 9343, my count is 2
i am a child, pid is 9344, ppid is 9343
i am a child, pid is 9344, ppid is 9343
i am a parent, pid is 9343, my count is 1
i am a child, pid is 9344, ppid is 9343
i am a child, pid is 9344, ppid is 1
i am a child, pid is 9344, ppid is 1
i am a child, pid is 9344, ppid is 1
i am a child, pid is 9344, ppid is 1
  • 然后再在父进程结束的前后分别查一下父子进程的状态
$ ps ajx | head -1 ; ps ajx | grep "test"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 8930  9343  9343  8930 pts/2     9343 S+    1001   0:00 ./test
 9343  9344  9343  8930 pts/2     9343 S+    1001   0:00 ./test
30740  9348  9347 30740 pts/1     9347 S+    1001   0:00 grep --color=auto test
$ ps ajx | head -1 ; ps ajx | grep "test"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1  9344  9343  8930 pts/2     8930 S     1001   0:00 ./test
30740  9355  9354 30740 pts/1     9354 S+    1001   0:00 grep --color=auto test
  • 不难发现,如果一个进程变成了孤儿进程,它会被pid为1的进程"收养"

  • pid为1的进程是谁??查一下

$ ps ajx | head -1 ; ps ajx | grep "1"
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     1     1     1 ?           -1 Ss       0   5:21 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
    0     2     0     0 ?           -1 S        0   0:00 [kthreadd]
# ...冒了一堆,我们只看前面几行
  • 不难发现,pid为1的进程,似乎是一个叫systemd的东西,那肯定跟系统有关,准确来说,它是系统的一部分(老内核中叫做init)

  • 所以意味着,如果一个进程变成孤儿进程,它就会被系统接管

  • 为什么需要被系统接管?

  • 因为如果没有进程接管它,一旦它进入僵尸状态,它的PCB就永远无法释放,永远不能进入死亡状态,就一定会造成内存泄漏,所以所有的孤儿进程,都会被系统接管

  • 顺带一提,所有的孤儿进程无论之前是前台进程还是后台进程,变成孤儿进程之后其都会自动后台进程,就意味着ctrl+c无法杀死它,此时就只能用kill杀死它

3.6 进程优先级

3.6.1 优先级是啥,为啥需要
  • 简单来说,优先级决定进程获取CPU资源的先后顺序

  • CPU按照时间片处理进程任务,就像是食堂阿姨给学生打饭,每次只花固定的时间,每个进程在某一段时间里,只能获得该CPU非常小的时间的资源,比方说1毫秒内,CPU只能为该进程工作3微秒

  • 所以不可能让某一个CPU专门只处理一个进程,而且因为处理的结果给人类看,人类的反应速度又不可能有微秒级别,所以根本不需要为进程单独分配CPU,当前CPU为进程1服务2微秒,下次就为进程2服务2微秒,速度太快了,在人类看来就像是同步完成的一样

  • 所以我们就可以通过1个CPU"同时"处理多个进程,于是就会产生队列,产生队列就一定会产生优先级,某些任务比较重要,需要分配的时间就需要多一些

  • Q:优先级和权限有什么区别?

  • A:优先级代表获取资源的强度/速度,权限代表能不能获得

  • 优先级信息也在task_struct

3.6.2 优先级的查询
  • 首先,优先级作为一种可量化数据,在Linux中一般也就用数字来表示

  • 数字越小,优先级越高,数字越大,优先级越低

  • 拿这个程序举例

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

int main()
{
    pid_t id = fork();

    if(id < 0)
    {
        perror("fork error");
        return 1;
    }
    else if(id == 0)
    {
        //child
        
        while(1)
        {
            printf("i am a child, pid is %d, ppid is %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    else
    {
        //parent
    
        int count = 7;
        while(1)
        {
            printf("i am a parent, pid is %d, my count is %d\n", getpid(), count--);
            sleep(1);
        }
    }

    return 0;
}
  • 我们可以用以下命令查询(先记住命令,选项暂时不了解)
$ ps -al
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1001 11181  8930  0  80   0 -  1054 hrtime pts/2    00:00:00 test
1 S  1001 11182 11181  0  80   0 -  1054 hrtime pts/2    00:00:00 test
0 R  1001 11188 30740  0  80   0 - 38332 -      pts/1    00:00:00 ps
  • 有两列内容需要我们注意:PRINI

  • PRI -- 优先级,默认优先级为:80

  • NI -- 即nice值,进程优先级的修正数据

  • PRI=默认优先级(就是PRI的默认值)+NI

  • 一定要注意:PRI的默认值一定是80,一定不会变,PRI的默认值跟PRI有啥关系?除了是默认值之外没任何关系!!!

3.6.3 调整进程优先级
  • 本小节不仅可以学习到调整优先级的方式,同时还可以验证优先级的计算公式

  • 调用监视窗口

$ top
  • 该窗口类似于Windows中的任务管理器,可以查看当前进程,以及各个硬件资源分配情况

image-13.png

  • 我们按一次r可以进入"renice"模式,即该模式下可以调整一个进程的nice

  • 然后它会要求你输入需要调整nice值的进程的pid,我们输进程的pid并回车就行

image-14.png

  • 然后输入你想调整后的nice值并回车

image-15.png

  • q可以退出top工具

  • 值得注意的是,虽然非root用户也可以用top,但还是不如root用户权限高,root可以频繁更改进程优先级,对于普通用户来说,如果频繁干影响系统的事情,系统会说你没有权限做

  • 改完之后我们可以再查一下该进程的优先级

# ps -al
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1001 32050 31577  0  70 -10 -  1054 hrtime pts/0    00:00:00 test
  • 不难看出,PRINI都改了,也应证了计算公式:"PRI=默认优先级(就是PRI的默认值)+NI"

  • 为什么要设置一个默认值?

  • 因为不设置默认值,每次改优先级之前,都需要再查一遍优先级,然后再改,就非常麻烦

  • 当然,我们也可以直接用命令调整优先级

# 该命令可以设置进程启动时的优先级
$ nice -n 10 /home/oldking/testdir/test
# 这里注意,不知为何只能用绝对路径运行程序,可能是shell的问题?也有可能是环境变量的问题??
$ pa -al | head -1 ; ps -al | grep "test"
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1001 19308 19234  0  90  10 -  1054 hrtime pts/0    00:00:00 test


# 该命令可以修改已经运行的进程的优先级
$ renice -n 10 -p 19269
# -n代表后面输nicenumber,-p代表后面输pid
$ pa -al | head -1 ; ps -al | grep "test"
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1001 19308 19234  0  90  10 -  1054 hrtime pts/0    00:00:00 test
3.6.4 优先级的极值
  • 可以做个实验
# renice -n 100 -p 20190
20190 (process ID) old priority 0, new priority 19
# ps -al | head -1 ; ps -al | grep "test"
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1001 20190 19234  0  99  19 -  1054 hrtime pts/0    00:00:00 test
# renice -n -100 -p 20190
20190 (process ID) old priority 19, new priority -20
# ps -al | head -1 ; ps -al | grep "test"
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1001 20190 19234  0  60 -20 -  1054 hrtime pts/0    00:00:00 test
  • 不难发现,NI的取值范围是[-10,19]

  • PRI的取值范围是[60,99]

  • 优先级差距设计得这么小,主要还是保证进程获取CPU资源的公平性,不能出现某个进程长期霸占CPU的情况,如果一个进程长期得不到CPU资源,我们称这种现象为:进程饥饿

3.7 进程和文件权限

  • Q:没有权限访问文件的用户不能访问文件,但我们知道,访问文件都是进程帮我们代为执行的,比方说cat,那系统怎么知道该进程背后的用户是谁?

  • 上小节我们学习的指令中有这么一条

$ ps -al
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1001 11181  8930  0  80   0 -  1054 hrtime pts/2    00:00:00 test
1 S  1001 11182 11181  0  80   0 -  1054 hrtime pts/2    00:00:00 test
0 R  1001 11188 30740  0  80   0 - 38332 -      pts/1    00:00:00 ps
  • 这个UID就是用户的代号,意味着进程会记录用户的代号,系统只需要对比文件的权限相关属性和进程的用户代号来判断该操作是否有权限执行

  • 我们可以以代号的形式看一下文件的属性

$ ll
total 20
-rw-rw-r-- 1 oldking oldking  176 Jan 19 02:30 Makefile
-rwxrwxr-x 1 oldking oldking 8616 Jan 19 04:20 test
-rw-rw-r-- 1 oldking oldking  602 Jan 19 04:20 test.c
$ ls -ln
total 20
-rw-rw-r-- 1 1001 1001  176 Jan 19 02:30 Makefile
-rwxrwxr-x 1 1001 1001 8616 Jan 19 04:20 test
-rw-rw-r-- 1 1001 1001  602 Jan 19 04:20 test.c

3.8 进程在获取CPU资源时需要保持的特性

  1. 独立性:即各个进程之间的运行互不受影响
  2. 竞争性:即进程之间必须要竞争少量的硬件资源,不能有进程独占资源,也不能有进程产生进程饥饿
  3. 并行:即多个CPU,拥有多个任务队列,进程不能随意更换任务队列
  4. 并发:基于单个CPU时,多个进程拥有某一段时间片的CPU资源,使得多个进程几乎同时间推进任务,给人类"多个进程在同一块CPU下同时运行的错觉"

3.9 进程切换

3.9.1 死循环进程
  • 常驻进程都是死循环进程,非死循环进程会自己退出
  • 一般情况下,常驻进程不会一次性跑完所有代码,理论上只要用户不退出,这个进程就需要一直向CPU申请资源
  • 但死循环进程也不会一直占有CPU,一旦当前时间片到了,该进程就会回到任务队列末
3.9.2 寄存器与CPU
  • 其实我们之前早就了解过寄存器的相关内容,不过这里需要重申的是:寄存器是类似于内存的存储空间,不是所存储的数据,只不过寄存器更贵,更快,所以只能做得非常小

  • 我们在学习C语言和CPP得时候,一定看过一些汇编代码

  • 不难发现,其实寄存器得功能跟它的名字一样,用于临时存放某些值,例如计算的结果,函数的返回值,以及栈顶栈底的地址等等

  • 例如需要进行加法运算,就会将左值和右值从内存读到某两个寄存器中,然后CPU从寄存器里拿值并运算,运算结果也放进某个寄存器中,最后将结果写回内存里

3.9.3 进程切换的具体方式
  • 当一个进程正在获取CPU资源时,我们知道寄存器一定会临时存储相关数据,一旦时间片结束时,寄存器会将这些临时的数据先保存到进程的Task_struct中一个叫做TSS的结构中,类似于一个书签,等到这个进程下一个时间片开始时,Task_struct中的TSS中的数据会恢复到CPU的寄存器中,此时CPU就知道本次时间片应该接着上次时间片结束的地方运行

  • 我们称临时数据为:进程的硬件上下文数据

  • 我们称保存临时数据的行为为:保存进程的硬件上下文

  • 我们称恢复临时数据的行为为:回复进程的硬件上下文

  • TSS的全称为"Task State Segment",即"任务状态段"

  • 我们可以把源码翻出来

  • linux-0.11\include\linux\sched.h(几乎就是linus最初版本的linux,老实说现在看到这个东西还是挺震惊的,本来我们说系统,仿佛是一个非常庞大的东西,没想到这个出版这么小,小得可怕)

//这个就是TSS,在里面我们能看到很多熟悉的寄存器
struct tss_struct {
	long	back_link;	/* 16 high bits zero */
	long	esp0;
	long	ss0;		/* 16 high bits zero */
	long	esp1;
	long	ss1;		/* 16 high bits zero */
	long	esp2;
	long	ss2;		/* 16 high bits zero */
	long	cr3;
	long	eip;
	long	eflags;
	long	eax,ecx,edx,ebx;
	long	esp;
	long	ebp;
	long	esi;
	long	edi;
	long	es;		/* 16 high bits zero */
	long	cs;		/* 16 high bits zero */
	long	ss;		/* 16 high bits zero */
	long	ds;		/* 16 high bits zero */
	long	fs;		/* 16 high bits zero */
	long	gs;		/* 16 high bits zero */
	long	ldt;		/* 16 high bits zero */
	long	trace_bitmap;	/* bits: trace 0, bitmap 16-31 */
	struct i387_struct i387;
};

struct task_struct {
/* these are hardcoded - don't touch */
	long state;	/* -1 unrunnable, 0 runnable, >0 stopped */
	long counter;
	long priority;
	long signal;
	struct sigaction sigaction[32];
	long blocked;	/* bitmap of masked signals */
/* various fields */
	int exit_code;
	unsigned long start_code,end_code,end_data,brk,start_stack;
	long pid,father,pgrp,session,leader;
	unsigned short uid,euid,suid;
	unsigned short gid,egid,sgid;
	long alarm;
	long utime,stime,cutime,cstime,start_time;
	unsigned short used_math;
/* file system info */
	int tty;		/* -1 if no tty, so it must be signed */
	unsigned short umask;
	struct m_inode * pwd;
	struct m_inode * root;
	struct m_inode * executable;
	unsigned long close_on_exec;
	struct file * filp[NR_OPEN];
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
	struct desc_struct ldt[3];
/* tss for this task */
	struct tss_struct tss; //这个就是TSS,保存在task_struct中
};
  • 不过现代操作系统中,在PCB里搞一个TSS已经过时了,因为现代CPU的寄存器太多了,在PCB中搞TSS,会让PCB变得非常臃肿,所以设计者搞了个TSS专区放进ROM里,在搞一个类似于进程身份认证的东西就能认领自己的硬件上下文信息

  • 如果一个进程是新创建的,CPU怎么知道它没有上一次的上下文?

  • 搞一个类似于first_running_flag标记符就行,task_struct被初始化的时候,默认让标记为true,获取CPU资源时,就不恢复上下文,第一次时间片结束之后,将标记符改成false

  • 我们先来看一张动图,然后再慢慢解释其中的相关内容(这图搞了很久很久QAQ)

序列 01-min_compressed.gif

  • 内存中,OS有个一叫调度器的部分,这个调度器维护了一至多个运行队列(runqueue),运行队列的个数取决于CPU的个数

  • 注意,这个runqueue本质上并不是队列,而是一个结构体,只不过这个结构体中最关键的部分在于其中的若干个队列

  • 当一个进程在获取CPU资源时,其会被一个叫current的指针指向,代表当前进程正在获取CPU资源

  • runqueue中,极为重要的部分是那两个被红色圆框和绿色圆框框住的部分

  • 每个圆框都是一个结构体,我们称为rqueue_elem

  • 其中包含三个成员

    1. nr_active:类型为int,用于计数,代表rqueue_elem中有多少个进程
    2. bitmap:类型是unsigned int [5],即一个无符号整型数组,也是一个位图,能在一定程度上加速进程优先级的遍历(这个我们会在后面再提到)
    3. queue:类型是struct task_struct* [140],即一个类型为task_struct的指针数组,这玩意本质是一个哈希表
  • 所以在一个runqueue中,包含两个rqueue_elem,但实际上这两个rqueue_elemrunqueue之间还有一层

  • 这个中间层我们称为array,是一个类型为struct rqueue_elem [2]的结构体数组,其中就维护了这两个结构体

  • 所以array[0]就是第一个rqueue_elem,同理array[1]就是第二个rqueue_elem

  • 同时,runqueue中还有两个结构体指针,一个叫active,另一个叫expired,他俩的类型都是struct rqueue_elem *,一个指向第一个rqueue_elem,一个指向另一个rqueue_elem,他们代表着活跃进程和过期进程(这个我们依旧会再提的),我们称active指向的队列为"活跃进程队列",称expired指向的队列为"过期进程队列"

  • 首先解决第一个疑问:为什么queue[140]一定是140个元素??

  • 在解决这个问题之前,首先我们要了解两个概念

    1. 分时操作系统 -- 其实就是我们之前聊的,基于时间片为进程调度的操作系统
    2. 实时操作系统 -- 相对于分时操作系统更为简单,因为它不基于时间片做进程调度,而是一旦有进程/任务产生,立马执行,不做等待
  • 这里补充一下实时操作系统的应用场景:例如汽车的刹车系统,电饭煲的定时系统,工厂的操作机器人等等需要精准,快速响应并完成任务的场景

  • 我们知道,优先级的数字越小,优先级等级越高,所以设计者把高等级优先级专门划分给了实时操作系统,目的是让系统快速响应,不做等待

  • 而剩下的部分就只剩下了40个元素,正好对应了我们之前讲过的Linux的优先级的上下限,同样也是正好40个等级

  • 这里我们只聊划分为分时优先级的部分

  • 每个等级都指向一个队列(不一定是链表,还有可能是堆),即优先级队列

  • 首先系统会从"活跃进程队列"的高等级优先级开始,一一分配时间片给队头的进程,进程被current指向,并恢复硬件上下文,开始获取CPU资源,进程一旦时间片结束,就会保存硬件上下文,然后从current脱离,进入"过期进程队列"

  • 如果中途新增了进程,该进程会立马被链入"活跃进程队列",这里要分两种情况

    1. 如果新产生的进程的优先级等级高于正在运行(获取CPU资源)的进程的优先级等级,则新进程会直接抢占当前进程的时间片,即当前进程直接结束并进入"过期进程队列",新进程直接进入自己的时间片
    2. 如果新产生的进程的优先级等级低于正在运行(获取CPU资源)的进程的优先级等级,则可能会正常链入"活跃进程队列",然后等待前面的进程运行完之后,再轮到自己.另一种可能是会进行没那么暴力的插队行为,即不抢占,等待当前进程的时间片结束之后,才插个队让自己获取CPU资源,当然,还有更加复杂的调度方式,这取决于设计者
  • 值得注意的是,这里的优先级一共有140个等级,哪怕是分时操作系统,也会有40个优先级,对于一个操作系统而言,每次都需要一一遍历才能知道所有等级的进程的分布情况,可能还是会稍微慢了些,所以设计者设计了一个位图bitmap[5]

  • 这个bitmap[5]设计的精妙之处在于,每个元素都是4字节,所以一共有160个bit位,每个bit一一对应一个等级,剩余的20个bit废弃,这样我们只需要判断这5个数字,就能清楚看到进程的等级分布情况,例如这个动图中的情况,就有整整五分之三的索引不需要访问(这也侧面应证了,如果一个操作系统的进程等级太过于分散,会导致操作系统调度进程的效率变低)

  • rqueue_elem中的进程的挪动,会导致bitmapnr_active的实时变化

  • 如果一个进程的时间片结束了,并且"活跃进程队列"中没有队列了,意味着一轮周期已经结束了,系统会直接交换active的值和expired的值,并进入一个新周期

  • 还有一个问题:为什么要两个rqueue_elem?只要一个不行吗?

  • 试想一下,如果只有一个rqueue_elem会发生什么:

    1. 因为只有一个rqueue_elem,所以你被迫要为每一个task_struct添加一个标识符,来判断该进程的时间片有没有结束
    2. 如果只有一个rqueue_elem,如果你不想让你的bitmap失效的话,可能还得为bitmap添加标识符,或者直接修改bitmap的值,等到一个周期结束之后,再遍历一遍索引并重新计算一边bitmap的值
  • 至此,难道不觉得只有一个rqueue_elem的设计有点别扭吗?似乎一直在妥协!并且效率还有点低!

  • 补充:

    1. cpu_load:这个成员在拥有多个CPU的时候才会有效,代表当前CPU负载,(基于运行队列中进程的平均数量的CPU负载因子),新的进程可能会进入负载没那么高的CPU(OS也会根据温度等等更多复杂情况判断进程应该进入哪个CPU的runqueue)
    2. nr_switches:上下文切换次数,或者说进程的切换次数,和cpu_load负载类似

  • 如有问题或者有想分享的见解欢迎留言讨论