进程是如今编程领域非常重要的一个概念,进程是比较抽象的,不容易直接理解。因为进程与操作系统息息相关,因此在介绍进程之前,笔者打算先简易讲一下操作系统的工作流程,理解操作系统是如何管理软件和硬件的,然后再讲解进程在操作系统中充当什么样的角色,发挥什么样的作用,这样站在一个更高的视角,便于大家理解进程是什么?
理解操作系统如何进行管理
说到操作系统,我们都知道,操作系统是硬件与软件的中间层,操作系统也是一个软件,不过它是一个比较特殊的软件,它既可以管理硬件,也可以管理软件,同时还要为用户提供各种服务,操作系统相当于我们电脑的管家,电脑里的所有软件,以及底层的硬件信息都在它的管理之下
操作系统管理电脑中的软硬件,并不是直接去操控它们,以操作系统管理底层硬件为例
操作系统在管理硬件的时候,并不是直接去操控硬件,而是掌握了各个硬件的状态信息,根据信息下达相应的命令,这个命令由执行者(某硬件所对应的驱动)来执行,比如点亮屏幕操作,操作系统找到显示器的信息,然后下达点亮命令,这个时候,显示器对应的驱动就会执行操作系统的命令,去操控显示器,并把执行信息返回给操作系统
这和我们在公司和学校是一样的道理,在公司老板肯定是掌握着每一个员工的信息,当他想开除一些员工时,并不会直接跑到这些员工面前对他们说,你们被开除了,而是通知公司的人事部,告诉人事部要开除哪些员工,让人事部来处理这些事情。因为老板作为一个公司的领导者,要管理公司的各个方面,决策公司的前进方向,不可能去做这些琐碎的事。
操作系统也是如此,操作系统作为电脑的管家,不仅要操心硬件层,还有很多软件等着它去做决策,由此可见,操作系统在管理软硬件时,本质是管理软件以及硬件的信息,通过信息来做决策,下达相应的命令
既然操作系统是通过管理信息来管理软硬件,软硬件有很多种,拿硬件来说有显示器,鼠标,键盘,音响等等,要想管理这些硬件的数据信息(比如该硬件的名字,该硬件是否存于工作状态,该硬件执行时是否有报错等),首先得有一个标准的数据类型来存放这些硬件的信息呀,而存储这么多信息,肯定不能靠单个int float等能描述完,而是要根据数据的信息,设计一个自定义类型,即结构体或是类。而这个过程就被称作为描述,也就是根据被管理的数据抽象出对应的数据类型,即类或结构体
硬件的信息都被描述完成,不同的硬件都是这个数据类型的一个对象,这么多对象被创建出来,总不能随意的存放呀,要把这些信息组织在一起,这样也方便操作系统管理这些信息,而将这些信息组织起来就涉及到数据结构方面的知识了,根据要对这些信息进行何种操作,我们可以选择不同的数据结构,在管理软硬件时,假设采用链表,将这些数据对象以链表的形式组织在一起,方便操作系统来查询,下达相应的命令
操作系统在对上层用户进行服务时,不允许任何人直接操控硬件或是修改硬件信息,操作系统会提供相应的接口,我们要想某些硬件执行命令时,只能调用操作系统提供的相应接口,在操作系统的监视下且合法的命令才能被有效执行,简单的了解了操作系统管理的本质,接下来就该介绍一下什么是进程了
进程
进程的概念
在冯诺伊曼体系结构中,cpu只与内存打交道,我们平时写好的程序,在未运行的时候,都只是磁盘中的一个文件。要想运行这个程序,就得把该程序文件从磁盘中写入到内存,然后cpu从内存中提取该程序的指令,就可以运行这个程序。
那么问题来了,被加载到内存中运行的程序很多很多,如果有运行结束的程序是需要被及时从内存中删掉的,否则会占用内存空间,导致内存的利用率和操控等效率都及其低下,所以这些在内存中的程序是需要操作系统来进行管理的。 到这里我们就要引出进程的概念,当磁盘中的程序文件被加载到内存中运行时,那么它在内存中就成了一个进程,此时再用程序称呼就不太好了,往后我们要区分这两个概念,当然我们需要慢慢理解,慢慢熟悉进程这个概念
还记得我们上面说过,操作系统若要管理这些在内存中运行的程序(也就是进程),并不会直接去操控这些进程,而是管理各个进程的信息来管理进程。那么首先我们要设计一个数据类型来存放这些信息,我们选用结构体,然后将进程的信息放到这个结构体中,再把这些存放着进程信息的结构体以某种数据结构连接起来,方便操作系统进行管理。这个存放进程信息的结构体我们称之为PCB,在Linux中,被称为task_struct
操作系统对这每一个结构体中的信息进行管理,也就实现了对进程的管理
Linux中查看和创建进程
说了这么多,总得实操一下是不是这样的,我们先写好一个C程序,然后将这个C程序编译运行,看看其在内存中是否形成了进程
在Linux命令行终端上,可以通过输入如下命令来查看该进程
ps ajx | grep 进程名
如果想加上描述信息,可以输入以下命令
ps ajx | head -1 && ps ajx | grep 进程名
以下面程序为例,等这个程序开始运行时,查看这个程序在内存中的进程状态
可见,a.out 这个程序已经被加载到内存中运行,并形成了进程
除此之外,还可以在程序代码中创建一个进程,这里我们需要用到 fork() 函数,这个函数会帮我们创建一个子进程,fork是有两个返回值的,当fork创建子进程成功后,会给父进程返回子进程的PID,给子进程返回0。如果fork创建子进程失败,那么给父进程返回-1。
PID是进程在系统中的标识号,相当于我们的身份证号,若想查看当前进程的PID 可以使用函数getpid(),如果想查看当前进程的父进程的PID,可以使用getppid()
接下来我们用代码实操,逐步理解,我们先看看getpid与getppid的用法
接下来,我们切换到另一个窗口,通过ps查看该进程的状态
同时我们追溯了该进程的父进程22824,发现原来是bash, 可见我们平时执行的程序都是bash的子进程 ,接下来再看看kill命令,使用kill -l 可罗列出kill的各种选项,kill是对进程状态进行修改的命令,例如kill - 9 进程名 含义是杀死该进程
演示完getpid 和 getppid 的用法,接下来看看fork的用法,fork在刚学时可能没有办法理解,一个函数有两个返回值是什么情况,简直颠覆了认知,别慌!我会在后面慢慢解释,现在我们只需要知道怎么使用就可以,接下来我们用代码来实操
上面这段代码,用fork创建了一个子进程,创建一个子进程之后,该程序就多了一个执行流,子进程会继承父进程的代码,也就是说,从pid_t id = fork() 这行开始,往后的代码子进程与父进程是共享的,下面是程序运行的结果
这就是fork的简单应用,主要是先让大家见见,有个印象,后面我们会经常使用
进程的状态
通过上面的介绍,我们知道操作系统通过控制PCB结构,修改其中的信息数据来实现对进程的控制。那么这种结构必然包含着当前进程状态的信息,比如,当前进程是否在运行,是否停止了运行,是否处于等待运行的状态等等,接下来我们的任务是探讨Linux中有哪些进程状态信息是需要我们着重掌握的?
在了解进程状态之前,先看看CPU是如何执行进程的,我们一台电脑中CPU的数量是很有限的,这个CPU可能有很多处理核,但是总体来说远远没有进程的数量多,那就意味着,一个CPU的处理核可能要处理多个进程,这就会导致一个排队问题,处理排队问题的数据结构是队列,所以CPU中的每一个处理核都有一个运行队列表。当一个可执行程序加载到内存中时,就会创建对应的PCB,来存放该进程的信息。之后被操作系统分配到CPU的某个处理核的运行队列中,等待执行
R运行状态:这个状态并不意味着此时进程一定在运行中,而是表明要么在运行中,要么在运行队列中
S睡眠状态:意味着进程在等待事件完成,也叫可中断睡眠状态
D磁盘休眠状态:该状态的进程通常在等待IO的结束,也叫不可中断睡眠状态
T停止状态:可以通过给某个进程发送SIGSTOP信号让其停止运行,成为T状态。被暂停的进程可通过发送SIGCONT信号,让其继续运行
X死亡状态:这个状态表明进程已经结束,是一种返回状态,我们不会看到这个状态
Z僵死状态:这个状态我们先不管,后面会着重介绍
不知道大家是否还记得,我们上面说过的查看进程的状态命令
ps ajx | head -1 && ps ajx | grep 进程名
接下来我们详细讲讲上面这些进程状态
R运行状态
R状态,表面当前进程正处于运行状态,我们用代码测试一下
该程序加载到内存中运行时,我们查看此时进程的状态,是处于R运行状态的,大家会发现R后面有一个+号,这个+代表什么意思呢? 有这个+号表示当前进程正在前台运行,没有+表示在后台运行,这么说大家可能有些懵,所谓前台运行就是在进程运行时,占用前台命令行操作,看下面这张图
此时的程序正在运行,但我无法在命令行上输入其他命令操作了,因为此时的进程在前台运行,占用了命令行操作界面,我可以使用 ctrl + c,终止该进程的运行,如下图
但是,如果进程处于后台运行,我们是没有办法使用ctrl + c 杀掉进程的,而是要使用kill -9
S浅度睡眠状态
S状态,为什么会出现这个睡眠状态,难道是进程运行着累了,直接睡了?
并非如此,我们知道CPU的运行速度是相当的快的,而我们日常程序会经常调用IO接口,比如,调用键盘,屏幕等硬件,当然不止你一个程序想调用某些接口和硬件,怎么办?当然是排队等待了,这属于阻塞运行,(如果内存的空间不够了,而处于阻塞状态的进程暂时不会用到,操作系统可能会将处于阻塞中的进程移动到磁盘上,以腾出内存空间,这个操作称之为挂起)对于CPU来说这些过程是相当的缓慢的,CPU是不可能停下来陪你一起等,所以系统就将这个进程的状态设为睡眠状态,什么时候该进程完成了硬件的调用和IO交互,再请求操作系统将进程状态设为R状态,继续运行。
接下来用代码来演示一下,看看S状态
如上图,程序运行后,我们查看进程的状态是处于S状态,这是因为该程序频繁调用printf,程序运行的大量时间都在等待IO交互,多以我们查看该进程时,几乎都是处于S状态
D深度睡眠状态
D状态,又叫深度睡眠状态,讲解这个状态前,我们先假设一个场景
假设某个进程要与磁盘进行巨量的IO交互操作,比如向磁盘传输4G的数据,因此该进程一直在等磁盘返回传输结果,但这个时候,内存已经快被占用完,操作系统检查到这个进程一直在等待,什么事也不做,于是直接把该进程给杀掉了,等到磁盘返回传输结果时,发现该进程已经不在了,便将传输的数据视为无用数据删掉,这就导致了数据的丢失。
为了防止这样的情况发生,我们给重要的数据传输的进程加上一块免死金牌,操作系统都没有权力杀掉该进程,这种杀不掉,长期处于等待的进程就是D状态,如果一个计算机内充斥大量的D状态的进程,那么表明该计算机处于崩溃的边缘,D状态的进程除非自己苏醒或是断电,否则无人能杀掉。
T暂停状态
T状态,这是比较简单的状态,大家通过kill 发送相关的暂停命令就可看到
X死亡状态
X状态,死亡状态,该状态是进程结束时,对进程占用的资源进行回收,回收的过程很快,所以我们是看不到X状态的
Z僵死状态
Z状态,僵死状态,记得我们之前提到过的fork创建子进程吗?
我们用fork创建了一个子进程之后,子进程的运行结果是要给父进程看的,如果子进程运行结束了,而父进程还在运行,那么子进程必须等待父进程结束,然后把运行结果给父进程看。在这个过程中,子进程已经结束,在等待父进程结束,自身转换为X状态之前的这段时间的状态就是僵死状态。僵死状态是一个问题,如果父进程迟迟不结束,那么子进程就一直要等待,这是要占用内存资源的,会导致内存泄漏,至于如何解决,我们后面再介绍。需要注意的是,僵死状态可是用kill -9杀不掉的,因为僵死状态本身已经死亡了。
孤儿进程
在上面的例子中,如果是父进程比子进程先结束,那么父进程直接被回收,而此时的子进程就成为了孤儿进程,不过,该孤儿进程会被操作系统领养,由操作系统来接收返回值
进程的优先级
CPU的资源是有限的,而进程的数量有很多,这就会导致资源的分配问题,通过给进程不同的优先级,来实现对进程的资源分配。比如你想优先运行某一个程序,那么就可以将该程序的进程的运行优先级提高,让CPU优先运行。
道理不难理解,那么我们该怎么修改进程的优先级呢?
Linux中是通过修改nice值来调整进程优先级的
最终优先级 = 最初优先级(PRI 默认值) + nice值(NI 范围是[-20, 19] )
需要注意的是,最终优先级的值越小,进程的优先级越高,普通用户在调整nice值时只能比默认nice值高,而不能调低,只有root才有权限将nice值调为负值
命令: ps -la 可以查看进程的PRI 值 和 NI 值
命令: top 之后按r 输入要修改nice值的进程的PID,然后回车,输入要修改的nice值
这样就完成了对进程优先级的调整
进程切换
CPU中的各个寄存器,是保障程序正常运行的基础,但是一个CPU里只有一套寄存器 ,但是进程的数量有很多,所以每个进程只能够享有很短时间的CPU,就要切换到下一个进程,那么此时寄存器里的数据就要被转存起来,让位给其他进程
让位给其他进程将寄存器中的数据转存的过程叫上下文数据存储
当CPU再次轮转到该进程并把之前数据写入寄存器的过程叫上下文数据恢复
除此之外,还有很多关于CPU处理和资源利用方面的知识,这里我们挑几个比较常见的介绍一下,包括大家平时所谈的并行,并发等概念
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的,为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
环境变量
什么是环境变量
不知道大家好不好奇在Linux系统上的shell命令是怎么运行的?实际上,我们输入的每一个指令,例如 ls,pwd,cd 等等,都是已经写好的C程序。当我们在命令行界面上输入这些命令时,这些命令的代码就会载入内存,然后由CPU执行。那么现在问题来了,我们只是输入了一个简单的命令,例如 ls ,我并没有告诉你 ls 的代码在磁盘的某个位置
就连我们运行自己的程序还有加上 ./ a.out(./表示当前目录)请问操作系统是如何找到 ls 的代码所在处,并且将其载入内存的?要解答这个问题,我们就要明白环境变量是什么东东
环境变量是指在操作系统中用来指定操作系统运行环境的一些参数,简而言之就是为了满足运行方便设立的全局变量(至于为什么说是全局变量,是因为其具有全局属性,这里不明白不要紧,后面会慢慢解释)。我们上面说的操作系统如何知道要运行的命令的地址,其实就是在已经设置好的环境变量里查找的,我们在命令行界面输入的命令都存放在 /usr/bin 目录下,而这个地址就被添加到环境变量中了,所以当运行指令时,操作系统直接就来到这个目录下搜索指令的代码并载入内存运行
如果我刚才说的是对的,那么就会存在这样一种情况,我把自己写的程序添加到 /usr/bin 这个目录下,那么在运行时也不需要加上 ./ 表示在当前目录下,我们来实践一下
看来还真是如此,不过环境变量地址只有 usr/bin 这一个吗?显示并不是,我们可以输入命令 echo PATH查看所有的环境地址变量,echoPATH 查看所有的环境地址变量 ,echo 变量名 可查询指定的变量, 除了环境地址变量,我们还可以查询 HOME SHELL等,环境变量可不是只有地址奥
HOME:指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL:当前Shell,它的值通常是/bin/bash
我们分别在普通用户和root用户下测试HOME环境变量
环境变量相关操作指令
接下来我们看看关于环境变量的一些指令
1. echo: 显示某个环境变量值
2. export: 设置一个新的环境变量
3. env: 显示所有环境变量
4. unset: 清除环境变量
5. set: 显示本地定义的shell变量和环境变量
例如添加环境地址变量,可以使用 export PATH=$PATH:xxxxxxxxxx ,不可直接使用export PATH=xxxxx 这样会把之前的环境地址变量全覆盖掉,导致所有指令失效
删除某个环境变量 unset 环境名
但如果是要删除PATH这种环境地址变量,则是要利用上面export全部重覆盖的方式进行删改
现在我们可以解释局部shell变量和环境变量,所谓局部shell变量,其实就是我们在命令面板上定义的变量,我们的指令面板就是bash,bash是操作系统中的一个系统进程,平时在Linux跑自己写的程序都是由bash调用的,也就是说我们自己写的程序都是bash的子进程,环境变量就是可以被子进程继承,具有全局属性,这也是为什么之前说它是全局变量的原因
在上图,我们开始设立了一个变量value,给其赋值为100,用echo也是可以查到的,但是用env查不到,说明这个value是局部shell变量,不具备全局属性,不能够被子进程继承。可通过export将其加入到环境变量中
获取环境变量的三种方法
接下来我们聊聊如何获取环境变量,以及利用获取环境变量的方法来模拟实现像pwd这样的简单指令并探讨子进程是怎样继承环境变量的
首先是获取环境变量的方法,一般有三种获取方法
1. getenv()
2. char *env[]
3. char** environ
getenv 返回值的类型是char * ,传入环境变量名,就可得到相应的环境变量
如上图,利用getenv函数,我们实现了对环境变量的提取以及模拟实现了pwd指令
在说char *env[] 和 char ** environ之前,我们先看一看main函数,我们平时写的程序的main函数,通常会省略掉main函数的参数,实际上main函数是有三个参数的。
int main( int argc, char *argv[ ], char *env[ ] ),我们先看看前两个参数有什么用
看上图程序的运行结果,不知道大家有没有发现,我们写指令的时候也是用的这种格式
例如 ls -a -h -l ,我们使用的指令也是用C语言写好的程序,可以发现,我们平时认为没有用的main函数的参数,在这里可以把我们输入的指令及其选型分割开并保存起来,这个工作是由shell和操作系统共同完成的。
你可能会说这有什么用呢?我们再看看main函数的第三个参数,第三个参数是一个指针数组,这个参数和我们说的获取环境变量的char* env[]好像是一样的,那这个指针数组是用来装什么的呢?
如果使用env查看所有的环境变量,可以发现,每一个环境变量,本质上就是一个字符串,这个时候你可能猜出来了,main函数的第三个参数可能是用来存放环境变量的,没错,接下来,我们用程序验证一下
第三个char ** environ 是和 char * env[ ]类似的,里面都存放着环境变量,区别是environ是由C函数库里提供的一个函数,我们可以通过引用相关头文件,然后直接调用environ,不过在使用environ时,要用extern 声明一下
进程地址空间
什么是进程地址空间
进程地址空间可以说是进程学习过程中的一大难点,因为其牵涉到系统设计方面的内容,进程的地址空间有点像操作系统给每个进程画的一个大饼,接下来把这个概念带入到我们的日常生活中,至于为什么要画饼,在后面会一一解释
比如在公司中工作,我们的上级领导多多少少会给我们画一些饼,今天对小明说,只要你好好干,你就是我未来的接班人,明天又对小王说,只要你好好干,经理的位置早晚是你的,我是相当看重你的,不然也不会找你谈话。就这样,老板给很多员工画过大饼,只要好好干,未来整个公司都是属于你的。老板很聪明,对每个员工画什么样的饼,他都会记录下来,方便日后管理每个员工
而操作系统和进程之间也是这样的,我们这些员工就是进程,老板就是操作系统,而大饼呢就是操作系统跟进程说,整个内存资源全是你自己的,进程地址空间就是这个大饼
我们再看看学习编程语言时画的内存空间分布图,如果学过C/C++那么对下图是很熟悉的
(这里要注意,程序加载到内存中后就形成了进程,不要被程序和进程之间的切换搞乱了)
在看这张图时,我们会有这种感觉,就是该程序加载运行后,仿佛该进程可以占用任意内存资源,只要不超过内存总数,就可以任意malloc,随意new,其实这个时候,我们就已经沉浸到操作系统给我们画的饼中去了
对于一个进程来说,表面上看那张内存分布图,好像我们可以使用整个系统的内存资源,好像我们就是在划分整个内存。事实并非如此,上图的内存分配图本质是一个虚拟的地址空间,就是一个画饼的口头支票,让每个进程认为他在独占整个系统的内存,这个虚拟的地址空间其实就是进程地址空间,这两个是一回事,只是以不同的角度看待,叫法就不一样。现在我们大概清楚了一些,进程地址空间就是一个模拟整个系统内存的虚拟的空间,让进程以为自己占用了整个系统内存
那问题来了,你说我们程序的代码,数据等在进程地址空间展现出的地址都是虚拟的,那代码和数据总要存在一个真实地地址上啊,那又是怎么回事呢?这就需要我们了解内存的物理地址,物理地址就是底层硬件通电产生出来的地址编号,所有的代码,数据等最终都是写在物理内存地址上的,我们的虚拟地址通过一个叫页表的东东,把虚拟的地址映射转换成真实的物理地址。
进程地址空间的意义
如果理解上面所说的,我们仍可能会疑惑,为什么要这个进程地址空间?为什么不把程序的数据和代码直接写到物理地址上?还绕这么一个弯,真是麻烦
其实大佬们这样设计是有原因的,接下来解释一下,为什么要这么搞,有什么意义
首先我们要搞明白,计算机中所有的进程,数据等都是在物理内存上存放的,包括操作系统的代码和数据,因此,物理内存地址是相当之重要的。我相信每一位读者应该都写过数组越界的程序,在你未运行时,你可能发现不了它已经越界了,如果把一个越界的程序直接写到物理内存地址上会发生什么?好一点的情况是你改写了其他进程的代码和数据,导致其他进程挂掉,坏一点的情况是你改了操作系统的代码或数据,导致操作系统直接挂掉。
而有了进程地址空间就不一样了,即使你的代码有越界的BUG,但是你不是直接写到物理内存地址上的,而是通过页表的映射,把虚拟地址转换成物理地址,在这个过程,页表能够检测出不正确的操作,直接警告并挂掉,避免了物理内存地址受到非法修改。
进程地址空间的好处当然不止这些, 不过在了解它其他优点之前,我们再进一步了解进程地址空间本身,我们假设在一个32位,有4G运行内存的电脑上运行着某个进程,按照我们之前说的,我们能看到的代码,数据存放的地址都是虚拟的,那是怎么虚拟的呢?
在进程的结构 task_struct 内部存放着一个类型为 struct mm_struct的指针,这个struct mm_struct中存放的就是进程地址空间,里面定义了:
uint32_t(unsigned int) code_statr, code_end; 用来存放代码区的起始地址和结束地址;uint32_t(unsigned int) date_statr, date_end; 用来存放变量数据区的起始地址和结束地址;uint32_t(unsigned int) stack_statr, stack_end; 用来存放栈区的起始地址和结束地址;uint32_t(unsigned int) heap_statr, heap_end; 用来存放堆区的起始地址和结束地址;
虚拟地址就是这样表示出来的
这个过程是抽象的,难以理解很正常(很大一部分原因是笔者的解释能力实在一般),大家也不要害怕,计算机的很多定义都是建立在各种抽象之上的,我们要习惯这种抽象,遇到不会的多想一想,问一问,至少,成长的路上,我们能结伴而行。接下来,用几张图来简单演示这个过程
由于篇幅原因,我只画了代码区的地址映射到物理内存这个过程,其他都是类似的,另外笔者偷了个懒,以下所有的地址是我随便编的,不具有真实性
看了上图,是不是大概能理解进程地址空间是个什么东东,我知道,你可能仍会有疑问?比如,进程地址空间为什么已经分配好了地址?代码区的地址为什么上来就有了?磁盘中的代码导入到进程地址空间这个过程,可未见提过啊!要解释这个问题,我们就要把上图的过程讲的更加详细才行,同时要引入一个新的概念,那就是逻辑地址
编译器有多种方法给程序编址,包括通过偏移量来编址等等,但这属于编译器的知识了,我们知道形成可执行程序后,可执行程序具备了逻辑地址即可。
大家看下图,可以发现,把逻辑地址载入到进程地址空间形成虚拟地址,再把物理地址载入到页表中,如此一来,逻辑地址和虚拟地址是一回事呀,那我们知道虚拟地址,就能够通过页表映射出物理地址,然后找到在物理内存中的具体位置
写时拷贝
如果你理解上面的过程,那么恭喜你,你翻过了学习进程的一座大山,接下来我们了解一下进程地址空间的其他优点,就结束了对进程地址空间的介绍
有没有发现,进程空间的存在可以使我们有一个统一的视角来看待进程对应的代码和数据等各个区域,方便了编译器以统一的视角去编译程序。
进程地址空间的存在,可以方便的进行进程和进程的代码数据的解耦,保证了进程的独立性,这个好像不太容易理解,我们先看看下面这个程序
程序运行的结果总觉得哪里不对劲,父进程与子进程都在打印a的值,这两个a的值不同,但是当我们取地址的时候发现,这两个a的地址竟然相同,这怎么可能呢?既然是同一个地址,那么值也应该是相同的啊,那只有一种可能,这个地址并非真实的地址。这是怎么回事呢?我们了解过子进程会与父进程共享代码和数据,那么子进程自然会共享父进程的进程空间地址和数据
因为进程的特点之一就是具有独立性,当子进程运行到a = 20时,这是在修改a的值,如果a的值被修改了,那么就会影响到父进程,因为父进程也在使用a的值,如果父进程使用的a的值被改了,那还能叫独立性吗?显然是不能的,因为子进程的操作已经影响到父进程了。这个时候就会发生写时拷贝 ,写时拷贝就是说父进程和子进程通过页表映射到相同的物理内存地址上,可以进行该物理地址上内容的 “读”,以实现数据和代码共享。但当任何一个进程试图修改物理内存上的内容时(以上图的子进程为例),为了保证进程的独立性,此时OS会将这个要被修改的变量a在物理内存上拷贝一份,并填入子进程要修改的值,再将子进程上的页表进行修改,将变量a的映射修改到拷贝的新的物理内存上
这个过程就是写时拷贝,想必你也明白了上面的那个程序了,之所以子进程的a和父进程的a的值可以不同,是因为子进程使用的是拷贝后的a,之所以取地址a的地址都相同,是因为取地址操作取的都是虚拟地址,而不是物理地址,自然相同了。同时你应该也明白了为什么说进程地址空间能保证进程间的独立性,方便了数据间的解耦。
进程控制
进程控制的要点
在进程控制这个章节中,我们将了解关于fork函数的一些知识,我们之前都是直接使用fork函数,好像并没有详细了解过这个函数,接下来我们将从进程创建,进程等待,进程终止,进程程序替换等方面,了解并掌握对进程的使用
进程创建
进程函数fork(),它是在已经存在的进程中创建一个新进程,新进程称之为子进程,原进程称之为父进程
pid_t fork(void) 该函数的返回值类型为pid_t ,若创建成功,子进程返回的pid_t的值为0,父进程返回的值为子进程的pid,若创建进程失败,则返回-1
我们首先需要理解的是,fork()函数为什么有两个返回值,按照我们之前所学的,一个函数只能有一个返回值。其原因是,fork函数在返回之前,就已经有两个执行流了,因为执行到return 时就标志该函数功能部分都执行完毕,即将返回。那么在执行到reutrn时,说明子进程要么创建好了,要么就创建失败,创建成功程序就有两个执行流,子进程刚创建时是与父进程的执行流同步的,固然都会执行到return,因而有两个返回值。创建失败,那么就只会返回-1,表示创建失败。
fork()能返回两个返回值,我们大概理解了,那么为什么子进程要返回0,而父进程要返回子进程的pid值呢?这是因为一个父进程可以有多个子进程,而且父进程要回收每个子进程的返回结果,自然要记住每个子进程的pid值,而一个子进程只有一个父进程,返回0,可表明自己是一个子进程。
除了上面两个问题,不知道大家有没有想过,fork有两个返回值,pid_t id = fork(),用来接受fork返回值的id不也是两个返回值吗?id就是一个变量啊,为什么有两个返回值,其实就是触发了我们上面所说的写时拷贝,大家仔细回味一下,这个过程是不是和我们介绍写时拷贝时创建的变量a一样呢?这里就不在赘述了。
进程终止
main函数是我们编写程序时,要写入的第一个函数,main函数在执行完毕后,总是会return 0,那么为什么要有返回值呢?main函数的结束意味着该程序已经执行完毕,也就是这个程序进程即将终止,返回值是为了告诉创建该进程的父进程(Linux中是bash)该进程执行的结果如何,这里假设我们的返回值为0不变
进程退出有以下三种情况
1.代码运行完毕,结果正确 return 0
2.代码运行完毕,结果错误 return !0
3.代码执行异常,退出码无意义
我们可以通过命令 echo $? 来查看最近一个结束的进程的退出码
那么进程终止退出的方法有哪些呢?除了main函数执行完毕,返回return 0之外
我们还可以通过调用函数exit 以及函数 _exit 来退出进程,exit和_exit有什么区别吗?
_exit是OS给我们提供的进程退出的接口,而exit则是我们使用用户编程语言封装好的函数,exit本质上还是通过调用_exit来实现进程的退出,不过exit在退出之前,还进行了一些其他操作,而_exit则是直接退出。
我们看上图的运行程序,_exit在退出进程后,就直接退出了,而exit则会将缓冲区里的内容刷出来,除此之外,exit还会执行我们定义的清理函数。
注意 _exit(int status) 中要传入的参数,该参数定义了进程的终止状态,父进程通过wait()来获取其中的值,虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255,关于wait,会在后面提到。
进程等待
之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。另外,进程一旦变成僵尸状态,那就刀枪不入。“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
进程等待的方法,可以使用wait() 以及waitpid()两个函数,还可以使用宏来捕获进程的退出状态,我们主要了解wait() 和 waitpid()这两个函数来实现进程等待
pid_t wait(int *status)
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
pid_ t waitpid(pid_t pid, int *status, int options)
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
pid:Pid=-1,等待任一个子进程,与wait等效。Pid>0 等待其进程ID与pid相等的子进程。
status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
如果子进程已经退出而父进程未退出,子进程形成僵死状态,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息
如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞
如果不存在该子进程,则立即出错返回
解释以下进程可能造成堵塞是什么意思
可见,等待堵塞会严重影响父进程的运行状态,后面会介绍非堵塞式的等待方式
接下来了解一下曾多次提到过的status
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图,这里只关注status低16比特位的情况
接下来,写一段程序来验证status
前面说过阻塞式进程等待,下面再看看非阻塞式等待,非阻塞式进程等待的原理其实也很简单,在我父进程运行到wait时,子进程不是还没有运行完成吗?那我就不等了,直接让wait返回,然后父进程该干嘛干嘛,不要在这等着浪费时间了。这个想法是好的,但是有限制条件的,父进程不可能真的不管子进程了,所以父进程只是暂时不管了,之后还是要再一次执行wait,回收子进程的资源,所以这就要求父进程要不断的轮询,也就是要不断地再次执行wait,直到回收了子进程的资源,下面是非堵塞式进程等待的代码演示
进程程序替换
在了解进程程序替换之前,我们需要明白,我们创建子进程的目的是什么
1.想创建一个子进程,然后执行父进程代码的一部分。我们上面说了那么多,写了那么多程序,好像都是围绕着这个目的,都是让子进程执行父进程的一部分
2.让子进程执行一个全新的程序。
进程程序替换的本质:将指定的程序的代码和数据加载到指定的位置,覆盖自己的代码和数据,实行这个功能,我们需要使用到execl函数
int exel (const char* path, const char* arg, ...)
参数: const char* path 将path路径下的程序加载到内存中,让指定的进程执行
...表示可变参数列表 const char*arg 和 ... 表示要执行的指令和相应的选项,以NULL结尾
相当于 cmd 选项1 选项2 ... 即在命令行中怎么执行就怎么传参,结束传参就加上NULL
execl中的l 表示list 即将参数一个一个的传入exec中
在这个过程中,不会创建新的进程,如果execl 调用失败,会返回错误值,如果调用成功,什么都不返回,在进行程序替换时,为了防止覆盖当前进程的后续代码,一般创建子进程来进行程序替换。下面是execl函数的使用演示
可见,当执行到 execl时,会去./bin/ls 这个路径下,将该路径下的程序代码导入进程中,接着我们传入参数 "ls",表明要使用的命令为ls ,然后传入参数 "-lh" ,表明该命令跟着的选项为 -lh ,NULL表示参数传入结束。
程序的运行结果表明,执行完execl之后,该程序的后续代码都被覆盖掉了
当然能进行程序替换的函数不止这一个,除了execl 还有 ececlp, execle, execv, execvp, execve,数目还是不少的,但是发现没有,前缀都是exec,我们可以根据后缀的不同来记忆
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH,也就是我们不必给出绝对路径,只要告诉是那个程序或指令,该函数会自动去环境变量中搜索。
e(env) : 表示自己维护环境变量,不采用当前父进程的环境变量,自己选择性的填入环境变量
int execl(const char *path, const char *arg, ...) int execlp(const char *file, const char *arg, ...) int execle(const char *path, const char *arg, ..., char *const envp[]) int execv(const char *path, char *const argv[]) int execvp(const char *file, char *const argv[])
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
execl | 列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 否,需要自己组装环境变量 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 否 | 否,需要自己组装环境变量 |
只列出相关的描述,其实是不容易理解的,接下来通过代码来看看这些函数的具体用法
上图是execlp函数的使用演示,我们可以看到,相比于execl我们不需要指定第一个参数的详细路径,而是直接给出程序名或指令名,该函数会自动在环境变量中帮我们查找,使用起来是比较方便的
上面代码是演示execle函数的使用方法,execle函数需要自己导入环境变量,这里直接把系统中的环境变量environ导入了,在运行结果处,我们可以看到我们自己设置的TEST环境变量是为NULL,这是因为系统环境变量中不存在名为TEST的变量,所以打出来的为空值,我们接着看下面一段程序和运行结果
这段代码和前面的很相似,区别在于,我没有继续使用系统环境变量,而是选择自定义环境变量,并且我只定义了TEST,所以结果处可以发现,除了TEST其他都是NULL,这也就说明,当你选择自定义环境变量时,是不会使用系统环境变量的
如果又想自定义一些环境变量,又想使用系统环境变量该怎么办呢?也是有办法的,使用putenv(),将你自定义的环境变量加入到系统环境变量中,然后再导入系统环境变量就行了
上面代码使用的是execv()函数,其实和execl差不多的,只是execl是一个一个传参,而execv则是把参数都放到一个指针数组中统一传递
这么多函数,只有execv属于系统调用,其他的函数都是封装好的,程序需要被加载到内存才能运行,那么这个加载的工作由谁来做呢?其实就是由execv函数来加载,所以这个函数又被称为加载器
到这里,进程的基本概念知识我们都了解一遍,但这才刚开始,我们后面仍然需要学习很多的东西,重要的是动手敲代码指令,让命令在自己手中跑起来
该篇文章的最后,我们一起编写一个简易的shell,把这些学过的知识应用起来
编写简易的shell
这里需要注意的地方是,在使用cd命令是没用的,因为我们是用子进程在执行命令,cd改变的是子进程的目录,子进程退出后,并不影响父进程。因此,我们需要提前检测出cd命令,不能让它用子进程执行,就用当前进程执行。
我们可以使用函数 chdir(const char* path),这个函数可以改变当前的工作目录到指定的位置
还有一点就是ls命令不会自动加颜色的,如果想要彩色分辨出目录和文件,我们需要自己手动加一个颜色选项 "--color=auto"