当前位置: 首页 > article >正文

【Linux系统编程】进程概念

目录

粗略的进程管理

进程

进程及进程管理的概念

task_ struct内容分类

进程的标示符

PID

PPID

创建进程

创建多进程

进程的状态

进程的状态

Linux进程的状态

进程的优先级

进程切换

Linux真实的调度算法

进程的组织结构

命令行参数

环境变量


粗略的进程管理

由上一篇文章,我们知道了计算机内的所有管理思想都是先描述,再组织。所以,进程管理也不例外。就是一个结构体内部放入了进程的属性,再用链表连接起来 

进程

进程及进程管理的概念

课本概念:程序的一个执行实例,正在执行的程序等
内核观点:担当分配系统资源(CPU时间,内存)的实体。这一点等到线程部分再讲解。

假设我们现在有一个源文件,code.c,编译后生成了一个可执行程序myexe,可执行程序myexe是磁盘上的一个文件,要运行myexe时,先要将myexe从磁盘加载到内存,这个过程是由操作系统的加载器完成的,而操作系统也是一个软件,并且是从开机就一直运行着的,所以操作系统也已经被加载到内存了。

实际上,当myexe被加载到内存上时,它就已经是一个进程了。前面我们说了操作系统主要的四大功能中就有进程管理,既然myexe已经是进程,那么操作系统就要对它进行管理:操作系统要将其放到CPU上,称为调度,若myexe被CPU执行时,到scanf,但一直没有输入,操作系统就会进行进程切换,将其从CPU上拿下来,放到某个等待位等,未来进程执行完毕,操作系统还要将进程占用的空间释放掉。这一系列从加载到调度,从调度到切换,从切换到运行,从运行到阻塞,从阻塞到死亡,从死亡到回收的整条生命线,就称为进程管理

操作系统为了进行进程管理,就需要先对进程进行描述。进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。在windows下,这个数据结构称为PCB,linux下,这个数据结构称为task_struct

struct tast_struct
{
    int pid; // 进程id
    int status; // 进程状态
    int prio; // 进程优先级
    void *memptr; // 代码和数据的地址
    上下文;
    struct tast_struct* next;
};

进程可能会有很多个,所以需要给每一个进程分配一个id值,memptr是一个指针,通过它可以找到这个进程对应的代码和数据。所有进程弄成一个链表后,这个链表也称为任务列表。要执行一个进程,到任务列表中找到对应的ip值,通过这个ip值所在结点的memptr,找到对应的代码和数据,将代码和数据的起始地址放到CPU对应的寄存器上,就完成了调度。所以,当操作系统将一个可执行程序加载到内存时,不仅仅只是将可执行程序加载到内存,还要使用一个结构体来保存进程的属性,并放入任务列表中。这样,对进程的管理就变成了对任务列表的增删查改

由这句话:当操作系统将一个可执行程序加载到内存时,不仅仅只是将可执行程序加载到内存,还要使用一个结构体来保存进程的属性,并放入任务列表中,可以得出进程的概念
进程 = 内核数据结构(task_struct) + 程序的代码和数据
就像一个人是XX大学的学生,不能说这个人在大学里面就是这个大学的学生,而是应该这个人的信息在大学的学生管理系统内才能是这个大学的学生

进程是被调度的

举个例子,我们在向公司投递简历时,简历就是人的属性的集合,这就是先描述,HR将简历根据某一种顺序整理好,就是再组织,将整理好的简历给面试官看的过程就是简历在排队的过程,排队时是属性在排队,简历对应的人是没有进行排队的。当开始面试,面试官根据简历上的电话号码找到对应的人,面试完通过,再将简历交给二面的面试官,面试完没有通过,将简历扔到垃圾桶。接下来在后序面试中,面试官还会不断拿到那份简历。在这个过程中,简历就是task_struct,面试者就是可执行程序,在这个过程中,简历不断在HR、一面面试官、二面面试官等中间来回切换。操作系统会不断地调度进程,从CPU上拿上来、放下去、再拿上来、再放下去(这个过程是通过调度器完成的)。所以,进程从最先加载,到执行完毕,呈现出动态被调度的过程。所以常说进程是运行起来的程序。

task_ struct内容分类

前面我们已经了解到了将一个程序加载到内存中,可执行程序要占用一部分内存,同时还要创建一个结构体对象,如task_struct,用它来管理进程。注意:创建tast_struct是在内存上创建的,是一个纯内存级的操作,与磁盘上的文件没有关系,如关机,再开机,进程就没有了。
我们现在来看一下task_struct中都有一些什么东西呢?

我们现在在Linux上创建一个程序,并将其编译形成可执行程序,运行起来形成进程



我们make后,形成了可执行程序myproc,./myproc让其运行起来后,运行起来的程序就是进程。若想要查看这个进程,可再打开一个终端。

ps ajx是显示系统中所有启动的任务,也就是所有的进程
ps ajx | grep myproc是显示含有myproc字段的进程
ps ajx | head -1是显示ps ajx结果的第1行,也就是进程信息的名称

命令行是执行一条语句,若想让命令行执行多条语句,可使用;,也可用&&代替
现在我们就可以看到myproc这个进程,还有进程的信息的名称。那下面的grep是什么呢?
一直有grep...是因为像ps、head、grep这些命令能够罗列进程或过滤掉某些进程信息,这些命令本身也会变成进程,而执行到grep时,ps、head这两个命令已经执行完了,当用grep查myproc时,grep自己也是一个进程,且含有myproc字段,所以就会将自己过滤出来

若不想显示grep...,可反向匹配,让含有grep字段的进程不显示
所以,像ls、pwd等也是创建进程。

把程序运行起来,双击/./xxx.exe本质就是在系统中启动了一个进程。进程有两种,一种是执行完立刻就退出,像ls、pwd等指令,另一种是一直不退,直到用户退出,也称为常驻进程


          
当我们ctrl + c结束掉这个进程后,再去查进程就查不到这个进程了

进程的标示符
PID

我们刚刚看进程信息时,看到一个PID,PID就是进程标识符

我们进行了两次ctrl + c,并再次运行,观察每一次重新运行起来后,进程PID的变化。会发现,同一个程序,在不同时间运行时,PID是会发生变化的。
PID是进程标识符,其重要作用就是区分进程,若想获得自己进程的PID,可以使用getpid,getpid是一个系统调用,作用是获取进程的PID。可以使用man 2 getpid查看

返回值是pid_t,是系统的数据类型,实际上就是long,只不过被typedef了

只需getpid一次,因为只要进程没结束,PID是不会变化的

此时就可以打印出进程的PID了
若我们想结束一个进程,可以使用ctrl + c,也可用kill,kill是向指定进程发送信号,信号有很多,可以通过kill -l查看

可以使用9号信号,是杀掉一个进程

我们上面是通过ps ajx | grep myproc命令来查看myproc这个进程的属性,但这看到的属性是有限的。在Linux中,一切皆文件,Linux会将进程的属性以文件的形式存放,根目录下有一个proc目录,里面有很多以数字命名的目录,这个数字是指定进程的PID,以数字命名的目录里面就是这个数字对应的进程的属性

当有了一个新进程,就会在proc目录下创建一个新目录,进程结束后,这个数字目录就会被删除。即proc中关于进程的目录是实时更新的。
我们将myproc这个可执行程序运行起来,看看proc里对应的目录下有那些属性

我们重点来看其中的execwd
exe是一个文件,记录源文件的路径。源文件是指这个进程的可执行程序是从磁盘中哪一个可执行程序加载而来的。所以,exe记录的路径就是磁盘上的那个可执行程序的路径
再启动一个终端,将磁盘上的可执行程序删除


会发现,可执行程序已经被删除了,但是进程仍然在正常运行。这是因为可执行程序已经加载到内存上了,删除的是磁盘上的可执行程序。此时再查看proc中的26275目录,会发现exe显示已经delete

接下来看cwd。在C语言中,使用fopen打开一个不存在的文件,会在当前源文件所在的路径下创建一个这个文件,为什么源文件同级路径下新建呢?

运行这个程序,会发现在当前路径多了一个log.txt

实际上,./myproc运行可执行程序时,所创建的进程会记录myproc所处的路径,并将路径记录到cwd属性中。运行到fopen时,会将cwd中存储的路径拼接到"log.txt"的前面,而cwd就是源文件所处的路径,所以就会在源文件所处路径下生成一个log.txt文件。当然,若是fopen中指定了路径,就会去指定路径下创建。
所以,我们现在对当前路径有一个更明确的定义:进程的CWD
可手动改变进程启动时所处的路径,使用chdir,是一个系统调用

谁调用这个接口,就将指定进程的工作路径改为指定字符串,返回0成功,返回1失败


运行起来后,会发现并没用改变。这是因为新建文件的过程一直是失败的,当前是普通用户。


确实是创建失败的

我们将路径修改成我们有权限创建文件的路径


此时就到我们指定的路径下了

ps和ls /proc都可以查看进程属性,常用的是ps。系统中提供给我们查看进程的接口只有/proc,所以ps命令底层实现就是打开/proc,并对里面的内容进行文本分析。注意:/proc是内存级的。关机后/proc里面就什么都没有了,开机后操作系统又会将进程放入。

PPID

PPID是父进程的id。在Linux系统中,启动之后,新创建任何进程时,都是由自己的父进程创建的。进程是由操作系统创建的,但是是父进程让操作系统创建的。可以通过getppid来获取父进程



会发现,重新创建进程后,父进程的id没有变化,为什么没有变化?父进程是谁?
我们可以使用ps来查看父进程的信息

会发现父进程是bash,所以./myproc就是在bash的进程下创建子进程。我们前面有提到过shell,实际上shell是所有命令行解释器外壳程序的统称,在linux中用到的shell一般是bash。每次登录系统都会为登录的用户创建一个bash程序。bash前面若有'-',说明用户是通过命令行终端登录的

此时用户cxf登录了两次,所以有2个bash。注意:若是使用ps ajx | grep "\bash"是没有结果的,因为当前是使用终端进行登录的。

创建进程

在前面,我们使用./可执行程序来运行可执行程序,实际上就是一种创建进程的方式。现在,我们使用系统调用fork函数,来创建进程。fork函数的功能就是创建子进程



fork的返回值是若创建成功,返回子进程的pid给父进程,返回0给子进程,若创建失败,返回-1给父进程,没有子进程会被创建。我们现在来使用一下fork

运行结果是

会发现第一句printf打印了一句,而第二句printf打印了两句,为什么呢?
实际上,运行完fork后,程序的执行分支就不再是一条执行分支了,而是两条。通过上面的运行结果也可看到,一条打印出来的pid和ppid与原先的是一样的,而另一条的ppid是原先进程的pid,也就是说,连一条进程是原先进程的子进程。这里面30630是bash。一个进程可以有多个子进程,但一个进程只能有一个父进程。所以,linux中进程整体是树形结构

我们来看一下,创建进程成功时,fork的返回值



会发现,确实有了两个进程。通过打印出的语句可以发现,两个死循环是同时跑的,if、else if同时成立,以前不会出现if、else if同时成立的情况是因为以前写的程序都是单进程的。所以,fork一旦调用,fork往后就有两个进程了,这两个进程各自执行各自的代码。只不过fork给父进程返回子进程的pid,给子进程返回0。
为什么要给父进程返回子进程的pid,而给子进程返回0呢?因为一个父进程可以有多个子进程,需要有子进程的pid才能够更好地管理,而一个子进程只会有一个父进程不需要特别标识,只需要知道是否创建成功即可。

此时会有一个疑问,为什么一个函数会有两个返回值呢?
一般而言,fork创建子进程后,子进程和父进程的代码是共享的,但是数据是各自私有一份的。父进程的代码和数据是从磁盘上加载而来的,子进程创建时,只增加了一个子进程的task_struct,没办法从磁盘再加载一份代码和数据,系统会直接让子进程的task_struct中指向代码的指针直接指向父进程的代码,也就是和父进程共享一份代码。

上面的运行结果,fork后printf会打印两次,也是因为代码共享的原因
进程具有很强的独立性。若进程之间会互相关联,电脑上打开了很多软件,这些运行着的软件就是一个一个的进程,其中一个挂掉了,其他都会受影响,这样显然是不好的,所以进程之间是具有很强的独立性的。一个进程运行时,主要关心代码和数据(无论是栈上的数据,还是堆上的数据)。进程对于代码是只读的,所以不会影响独立性,但对于数据不是,所以代码可以共享,但数据必须要各自私有一份。在上面的代码中,fork后,父进程和子进程的id是不一样的,这也是为什么父进程和子进程一个加入if,另一个加入else if的原因。我们还可以再对其进行验证。


此时可以看到,父进程的gval是一直不变的,而子进程是每次循环都会加1的。所以,父子进程是各自拥有一份数据的。

创建多进程

我们现在使用一个C++代码,来创建多进程,C++的文件后缀可使用.cpp,也可使用.cc
g++是用来编译C++代码的

vim中支持将某一字段批量修改。可使用sudo yum install -y gcc-c++来安装g++

#include<iostream>
#include<vector>
#include<sys/types.h>
#include<unistd.h>
using namespace std;

const int num = 10;

void SubProcessRun()
{
    while(1)
    {
        cout << "I am sub process, pid: " << getpid() << ", ppid: " << getppid() << endl;
        sleep(1);
    }
}

int main()
{
    vector<pid_t> allchild;
    for(int i = 0;i < num;i ++)
    {
        pid_t id = fork();
        if(id == 0) // 子进程进入,并会被一直卡在SubProcessRun的循环内
        {
            SubProcessRun();
        }
        // 只有父进程能运行到这里
        allchild.push_back(id);
    }
    
    // 只有父进程能够运行到这里
    cout << "我的所有孩子的pid是: ";
    for(int i = 0;i < num;i ++)
    {
        cout << allchild[i] << " ";
    }
    cout << endl;
    while(1)
    {
        cout << "我是父进程, pid: " << getpid() << endl;
        sleep(3);
    }
    return 0;
}

插一个题外话:为什么写代码时,编译器能检测出语法错误?因为编辑器(如vim、vs中的编译器)在调用gcc/g++,gcc/g++有两种调用方式,一种在命令行,另一种在后端运行,都会做语法扫描

将上面代码运行

我们现在具体来说一下fork为什么会有两个返回值
fork是系统调用,本质就是一个函数,只是是操作系统提供的。函数就一定会有代码,并且父进程执行到时一定会进入。父进程运行到fork内部时,会进行创建子进程的工作,此时有一个步骤就是创建子进程的task_struct,当父进程创建好了子进程的task_struct并完成了创建子进程的其他所有工作后,就会return,也就是说到了return这一步时,子进程已经完全创建好了。所以,不是fork完才有两个分支,而是在fork过程中,创建完子进程并运行起来后,就已经有两个分支了,所以父、子进程会各自return一次,并且数据不同,所以会有两个返回值。这里说一下,子进程的task_struct是拷贝自父进程的task_struct的,部分属性会自己调整。

至于子进程和父进程的数据的独立是如何做到的,等到了地址空间时再做出解释。

进程的状态
进程的状态

现在谈的是对于所有的操作系统都适用的进程,具体到某一操作系统可能会有些许不同,后面会具体讲解Linux操作系统进程的状态


刚创建好的进程就处于新建状态,准备好,可以被操作系统调度就处于就绪状态,当CPU有空了,就将进程放到CPU上运行,此时就是运行状态,若处于运行状态的进程的代码中有scanf,并且运行到scanf时没有输入,就会将进程从CPU上拿下来,放到某个队列让其等待,此时就是阻塞状态,进程运行完,销毁后就变成终止状态

接下来,我们通过几个概念来更深入地理解进程的状态

并行和并发

并发:CPU执行进程代码时,不是把进程代码执行完毕,才开始执行下一个。而是给每一个进程分配一个时间片,基于时间片,进行调度轮转。我们下面的模拟是在单CPU下模拟的

现在一个CPU同时运行4个进程,CPU一次只能运行1个进程,为了让这4个都能运行,CPU会先运行进程1,运行一个时间片(也就是很小的一段时间),放下,运行进程2,运行完一个时间片,放下,再运行进程3,以此循环运行。当一个进程被换下后,感觉不到进程对应的程序卡住是因为CPU切换和运行速度非常快。像动画片也是以非常快的速度一张图片一张图片翻的。这也是为什么当我们程序中出现死循环后,不会完全卡死的原因。
物理上(真实情况下):CPU执行一个时间片后就换下一个执行
逻辑上:我们可以认为是4个进程同时运行的,每个进程都占1/4CPU

时间片

linux/windows这种民用级别的操作系统,一般是分时操作系统,CPU在调度进程时,追求的是任务的公平,也就是每个进程的调度时间是相同的,也就是使用时间片来控制每个进程的运行时间相同。另一种是实时操作系统,每个进程的优先级是不同的。
为什么要进行分时?因为运行任务是没有明显的优先级的,调度任务追求公平

等待的本质

我们先弄清楚运行状态、阻塞状态、挂起状态

操作系统会为每一个CPU提供一个运行队列,运行队列就是在操作系统内部的一个结构体。若有多个CPU,每一个CPU都要提供一个运行队列。

struct runqueue中存储的是运行队列的属性,还有一个task_struct类型的指针,当运行某一进程时,就会将进程的task_struct链接到这个指针的后面。当CPU要运行进程时,根据FIFO调度算法到runqueue中选择进程即可。一般是先运行队头的进程,此时会将队头的task_struct取下来,放到CPU上运行,运行一个时间片后,将其放到队尾,运行新的队头,以此类推。

所以,运行状态指的是只要进程在运行队列中,该进程就叫做运行状态,不是真的要在CPU上才叫运行状态。所以,很多时候,就绪和运行是合二为一的。

进程的代码和数据中,一般是会包含很多的IO操作的,也就是说,会进行外设的访问。假设此时在CPU上运行的进程的代码中有scanf,并且此刻刚好运行到这一句,用户还没输入,也就是键盘数据还没有准备好,进程就会被设置为阻塞状态,并将其放到等待队列中,等到键盘数据准备好,才会将task_struct重新放到runqueue中

那进程是如何被放到等待队列中的呢?此时就需要了解操作系统是如何管理底层硬件的。操作系统管理底层硬件的方式同样是先描述,再组织。操作系统会使用结构体device来存储每一个硬件的信息,这个结构体中有struct device类型的指针,可以用来组织起各个device类型的结构体,并且还有一个task_struct类型的指针,这也就是上面所说的等待队列


操作系统通过驱动层即可获取各个硬件的属性。
每一个device都有一个等待队列。当前CPU上的进程运行到scanf时,scanf中的系统调用就通过操作系统去查键盘对应的struct device,再通过驱动程序去识别键盘上是否有数据,若没有数据,操作系统不将task_struct放到runqueue的后面,而是将进程链接到设备的等待列表中,并将进程的状态改为阻塞。所以,阻塞状态就是在硬件的等待列表上等待的状态

运行和阻塞的本质:让不同的进程,处在不同的队列中

等待的本质:链入目标外部设备,CPU不调度。运行队列中是等待CPU资源,在等待队列中是等待硬件资源。本质是相同的。

在CPU上运行进程时,代码时会推进的。

当我们向键盘输入数据后,操作系统就会知道,然后会将键盘的等待队列中的第一个进程放到运行队列的尾部。所以,整个进程的调度过程,就变成了对数据结构的增删查改。所以,程序卡住就是CPU没有调度它了,有可能是在设备的等待队列中,也有可能是进程太多,轮转时间太长了。并且进程变多,对硬件的操作也会变多,使得程序变卡。

挂起

处于阻塞状态的进程,因为PCB是没有被调度的,所以其代码和数据是没有意义的,当内存资源严重不足时,操作系统为了保证自身的安全(当要申请task_struct时,若因内存不足而导致申请失败,操作系统就会挂掉),此时会将PCB留着(仍然放在等待列表中),将代码和数据换出到磁盘。当用户输入,操作系统就会识别到,就会将PCB重新链到运行队列中,此时若直接链入,只有PCB而没有代码和数据是会出问题的。所以,在阻塞状态到运行状态之前,要将磁盘中的代码和数据重新换入内存中。通过换出和换入,可将暂时不用的代码和数据临时放到磁盘中。磁盘中有一个分区专门用来进行换出、换入操作,称为swap分区。将被换出的进程称为阻塞挂起状态。换出是换出所有处于阻塞状态的进程,而不是一个。有可能还会有运行时挂起,当进程太多,位于runqueue后面的要很久才能轮转到。但一般不会,因为风险太高。

因为有频繁的换出、换入,所以挂起本质上是使用时间换空间的。换出、换入本质是在做IO,即与外设作交互,也就意味着会非常慢。在云服务器或者一些公司中,通常会禁掉swap分区,提高效率。若已经做了换出,或者没有swap分区,内存资源仍然严重不足,这时候操作系统可能会直接结束掉某些进程,以保证操作系统自身的安全。

Linux进程的状态

上面的状态是针对整体的操作系统而言的,具体到某一操作系统可能会有所变化。在这里,我们介绍linux操作系统的进程状态

linux进程有以下7种状态


R与S

R状态是代表一个进程正在运行,S状态是代表一个进程正在等待
我们将上面的程序运行起来看看创建的进程是什么状态的

STAT就是一个进程的状态,会发现此时进程的状态是S,+是代表进程是在前台跑的。明明这个进程已经在运行了,为什么状态会是在等待呢?因为我们的while循环中有printf语句,一个进程的时间片是非常短的,根据冯诺依曼体系结构,printf打印时并不是直接向显示器打印的,而是先向内存中写入的,但一直死循环,缓存很快就满了,会导致显示器并不一定时时刻刻都是就绪的。总而言之就是,有printf语句,就有IO,因为IO速度非常非常慢,所以这个进程99%的时间都在做IO,只有在while判断,或者执行printf语句时,才是运行状态(R),其余时间均为等待状态(S)。此时若是我们一直查看进程状态,查看的次数足够多的话,可能会看到R状态。

假如我们现在将printf语句注释掉


此时的进程状态就是运行状态了



当我们将这个程序运行起来,但是不输入时,也会是S状态
处于S状态的进程是可以被信号中断或直接杀掉的

S状态也称为休眠状态,或阻塞等待状态,可中断休眠,是浅睡眠

D

假设有这样一个场景。用户创建了一个进程A,来向磁盘导入数据,此时就是在做IO,是非常慢的,所以进程A会处于休眠状态,A的PCB就会被链入磁盘的等待队列中。倘若此时内存资源严重不足,操作系统就会销毁掉进程A,因为进程A被销毁,内存和数据也会被销毁,因为用户向磁盘导入数据时,不是直接对磁盘写入的,是会先写入用户级的缓冲区,然后合适的时候再次刷新到内核级的缓冲区,合适的时候再向磁盘写入。所以,即使进程A被销毁了,向磁盘导入数据的操作仍然是在进行的,若此时恰好磁盘读取数据失败了,那么磁盘就会向进程A返回错误信息,等待进程A的指示,但是此时进程A已经被销毁了,所以磁盘会将原先写入的数据清理掉,此时就造成了数据丢失。

所以,当进程在于磁盘进行IO时,禁止操作系统销毁这个进程,需要有一个新的阻塞状态,就是D状态。D状态是阻塞等待状态的一种,不可中断睡眠,是深度睡眠。此时若磁盘读取失败,进程一定可以得知,就可做出应对,不会让数据丢失。D状态是专门针对磁盘的。通常,D状态是很难出现的。

T

信号19可以让一个状态暂停,也就是进入了T状态。


此时若想让其继续运行,可以使用信号18

会发现继续运行后,由S+变成了S,这是由前台进程变成了后台进程。此时使用ctrl + c是无法结束进程的,并且进程正在运行时,向命令行输入ls、pwd等指令也是可以正常运行的。要结束后台进程需要使用kill -9
我们在创建进程时,也可以将一个进程创建为后台进程

为什么要将进程放在后台呢?如果想进行较为耗时的任务,但并不想影响命令行的使用,如下载时,就可以将进程放到后台。在windows中,将下载任务最小化就是让其到后台。
T状态主要是进程做了非法但不致命的操作时,被OS暂停的状态

t

t状态也是一个暂停状态。但t状态与T状态不同,通常指的是一个进程被追踪。当一个进程需要被追踪时,该进程可以被调试器设置为t状态来进行追踪。


我们让另一个终端每隔一秒就查询一次进程的状态。



gdb code后,会发现gdb进程就开始运行了。我们现在给code打上断点


打上断点并运行起来后,会发现code的进程有了,并且此时进程的状态就是t。所以,打断点让程序停下来的本质是当前进程被暂停了,此时gdb进程控制着code进程


我们让其执行printf语句,会短暂地变成S状态,随后又变成了t状态,因为n只是让其运行一句。
t状态是当进程被追踪时,断点停下,进程的状态就是t

对上面5种状态做一个总结。R就是进程状态中的运行状态,S/D是进程状态中的阻塞状态,T/t是linux独有的。S/D是因为要等待某种资源而暂停,T/t是因为操作的需要而暂停。需要将一个进程暂停,要么是进程做了违规操作被操作系统暂停了,要么是这个进程被其他进程追踪了,如被gdb追踪了。

Z与X

我们来看看下面的这一个操作

$?是用来记录最近的一个进程退出时的退出信息的。ls code是执行成功的,所以其退出信息是0。ls mycode是执行失败的,因为没有mycode,可以看到其退出信息是2。实际上,0表示成功,非0都表示失败。ls是C语言写的,就会有main函数,而main有返回值,所以main的返回值就是为了告诉父进程/操作系统,该进程的执行结果。

Z状态就是僵尸状态,是进程从退出到死亡的一个中间状态
X是死亡状态

为什么要有Z状态呢?当一个进程被创建,一定是某一个进程的子进程,并且是为了完成某个任务的,要知道任务完成的如何,就需要在进程退出时由父进程/操作系统来检测退出信息,而为了能够支持检测,进程退出时不能立刻死亡,需要先处于僵尸状态,知道父进程或操作系统未来通过某种方式读取退出信息,然后进程才会处于死亡状态,死亡状态时,进程才会真正地被回收。

进程退出后,进程就不会再被调度了,也就是代码不会再执行了,但是PCB仍然要保留着,以便于父进程或操作系统从PCB中读取退出信息(进程的退出信息是保留在PCB当中的)。注意:这里的退出信息不只有退出码。之前的getpid,就是有一个指针指向当前进程的task_struct,调用getpid时就返回里面的pid。所以上面的echo &?可以拿到上一进程的退出信息也是因为PCB仍在。

创建进程时,是先创建内核数据结构,再加载代码和数据的。一旦task_struct创建,即使代码和数据还未加载,进程也已创建,只是此时不会被调度而已。此时就是新建,代码和数据加载完就是就绪,这两个状态太过于瞬时,linux状态中就没有。释放时,是先释放代码和数据,再PCB。代码和数据被释放,PCB被维护时进程所处的状态就是Z。

若我们想要看到Z状态,只需让子进程退出,让父进程活着(让父进程活着就是为了让其不回收子进程)。子进程运行完,父进程还在运行时,子进程所处的状态就是Z



并使用另一个终端实时查看进程的状态


可以看到,当子进程运行结束后,父进程还在运行时,子进程的状态就是Z。并且子进程的进程信息后面还有defunct,表示这个进程失效了。
当父子进程都是死循环,使用kill杀掉子进程,父进程仍然正常运行,子进程的状态也会是Z。
若子进程处于Z状态,并且一直没有被父进程/操作系统回收,那么就会造成内存泄漏。我们可以与前面所学联系一下,new/malloc申请的空间,若没有手动释放,进程结束是否会释放?会。因为申请的空间是属于进程的数据,进程退出时就已经释放。
所以,僵尸进程的危害就是容易造成内存泄漏。

孤儿进程

上面我们看了父进程还在,子进程退出了的情况,子进程会变为僵尸状态,现在来看父进程退出,子进程还在的情况,也就是使用kill杀掉父进程。



当我们使用kill杀掉父进程后,子进程的PPID就变成了1,此时还会发生两个现象
(1)父进程直接就没了,没有变成Z状态
父进程的父进程bash直接将其回收了
(2)ctrl+c无法结束子进程
被领养的进程一般默认在后台运行
1就是操作系统,此时子进程的父进程被杀掉后,子进程就会被操作系统领养,被领养的进程被称为孤儿进程
X状态看不到,因为是瞬时状态

进程的优先级

进程的优先级是什么?
进程的优先级就是获得某种资源的先后顺序。例如,在食堂打饭时需要排队,排队的本质就是在确认优先级。在这里资源就是打饭的窗口。
为什么要有优先级?
本质其实就是目标资源太少了。例如,排队打饭时,打饭的窗口就是竞争的资源,而学生是众多的,打饭的窗口确实较少的。在操作系统中,CPU只有1个、2个,但进程却可能有几十个,并且各自设备,如磁盘、显示器、键盘等相较于进程,都是非常少的。
进程的优先级竞争的是CPU资源

查看进程优先级

我们将上面的程序运行,然后通过某些指令就可以查看进程的优先级了

这里面的PRI就是Linux进程的优先级,NI是nice,也就是优先级的nice数据
最终优先级PRI = 默认优先级(80) + nice
当我们要修改一个进程的优先级时,不能直接修改PRI,而是修改nice值。因为若是一个进程正在被调度,此时又修改了优先级,可能会出错,而先修改nice值,再由系统自动更新进程的优先级,这样就不会出错。

再看看上面进程信息中,还有一个UID
UID就是用户的ID,cxf是用户的名称。cxf是一个字符串,在修改、比较时时间复杂度都是比较高的,所以,在操作系统内会为每一个用户维护一个UID。ls -n可以将能显示乘数字的信息都显示乘数字,就可以查看用户的UID了

进程信息中有UID有什么作用呢?
通过进程信息中的UID就可以知道一个进程是哪一个用户启动的,当某一进程操作文件时(linux下一切皆文件),就可以与文件的拥有者、所属组比较,从而进行权限控制。
所以权限控制的一个基本实现原则就是文件可追溯

修改进程优先级

修改进程的优先级既可以使用指令修改,也可以使用代码修改。但修改进程的优先级不是高频,而且也不建议修改,所以我们这里只介绍简单的指令修改。进程的优先级是PRI越小,表示优先级越高

我们可以看到修改之前,code这个进程的优先级为80

修改进程优先级的步骤是先top,然后r,然后先填进程的PID,回车,再填要修改的nice值


我们再来查询一下修改之后的进程优先级

会发现进程的优先级就到了99,nice值没有到100,只到了19。所以,nice的最大值是19

再修改一次进程的优先级



此时会发现我们因为频繁修改而无法修改,我们可以使用超级用户或使用sudo

再重复上面的操作,结果是

由此可以得知,nice的最小值是-20

综上所述,nice的值为[-20, 19],所以PRI的值在[60, 99],一共有40个PRI

为什么nice值要在可控范围之内呢?
因为我们现在使用的操作系统是分时操作系统,分时操作要求进程调度尽量公平。nice值在可控范围之内正是为了保持分时操作系统的公平,防止某一进程优先级过高而失去公平。

为什么nice值是[-20, 19]呢?
这个问题在下面进行回答。

其他概念

竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级

独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行

并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发

单CPU下一定只有并发,没用并行;多CPU下可能既有并发,也有并行。

进程切换

概念储备 

为什么会有进程切换?
Linux是基于时间片进行调度轮转的,每个进程都会有自己的时间片,时间片到了,进程就会被切换。但进程的时间片到了时,并不一定就跑完了,可以在任何地方被重新调度切换。

感性理解进程切换
举一个小例子。张三是一名大二学生,在大二上学期应征入伍了。这时候张三需要与学校打一声招呼,将他的学籍保留,学籍里的信息就包含了张三在大学期间修了多少学分、上过那些课等等的信息,等到张三当兵回来,又需要将学籍还给学校,学校安排张三继续从原先的地方开始继续学习。在这里,保留学籍不是目的,是手段,未来恢复学籍才是目的。进程切换也是同样的道理,当进程的时间片到了,就需要进行进程切换,此时需要保存进程运行到了代码的哪里等信息,统称为上下文数据,当下一次CPU又要调度这个进程时,就需要恢复上下文信息。保存上下文信息不是目的,是手段,未来恢复上下文数据才是目的。

进程切换的过程和理解

进程在运行的时候,会产生非常多的临时数据,这些临时数据都在CPU的寄存器上保存。这里的临时数据不是指堆、栈上的数据,而是指运行到某一行代码,这行代码上的代码和数据等。这些临时数据会放到CPU的寄存器上,在CPU上计算后,再将结果返回内存中的进程

CPU上有非常多的进程,如eax、ebx、ecx、edx、eflag、ecs、eds、efs、egs、cr0、cr1、cr2、ebp、esp、eip(pc指针)、ir等等,这里重点介绍eip(pc指针)和ir

eip(pc指针):当前正在执行指令下一条指令的地址
ir:指令寄存器。存放当前正在执行的指令

CPU内部的寄存器的数据,是进程执行时的瞬时状态的数据。CPU内有一套寄存器,寄存器 != 寄存器里的数据,寄存器是指CPU上的硬件,寄存器里的数据就是上下文数据。

我们现在来演示一下进程切换的过程。核心:进程上下文数据的保存和恢复


进程的代码和数据,每条指令都会有地。现在刚开始运行代码,会将main函数的地址加载到pc指针中,控制器根据pc指针中的地址,拷贝这个地址的代码和数据到ir中,pc指向下一行,然后控制器开始执行这一行代码,以此重复

准确来说,pc指针不是指向下一行,而是当前地址 + 读进来的指令的长度


现在刚好运行到pc指针指向下一行时,时间片到了,要切走,此时会拷贝一份上下文数据,这里的上下文数据就是10、20、0x30、add eax ebx,当被切换回来时,需要先恢复上下文数据。所有的进程都需要做这个工作,所以每次切换时,CPU都是全新的。

前面我们说了寄存器 != 寄存器里的数据,现在可以有一个更加深入的理解,寄存器只有一套,而每个进程都会有自己的上下文数据,所以两者是不相等的

被切换走的进程的上下文数据保存在哪里呢?
认为上下文数据被保存到了进程的PCB中即可,实际情况非常复杂。

Linux真实的调度算法

当CPU在运行进程1时,切换成教程2,为什么是进程2,而不是进程3?
我们前面介绍了一个FIFO的调度算法,当一个进程时间片到了,就会到调度队列的末尾,队头的进程就是下一个,但是除了特别老的内核,否则不会使用FIFO调度算法,因为这样无法体现优先级。

在linux真实的调度算法中,linux操作系统为了体现优先级,设计了一个hash桶

这个哈希桶中前100个元素(下标0-99)不需要考虑,是给实时进程的,后40个位置才是给普通进程的,我们在进程优先级哪里谈到了进程有40个PRI,刚好对应这里的40个位置。相同优先级的进程会被挂在一起,以后调度进程(普通进程),直接根据这个hash桶,从前往后找

我们来看看Linux2.6内核中进程队列的数据结构

一个CPU就有一个运行队列,一个运行队列有两张哈希表,都叫queue[140]
为什么一个运行队列要有两张哈希表呢?
若一个运行队列只有一张哈希表,此时一直产生优先级高的新进程,那么优先级低的进程将难以被调度,这也称为进程饥饿问题。并且当优先级较高的进程的时间片到了,又会插回到前面,此时除非优先级高的退出了,否则永远无法论到优先级低的。我们的要求是:调度器既要考虑优先级,又要非常均衡地进行进程调度
我们来看看是如何通过两张哈希表来完成这个要求的调度器
我们先对上面的数据结构进行简化

这两个哈希表一个是活跃的,一个是过期的,分别由active和expired两个指针指向。初始时,active、expired分别指向array[0]、array[1],称为活跃队列和过期队列


下面是不考虑前面100个位置的情况

CPU开始调度active指向的哈希表上的进程,在这个过程中,时间片到了被换下的进程和新产生的进程都会被放到expired指向的哈希表上。直到active指向的哈希表为空,此时只需交换一下active和expired的指向即可。这样就能保证在一个调度周期内,优先级低的和高的都能够被调度,且优先级高的优先调度
nr_active就表示这个哈希表中有多少个进程,这也决定了什么时候交换
此时根据进程优先级将进程放到哈希表中的时间复杂度是O(1),但在CPU根据优先级调度时,会遍历哈希表,时间复杂度是O(n),为了降低时间复杂度就设计了bit_map[5],充当位图。bit_map是一个int类型的数组,1个int是32bit,5个int是160bit,刚好覆盖了140,140个比特位,比特位为0时,就说明哈希表在这个位置没有进程,为1则说明有进程。这样最多只需要循环5次,1次就能判断32个位,当5次中某一次不为0时,再去判断里面里面那个位置有教程,时间复杂度是O(1)。所以,这个算法也被称为Linux内核O(1)调度算法

进程的组织结构

前面我们已经介绍过了,进程都需要使用链表来链接(前面说的都是单链表,实际上都是使用双链表来链接的)。进程就是运行起来的程序,因为进程会被调度、切换,所以只要是一个进程,就会被放在全局链表中,方便统一管理,当被调度时,就放到调度队列中,当等待时,就放到等待队列中,时如何做到的呢?
Linux内核中的链式结构只有链接字段,没有属性字段

这样做的意义就是:一个进程,既可以在全局链表中,又可以在任何一个其他数据结构中,只要加结点字段即可。

此时又会有一个问题,我们怎么根据struct node类型的link来获取struct task_struct类型的结点的其他信息呢?

假设我们现在知道了结构体对象obj中c的地址,那我们要如何获取obj的地址呢?
此时只需要获取起始地址与c地址的偏移量即可,(struct A*)(&c - 偏移量)就是指向obj的的指针

我们此时可以假设obj起始位置的地址位0,那么&((struct A*)0 -> c)就是c的地址,因为obj起始位置是0,所以这也是偏移量。所以,(struct A*)(&c - &((struct A*)0 -> c))就是obj的起始位置的地址。可以直接定义成一个宏,这样就能够直接调用获取结果了。在Linux内核中,就定义成了offset
#define offsetof(type, member) ((size_t) &((type *)0)->member)

命令行参数

main函数的参数称为命令行参数



当我们使用了main函数的命令行参数时,我们就可以在启动程序时,后面加上选项
argc是参数的个数,argv[]是一个指针数组,表示参数的清单
同一个程序,可以根据命令行参数,根据选项的不同,表现出不同的功能。指令是C语言写的,指令可以带选项就是通过命令行参数实现的。

main函数的参数是由谁传递的呢?


当我们输入一个指令及其选项后,实际上就是一个字符串,这个字符串首先会被命令行解释器shell进程拿到,shell进程就会对这个字符段按照空格打散,形成一张表(argv),和元素个数(argc),打散形成的argv和argc是shell进程的代码和数据的全局变量。ls是指令,一运行起来就变成了进程,这个进程是由shell进程通过fork创建的,所以shell进程是这个进程的父进程,子进程会通过exex()系列系统调用加载并执行ls这个可执行程序(也就是去执行ls中自己的代码),ls指令是C语言写的,所以这个可执行程序中也有一个main函数。虽然父子进程都各自拥有一份数据,但是父进程的数据(尤其是只读的数据),子进程是可以读到的,所以,子进程是可以拿到父进程打散形成的argv和argc的,子进程读到argv和argc后,再将argv和argc传给自己的main函数

对上面./code 1 2 3进行解释:当shell进程处理好了argv和argc并创建了子进程后,子进程是能够读到父进程处理好的argv和argc的。创建好子进程后,根据fork的返回值,子进程会进入到返回值为0的分支中,这个分支中会调用exec()系列系统调用,子进程调用了exec()系列系统调用后,代码段会被替换成code.c中的代码,并且子进程会将argv和argc以参数的形式传递给code.c中的main函数

main函数是程序中第一个被调用的函数,谁调用的main函数呢?
main函数是程序的入口,但系统调度这个程序时,并不一定是从main函数开始的。实际上会由系统或编译器在前面先调用CRTstartup

环境变量

实际上,main函数还有第三个参数env,称为环境变量。可以使用下面这个程序查看


会发现,所有的环境变量都是key = value的格式
每一个进程都会有一张环境变量表,且这张表继承于他的父进程,与命令行参数类似

在命令行中使用env指令,可以直接看到shell进程的命令行参数

现在我们来见一见几个常用的命令行参数

PATH
我们会发现,执行系统命令时不用./,而执行我们自己写的可执行程序时一定要加,这是因为系统查找命令时默认去/usr/bin/下查找,为什么系统知道命令在/usr/bin/下呢?
正是因为环境变量PATH。我们可以echo $PATH来查看环境变量中的PATH。$是将后面的PATH当成变量来看,若不加,则是当成字符串来看

所以,PATH就是系统可执行文件的搜索路径集合。PATH这个环境变量,告诉了shell,应该到哪一个路径下取查指令

若我们不想带路径,想直接让我们的程序运行起来。此时有两种方法:
1. 将我们程序的可执行文件放到/usr/bin目录下
2. 将我们程序的可执行文件所在的目录添加到PATH中

为什么ls用不了了呢?因为当前这种写法是覆盖式的,之前的全没了。shell进程的环境变量是内存级的,只要关掉XShell,再重启即可恢复(改的PATH只在bash内部有效)。所以,PATH就是一个内存级的变量,是在shell内部维护起来的

我们现在重启XShell,将程序的可执行文件所在目录加入到PATH中

这样就可以了
环境变量是被导入到shell中的,那环境变量最先开始从哪里来呢?
一定不在内存,在系统的配置文件中。shell是C语言写的,可打开文件、读取配置文件等。shell进程启动时,会读取系统的配置文件而形成自己的环境变量。所以,若想让一个环境变量永久改变,需要修改配置文件。我们上面那样子修改只是暂时的。
配置文件就在用户的家目录下.bashrc和.bash_profile这两个文件。我们登录时,系统会为我们启动一个shell进程,这个shell进程在启动时就会读取用户和系统相关的环境变量的配置文件,从而形成自己的环境变量表。

用户一登陆就在自己的家目录下,就是为了能够读取这两个配置文件
注意:当我们打开配置文件,修改了里面的配置信息后,此时是永久有效的,但是需要我们重新启动XShell才能生效,因为需要重新读取配置文件

HOME

环境变量中的HOME表示的是当前用户的家目录

当我们登录时,系统就会创建一个bash进程,这个进程在创建时就会读取配置文件,从而设置好PATH、HOME等环境变量,并且bash是一个进程,就会有cwd,会将cwd设置HOME,所以一登录就会处在家目录

一个进程可以通过chdir更改当前的工作目录

命令行启动的进程都是bash的子进程,并且这个子进程的task_struct中的大部分属性也是进程于父进程,其中就有cwd,所以,创建一个进程后,所处的路径仍然是bash的路径

SHELL

记录用户登录后启动的是哪一个shell

PWD

记录用户当前所在的位置

我们来看看PWD存在的意义是什么?

假设我们先不想通过env来获取环境变量的值,而是想通过代码来获取环境变量的值
此时需要使用到getenv,getenv的作用是获取环境变量的值

环境变量PWD的意义就是:
1. 实现pwd指令(通过getenv)
2. 在新建文件时进行补全

USER

USER表示的是当前正在使用系统的用户是谁

su - 是以root的身份重新登录,USER会变,而su只是将当前用户切换成root,并没有重新登录,所以su后USER是不变的

有了USER这个环境变量后,我们可以写一个根据不同用户有不同功能的程序

OLDPWD

OLDPWD表示的是上一次的路径
可以用于实现cd -

我们已经看到了很多的环境变量,现在来总结一下环境变量的本质

环境变量的本质:系统提供的具有"全局"属性的变量

我们接下来就是为了理解这句话

shell是支持在命令行中定义变量的,定义的变量称为本地变量

在命令行中输入a=10,这是一个字符串,会被shell先拿到,因为这并不是一个命令,所以shell会将这个字符串保存起来(在进程内部,malloc一块内存空间保存起来,与环境变量、命令行参数类似),当我们echo $a时,就会去这个进程维护的所有变量'='的左边进行字符串匹配。当我们定义了本地变量a后,只要env是看不到的,因为env是看环境变量的,此时可以使用set,来查看包括本地变量在内的所有变量
我们可以使用export来将本地变量变成环境变量

关掉XShell后,export的变量也没了
export也可以不用先定义

可以使用unset来取消一个环境变量

所以,bash会同时维护3张表

我们刚刚在命令行中定义了变量,前面也在命令行中写过while。所以,因为bash进程可定义变量,又可以解释while、for等语法,所以衍生出了shell脚本,shell脚本的本质就是一个文件,这个文件会被bash一行一行解释

子进程会继承父进程的env、argv,但是不会继承本地变量表。

为什么说环境变量具有全局属性?
我们知道,在命令行中启动的所有进程,都是bash的子进程,因为环境变量可以被bash之后的所有进程拿到,所以,环境变量具有全局属性。

为什么环境变量要具有全局属性?为什么要有环境变量呢?
a. 系统的配置信息,尤其是具有"指导性"的配置信息(具有"指导性"的配置信息是指配置信息有特定的作用,如当前用户是谁、当前所在工作目录是那、相关编码方式等),但这些配置并不一定生效,要生效就需要保证所有进程都能够拿到配置信息并遵守。所以,"全局属性"是系统配置起效的一种表现
b. 进程具有独立性,环境变量可以用来在进程之间传递数据(一般是只读数据)

程序中要获取环境变量的方式有那些呢?
1. main函数的第二个次数
2. getenv
3. 全局变量environ
bash不仅维护了env,还维护了一个全局变量environ,environ是指向env表的,因为env表的char*类型的,所以environ是char **类型的

使用environ需要先声明一下

所以,环境变量就是系统中全局、有效的配置信息


http://www.kler.cn/a/548807.html

相关文章:

  • Redis 主从复制的核心原理
  • pnpm和npm安装TailwindCss
  • 【人工智能】深度学习中的梯度检查:原理详解与Python实现
  • Leetcode Hot100 第30题 416.分割等和子集
  • InnoDB如何解决幻读?深入解析MySQL的并发控制机制
  • dify新版,chatflow对deepseek的适配情况
  • 72.git指南(简单)
  • HTTP
  • cmake Qt Mingw windows构建
  • 物联网 网络安全 概述
  • 杜绝遛狗不牵绳,AI技术助力智慧城市宠物管理
  • 介绍两本学习智谱大模型的入门图书
  • 大数据实训室解决方案(2025年最新版)
  • 小米14 机型工程固件预览 刷写以及更改参数步骤 nv.img的写入
  • 【Bluedroid】 BLE连接源码分析(一)
  • LeetCode每日精进:203.移除链表元素
  • 开发中需要使用到volatile的情况
  • 【大模型系列】入门常识备忘
  • IT行业方向细分,如何做到专家水平——7.边缘计算与物联网(IoT)
  • 算法刷题--哈希表--字母异位词和两个数组的交集