目录
前言
在讲解进程之前,我们需要先铺垫一些计算机硬件、操作系统的知识,进而丝滑的理解进程的概念,然后再通过进程切入,从而理解操作系统。
1. 冯诺依曼体系结构
根据冯·诺依曼体系结构构成的计算机,必须具有如下功能:
1.把需要的程序和数据送至计算机中。
2.必须具有长期记忆程序、数据、中间结果及最终运算结果的能力。
3.能够完成各种算术、逻辑运算和数据传送等数据加工处理的能力。
4.能够按照要求将处理结果输出给用户。
为了完成上述的功能,计算机必须具备五大基本组成部件,包括:
1.输入数据和程序的输入设备;(键盘、磁盘、网卡、显卡、话筒、摄像头等)
2.输出处理结果的输出设备。(显示器、磁盘、网卡、显卡、音响等)
3.记忆程序和数据的存储器;(内存)
4.完成数据加工处理的运算器;(CPU)
5.控制程序执行的控制器;(CPU)
电脑本质就是将外部输入的数据进行计算后进行输出,按道理只需要输入设备、中央处理器、输出设备三部分,那为什么要有内存呢?
技术角度:
CPU的运算速度(纳秒级别) > 寄存器的速度 > L1~L3Cache > 内存(微秒级别) >> 外设(磁盘)(毫秒级别甚至秒级别 ) >> 光盘磁带
它们的速度差别是非常大的
根据木桶原理,整体的效率一定是由速度最慢的设备,即外设决定的,如果外设和CUP直接交流,总体效率会非常慢,
所以从数据角度,外设几乎不和CPU打交道,直接和内存打交道;CPU也同样如此
内存在我们看来,就是体系结构的一个大的缓存,适配外设和CPU速度不均的问题
成本角度:
为了追求速度,我们为什么不把CPU的寄存器直接做大到内存的级别呢?
答案很简单,成本太高了!
成本:寄存器 >> 内存 >> 磁盘(外设)
当输入设备获取到数据信号后,先将其转入内存,经由CPU的处理后,返还给内存,再由输出设备接收,让用户获取到相应的信息。
总感觉这种过程有些多余,为何不直接将输入设备获取到的数据信号直接由CPU处理,然后传个输出设备呢?
其原因是两者的存储速度相差太大,但不是说做不到实现CPU与外设打交道,设计出计算机的目的就是为了解决人类难以解决的问题以及快速解决问题的能力。
以下是存储器存储速度的金字塔结构:
内存的存取速度会直接影响计算机的运算速度,由于CPU是高速器件,但是CPU的速度是受制于内存的存取速度的,所以为了解决CPU和内存速度不匹配的问题,在CPU和内存直接设置了一种高速缓冲存储器Cache。 Cache是计算机中的一个高速小容量存储器,其中存放的是CPU近期要执行的指令和数据,其存取速度可以和CPU的速度匹配,一般采用静态RAM充当Cache
总之,内存最大的意义就是用较低的成本,获得较高的性能。
此时我们就可以分析一下如下现象:
我们之前可能听过,编译好的代码必须先加载到内存里才能运行,为什么呢?
编好的程序是一个.exe文件
,存在磁盘中(外设),所以必须先加载到内存中,这是体系结构决定的。
2. 操作系统
2.1 什么是操作系统
像我们的Linux、Windows这样的搞管理的软件
对下要管理好软硬件资源,对上要提供良好的软件服务
接下来,我们通过两个案例类比一下什么是操作系统
管理视角:
既然操作系统你有一个功能是管理,那么什么又是管理呢?如何管理呢?
我们先从哲学的角度去思考一下:
我们一般很少能见到我们的大学校长,但是作为一个管理者他依然能做出各种决策,将全校几万人管理的很好,是因为他有我们的数据
可以见得,管理的本质不是对被管理对象直接进行管理,而是只要拿到被管理对象的所有相关数据,只要对数据进行管理,就可以实现对人的管理。
但是校长连我的面都不见,怎么拿到我的数据呢?有辅 导员啊,辅导员可以直接接触到学生,拿到数据,把数据上交给校长,校长通过数据可以做出决策。
同时辅导员还有一个角色:执行者,他可以将校长做出的决策进行向下落实。
这里学生就是一个被管理者。
这里面校长-辅导员-学生的关系,就相当于操作系统-驱动-硬件的关系
操作系统通过驱动拿到硬件相关的数据,在操作系统层面保存下硬件的所有数据,当有一些需求、任务的时候,操作系统做好决策,交给驱动程序,驱动程序再让硬件去完成。
那么,第二个问题来了,上万名学生,每个学生又有电话、地址、成绩……等大量信息,大量的数据都集中到校长,如果不做处理,根本无法使用,那么又怎么对数据进行管理呢?
好在,这个校长以前是个程序员,他发现,虽然数据多,但是重复属性的数据也很多,每个同学都有姓名、电话、地址、成绩……,要管理A学生和B学生所需要的数据种类是一样的;所以,校长把所有的信息以学生为单位,组织好。
换个角度,人是通过事物的属性认识世界的,一切事物都可以通过抽对象的属性,来达到描述对象的目的;校长做的,其实就是抽取所有学生的属性,来描述对应的学生。
我们知道,Linux使用C语言写的,那么在C中有没有一种数据类型,能够达到描述对象的功能呢?
每个学生都能产生自己的对象或者说变量,为了将这几万名学生管理起来,于是校长想到一种数据结构——链表,于是又给每个结构体增加了两个指针,分别记录前后两名学生,从而将这几万名学生组织起来,校长只要拥有一个头节点,就掌握了所有学生的所有信息。
从此,对学生的管理,就变成了对链表的增删查改。
自此,我们又凝练出一套理论:
管理的核心理念:先描述,再组织
再回到操作系统
那操作系统要进行哪些管理呢?
操作系统:内存管理、进程管理、文件管理、驱动管理
之后我们着重研究进程管理
服务视角:
我们也可以把操作系统看成是一个银行系统,银行中有电脑、桌椅、仓库、宿舍等硬件(对应计算机的硬件);也有一些员工,管理使用着这些硬件(对应我们的软件);行长(操作系统)同时掌控着员工、硬件,相当于系统内核既要管理硬件,也要管理软件;整个银行是封起来的,但会提供一些窗口,向外提供服务,用户可以从进行存钱取钱等请求,让银行做出相应的反应,相当于操作系统会向外提供一些接口——C语言的函数调用,这些函数被称之为系统调用
在操作系统的实际使用中,一些小白或者初级工程师如果面对大量的底层系统调用去调用网卡、硬盘等这些硬件,学习成本实在太高了,于是操作系统便设计了”接待“,小白可以简单上手的:图形化界面(常见的Windows界面)、命令行解释器(Linux),工程师在编码时使用的,如printf()这样的库函数。
总结:
当我们用户进行下载卸载程序这样的管理操作、双击应用打开这样的指令操作、编程时写下的printf(),这些操作会调用硬盘、网卡这些硬件吗?
一定会,但这些都是操作系统进行了层层调用而实现的
或者说,我们平时写代码时调用的一些库函数,也或多或少会调用一些系统调用。
这里又一个问题产生了,Linux和Windows的系统接口是一样的吗?它们的库一样的吗?
答案是不一样的。
两种操作系统的编写细节一定是不同的,那它们的可以调用的接口也一定不同
然而不同平台的C语言库都必须提供的printf(),因为系统调用不同,它们的printf()实现也不同,所以每个平台都需要提供自己独有的C语言库。(也就是说,如果今天有个新的操作系统要被大量用户使用,C语言委员会就得为它写库)
但是,我上层不需要知道,同一段源代码,我在Linux上编译,会自动链接Linux的库;在Windows上编译,会自动链接Windows的库。
像这样,我需要的C语言函数的功能是不变的,但底层实现会因系统的改变而改变,这不就是C++多态的概念吗?
所以操作系统是管理和控制计算机硬件与软件资源的计算机程序,是直接运行在“裸机”上的最基本的系统软件,任何其他软件都必须在操作系统的支持下才能运行。
3. 进程
3.1 基本概念
课本概念:程序的一个执行实例,正在执行的程序等
内核观点:担当分配系统资源(CPU时间,内存)的实体。
一个简单的解释:进程其实就是一个运行起来的程序
我们的源文件写好放到磁盘,再经过编译链接形成可执行程序,此时我们双击运行这个程序,此时程序会加载到内存中,那么,内存中的这个.exe文件就是进程了嘛?
其实这个解释有一点狭隘,它依然只是程序!
具体什么是进程,我们先不说,先想想这样一个问题:
打开Windows的进程管理器,我们可以看到系统中同时存在大量的进程
那么操作系统就一定要将所有的进程管理起来;
对进程的管理,就是对进程的数据进行管理;
根据之前说的,我们就要先描述,再组织。
所以,操作系统就要为管理该进程,创建对应的数据结构
3.2 描述进程——PCB
既然编写Linux的是C语言,那么描述这个事物,我们用什么类型呢?
任何进程在形成之时,操作系统要为改进程创建PCB——进程控制模块;简单的将,PCB就是一个结构体( Linux操作系统下的PCB是: struct task_struct ),里面存放了进程相关的属性信息
在Linux操作系统中,定义一个struct task_struct的结构体,其中包含了进程的所有属性数据,于是就可以把磁盘中加载进来的程序的一些属性实例化出一个对象,通过这个struct也可找到内存中程序的位置;
再将这一个一个的结构体进行通过一定的数据结构(其实就是链表)进行连接,就可以把所有进程组织起来;
此时操作系统对进程的管理就成为对内核数据结构的管理,即对链表的管理。
3.3 进程和程序的区别
首先,我们编写好的程序,在经过编译处理之后,所产生的文件(可执行程序)会放在中,当我们运行程序时,操作系统将磁盘上的文件加载到内存中,同时为它创建PCB(进程控制模块),这两个组合起来才是进程。
3.4 task_struct-PCB的一种
在Linux中描述进程的结构体叫做task_struct。
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息
3.5 task_struct的内容分类
1.标示符: 描述本进程的唯一标示符,用来区别其他进程。
2.状态: 任务状态,退出代码,退出信号等。
3.优先级: 相对于其他进程的优先级。
4.程序计数器: 程序中即将被执行的下一条指令的地址。
5.内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
6.上下文数据: 进程执行时处理器的寄存器中的数据。
7.I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
8.记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
9.其他信息
4. 查看进程
4.1 通过系统文件查看进程
通过系统文件/proc,来查看当前所有的进程
$ ls /proc/
4.2 通过ps指令查看进程
ps指令用于报告当前系统的进程状态。可以搭配kill指令随时中断、删除不必要的程序。ps命令是最基本同时也是非常强大的进程查看命令,使用该命令可以确定有哪些进程正在运行和运行的状态、进程是否结束、进程有没有僵死、哪些进程占用了过多的资源等等,总之大部分信息都是可以通过执行该命令得到的。
我们先编写一段代码,让程序一直运行
这里,我们的可执行程序process,启动之后就是一个进程。
那怎么查看我们系统中的进程呢?
$ ps ajx
我们再用grep
,把刚刚写打开的进程筛选出来
while :; do ps axj | head -1 ; ps axj | grep proc | grep -v grep ; sleep 1; done
4.3 /proc系统文件夹
在我们的根目录下有很多的路径
我们见过的,比如
-
home:当前用户的家目录
-
root:root用户的默认家目录
-
tmp:一个共享目录
-
usr:/usr/bin里面存了指令
这里,我们再来了解一个:proc
proc是一个内存文件系统,它里面放着系统的实时进程信息
这里的蓝色文件是目录文件,这些数字是进程的pid,那么什么是pid呢?
4.4 PID
每一个进程在系统中,都会存在一个唯一标识符号pid(process id),就如同学生在学校里的学号。
我们再将process程序运行起来,查看它的进程信息,其中有一项PID
,可以看到proc下确实有这个以的PID命名的文件
当我们结束process进程时,再找这个文件就会显示不存在
经过验证,可以发现,proc目录中确实存在着一些以进程PID命名的实时文件。
此时,再把前面的程序运行起来,再次执行时查看文件夹,会发现不存在此文件,再次查看进程信息会发现PID变了
其实也很好理解,因为重启进程后,这就是一个新的进程了,新的进程就会有新的PID。
5. 通过系统调用获取进程标示符
5.1 PID
我们想要获取到pid和ppid,就要用到系统调用接口:
pid_t getpid( void ) --- 返回的是子进程ID
#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
其实也可以通过PID结束指定进程:
如果知道进程的PID,我们可以通过另一个终端使用kill指令杀掉一个进程
kill -9 [PID]
5.2 PPID
pid_t getppid( void ) --- 返回的是父进程ID
#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进程的子进程。
5.3 进程组和会话
实际上,进程之间不只有父子关系
我们创建一个后台进程
其中:
- PPID:父进程ID
- PID:进程ID
- PGID:进程组号
- SID:会话ID
- TPGID:终端进程组ID
- STAT:进程状态
- UID:用户ID(每个用户都有一个自己的进程ID)
- TIME:进程运行时长
- COMMAND:命令名
- 上面三个进程就属于同一个组 ,它们共同完成一个任务,组号(PGID)就是第一个进程的PID
一个进程也可以成为一个进程组
当我们登陆Linux,操作系统就会为我们形成一个会话,这个会话由多个进程组构成
其中,在任何时刻有且只有一个前台进程组,0/n 个后台进程组
当我们登陆时出现的命令行界面也是一个进程,我们称之为bash进程
一般它是会话的话首进程组
所以,bash进程的进程ID同时也是它的进程组ID和它所在的会话ID
上面的三个sleep进程就属于这个以bash为首的7237号会话
5.4 当前路径
提到当前路径,我们的印象可能就是我们在学习C语言的文件操作时,如果进行路径指定,默认放到的路径,而根据经验,这个路径就是我们的源文件所在的位置。
其实这个认知是不正确的
我们进入那个以PID命名的文件夹
其中:
- cwd --> 进程当前的工作路径
- exe --> 进程的可执行程序的磁盘文件
当前路径,其实是当前进程所在的路径,进程自己会进行维护,这里的cwd就是我们所谓的当前路径
那么,PID,还有cwd,exe这些文件在哪呢?
这些都是进程的属性,它们就存在进程控制块PCB(task_struct)结构体中。
6. 通过系统调用创建进程-fork初识
6.1 四种主要事件会导致进程的创建
1.系统初始化
2.正在运行的程序执行了创建程序的系统调用
3.用户请求创建一个新进程
4.一个批处理作业的初始化
6.2 用户如何请求创建一个新进程
通过fork函数来进行进程的创建,我们可以man fork查看相关的函数信息。这是一个系统调用,它会创建一个与调用进程相同的副本。在调用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语言中,有没有可能两个死循环同时运行?
答案是肯定的,都不行
从上述的结果可以看出,main函数的进程和fork创建的进程打印的结果是一样的,并且通过pid和ppid发现,fork的父进程就是main函数的进程,说明fork所创建出来的子进程和父进程在内存上映射。
fork之后,父进程和子进程会共享代码——printf()打印了两次
fork之后父进程和子进程的返回值不同,可以通过不同的返回值让父进程和子进程执行不同的任务
现在回答一下为什么要给父进程返回子进程的PID,给子进程返回0:
一个父进程可以有多个子进程,
-
父进程必须有标识子进程的方案:fork之后,给父进程返回子进程的PID
-
子进程最重要的就是要知道自己被创建成功了,因为子进程找父进程的成本是非常低的:getppid()即可
那它是怎么实现两次返回的?
fork作为一个系统调用,它调用了之后,操作系统做了什么?
无疑肯定是多了一个进程
一个进程相当于task_struct + 进程的代码和数据
fork()过程中,操作系统也一定会为这个子进程创建它task_struct,给它找到相应的代码和数据
子进程的task_struct结构体中的数据从何而来?
基本是从父进程给继承(拷贝)下来的。
子进程要执行的代码、计算的数据从哪来?
子进程和父进程执行的是相同的代码,所以fork()之后,父子代码是共享的;而数据要各自独立。
虽然代码是共享的,但可以通过不同的返回值执行不同的任务
在父进程中,调用fork函数,会执行前置1中的一系列任务,当这个子进程被创建后,会被放到运行队列中,此时运行队列中同时存在父子两个进程的task_struct,并且它们都还未执行到return pid;这一步,再往后,两个进程也无疑都会进行return,有两个返回值也就是必然的事了。
因为两个进程共享代码,至于当两个进程跳出fork函数体后将返回值都赋给同一个变量,操作系统如何处理我们后序进程地址空间可以看到答案
这些task_struct作为结构体变量,无疑在不同的队列中进行转移;
而其中有一种队列被称为运行队列(runqueue),这些队列被调度器所掌控,
调度器可以让CPU去运行这些进程,CPU可以通过task_struct所指向的代码和数据,进行相应的执行操作
7. 进程状态
7.1 进程状态有哪些
CPU对进程处理,取决于进程当前进程所处的状态,CPU对于不同状态的进程会采取不同的措施。
7.2 进程状态的分析
7.2.1 进程终止
一个进程进入终止状态,是说它已经被释放了呢?还是进程还在,只不过永远不运行了,随时等待释放。
答案是第二个。
但是问题来了,进程都终止了,为什么不立马释放相应的资源,而要维护一个终止态?
释放无疑要花时间,有没有一种可能,当前你的操作系统很忙。
一个系统结束了,先不释放,先给个标记,等操作系统不忙的时候在进行释放。
7.2.2 进程阻塞
一个进程,使用资源的时候,不仅仅在使用CPU资源
进程可能申请更多其他资源:磁盘、网卡、显卡、显示器资源、声卡、音响……
进程运行的时候要申请CPU资源,暂时无法满足时,需要排队——运行队列
那如果我们申请的是其他设备的资源呢? —— 也是需要排队的(task_struct在进行排队)
每一个硬件资源都要被操作系统管理,就都需要有对应的数据结构,操作系统对硬件的管理就变成了对硬件数据的管理。
假设CPU和一些硬件对应的数据结构是如下这样
当一个进程开始运行,就会挂到运行队列,让CPU运行,当需要访问某些资源(磁盘、网卡……)的时候,CPU会向对应的硬件发送请求;
如果该资源处于就绪状态,就直接调用此设备执行
如果暂时还没有准备好,或者正在给其他进程提供服务,此时
当前的进程控制块会被拉出CPU的runqueue,
放到对应设备的描述结构体中的等待队列
当那个设备执行完之前的任务之后,就会将这个进程控制块放回CPU的runqueue,CPU再执行这个进程的时候,就可以直接调用对应硬件设备了
当我们的进程在外部等待资源的时候,该进程的代码就不会被执行了!!
从我们用户的上层角度看,就是这个进程卡住了,所以这就叫做进程阻塞
7.2.3 进程挂起
当一个程序要运行时,它的代码和数据就要被加载到内存中,同时要创建一个task_struct,这些都会占据内存空间,
随着更多的程序被打开,势必会出现内存不足的情况;
此时,轻则所有的程序malloc、new等申请内存的操作都将无法进行,
重则要知道操作系统作为一个软件也同时会瘫痪。
为了避免这样的事发生,操作系统就要帮我们进行辗转腾挪:
其中有一些短期之内不会被调度(比如:一些进程等的资源短期之内不会就绪,或者一些等待队列排的很长)的进程,
它的代码和数据依旧在内存中,就是白白浪费空间
OS就会在磁盘上找一段空间,把进程的代码和数据暂时置换到磁盘上
这就叫做进程挂起
7.2.4 运行状态
R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
上图想表达的意思是,进程A处于运行中,在一段时间后,就会切换到进程B....,这个时间很快,CPU运行这些进程是采用了时间轮转调度算法。在时间片轮转调度算法中,系统根据先来先服务的原则,将所有的就绪进程排成一个就绪队列,并且每隔一段时间产生一次中断,激活系统中的进程调度程序,完成一次处理机调度,把处理机分配给就绪队列队首进程,让其执行指令。当时间片结束或进程执行结束,系统再次将cpu分配给队首进程。
7.2.5 浅度睡眠状态
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep))。
#include <stdio.h>
#include <unuistd.h>
int main()
{
printf("hello linux!\n");
sleep(50);
return 0;
}
处于S状态的进程,是可以被立即终止的
7.2.6 深度睡眠状态
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。例如,当进程要求对磁盘进行写入操作,那么在磁盘进行写入期间,该进程就处于深度睡眠状态,是不会被杀掉的,因为该进程需要等待磁盘的回复(是否写入成功)以做出相应的应答。(磁盘休眠状态)
7.2.7 停止状态
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
#include <stdio.h>
#include <unuistd.h>
int main()
{
while(1)
{
printf("hello linux!\n");
sleep(1);
}
return 0;
}
在上述程序跑起来之后,处于浅度睡眠状态,我们可以发送 SIGSTOP 信号给进程来停止(T)进程
我们发送 SIGSTOP 信号给进程来停止(T)进程后,还可以发送SIGCONT 信号让进程继续运行
查看kill相关信号:
7.2.8 僵死状态
僵死状态(Zombies)是一个比较特殊的状态。当子进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵死(尸)进程,僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
7.2.9 死亡状态
X死亡状态(dead):死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,所以你不会在任务列表当中看到死亡状态。
7.3 僵尸进程
有如下代码,我们执行之后,子进程会不断的打印数据,父进程等待子进程的过程中,我们立刻杀掉子进程,那么子进程就会处于僵尸状态,而此时程序还在执行,父进程在等待子进程退出的状态,我们把这种进程称之为僵尸进程
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;
}
可以看到,子进程在前五秒一直是S,接下来父进程没有退出,子进程退出就成了Z状态
长时间僵尸状态且没有人回收子进程,该状态会一直维护!该进程的相关资源(task_struct)不会被释放,就放生了内存泄漏而且,如果父进程不退出,这个僵尸进程是无法被kill -9 杀死的。
僵尸进程的危害:
进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态?
是的!
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护?
是的!
那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?
是的! 因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
内存泄漏?
是的!
7.4 孤儿进程
如果子进程退出,没有回收,父进程没有退出,那么子进程就叫僵尸进程
那么,如果父进程已经退出了,但子进程还在呢?
我们之前说,父进程可以通过某种方式回收子进程,而结束子进程的僵尸状态;
但此时恰恰相反,父进程没等子进程结束就已经提前退出(被它的父进程回收了),那子进程也就没人管理,等到子进程退出的时候,也就没有人回收了。
好在我们的操作系统提前想到了,如果父进程提前退出,子进程还在运行,子进程就会被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号进程
如上就是1号进程——操作系统
我们可以发现,子进程变为孤儿进程前状态是S+,变为孤儿进程后变为S
- S+代表前台进程,可以
ctrl+C
杀掉 - S是后台进程,无法被
ctrl+C
杀掉
7.5 守护进程(精灵进程)
有些程序启动后会一直运行,即使用户退出也不会终止,除非用户手动kill(如一些服务器程序),这样的进程我们称为守护进程
其实我们在进程状态这一部分提到的孤儿进程就是守护进程
也就是说,守护进程的父进程一定是1号进程
创建方式1
前面我们了解过进程组和会话的内容
我们知道,在命令行中启动一个新的进程,一般这个进程是属于当前bash所在的会话的
当此用户退出登陆,此会话下的所有进程组都会终止
所以当我们想创建一个守护进程,就要想办法让它和当前会话脱钩
setsid()可以设置一个进程的会话ID
但是,要求调用setsid()
的进程不能是一个进程组的组长
所以,一般我们需要fork()
一个子进程,让子进程setsid
,父进程可以直接exit()
;
更改成功则返回新的SID,调用失败返回-1,并设置errno
if(fork() > 0) exit(0);
setsid();
创建方式2
系统为我们提供了守护进程化的接口daemon()
int daemon(int nochdir, int noclose);
8. 进程优先级
- cpu资源分配的先后顺序,就是指进程的优先权(priority)。
- 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
- 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
优先级是进程获取资源的先后顺序
我们区分一下优先级和权限这两个概念
- 权限是能或不能的问题
- 优先级一定是能,只不过是先还是后的问题
为什么会存在优先级
排队的本质就是确认优先级
为什么要排队呢?——因为资源不够
系统中永远都是进程占大多数,而资源占少数
所以竞争资源就是常态
便一定要确认先后——确认优先级
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上正在运行的进程剥离下来,直接运行优先级更高的进程
如今的很多操作系统都是支持这样的抢占式内核的
8.1 进程间切换
我们我们前面讲冯诺依曼体系结构的时候提到,CPU作为中央处理器,其中有运算器和控制器,其实现代计算机的CPU中还有一部分叫做寄存器的存储空间;
这些寄存器可以临时存储一些数据,这部分数据非常少,但是非常重要!
我们在hello函数内返回变量a,外部用z接收,由于从从hello函数回到main的过程中,hello中定义的变量已经被释放,也就是走到main的赋值语句的时候,a的空间已经不可用,也就是必须找一个空间存储临时a的值,回到main再赋值给z;这个临时的空间就是寄存器
以上是寄存器的一个小用途;
那当我们的当前进程时间片用完,要执行下一个进程的时候,CPU的寄存器上一定存有大量的临时数据,如果不管这些临时变量,直接将进程切走,那这些寄存器空间一定会被下一个进程所覆盖,就发生了数据丢失,当那个进程再进来,找不到正确的寄存器数据,就会出问题
我们把进程在运行中产生的各种寄存器数据,称为进程的硬件上下文数据
当进程被剥离,需要保存上下文数据
当进程恢复的时候,需要将曾经的上下文恢复到寄存器当中
9. 环境变量
9.1 基本概念
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
9.2 常见的环境变量
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
9.3 查看环境变量
系统中存在相关的环境变量,保存了程序的搜索路径!
我们系统中搜索可执行程序的环境变量叫作PATH
可以通过echo命令来查看环境变量
(这里的$表示取PATH的值,否则仅会输出PATH字符串)
后面是一些路径(里面存了指令对应的文件),每个路径用:(冒号)分隔
当我们输入一个指令,操作做系统就会找PATH后面的一个一个路径,看里面是否有对应指令的文件
我们程序之所以必须带路径,就是因为在上述PATH的所有路径中都找不到我们的程序
9.3.1 测试PATH
我们在输入指令时(ls / pwd等)并没有输入相应的路劲,只要指令正确就一定能执行,但是我们生成的可执行程序却要加上 ./(当前路径下)才可以被执行。这主要是以为系统在环境变量中找不到你当前可执行程序相应的路径。
我们通过echo $PATH查看环境变量,发现有许多有路径,并且由(:)号隔开。 当我们输入指令时会通过环境变量查找相应的路径。
如何让我们自己的可执行程序也想系统命令一样直接执行呢?
方法一:
直接将可执行程序拷贝到里面去,但是不推荐这样做,如果你添加了,忘记删除了,后期在执行某些指令时,突然多出一些东西。污染环境
方法二:
直接将可执行程序所在路径拷贝到里面去。通过export指令+环境变量的名(export PATH)对环境变量进行新的设置
$ export PATH=[路径]
但是这样设置覆盖原来的环境变量,导致很多指令都用不了了
其实也不用担心,我们只要退出在重新启动就恢复了
正确的做法:
$ export PATH=$PATH:[路径]
8.3.2 测试HOME
为什么每次登录不同的用户,所在的家目录都是不一样的,就是因为环境变量HOME
9.3.3 测试SHELL
我们在命令行所输入的指令,都是由命令行解释器进行解释的。我们可以通过查看环境变量SHELL来知道自己当前所用的命令行解释器。
9.4 和环境变量相关的命令
9.4.1 echo命令
这个处理可以查看相关的环境的变量,还可以直接打印一些数据
9.4.2 export命令
设置一个新的环境变量,还可以将本地变量导出环境变量;所谓的本地变量就相当于我们在C/C++中定义一个变量;
9.4.3 env命令
显示所有环境变量
可以看到,每一个环境变量都是以[name]=[value]
的形式进行写的
9.4.4 set命令
显示本地定义的shell变量和环境变量
9.4.5 unset命令
清除环境变量;处理环境变量外,还有本地变量,所谓的本地变量就相当于我们在C/C++中定义一个变量。如下图所示:
9.4.6 环境的组织方式
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。
10.通过代码获取环境变量
10.1 main函数命令行参数的了解
对于main函数我们已经很熟悉了,它其实可以带有参数的,你了解吗?main函数有三个参数
int main(int arge, char* argv[], char* envp[])//这里的三个参数就是命令行参数
{}
#include <stdio.h>
int main(int argc, char* argv[])
{
for(int i = 0; i < argc; ++i){
printf("argv[%d]:%s\n",i,argv[i]);
}
return 0;
}
结合代码及运行结果可以看出,我们在命令行中只有一个命令,那么对于的数组(argv[ ])中就有一个元素(字符串“./myproc”),有多个携带的参数就有多个字符串。
我们这里想说明的是,当我们在命令行中敲出一个命令后通过带入不同的参数选项,会有不同的结果。我们通过模拟实现一下这样的效果
#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[])
{
if(argc != 2){
printf("Using: %s -[a|h|ah]\n",argv[0]);
return 1;
}
if(strcmp(argv[1],"-h") == 0){
printf("hello world!\n");
}
else if(strcmp(argv[1],"-a") == 0){
printf("hello linux!\n");
}
else if(strcmp(argv[1],"-ah") == 0){
printf("hello world!\n");
printf("hello linux!\n");
}
else{
printf("hello C++!\n");
}
return 0;
}
通过上面的代码我们也实现出了通过指令加选项的操作达到不同的效果,指令有很多选项,用来完成同一个命令的不同子功能,选项的底层使用的就是命令行参数。
10.2.main函数的环境变量获取
main函数除了有命令行参数,还有环境变量,也就是第三个参数。它是用来获取环境变量的
#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[], char* env[])
{
for(int i = 0; env[i]; i++){
printf("%d->%s\n",i, env[i]);
}
return 0;
}
10.3 通过第三方变量environ获取
这是系统提供的一个获取环境变量的第三方变量,是一个二级指针
#include <stdio.h>
#include <string.h>
int main()
{
extern char** environ;
for(int i = 0; environ[i]; i++){
printf("%d->%s\n",i, environ[i]);
}
return 0;
}
11. 通过系统调用获取环境变量 (常用)
以上两种获取环境变量太过于麻烦,我们最长用的还是通过系统调用来获取环境变量。
使用getenv函数获取
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n",getenv("PATH"));
printf("%s\n",getenv("HOME"));
printf("%s\n",getenv("SHELL"));
return 0;
}
环境变量是具有全局属性的,可以被子进程继承下去
#include <stdio.h>
#include <stdlib.h>
int main()
{
char * env = getenv("MYENV");
if(env){
printf("%s\n", env);
}
return 0;
}
我们的程序执行起来后(进程),其共享父进程bash的环境变量(bash的环境变量是系统给的),那么只有bash的环境变量有所改变,影响的就是全局。
12. 程序地址空间
作为一个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的值和地址
可以看到,它们的全局变量的值和数据都是相同的
那么如果其中一个进程更改了全局变量呢?
一样的地址,读取的内容不一样
这就相当于,我给你一块地址,你读出来的值和我读出来的值不一样
这种也被称之为写时拷贝:就是等到修改数据时才真正分配内存空间,这是对程序性能的优化,可以延迟甚至是避免内存拷贝,当然目的就是避免不必要的内存拷贝。
所以我们得出结论:我们在C/C++中使用的地址绝对不是物理地址
那是什么呢?在Linux下,它们被称为虚拟地址/线性地址/逻辑地址。
我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理,OS必须负责将 虚拟地址 转化成 物理地址。
12.1 进程地址空间
12.1.1 抽象概念
这个地址空间其实就是操作系统给每个进程画的大饼
就是操作系统通过软件的方式,给进程提供一个软件视角,让每一个进程都认为自己是独占系统中的所有资源
12.1.2 实体结构
既然要为每个进程都画一张饼,也就是每一个进程在启动的时候,都会让操作系统给他创建一个地址空间,该地址空间就是进程地址空间。
每一个进程都有一个自己的进程地址空间
那么操作系统就要管理这些地址空间
管理–>先描述,再组织
进程地址空间,其实是内核中的一个数据结构,struct mm_struct
mm_struct中的每一个地址就可以通过页表与物理地址对应
这样,当CPU访问某一段代码或数据的时候,就可以通过拿虚拟地址到页表中找到对应的物理地址,从而访问物理内存。
进程地址空间就类似于一把尺子,每个空间都有对应的起始位置和结束位置。通过这个虚拟地址去间接访问内存。
为什么不能直接去访问物理内存?
如果没有进程地址空间的加持,那么程序就会直接访问物理内存,没有区间可言,会存在恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有bug的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。
为什么要有虚拟地址空间
如果像如下这种,每个进程都挨着开一段连续的空间,那么一旦发生了越界,就会对其他进程的数据或者代码发生更改,产生不可预知的后果
但是,如果使用进程地址地址空间的方式
通过虚拟地址,可以在页表中找打对应的物理内存
一旦发生了越界,那么就无法在页表中找到对应的虚拟地址,这个进程就会直接被杀掉。
所以进程地址空间的第一个意义就是保护内存
一个进程申请空间,可以先在现在mm_struct把堆区对应的开始或者结尾的值进行填写,等这段空间真的要写入使用的时候,再区实际的物理内存开空间,
那么在申请到使用这个时间段,别人就能使用这些空间,
这无疑是对资源的一种节省策略。
这就是Linux在进程管理和内存管理的时候,通过地址空间,进行了功能模块的解耦;
换言之,如果用图一的方式,进程要开一段空间,操作系统必须立即开辟对应的内存
让进程或者程序可以以一种统一的视角看待内存
也就是以统一的方式来编译和加载所有的可执行程序
总结:
简而言之,首先,程序数据加载到内存后,由操作系统分配进程PCB(task_struct和mm_struct(进程虚拟地址空间))和页表。此时我们的进程就算是创建好了。
虚拟地址的设计有何好处:
- 有了虚拟地址,每个进程都认为自己独占内存资源,这样对于操作系统来讲,也更加偏于管理进程。
- 采用间接的地址访问方法访问物理内存。程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠。
- 如果没有进程地址空间的加持,那么程序就会直接访问物理内存,没有区间可言,会存在恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。反之有利于保护物理内存。