1.操作系统
任何计算机系统都包含一个基本的程序合集,称为操作系统(Operator System)。笼统的理解,操作系统包括:
内核(进程管理,内存管理,文件管理,驱动管理)
其他程序(函数库,shell程序)
OS的本质是一种进行软硬件资源管理的软件,并为用户提供一个良好的使用环境。
从操作系统向下看,下三层描述了操作系统管理硬件的原理。每一个硬件都是一个独立的个体,操作系统无法直接向硬件去获取或者发送信息,于是操作系统和硬件之间就出现了一个“驱动程序”来将它们二者联系起来,通过某个硬件的驱动程序,操作系统就可以控制这个硬件。
硬件通过它的驱动将自己的各种信息传给操作系统,操作系统获取到这些硬件信息,这个过程叫描述硬件。之后操作系统把这些硬件通过链表或者别的数据结构穿起来,进行管理,这个过程叫做组织硬件。于是操作系统管理的本质就是 "先描述,在组织"
还剩下上三层,这三层讲述的是用户使用操作系统的过程。首先操作系统为了防止用户在使用自己的时候乱搞,把自己搞崩溃了,因此首先在自己上层封装了一层接口,也就是系统调用接口,用户或者程序员只能通过这些接口来使用操作系统的服务,比如通过某个系统调用接口让操作系统去磁盘上读10个字符进内存中。
但是这些系统调用接口还是太不好用了,于是又在上面又封装了一层,将各种系统调用接口也封装起来,方便使用系统调用接口。比如c语言库,图形化界面,fopen中就封装了系统调用接口open。最后用户就可以通过图形化界面来看视频,或者打游戏,而不是命令系统调用去进行什么操作。
2. 进程
进程是程序的一个运行实例,或正在执行的程序
具体来说,当我要点开一个exe可执行程序,首先操作系统会帮我把这个可执行程序从磁盘拷贝到内存中去,然后创建一个结构体(PCB)来描述这个程序。
PCB全称 process control bolck 进程控制块,具体到Linux操作系统中,进程控制块叫做task_struct 任务结构体,这里面包含了这个任务进程的id,执行或闲置的状态,这个任务所处的地址等等信息。操作系统中肯定管理了不止一个任务,也就是说操作系统中有若干个PCB,然后这些PCB需要用一个链表串起来,形成内核数据结构。也就是说操作系统中的进程管理也是一个先描述再组织的过程。
本节我们将会涉及到PCB结构体中的如下几个成员:
标识符、状态、优先级、程序计数器、内存指针、上下文数据、I/O状态信息、记账信息
那么一个进程精确的讲,是由内核数据结构(PCB) 与 程序的代码和数据 共同组成的。
2.1 查看Linux下的进程
首先我们写一个死循环程序test.c ,make出可执行文件myproc
我们让这个程序跑起来,然后再开一个新终端,使用指令 ps axj 查看当先机器上正在跑着的所有进程
这个进程太多了,我们可以用grep筛选一下
单纯的筛选因为没有表头所以不太好看,我们可以使用 head -1 命令把表头打印出来,分号是为了在一行中执行两个命令。
可以看到目前正在执行的关于myproc的进程有两个, ./myproc就是我们正在执行的那个程序所创造的进程。 出现有grep字的进程是因为grep指令本身也是一个程序,因此在使用grep指令的时候自然也创造了一个grep的进程。
如果不想看到这个grep进程我们可以使用反向筛选 grep -v grep
把程序运行起来,本质上就是在操作系统中启动了一个关于这个程序的进程。包括ls pwd grep 这些命令,它们在命令的时候也创建了一个进程,只不过这个进程运行的很快,交给CPU之后很快就运算完了,这种进程叫瞬时进程。还有一种进程,比如浏览器,我们刚刚写的myproc文件,用户不主动退出它们就会一直运行下去,这种进程我们称为常驻进程。
2.2 pid (标识符)
pid (process id)是每个进程的唯一标识符
在代码中想查看pid可以通过系统调用 getpid() ,想要使用这个函数需要包含头文件 <sys/types.h> ,这个函数参数为空,返回值类型是 pid_t 其实就是一个整形。
这里我们改造一下刚才的代码,让它把自己的pid也打印出来。
2.3 kill 终止进程
kill命令可以向进程发送信号,这个信号有很多种,我们目前只需要知道 9 号信号就行。
下面我们直接选择9号选项然后选择要干掉的进程id值就可以终止这个进程了。
事实上我们之前用的 ctrl+c 的指令也是在杀进程而终止这个程序的。
2.2 proc目录
刚才我们说过使用 ps ax j指令可以查看所有正在执行的进程和它们的属性。但是这个方法所能查看到的属性还是太少了。
在Linux系统的根目录下有一个proc目录
这里面包含着当前所有正在执行的进程文件。
左侧的这些数字就是每一个进程的pid。
我们再启动一下myproc文件,可以看到pid:31266出现在了proc目录下,说明这个进程确确实实启动了。
我们可以进到31266进程目录下,可以看到这个进程有诸多属性
我们讲解一下其中的cwd和exe属性
2.2.1 exe属性
这个exe属性就是表明当前进程链接到可执行程序的一个链接路径。
我们沿着这个路径把可执行文件删掉之后在回来看这个进程的属性就会发现这个exe属性报警了,因为可执行文件已经没了。但是这个进程还能正常运行,因为这个可执行文件被加载到内存中去了,现在它是在内存中在跑,而磁盘中这个文件已经没了,如果此时结束这个进程,那就真的无法再运行了。
2.2.2 cwd属性
cwd (current work dir)当前工作目录,我们之前学习C语言的时候fopen新建一个文件时,都是建在与源代码同级的目录下,原因就是这个cwd属性。
cwd当前进程的工作目录,因此如果不指定新的路径,所有新建的内容都会被存放到这个工作目录下。
如果想更改这个工作目录可以调用系统接口
int chdir(const char* path)
可以使用 man chdir 查看这个接口的基本介绍,如果查不到需要 yum install -y man-pages 安装一下
其头文件为 <unistd.h>
比如我们这里把工作目录改成上级目录,然后重新make,再执行 myproc 文件。
此时再去看该进程的cwd属性
可以看到工作目录已经变了,同时我们可以在这个工作目录下看到新建的text.txt文件。
2.4 ppid (标识符)
ppid和pid很像,不过pid指的是本进程的id,ppid是父进程的id值。
ppid可以用系统调用 getppid() 取到
我们给test.c代码改造一下,然后编译运行
我们多启动几次这个进程,发现pid一直在变化,因为一个进程的id值是根据它所创建的时间和顺序递增增加的。
而ppid却一直是固定的,因为这个父进程是bash
在命令行中,执行命令或者程序,本质上都是bash创建了子进程,然后由创建的子进程来执行我们的代码。
bash是针对Linux操作系统的命令行解释器,而shell是对于命令行解释器的一个统称。
3. 使用系统调用创建进程 fork()
这里我们需要用到一个系统调用函数 fork 它的作用就是船舰一个子进程。
fork()会给父进程返回子进程的pid,给子进程返回0
我们可以写这样一个程序
运行出来的效果如下
这里我们关注全局变量val的值,父进程中val值是只读的,子进程中val值每次会加1
然后我们观察这个运行结果,发现val值虽然作为全局变量,但是子进程上的自增并没有影响到父进程的val值。
这是因为父子继承之间只是代码共用同一份,但是它们的数据是各自有一份。
这也能解释为什么 fork() 函数可以有两个返回值,因为fork新增了一个进程,在原进程中返回子进程的pid,在新进程中返回0
进程之间有很强的独立性,即使是父子进程,多个进程之间运行互不影响。比如我们在windows的使用中可能会遇到有些程序崩溃了,但是这个程序的崩溃并不会影响到它的shell,子进程崩溃了,但是shell和其他的进程还在好好的跑着。
我们还可以尝试一次性创建多个进程
看上面这段代码,其内容就是从一个父进程中创建出了10个子进程,并且将子进程的pid全部都由父进程中的一个顺序表管理起来,这样可以方便后续父进程对子进程的一个控制。
绿框中使用10次for循环创建了10个子进程,之后红框中让每一个子进程陷入循环防止其结束,黄框中将子进程的pid记录到父进程中的一个顺序表中。
我们看一下程序运行起来的效果
明显可以看出父进程的pid是763,后面又10个子进程从父进程中分离出来,子进程的pid逐个递增,并且父进程也知道自己的子进程的pid都是什么。
可以看到我通过sleep函数控制住了父进程和子进程的打印顺序,但是实际上,fork出来的进程之间谁先运行的顺序是不确定的,这个是由OS的调度器自主决定的。
ps:这里要说明一下,上面这段代码是用C++编写的,同时使用了C++11的auto_for循环,因此在编译这段代码的时候要,声明使用C++11标准编译
4. PCB 中的状态
一个进程肯定不可能一直无脑的在被CPU调度,否则别的进程都别执行了,这个进程可能还会因为一些特殊的原因导致自己一直不被调度。就比如scanf函数就会让这个进程停下来等待用户输入,那此时CPU就不会调度这个进程,直到用户输入之后才会将这个进程唤醒。
上图就是一个进程的状态变化图,我们跟着图捋一下进程从创建到终止的整个生命周期。
首先进程被加载到内存的过程就是进程处于创建的状态;当进程加载完毕就处于就绪状态,就绪状态下进程随时可以被提交到CPU中进行计算;当进程被提交到CPU中计算的时候就是运行状态 (这里对于运行状态的描述不是官方教材中的描述,后面马上会说教材中的运行状态是怎么描述的) ;运行的时候遇到scanf需要等待用户输入此时进程就处于阻塞状态,同时被从CPU中踢出停止计算该进程;用户输入完之后这个进程又可以被继续计算了此时的进程重新回到就绪状态;最后这个进程被计算完了就会被释放掉此时为结束状态。
以上过程时最简单的一个进程声明周期中会经理的状态梳理,但是真实情况与刚才的描述还是有很多不同的。在此我们要补充一些概念:
1. 进程的并行和并发
CPU执行进程的时候不是把一个进程代码执行完,才去执行下一个进程,而是会给每个进程分配一段很短的时间,比如1ms,这段很短的时间我们叫做时间片,基于时间片的限制给每个进程进行轮转调度。
比如即使电脑配了一个单核的CPU,我们也可以在这台电脑上同时进行听歌、写代码、听网课、玩金铲铲。这是因为CPU会先调用一个时间片长度的歌曲进程,再调用一个时间片长度的代码进程,再调用一个时间片长度的网课进程,以此类推。因为CPU的运算速度非常快,而且每个时间片又特别短,最后导致我们人类根本感知不到歌曲暂停了,而是会认为这四个进程是在同时运行着的。
并发:多个进程在一个CPU下采用进程切换的方式,在一段时间内,让多个进程都得以推进。
并行:多个进程在多个CPU下,分别、同时的运行,这种运行是真正的多个线程在同时跑。
2. 时间片
刚才我们已经提过时间片是什么了,也是因为有时间片和并发逻辑的存在,就能解释上图中进程在运行状态中可能会因为超时而返回就绪状态的原因了,说白了就是这个进程的时间片用完了,于是CPU就把他剥离回内存,去计算其他进程了。
如Linux/Windows这种民用级操作系统,都是分时操作系统,这种操作系统的特点就是会追求调度任务的公平性,它会认为每一个进程的优先级都是相同的,于是给每个进程都均匀的分配时间片长度。
实时操作系统是与分时操作系统相对的概念,其进程之间会有强烈的优先级之分,比如车载操作系统中遇到紧急情况刹车的优先级就一定比播放音乐的优先级高,那此时实时操作系统将会优先并且尽量完全执行完优先级高的进程。
3. 进程具有独立性
进程之间有很强的独立性,包括父子进程,即使父进程出现问题也不会影响到子进程的正常执行。
4.1 运行状态和阻塞状态
接下来我们所有的讲解都是基于单核CPU的进程并发体系的。
为了了解运行状态的本质,我们首先要直到多进程的并发体系是如何实现的
前面提到过操作系统管理的本质是"先描述,再组织",这个规则同样适用于操作系统对于进程的管理。
我们知道每个进程都有自己的PCB(进程控制块),在Linux操作系统中具体为 task_struct ,这个东西就是结构体,其中记录了该进程的各种信息。此时利用PCB完成了对于进程的描述。之后我们在每个PCB中添加一个 next 指针指向下一个PCB,这样就将所有PCB形成一个链表,完成对于所有进程的组织。
在操作系统中还有一个结构体 struct runqueue (运行队列结构体),这个结构体中包含了诸多信息,比如其下控制的PCB的数量,等等成员变量,其中最重要的就是提供一个头节点,指向已经被组织好的PCB链表。
如此,使用运行队列直接控制好所有进程,然后按顺序把每一个进程拿到CPU中运行,运行时间片到了就再拿出来,重新尾插到运行队列中。也就是说依靠链表的头删尾插,或者说数据结构的增删查改,完成了控制去运算哪个进程的任务。
运行状态:处于运行队列中,和处于CPU中正在执行计算的所有进程都处在运行状态。而不是只有在CPU中计算着的才算运行状态,或者说运行状态是包括了图中的就绪和运行两块的。
我们知道硬件也是要被操作系统管理起来的,每一个硬件首先都被描述成一个结构体 struct device 这个结构体中包含这硬件的各种信息,比如硬件的类型、状态,其中最重要的是 wait_queue (等待队列)成员,这个成员的类型是一个指向PCB的一个指针,也就是说这个成员后面可以挂进程。然后我们再把描述硬件的结构体用next指针组织起来,完成管理硬件的先描述再组织。
此时CPU正在执行一个进程的时候发现进程中有scanf函数,那这个进程此时需要等待用户从键盘输入数据后才能继续执行。此时CPU肯定不能一直等着用户输入,而把其他进程耽误了。因此CPU就会把这个进程挂到对应描述键盘的结构体的等待队列列中,而不是运行队列的尾。直到操作系统发现键盘被输入了,这个进程可以继续执行了,那就会把这个进程重新挂回运行队列。
标准一点的讲,当一个进程执行的时候需要从硬件中获取数据,但是此时硬件还没有准备好(键盘还未被输入),那操作系统就会把进程从CPU中拿出来挂到对应硬件的等待队列中,直到硬件准备好了,这个进程可以被继续执行了,操作系统就会重新把该进程挂回运行队列。
阻塞状态:处于某个硬件结构体的等待队列中。
运行和阻塞的本质是让不同的进度处在不同的队列中,一个进程占有CPPU资源和占有外设资源时是交叉的,在操作系统层面上,这种交叉本质上就是把一个PCB一会儿放运行队列里,一会儿放外设等待队列里,在外部表现出来的概念就是运行和阻塞。
实际上,PCB中表示状态是很简单的,就是单纯使用宏来表示
当进程放在运行队列中时,status就设置成RUNNING,当要等待硬件给出数据的时候,就先把状态设置成BLOCK在把PCB连接进对应硬件的等待队列中。
4.2 挂起状态
一般谈到挂起状态时都有一个大背景,就是内存资源严重不足了。
当内存资源严中不足的时候,操作系统会选择将阻塞状态的进程的代码和数据移出内存,将这些代码和数据存到磁盘中的 swap分区(交换分区) ,直到进程的阻塞状态取消要进入运行状态时,操作系统会把对应进程的代码和数据重新加载进内存,并将进程挂入运行队列。
将代码数据移出内存的操作叫换出,换出后进程的状态就是阻塞挂起状态,将代码和数据重新加载进内存的操作叫做换入。
因为挂起时要进行内存和磁盘的IO交互,这个过程中势必要有大量的时间消耗,因此挂起是一种用时间换空间的做法。
当内存资源严重不足的不要不要的时候,操作系统还可能会选择将运行队列中队尾的几个进程的数据和代码换出到swap分区,这是运行挂起状态。
但是这么做的危险性太大了,因此真的出现了特别特别严重的情况时,操作系统一般会采用杀进程的方式来解决问题,将占用资源最多的进程直接干掉来保证自身的正常运行。这也是为什么有的时候我们打开软件时半天打不开,最后直接闪退了的原因。就是因为打开这个软件的时候可能出现了一些问题,导致内存压力过大,然后操作系统为了保证自身的安全,于是就直接把这个进程给杀掉了。
5. Linux下进程的状态
刚才我们在概念上将操作系统的运行、阻塞、挂起状态介绍完了,下面我们将具体到Linux中的进程控制块,也就是 task_struct 中看看进程状态是怎么描述的。
上图就是Linux源代码中对于进程状态设置的代码。
5.1 R状态 S状态
我们写这样一段代码,查看有 printf() 和注释掉 printf() 这行之后,这个进程的状态
可以看到无打印语句时,该进程的是运行状态;有打印语句是,是休眠状态。
这是因为有打印语句的时候绝大多数的时间都用在了IO硬件上,要等待屏幕准备完毕,因此查进程状态的时候一般都会处在阻塞状态。而不打印的话,进程就是在运行队列或CPU中来回切换,因此一直会处在运行状态。
同时可以注意到,S和R后面都有一个加号(+),这个符号表示该进程是在前台运行的,叫前台进程;而如果这个符号没有了的话就说明该进程是在后台运行的,叫后台进程。前台运行的进程是可以直接 ctrl+c 杀进程的,但是后台运行的进程只能用 kill -9 pid 杀进程。
5.2 D状态
disk(磁盘) 这个状态是专门为IO磁盘时准备的状态。
为了防止在写入磁盘时,内存突然压力过大,使得操作系统把正在磁盘等待队列的进程干掉,使得正在写入磁盘的数据有丢失风险。直接将正在给磁盘写入的进程设置为D状态,这种状态是禁止操作系统杀掉该进程的。
D状态也是一种阻塞状态,但是是无法被杀进程的,相对应的S状态的进程就可以被干掉。
因此D状态被称为不可中断睡眠或深度休眠状态,S状态被称为可中断睡眠或浅休眠状态。
在实际生产应用中,如果查到了某个进程处于D状态,那说明磁盘出现了严重问题,系统差不多也要挂了。
5.3 T状态
这个状态我们是可以进行手动设置的,我们需要用到kill的18 19号信号。
首先我们把打印hello word的程序跑起来,然后用另一个终端直接把进程暂停
还可以 kill -18 恢复进程
但是我们发现S后面的加号没有了,此时的4534号进程已经变成了一个后台进程,此时使用ctrl+c已经无法关闭这个进程了,必须在另一个终端上用kill -9命令干掉这个进程。
实际上我们还可以把一个进程手动启动成一个后台进程,方法就是在可以行文件后面加上&符号
后台进程的好处就是可以不受干扰的继续执行命令行指令,但是还是无法ctrl+c干掉。
在进程执行期间,如果出现了非法但不致命的操作,进程就会被OS用T状态暂停
5.4 t状态
这个状态在我们调试代码打断点的时候会出现,当进程被追踪,断点停下时,进程状态就是t
5.5 X状态 Z状态
要谈进程的退出,我们就要先明确一个观点,就是进程被创建的原因是什么?
事实上,进程被创建的原因就是要完成一些任务,那任务是否完成就是一个很重要的事情。一般来说会通过进程执行的结果,来告知父进程或OS,进程对于任务的完成度如何。
Linux中可以通过 $? 查看最近程序退出时的错误信息
可以看到正常退出的程序错误信息为0,不正常退出的程序错误信息就是非0
那么在一个进程结束的过程中也要向父进程和操作系统来报告结束信息,是正常结束的,或是异常结束的,这个报告的过程中,进程就处于Z状态。报告之后进程结束,进程就处于X状态。
具体来讲,进程结束要退出的时候,它不会立即退出,而是将自己的退出信息先维护在自己的 test_struct 中,用来准备给父进程反应自己的退出信息。如果父进程一直不来查看子进程的退出信息,并释放子进程,那子进程就会一直处于Z状态。在Z状态的时候,子进程的各种资源还是暂时存在内存中,此时如果一直得不到释放就会引起操作系统层面上的内存泄漏。
也就是说后面我们要让父进程对子进程的状态进行管理,及时释放掉处于僵尸状态的子进程,防止内存泄漏。
6. 孤儿进程
前面处于Z状态的进程我们叫做僵尸进程,其特点是子进程结束了,父进程还在跑。而孤儿进程是父进程先退出,子进程此时就是一个孤儿进程了。
现在对于孤儿进程就有了一个奇怪的问题,如果说孤儿进程的父进程已经退出了,那到时候孤儿进程想退出的话,它的退出信息该交给谁,又由谁来释放孤儿进程的资源。
事实上,当孤儿进程出现时,就会被 pid 为 1 的进程给领养,这个进程是系统进程,也就是说,之后会由系统进程来管理孤儿进程,包括获取它的退出信息,释放它的资源。当领养完成后,孤儿进程会自动变成一个后台进程。