进程概念(linux)_massachusetts_11的博客-爱代码爱编程
前置知识
在讲解进程之前,我们需要先铺垫一些计算机硬件、操作系统的知识,进而丝滑的理解进程的概念,然后再通过进程切入,从而理解操作系统。
硬件——冯诺依曼体系结构
冯诺依曼体系结构计算机分为五大单元:
- 输入:
即输入设备,想办法将人的数据交给计算机的设备
包括:键盘、话筒、摄像头、磁盘、网卡……
输出:显示器、音响、磁盘、网卡、显卡……
(运算器+控制器)[CPU]
存储器:就是我们所说的
内存
电脑本质就是将外部输入的数据进行计算后进行输出,按道理只需要输入设备、中央处理器、输出设备三部分,那为什么要有内存呢?
技术角度:
CPU的运算速度(纳秒级别) > 寄存器的速度 > L1~L3Cache > 内存(微秒级别) >> 外设(磁盘)(毫秒级别甚至秒级别 ) >> 光盘磁带
它们的速度差别是非常大的
根据木桶原理,整体的效率一定是由速度最慢的设备,即外设决定的,如果外设和CUP直接交流,总体效率会非常慢,
所以从数据角度,外设几乎不和CPU打交道,直接和内存打交道;CPU也同样如此
内存在我们看来,就是体系结构的一个大的缓存,适配外设和CPU速度不均的问题
成本角度:
为了追求速度,我们为什么不把CPU的寄存器直接做大到内存的级别呢?
答案很简单,成本太高了!
成本:寄存器 >> 内存 >> 磁盘(外设)
总之,内存最大的意义就是用较低的成本,获得较高的性能。
此时我们就可以分析一下如下现象:
我们之前可能听过,编译好的代码必须先加载到内存里才能运行,为什么呢?
编好的程序是一个.exe文件
,存在磁盘中(外设),所以必须先加载到内存中,这是体系结构决定的。
软件——操作系统
什么是操作系统呢?
像我们的Linux、Windows这样的搞管理的软件
对下要管理好软硬件资源,对上要提供良好的软件服务
接下来,我们通过两个案例类比一下什么是操作系统
案例一:管理
既然操作系统你有一个功能是管理,那么什么又是管理呢?如何管理呢?
我们先从哲学的角度去思考一下:
我们一般很少能见到我们的大学校长,但是作为一个管理者他依然能做出各种决策,将全校几万人管理的很好,是因为他有我们的数据
可以见得,管理的本质不是对被管理对象直接进行管理,而是只要拿到被管理对象的所有相关数据,只要对数据进行管理,就可以实现对人的管理。
但是校长连我的面都不见,怎么拿到我的数据呢?有辅 导员啊,辅导员可以直接接触到学生,拿到数据,把数据上交给校长,校长通过数据可以做出决策。
同时辅导员还有一个角色:执行者,他可以将校长做出的决策进行向下落实。
这里学生就是一个被管理者。
这里面校长——辅导员——学生
的关系,就相当于操作系统——驱动——硬件
的关系
操作系统通过驱动拿到硬件相关的数据,在操作系统层面保存下硬件的所有数据,当有一些需求、任务的时候,操作系统做好决策,交给驱动程序,驱动程序再让硬件去完成
那么,第二个问题来了,上万名学生,每个学生又有电话、地址、成绩……等大量信息,大量的数据都集中到校长,如果不做处理,根本无法使用,那么又怎么对数据进行管理呢?
好在,这个校长以前是个程序员,他发现,虽然数据多,但是重复属性的数据也很多,每个同学都有姓名、电话、地址、成绩……,要管理A学生和B学生所需要的数据种类是一样的;所以,校长把所有的信息以学生为单位,组织好。
换个角度,人是通过事物的属性认识世界的,一切事物都可以通过抽对象的属性,来达到描述对象的目的;校长做的,其实就是抽取所有学生的属性,来描述对应的学生。
我们知道,Linux使用C语言写的,那么在C中有没有一种数据类型,能够达到描述对象的功能??
struct
每个学生都能产生自己的对象或者说变量
为了将这几万名学生管理起来,于是校长想到一种数据结构——链表,于是又给每个结构体增加了两个指针,分别记录前后两名学生,从而将这几万名学生组织起来,校长只要拥有一个头节点,就掌握了所有学生的所有信息
从此,对学生的管理,就变成了对链表的增删查改。
自此,我们又凝练出一套理论:
管理的核心理念:先描述,再组织
再回到操作系统
那操作系统要进行哪些管理呢?
操作系统:内存管理、进程管理、文件管理、驱动管理
之后我们着重研究进程管理
案例二:服务
我们也可以把操作系统看成是一个银行系统,银行中有电脑、桌椅、仓库、宿舍等硬件(对应计算机的硬件);也有一些员工,管理使用着这些硬件(对应我们的软件);行长(操作系统)同时掌控着员工、硬件,相当于系统内核既要管理硬件,也要管理软件;整个银行是封起来的,但会提供一些窗口,向外提供服务,用户可以从进行存钱取钱等请求,让银行做出相应的反应,相当于操作系统会向外提供一些接口——C语言的函数调用,这些函数被称之为系统调用
再设想一个场景:
一个老太太来到银行柜台,要存钱,工作人员要她进行填单等一系列操作,老太太完全不会,恰好银行门口有接待员,于是把钱给到接待员,让他完成了存钱的任务。
在操作系统的实际使用中,一些小白或者初级工程师如果面对大量的底层系统调用去调用网卡、硬盘……这些硬件,学习成本实在太高了,于是操作系统便设计了”接待“,小白可以简单上手的:图形化界面(常见的Windows界面)、命令行解释器(Linux),工程师在编码时使用的,如printf()
这样的库函数。
总结:
当我们用户进行下载卸载程序这样的管理操作、双击应用打开这样的指令操作、编程时写下的printf(),这些操作会调用硬盘、网卡这些硬件吗?
一定会,但这些都是操作系统进行了层层调用而实现的。
或者说,我们平时写代码时调用的一些库函数
,也或多或少会调用一些系统调用。
这里又一个问题产生了,Linux和Windows的系统接口是一样的吗?它们的库一样的吗?
答案是不一样的。
两种操作系统的编写细节一定是不同的,那它们的可以调用的接口也一定不同
然而不同平台的C语言库都必须提供的printf(),因为系统调用不同,它们的printf()实现也不同,所以每个平台都需要提供自己独有的C语言库。(也就是说,如果今天有个新的操作系统要被大量用户使用,C语言委员会就得为它写库)
但是,我上层不需要知道,同一段源代码,我在Linux上编译,会自动链接Linux的库;在Windows上编译,会自动链接Windows的库。
像这样,我需要的C语言函数的功能是不变的,但底层实现会因系统的改变而改变,这不就是C++多态的概念吗?
进程管理
我们知道了,操作系统的一个任务就是进程管理
那么,什么是进程呢?
一个简单的解释:进程其实就是一个运行起来的程序
我们的源文件写好放到磁盘,再经过编译链接形成可执行程序,此时我们双击运行这个程序,此时程序会加载到内存中,那么,内存中的这个.exe
文件就是进程了嘛?
其实这个解释有一点狭隘,它依然只是程序!
具体什么是进程,我们先不说,先想想这样一个问题:
打开Windows的进程管理器,我们可以看到系统中同时存在大量的进程
那么操作系统就一定要将所有的进程管理起来;
对进程的管理,就是对进程的数据进行管理;
根据之前说的,我们就要先描述,再组织
所以,操作系统就要为管理该进程,创建对应的数据结构
既然编写Linux的是C语言,那么描述这个事物,我们用什么类型呢?
毫无疑问:struct
在Linux操作系统中,会定义一个struct task_struct
的结构体,其中包含了进程的所有属性数据,于是就可以把磁盘中加载进来的程序的一些属性实例化出一个对象,通过这个struct也可找到内存中程序的位置;
再将这一个一个的结构体进行通过一定的数据结构(其实就是链表)进行连接,就可以把所有进程组织起来;
此时操作系统对进程的管理就成为对内核数据结构的管理,即对链表的管理。
那么进程是什么呢?
进程 = 进程控制块—task_struct + 加载进来的可执行程序合称进程
task_struct是Linux中独有的名称,在整个操作系统学科中,这个结构称为PCB(process ctrl block).
以下是Linux-2.6.32中的部分task_struct
接下来,我们讲的实际就是进程控制块的各种属性。
查看进程
ps指令
我们先编写一段代码,让程序一直运行
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("I am a process!\n");
sleep(1);
}
return 0;
}
这里,我们的可执行程序mytest
,启动之后就是一个进程。
那怎么查看我们系统中的进程呢?
ps ajx
这条指令会把系统中所有的进程显示出来
我们再用grep
,把刚刚写打开的进程筛选出来
ps ajx | grep ‘mytest’
可以看到这里第一条就是刚刚我们加载的进程,那第二条是什么呢?
可以找到一个grep
我们写的代码,编译形成可执行程序,启动就是一个进程
那么别人写的程序启动后是不是进程?肯定是!!
像我们之前的一些:ls pwd touch ……的指令就是别人写的程序
它们存储在/usr/bin/
的路径中
然而,在grep过滤的时候,grep这个进程也启动了,所以ps的时候,就能看到grep这个进程
所以,我们再用-v选项,把grep这条去除
ps ajx | grep ‘mytest’ | grep -v grep
这一条就是我们的进程
/proc系统文件夹
在我们的根目录下有很多的路径
我们见过的,比如
-
home:当前用户的家目录
-
root:root用户的默认家目录
-
tmp:一个共享目录
-
usr:/usr/bin里面存了指令
这里,我们再来了解一个:proc
proc是一个内存文件系统,它里面放着系统的实时进程信息
这里的蓝色文件是目录文件,这些数字是进程的pid,那么什么是pid呢?
PID
每一个进程在系统中,都会存在一个唯一标识符pid
(process id),就如同学生在学校里的学号。
验证:
我们再将mytest程序运行起来,查看它的进程信息,其中有一项
PID
,可以看到proc下确实有这个以8439的PID命名的文件此时,我们结束mytest进程,再找这个文件就会显示不存在
经过验证,可以发现,proc目录中确实存在着一些以进程PID命名的实时文件。
此时,再把前面的程序运行起来,再次执行ls /proc/8439 -d
查看文件夹,会发现不存在此文件,再次查看进程信息会发现PID变了
其实也很好理解,因为重启进程后,这就是一个新的进程了,新的进程就会有新的PID。
当前路径
提到当前路径,我们的印象可能就是我们在学习C语言的文件操作时,如果进行路径指定,默认放到的路径,而根据经验,这个路径就是我们的源文件所在的位置。
其实这个认知是不正确的
我们进入那个以PID命名的文件夹
我们可以看到有两个长得颇像路径的的东西cwd
、exe
其中:
- cwd --> 进程当前的工作路径
- exe --> 进程的可执行程序的磁盘文件
当前路径,其实是当前进程所在的路径,进程自己会进行维护,这里的cwd就是我们所谓的当前路径
那么,PID,还有cwd,exe这些文件在哪呢?
这些都是进程的属性,它们就存在进程控制块PCB(task_struct)结构体中。
通过系统调用获取进程标示符
PID
那么在我们自己的程序内部,如何获取PID这个进程属性呢?
既然是跟操作系统直接要这个进程参数,我们就要用到系统调用了
getpid()
test.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
while(1)
{
printf("I am a process!,pid:%d\n",getpid());
sleep(1);
}
return 0;
}
编译运行,可以发现,确实获取了此进程的PID
之前我们可以通过热键ctrl+C
终止一个进程
其实也可以通过PID结束指定进程:
如果知道进程的PID,我们可以通过另一个终端使用kill指令杀掉一个进程
kill -9 [PID]
PPID
PPID是父进程的PID,可以通过getppid获取ppid
执行如下程序,
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
while(1)
{
printf("I am a process!,pid:%d, ppid:%d\n",getpid(),getppid());
sleep(1);
}
return 0;
}
反复重启进程可以发现pid会一直跟新,但是ppid却一直不变
我们的父进程为什么不变?是谁呢?
我们在命令行上执行的所有指令,都是bash进程的子进程。
通过系统调用创建进程-fork初识
那么在linux中,除了在命令行当中用./
这样的方式,还有什么方式可以在我们的代码中建进程呢?
接下来,我们再讲下一个系统调用:fork()
它的作用是创建一个子进程
返回类型pid_t
其实就是一个无符号整型
通过文档,我们可以看到
- 如果创建子进程成功,给父进程给返回子进程的PID;给子进程返回0;
- 创建失败给父进程返回-1;
为什么要返回两个值? 一个C语言函数可以返回两次吗???
这个两个问题我们先不回答,在文章的后面会有答案。
我们现在就当它可以按如上规则处理返回值
我们写一段代码,
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
//id=0:子进程 ; id>0:父进程
if(id == 0)//子进程
{
while(1)
{
printf("我是子进程,我的pid:%d, 我的父进程:%d\n",getpid(),getppid());
sleep(1);
}
}
else//父进程
{
while(1)
{
printf("我是父进程,我的pid:%d, 我的父进程:%d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
}
逻辑就是用fork创建一个进程,默认创建的子进程会从fork()
调用开始和父进程执行相同的代码;
-
如果是父进程,得到的返回值是子进程的pid(>0),执行else内的代码
-
如果是子进程,得到的返回值是0,执行if内的代码
在执行前,我们先想一下这两个问题:
- C语言上 if 和 else 可以同时执行吗?
- C语言中,有没有可能两个死循环同时运行?
答案是肯定的,都不行
接下来,我们编译,执行
其中7920是bash进程,
这里的11306就是父进程,11307是fork出来的子进程
我们看到, if 和 else 确实在同时执行,而且同时在执行两个死循环
结论:
-
fork之后,父进程和子进程会共享代码——printf()打印了两次
-
fork之后父进程和子进程的返回值不同,可以通过不同的返回值让父进程和子进程执行不同的任务。
现在回答一下为什么要给父进程返回子进程的PID,给子进程返回0:
一个父进程可以有多个子进程,
父进程必须有标识子进程的方案:fork之后,给父进程返回子进程的PID
子进程最重要的就是要知道自己被创建成功了,因为子进程找父进程的成本是非常低的:
getppid()
就可
那它是怎么实现两次返回的?
前置1:
fork作为一个系统调用,它调用了之后,操作系统做了什么?
无疑肯定是多了一个进程
一个进程相当于task_struct + 进程的代码和数据
fork()过程中,操作系统也一定会为这个子进程创建它task_struct,给它找到相应的代码和数据
前置1.1
子进程的task_struct结构体中的数据从何而来?
基本是从父进程给继承(拷贝)下来的。
子进程要执行的代码、计算的数据从哪来?
子进程和父进程执行的是相同的代码,所以fork()之后,父子代码是共享的;而数据要各自独立。
虽然代码是共享的,但可以通过不同的返回值执行不同的任务
在父进程中,调用fork函数,会执行前置1中的一系列任务,当这个子进程被创建后,会被放到运行队列中,此时运行队列中同时存在父子两个进程的task_struct,并且它们都还未执行到
return pid;
这一步,再往后,两个进程也无疑都会进行return,有两个返回值也就是必然的事了。因为两个进程共享代码,至于当两个进程跳出fork函数体后将返回值都赋给同一个变量,操作系统如何处理我们后序进程地址空间可以看到答案
后置补充:
这其中我们提到的这个运行队列又是什么呢?这些task_struct作为结构体变量,无疑在不同的队列中进行转移;
而其中有一种队列被称为运行队列(runqueue),这些队列被调度器所掌控,
调度器可以让CPU去运行这些进程,CPU可以通过task_struct所指向的代码和数据,进行相应的执行操作
进程状态
说到进程,首先应该想到的是task_struct
这个进程控制块;
那么进程状态是什么呢?
他其实就是task_struct
当中的一个整数
不同的值就代表不同的状态
四种状态的理解
进程运行
这里的运行态是指在cpu中运行,还是进程只要在运行队列中就叫运行态?
事实上,一个进程只要在运行队列中,就叫做运行态代表我们已经准备好,随时可以调度。
进程终止
一个进程进入终止状态,是说它已经被释放了呢?还是进程还在,只不过永远不运行了,随时等待释放。
答案是第二个。
但是问题来了,进程都终止了,为什么不立马释放相应的资源,而要维护一个终止态?
释放无疑要花时间,有没有一种可能,当前你的操作系统很忙。
一个系统结束了,先不释放,先给个标记,等操作系统不忙的时候在进行释放。
进程阻塞
一个进程,使用资源的时候,不仅仅在使用CPU资源
进程可能申请更多其他资源:磁盘、网卡、显卡、显示器资源、声卡、音响……
进程运行的时候要申请CPU资源,暂时无法满足时,需要排队——运行队列
那如果我们申请的是其他设备的资源呢? —— 也是需要排队的(task_struct在进行排队)
每一个硬件资源都要被操作系统管理,就都需要有对应的数据结构,操作系统对硬件的管理就变成了对硬件数据的管理。
假设CPU和一些硬件对应的数据结构是如下这样
当一个进程开始运行,就会挂到运行队列,让CPU运行,当需要访问某些资源(磁盘、网卡……)的时候,CPU会向对应的硬件发送请求;
-
如果该资源处于就绪状态,就直接调用此设备执行
-
如果暂时还没有准备好,或者正在给其他进程提供服务,此时
-
当前的进程控制块会被拉出CPU的runqueue,
-
放到对应设备的描述结构体中的等待队列
当那个设备执行完之前的任务之后,就会将这个进程控制块放回CPU的runqueue,CPU再执行这个进程的时候,就可以直接调用对应硬件设备了
-
当我们的进程在外部等待资源的时候,该进程的代码就不会被执行了!!
从我们用户的上层角度看,就是这个进程卡住了,所以这就叫做进程阻塞
进程挂起
当一个程序要运行时,它的代码和数据就要被加载到内存中,同时要创建一个task_struct,这些都会占据内存空间,
随着更多的程序被打开,势必会出现内存不足的情况;
此时,轻则所有的程序malloc、new等申请内存的操作都将无法进行,
重则要知道操作系统作为一个软件也同时会瘫痪。
为了避免这样的事发生,操作系统就要帮我们进行辗转腾挪:
其中有一些短期之内不会被调度(比如:一些进程等的资源短期之内不会就绪,或者一些等待队列排的很长)的进程,
它的代码和数据依旧在内存中,就是白白浪费空间
OS就会在磁盘上找一段空间,把进程的代码和数据暂时置换到磁盘上(下图的①)
这就叫做进程挂起
如果大家装过操作系统,除了我们平时见到的C盘、D盘……这些磁盘分区,还会看到有一个swap分区,
不管是Windows还是Linux,操作系统都会默认分配一个这样的分区,我们挂起的程序其实就被放到了这个分区
(实际上,因为这些要挂起的代码大多情况是从磁盘加载进来的,很多情况不需要再存到swap,直接把内存中的代码数据删除,保存好进程控制块,需要的时候再加载进内存(上图②),要进swap的很多都是操作系统的数据代码)
所以我们可以看到一个现象,当内存不足的时候,磁盘也被高频访问。
linux的进程状态
我们先看一下task_struct中描述进程状态的代码:
R&S状态
还是写一点简单的代码
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("I am a process:%d\n",getpid());
sleep(1);
}
return 0;
}
-
编译运行后,我们用另一个终端显示当前进程的信息
可以看到,它的状态居然是S,休眠状态
其实原因也很好理解,需要执行的语句其实就一句printf(),绝大多数时间都在sleep(),我们随机查看,大概率就是S
-
那把
sleep()
语句去掉呢?可以看到打印语句开始迅速刷屏,但是状态还是S???
这就要回到冯诺依曼模型,当CPU执行打印任务,会将打印内容放到内存的缓存区
然后就会开始调用屏幕等外设,但是相对于CPU来说,这些外设是非常慢的,所以进程大部分时间都是在外设的等待队列,处于阻塞状态,所以我们测试的时候,极大概率是S。
-
那再把打印的语句也去掉呢?
-
此时就变成了R,运行状态,因为这个死循环没有调用任何外设,会一直在CPU的运行队列当中,也就一直是运行状态
可以总结出:
- linux状态中的R无疑就是我们所说的运行状态
- 这里的S,也就是就是我们所说的阻塞态
当进程在等待某些非CPU资源,如磁盘、某些其他软件,就会被操作系统设置为S状态,放到等待队列;
当资源就绪,就会被设置为R状态,放回运行队列。
D状态
我们前面的S状态被称为浅度睡眠,也称为可中断睡眠,当前进程可以被操作系统、软件、用户杀掉
这里的D状态也是一种阻塞状态,也是让进程等待某种资源
一般而言,Linux中,如果我们等待的是磁盘资源,我们进程阻塞所处的状态就是D ;
那为什么一个睡眠还分了两种呢?
我们设想这样一种情况:
有一个进程,它有一个任务:将500MB数据存到磁盘中,于是它来到磁盘把任务交给磁盘,把自己置为S状态,开始等磁盘完成存储任务后把结果告诉自己;这时内存中的进程越来越多,操作系统越来越忙,当操作系统扫描所有进程时,发现这个进程什么都不干,于是就把它杀掉了;此时磁盘发生了意外:存了300MB,磁盘满了,于是出来把结果告知刚刚的进程,好让它再给上层结果,然而磁盘没有找到这个进程,于是它一脸懵逼,便不知道该如何处理了。
这里操作系统、进程、磁盘都没有发生错误,操作系统为了保住内存,有权利杀掉睡眠的进程,没有错;进程等磁盘的结果没有错;磁盘就是个跑腿的更没有错。
于是,我们将规则改一下,进程等磁盘的时候用一种新状态:D,此时操作系统便不能杀掉这个进程,只能等磁盘给它结果后,它自己醒来;其他方式要结束这个D,就只能重启或者拔电源了。
Z&X状态&孤儿进程
这里的X就是之前所说的终止态,资源可以立马回收
Z状态也是一种死亡状态,只不过先不进行释放空间,那它具体又是干什么呢?
当一个Linux中的进程退出的时候,一般不会直接进入X状态,而是进入Z状态,为什么呢?
在回答这个问题前,再想想另一个问题:一个进程为什么被创建出来呢?
很简单,一定是有任务让这个进程执行;
那么当进程退出的时候,我们怎么知道这个把任务完成的如何了呢?
一般需要将进程的执行结果告知父进程/操作系统
进程进入Z状态,就是为了让task_struct
维护退出信息,可以让父进程或者OS通过进程等待读取,至于怎么等待,后续会谈。
下图是Linux的task_struct
中存储退出信息的部分
其实我们main函数的返回值就被保存在了上面的exit_code
中了。
如何模拟一个僵尸进程呢?
如果创建子进程,子进程退出了,父进程不退出,也不等待子进程(等待后面讲),子进程退出之后所出的状态就是Z
int main()
{
pid_t id = fork();
if(id==0)
{
//child
int cnt = 5;
while(cnt)
{
printf("我是子进程,还剩下 %dS\n", cnt--);
sleep(1);
}
printf("我是子进程,我已经僵尸了,等待被检测\n");
exit(0);
}
else
{
//father
while(1)
{
sleep(1);
}
}
return 0;
}
当这个进程开始运行,会创建一个子进程,子进程执行五秒后进程结束,父进程会一直执行
我们再开一个终端,写一段监控脚本,方便我们在命令行上看到进程状态刷新的过程
while true; do ps axj | head -1 && ps axj | grep process | grep -v grep; sleep 1; echo “#################”; done;
可以看到,子进程在前五秒一直是S,接下来父进程没有退出,子进程退出就成了Z状态
长时间僵尸,有什么问题呢?
如果没有人回收子进程,该状态会一直维护!该进程的相关资源(task_struct)不会被释放,就放生了内存泄漏
而且,如果父进程不退出,这个僵尸进程是无法被kill -9
杀死的(因为它已经死了)
所以一般要求父进程进行回收,如何回收后面说。
孤儿进程
如果子进程退出,没有回收,父进程没有退出,那么子进程就叫僵尸进程
那么,如果父进程已经退出了,但子进程还在呢?
我们之前说,父进程可以通过某种方式回收子进程,而结束子进程的僵尸状态;
但此时恰恰相反,父进程没等子进程结束就已经提前退出(被它的父进程回收了),那子进程也就没人管理,等到子进程退出的时候,也就没有人回收了。
好在我们的操作系统提前想到了,如果父进程提前退出,子进程还在运行,子进程就会被1号进程领养(1号进程就是操作系统)。
我们把这种被领养的进车就叫做孤儿进程。
我们还是写一段代码模拟一个孤儿进程:
#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id!=0)
{
//father
int cnt = 5;
while(cnt)
{
printf("我是父进程,还剩下 %dS\n", cnt--);
sleep(1);
}
printf("我是父进程,我已经结束了\n");
_exit(0);
}
else
{
//child
while(1)
{
sleep(1);
}
}
return 0;
}
与前面相反,这里我们父进程运行5秒后退出,而子进程会一直运行。
可以看到,5秒后,父进程退出,只留下一个子进程,并且它的父进程变为了1号进程
通过top
指令可以看到当前的系统状态及任务
如上就是1号进程——操作系统
我们可以发现,子进程变为孤儿进程前状态是S+,变为孤儿进程后变为S
- S+代表前台进程,可以
ctrl+C
杀掉 - S是后台进程,无法被
ctrl+C
杀掉
只能用kill -9
[PID]命令进行退出.
T/t状态
T
linux的T状态并不对应我们前面所说的四种状态:运行、阻塞、挂起、终止
阻塞态对应了S或D
根据操作系统的实现不同,挂起态可能是S、D、T中的一种。
除此之外,T状态其实也很常见,比如:播放音乐、视频时的暂停……
是一个功能性较强的状态。
我们使用kill -9
,9号信号可以将进程杀死
使用kill -l
,指令可以显示各种信号对应的编号
其中19号SIGSTOP
就是暂停一个进程
我们先运行如下代码:
#include <stdio.h>
int main()
{
while(1)
{
printf("I am a process\n");
}
return 0;
}
然后用另一个终端执行kill -19 [PID]
,
此时这个进程就变成了T状态
当我们想让进程继续执行,再执行18号信号:SIGCONT
(signal continue)
就可让进程继续执行。
t
与T相同,都是让进程暂停,但t (tracing stop)特指追踪暂停,一般发生在进程被调试过程遇到断点
我们用-g
选项编译一段代码,然后用gdb调试:
在41行打断点,然后运行到断点出
此时,就会有一个t状态的进程。
进程优先级
什么是优先级vs权限
优先级是进程获取资源的先后顺序
我们区分一下优先级和权限这两个概念
- 权限是能或不能的问题
- 优先级一定是能,只不过是先还是后的问题
为什么会存在优先级
排队的本质就是确认优先级
为什么要排队呢?——因为资源不够
系统中永远都是进程占大多数,而资源占少数
所以竞争资源就是常态
便一定要确认先后——确认优先级
Linux下的优先级相关概念和操作
我们运行一个进程,使用
ps -al
可显示当前进程的优先级
Linux下,继承优先级由两个值:PRI(priority)、NI(nice)决定
PRI越小,优先级越高(PRI = 初始值80-NI)
要更改优先级,需要更改的不是PRI,而是NI
nice:进程优先级的修正数据
我们通过top指令去修改nice值
在root权限下
进入top
输入r,
输入对应进程的PID,
输入新的NI值,即可修改。
此时,我们将NI设为-100
但实际的NI仅仅变为了-20
其实,Linux不允许进程无节制的设置优先级,其取值范围是-20到19共40个级别
实际上,Linux系统一共由140个优先级别,其中很多不是给用户用的。
这里的prio = prio_old + nice
每次设置优先级,old都会恢复为80
其他概念(并行、并发)
既然提到了处理多个进程的优先级,我们再拓展一些其他概念
-
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
-
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰;
既然所有进程的代码数据都在内存中,那是如何做到和不干扰的呢?后面进程地址空间就可知道。
-
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行。
-
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
一般我们的民用电脑都是单CPU的,像上面的双CPU一般出现在企业服务器中
我们平时使用电脑都是多个软件,如记事本、音乐、浏览器……同时使用的,
其实,进程一旦占有CPU不会一直执行到结束才释放CPU资源,我们遇到的大部分操作系统都是分时的
操作系统会给每一个进程,在一次调度中,赋予一个时间片的概念
比如,给第一个进程10ms,换第二个进程15ms……这样在1秒内这几个进程都会执行都次,在用户看来就是同时执行的
想这样在一个时间段内,多个进程都会通过切换交叉的方式,让多个进程代码,在一段时间内都得到推进,这种现象就叫并发
那么操作系统就是简单按照队列来进行先后调度的吗?有可能突然来了一个优先级更高的进程?
它就会插到前面,更有甚者,会直接将CPU上正在运行的进程剥离下来,直接运行优先级更高的进程
如今的很多操作系统都是支持这样的抢占式内核的
进程调度队列——了解
这里又出现一个问题:既然是优先级队列,那它是只能头删或者尾插,怎么会有‘插队’这样的概念呢?
事实上我们的优先级队列是一个哈希桶,
会根据不同的优先级,将特定的进程放入不同的队列中
当前一个进程的时间片用完,要运行下一个进程时,调度器就会从前向后扫描这个数组,找到有节点那个数组
因为有140个优先等级,也就意味着最多会判断140次,相对较慢,Linux并非是遍历整个数据,而是有一个位图,每一个二进制位对应着数组的所有元素,通过0/1表示对应优先级是否有进程控制块,只需找到最左侧的1,就可以到对应的数组元素找PCB
而我们的runqueue
这个结构体中包含着这样两套相同的哈希桶
每次有新进程,就先链到old
结构体当中,当CPU将active
中的所有进程都执行完,就会swap(active,old);
交换两个结构体,执行另一个队列
以上是runqueue的结构体,我们只去体会它的这个优秀的数据结构,更多具体的细节就不再深究
进程间切换
我们我们前面讲冯诺依曼体系结构的时候提到,CPU作为中央处理器,其中有运算器和控制器,其实现代计算机的CPU中还有一部分叫做寄存器的存储空间;
这些寄存器可以临时存储一些数据,这部分数据非常少,但是非常重要!
我们在hello函数内返回变量a,外部用z接收,由于从从hello函数回到main的过程中,hello中定义的变量已经被释放,也就是走到main的赋值语句的时候,a的空间已经不可用,也就是必须找一个空间存储临时a的值,回到main再赋值给z;这个临时的空间就是寄存器
以上是寄存器的一个小用途;
那当我们的当前进程时间片用完,要执行下一个进程的时候,CPU的寄存器上一定存有大量的临时数据,如果不管这些临时变量,直接将进程切走,那这些寄存器空间一定会被下一个进程所覆盖,就发生了数据丢失,当那个进程再进来,找不到正确的寄存器数据,就会出问题
我们把进程在运行中产生的各种寄存器数据,称为进程的硬件上下文数据
当进程被剥离,需要保存上下文数据
当进程恢复的时候,需要将曾经的上下文恢复到寄存器当中
这些上下文数据就被存到了task_struct
中
环境变量
基本概念
我们自己写一个程序
使用./
可以运行
那一些操作系统的指令有什么区别呢?
其实并没有区别,都是一些二进制的可执行程序
这些指令、工具、程序其实都是一种东西,我们自己写的程序和系统中的指令并没有区别。
那为什么,执行一些系统指令,如:ls/pwd/top,直接输入就可,不用带路径
但我们的程序必须加“./”
的路径形式呢?
其实系统的指令也可以带路径
但我们的程序必须带路径,如果不带,就会显示
执行一个可执行程序,前提是要找到它;
也就是说,系统的指令不带路径也能找到,我们的程序必须带路径
查看环境变量
系统中存在相关的环境变量,保存了程序的搜索路径!
使用env
指令,可以看到系统的环境变量
可以看到,每一个环境变量都是以[name]=[value]
的形式进行写的
我们系统中搜索可执行程序的环境变量叫作PATH
(这里的$
表示取PATH的值,否则仅会输出PATH字符串)
后面是一些路径(里面存了指令对应的文件),每个路径用:
(冒号)分隔
当我们输入一个指令,操作做系统就会找PATH后面的一个一个路径,看里面是否有对应指令的文件
我们程序之所以必须带路径,就是因为在上述PATH的所有路径中都找不到我们的程序
修改环境变量
要让我们的程序也能不通过路径执行,也很简单:
-
方式一:
把文件放到PATH后面的任意路径中
此时,就可不指定路径
其实这句话就相当与程序安装到里系统中
但不建议这样做,时间久了,我们也不知道添加过什么,这样会污染我们的linux下的命令
如果要移除,把这个文件删掉就相当于卸载了
(注意:这些安装卸载等操作需要在root权限下进行)
-
方式二:
把process的路径也添加到PATH中
在linux的命令行下也可以定义变量,
分为两种:
- 普通变量:只在本地可见
如果
env
查看环境变量,发现并不存在aaa- 环境变量(具有全局属性)
前面加个
export
或者
export [全局变量]
,将一个普通变量转位全局变量如果要取消某个环境变量:使用
unset
指令相反,还有一个
set
指令可以查看普通变量和全局变量此时我们就可以修改PATH值了
配置文件:
但是如果直接把我们的目标值赋给PATH,会将之前所有的数据覆盖,
所有的系统命令就无法使用,但
- 以上我设置的环境变量不会改配置文件
(注意:我们在命令行下设置的环境变量具有临时性,只会修改内存中的数据,重新登陆后会又会恢复原始数据,如果要永久改,需要修改配置文件,一般在这两个文件
所以只需要重新启动终端,又会重新加载回来
那如何给PATH追加路径呢呢?
如上,先
$PATH
获取先前的所有路径,再“:”
追加新的路径,赋给PATH。我们之前的
which
指令——获取指令的路径,其实就是输出了PATH中的包含了对应指令的路径
常见环境变量
-
PATH : 指定命令的搜索路径
-
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
如上分别是root用户和一个普通用户的HOME值
可以看到root和yb的初始目录就是HOME值
-
SHELL : 当前Shell,它的值通常是/bin/bash。
-
HISTSIZE:历史命令的做多条数
可以看到我这里的最大存储量是10000条
环境变量的C、C++获取方式
方式一
首先问大家一个问题,main函数可以带参数吗?最多可以带多少?
答案是有:
前两个参数:
其中第二个参数是一个指针数组,第一个元素就表示它的元素个数;
那数组中存的什么呢?——
char*
,大概率是个字符串,我们不妨打印一下int main(int argc, char* argv[]) { for(int i = 0; i < argc; i++) { printf("argv[%d]:%s\n", i, argv[i]); } }
这个过程发生了什么嗯?
实际上,我们在命令行上输入的程序名(
./mytest
)、选项(-a/-b/-c)都被以字符串的形式,放入到了如上这样的指针数组当中,然后被传给main函数
所以我们给main函数传递的
int argc, char* argv[]
,是命令行参数,包含输入的程序名和选项。那么就这两个参数而言,对我们有什么意义呢?
我们这里实现一部分简单的命令行计算器:
#include <stdio.h> #include <string.h> #include <stdlib.h> int main(int argc, char* argv[]) { if(argc!=4) { printf("输入形式错误\n选项:加-->[-a];减-->[-s];乘-->[-m];除-->[-d]\n例:1+1-->1 1 -a\n"); } int a = atoi(argv[1]); int b = atoi(argv[2]); if(strcmp(argv[3],"-a") == 0) { printf("%d\n",a+b); } //这里只实现一个加法,剩下的大家如果有兴趣可以自己完成 return 0; }
可以看到,我们命令行输入
./mytest 1 2 -a
这样的命令,就可以实现相应的加法操作。所以,这里main函数前两个参数的意义就在于:同一个程序,通过不同的参数有不同的执行逻辑,执行结果
这就是为什么,我们(ls/ls -a/ls -a -l/ls -a -l -i )都调用
ls
这个程序,却有多种显示
第三个参数——环境变量
第三个参数就是我们的环境变量
这个env中就以如下方式,存着一个一个的环境变量
我们写一段程序,把所谓的环境变量输出
int main(int argc, char* argv[], char* env[]) { for(int i = 0; env[i]; i++) { printf("env[%d]:%s\n", i, env[i]); } return 0; }
可以看出,一个进程调用的时候是会被传入环境变量的
小贴士:
我们平时写main函数的时候一个参数都没有写,不会出错吗?
事实上,C语言的语法允许函数调用和声明的参数数量不一致
像这样一段代码是可以正常运行并打印的
这个调用的过程,参数依然会被压栈,只不过没有没函数使用罢了
如上,我们看了C语言查看环境变量的一种方式,
方式二:
接下来再看一种:
C语言为我们提供了一个全局变量:
environ
int main() { extern char** environ; for(int i = 0;environ[i];i++) { printf("env[%d]:%s\n",i, environ[i]); } }
使用此方法依然可以用c语言查看我们的环境变量
方式三:
或者也可以同过一个函数
getenv()
获取某一个环境变量int main() { char* val = getenv("PATH"); printf("%s\n",val); }
意义何在
举一个小小的例子,我们今天想写一个程序,这个程序只有自己这个用户可以运行
int main()
{
char* val = getenv("USER");
if(strcmp(val, "yb")==0)
{
printf("我的小秘密!!\n");
}
else
{
printf("别来沾边!!\n");
}
return 0;
}
当我们进入root用户,就无法查看
为什么要获取环境变量呢?——一定有特殊用途
环境变量为什么具有全局性
前面讲PPID的时候我们看到,同一个进程启动多次,虽然PID一直在变,但PPID一直都不变,都是bash
bash
那我们如果
kill -9
杀掉这个bash进程呢?会发现,我们输入任何命令都没有反应,我们的命令行直接挂掉了。
实际上,我们命令行输入的所有命令都会被bash这个进程获取,如下是它程序代码的位置
当我们登录一个用户,系统就会给这个用户创建一个bash进程;
既然是个c/c++程序,这个bash进程就可以通过scanf()/cin这样的方式,从我们的命令行获取我们输入的字符串;
所以,我们只能退出重新登陆了。
于是,我们得出结论,命令行中启动的进程,父进程都是Bash
那我们在命令行输入的定义变量的命令,其实就是给bash这个进程定义变量;
其中环境变量就会被子进程继承下去,被子进程的子进程继承……
而本地变量,本质就是在bash内部定义的变量,不会被子进程继承。
内建命令
那么此时又产生一个问题:
我们定义一个局部变量,可以使用echo
显示这个变量;
但是,echo作为一个bash的子进程,它为什么能访问bash的局部变量呢?
事实上,linux下大部分命令都是通过子进程的方式执行的;
但是,还有一部分命令,不通过子进程的方式执行,而是由bash自己调用自己对应的函数来完成特定的功能我们把这种命令叫做内建命令
程序地址空间
研究背景
- kernel 2.6.32
- 32位平台
程序地址空间回顾
之前我们作为一个C/C++程序员,看待程序的方式是:它的数据分布到如下的栈区、堆区、已初始化数据区……
一个32位平台,所能控制的字节量就是232 = 4G,如上的各个数据段就分布到这4G的空间上。
那么我们曾经学的进程地址空间,是内存吗?
答案是,我们以前所说的进程地址空间不是物理内存!
感知地址空间的存在
我们写一段代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if(id==0)
{
//child
while(1)
{
printf("我是子进程:%d, ppid:%d, g_val:%d, &g_val:%p\n\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
else
{
while(1)
{
//parent
printf("我是父进程:%d, ppid:%d, g_val:%d, &g_val:%p\n\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
}
创建一个子进程,父子进程都打印全局变量g_val的值和地址
可以看到,它们的全局变量的值和数据都是相同的
那么如果其中一个进程更改了全局变量呢?
int g_val = 100;
int main()
{
pid_t id = fork();
if(id==0)
{
//child
int flag = 0;
while(1)
{
printf("我是子进程:%d, ppid:%d, g_val:%d, &g_val:%p\n\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
flag++;
if(flag==2)
{
g_val = 200;
printf("我是子进程,全局变量我已经改了\n");
}
}
}
else
{
while(1)
{
//parent
printf("我是父进程:%d, ppid:%d, g_val:%d, &g_val:%p\n\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
}
我们让子进程执行两秒后更改g_val值
???一样的地址,读取的内容不一样
这就相当于,我给你一块地址,你读出来的值和我读出来的值不一样,量子力学???
所以我们得出结论:我们在C/C++中使用的地址绝对不是物理地址!
那是什么呢?在Linux下,它们被称为虚拟地址/线性地址/逻辑地址。
我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理,OS必须负责将 虚拟地址 转化成 物理地址
进程地址空间
抽象概念
这个地址空间其实就是操作系统给每个进程画的大饼
就是操作系统通过软件的方式,给进程提供一个软件视角,让每一个进程都认为自己是独占系统中的所有资源
实体结构
既然要为每个进程都画一张饼,也就是
每一个进程在启动的时候,都会让操作系统给他创建一个地址空间,该地址空间就是进程地址空间
每一个进程都有一个自己的进程地址空间
那么操作系统就要管理这些地址空间
管理–>先描述,再组织
进程地址空间,其实是内核中的一个数据结构,struct mm_struct
其中既然地址空间要进行分区,那什么是区域呢?
我们举个小例子:
就好比小学的时候,一个boy和一个girl坐到了一起,此时众所周知要产生一条三八线,对桌子进行区域划分,谁都不允许越过这条三八线
描述每个小朋友的区域就可以用如上这样一个结构体来描述,
那么此时,从1到50每一个厘米都属于小男孩,51到100便是小女孩的区域
相似的我们的mm_struct
也可以用这样一个结构体划分出每一个区域
mm_struct中的每一个地址就可以通过页表与物理地址对应
这样,当CPU访问某一段代码或数据的时候,就可以通过拿虚拟地址到页表中找到对应的物理地址,从而访问物理内存。
原码
首先从task_struct找到mm_struct
然后转到它的定义
我们并没有发现像我们刚刚所说的一个一个区域
其实,原码用一个链表mmap
,把一个一个的区域串联起来
每一个节点都是一个vm_area_struct
(virtual memory)
可以看到,它里面有开始和结束的虚拟地址,还有权限(比如:我们访问代码区,它是只读的,就是变量决定的)
程序是如何变成内存的
首先,我们思考一个问题:
程序被编译出来,没有被加载到内存的时候,程序内部有地址吗?有区域吗?
答案都是有的。
首先,我们回忆一下链接,如果没有地址,它能从库里找到我们调用的printf吗?
所以肯定是有地址的
linux中有一个指令——readelf
,可以查看可执行程序的文件信息
readelf -S myproc
可以看到,我们的可执行程序在加载到内存之前就有相应的分区了。(注意:栈和堆的地址在运行的时候才产生)
以上这些每一个区域的地址划分的时候,并不是采用在内存中的地址,而是一个相对地址(逻辑地址),就好比,把起始地址设为0,代码区地址位1,全局数据区为2,已初始化3……以此类推,每一个分区的地址就成了一个相对于起始地址的偏移量。
此时,当我们要把程序的一个分区加载到内存中来
假设要从程序加载的只有三个字节,对应的逻辑地址是0x1F、0x20、0x21
要放到内存的起始地址:0x100
此时,把每个逻辑地址+0x100的0x11F、0x120、0x121放入内存
我们把内存想象成是从全0到全F的一个线性地址,当我们的程序被加载到内存上的地址称为虚拟地址
写时拷贝
此时,我们就可以解答之前父子进程g_val可以不同的现象了
开始,我们的父子进程通过页表对应的代码区和数据g_val都是一样的空间,也是一样的值。
当我们的子进程要进行修改
由于进程独立性的特性,绝对不能让子进程直接改原物理空间的数据
所以,此时操作系统会给子进程新开一个g_val的空间,把100拷贝下来,然后改变子进程页表的映射关系
所以,我们看到的就是虚拟地址不变,但对应的内容不同
我们把这种该改变全局数据时,为子进程拷贝新空间,重新改变映射关系的行为称为写时拷贝
此时,之前fork()时为什么同一个变量id可接收两个进程的返回值的问题也可以理解:
pid_t id
是属于父进程栈空间中定义的变量,fork过程中产生了子进程,父子都要在fork中进行return,都是通过寄存器将返回值写入到id
变量中,写入就要发生写时拷贝
此时,谁先返回,,谁就要发生写时拷贝,所以同一个变量,会有不同的内容值,本质是因为它们的虚拟地址是一样的,但物理地址不同。
为什么要有虚拟地址空间
-
如果像如下这种,每个进程都挨着开一段连续的空间,那么一旦发生了越界,就会对其他进程的数据或者代码发生更改,产生不可预知的后果
但是,如果使用进程地址地址空间的方式
通过虚拟地址,可以在页表中找打对应的物理内存
一旦发生了越界,那么就无法在页表中找到对应的虚拟地址,这个进程就会直接被杀掉。
所以进程地址空间的第一个意义就是保护内存
-
一个进程申请空间,可以先在现在mm_struct把堆区对应的开始或者结尾的值进行填写,
等这段空间真的要写入使用的时候,再区实际的物理内存开空间,
那么在申请到使用这个时间段,别人就能使用这些空间,
这无疑是对资源的一种节省策略。
这就是Linux在进程管理和内存管理的时候,通过地址空间,进行了功能模块的解耦;
换言之,如果用图一的方式,进程要开一段空间,操作系统必须立即开辟对应的内存
-
让进程或者程序可以以一种统一的视角看待内存
也就是以统一的方式来编译和加载所有的可执行程序