• 图解系统(二)——进程管理


    2 进程管理

    ​ 进程控制(状态切换)

    =》 进程通信 (5种)

    =》 进程调度(6种)

    =》 进程互斥和进程同步(信号量PV操作、管程)

    =》锁、死锁(生产者消费者、读者写者、哲学家就餐、银行家算法)

    2.1 进程、线程基础知识

    进程

    多个程序、交替执行的思想,就是CPU管理多个进程的初步想法

    并行:多个进程一起执行

    并非:多个进程交替执行

    虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发

    进程的状态

    运行状态、就绪状态、阻塞状态

    • 运行状态(Running):该时刻进程占用 CPU;
    • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
    • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;

    当然,进程还有另外两个基本状态:

    • 创建状态(new):进程正在被创建时的状态;
    • 结束状态(Exit):进程正在从系统中消失时的状态;

    为防止多个进程在阻塞态大量消耗物理内存,设置挂起态

    挂起状态可以分为两种:

    • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
    • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

    进程的控制结构

    在操作系统中,是用进程控制块process control block,PCB)数据结构来描述进程的。

    进程描述信息:

    • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
    • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;

    进程控制和管理信息:

    • 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
    • 进程优先级:进程抢占 CPU 时的优先级;

    资源分配清单:

    • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。

    CPU 相关信息:

    • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。

    通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:

    • 将所有处于就绪状态的进程链在一起,称为就绪队列
    • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列
    • 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。

    进程的控制

    创建进程

    创建进程的过程如下:

    • 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等;
    • 为该进程分配运行时所必需的资源,比如内存资源;
    • 将 PCB 插入到就绪队列,等待被调度运行;

    终止进程

    阻塞进程

    唤醒进程

    进程的上下文切换

    通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:

    线程

    为什么使用线程

    对于多进程的这种方式,依然会存在问题:

    • 进程之间如何通信,共享数据?
    • 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息;

    那到底如何解决呢?需要有一种新的实体,满足以下特性:

    • 实体之间可以并发运行;
    • 实体之间共享相同的地址空间;

    这个新的实体,就是线程( *Thread* ),线程之间可以并发运行且共享相同的地址空间。

    什么是线程

    线程是进程当中的一条执行流程。

    线程的优点:

    • 一个进程中可以同时存在多个线程;
    • 各个线程之间可以并发执行;
    • 各个线程之间可以共享地址空间和文件等资源;

    线程的缺点:

    线程与进程的比较

    线程与进程的比较如下:

    • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
    • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
    • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
    • 线程能减少并发执行的时间和空间开销;

    对于,线程相比进程能减少开销,体现在:

    • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
    • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
    • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
    • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

    所以,不管是时间效率,还是空间效率线程比进程都要高。

    线程的上下文切换

    所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。

    对于线程和进程,我们可以这么理解:

    • 当进程只有一个线程时,可以认为进程就等于线程;
    • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;

    另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

    这还得看线程是不是属于同一个进程:

    • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
    • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

    所以,线程的上下文切换相比进程,开销要小很多。

    线程的实现

    主要有三种线程的实现方式:

    • 用户线程(*User Thread*):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
    • 内核线程(*Kernel Thread*):在内核中实现的线程,是由内核管理的线程;
    • 轻量级进程(*LightWeight Process*):在内核中来支持用户线程;

    调度

    调度时机

    在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。

    比如,以下状态的变化都会触发操作系统的调度:

    • 从就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
    • 从运行态 -> 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须选择另外一个进程运行;
    • 从运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;

    因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。

    另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:

    • 非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
    • 抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制

    调度原则

    针对上面的五种调度原则,总结成如下:

    • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
    • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
    • 周转时间:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;
    • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
    • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。

    说白了,这么多调度原则,目的就是要使得进程要「快」。

    调度算法

    (1)先来先服务调度算法

    (2)最短作业优先调度算法

    (3)高响应比优先调度算法

    (4)时间片轮转调度算法

    (5)最高优先级调度算法

    (6)多级反馈队列调度算法

    2.2 进程间通信方式

    (1)管道

    管道传输数据是单向的

    同时,我们得知上面这种管道是没有名字,所以「|」表示的管道称为匿名管道,用完了就销毁。

    管道还有另外一个类型是命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式。

    我们可以看出,管道这种通信方式效率低,不适合进程间频繁地交换数据。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。

    其实,所谓的管道,就是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。

    (2)消息队列

    前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。

    对于这个问题,消息队列的通信模式就可以解决。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

    再来,消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

    (3)共享内存

    共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

    (4)信号量

    为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。

    信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据

    信号量表示资源的数量,控制信号量的方式有两种原子操作:

    • 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
    • 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

    P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。

    (5)信号

    对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

    所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。

    信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

    1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。

    2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

    3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任何时候中断或结束某一进程。

    (6)socket

    前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。

    实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。

    2.3 多线程冲突

    互斥与同步概念

    互斥:共享资源互斥访问

    同步:进程的先后顺序

    互斥与同步的实现及使用

    使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。

    任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。

    信号量

    信号量是操作系统提供的一种协调共享资源访问的方法。

    通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。

    另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:

    • P 操作:将 sem1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
    • V 操作:将 sem1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;

    P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。

    生产者-消费者问题

    生产者-消费者问题描述:

    • 生产者在生成数据后,放在一个缓冲区中;
    • 消费者从缓冲区取出数据处理;
    • 任何时刻,只能有一个生产者或消费者可以访问缓冲区;

    我们对问题分析可以得出:

    • 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥
    • 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步

    那么我们需要三个信号量,分别是:

    • 互斥信号量 mutex:用于互斥访问缓冲区,初始化值为 1;
    • 资源信号量 fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空);
    • 资源信号量 emptyBuffers:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n (缓冲区大小);

    经典同步问题

    哲学家就餐问题

    读者-写者问题

    前面的「哲学家进餐问题」对于互斥访问有限的竞争问题(如 I/O 设备)一类的建模过程十分有用。

    另外,还有个著名的问题是「读者-写者」,它为数据库访问建立了一个模型。

    读者只会读取数据,不会修改数据,而写者即可以读也可以修改数据。

    读者-写者的问题描述:

    • 「读-读」允许:同一时刻,允许多个读者同时读
    • 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
    • 「写-写」互斥:没有其他写者时,写者才能写

    2.4 如何避免死锁

    死锁只有同时满足以下四个条件才会发生:

    • 互斥条件;
    • 持有并等待条件;
    • 不可剥夺条件;
    • 环路等待条件;

    互斥条件

    持有并等待条件

    不可剥夺条件

    环路等待条件

    2.5 什么是悲观锁、乐观锁

    互斥锁

    互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞

    互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。

    那这个开销成本是什么呢?会有两次线程上下文切换的成本

    • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
    • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。

    自旋锁

    自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

    一般加锁的过程,包含两个步骤:

    • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
    • 第二步,将锁设置为当前线程持有;

    自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。

    自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。

    自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对

    它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。

    读写锁

    读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。

    所以,读写锁适用于能明确区分读操作和写操作的场景

    读写锁的工作原理是:

    • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
    • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。

    所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。

    知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势

    2.6 一个进程最多可以创建多少个线程

    一个进程最多可以创建多少个线程?

    这个问题跟两个东西有关系:

    • 进程的虚拟内存空间上限,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。
    • 系统参数限制,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。

    在前面我们知道,在 32 位 Linux 系统里,一个进程的虚拟空间是 4G,内核分走了1G,留给用户用的只有 3G

    那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是我们可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。

    2.7 进程崩溃、线程崩溃

    同时持有。

    知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势

    2.6 一个进程最多可以创建多少个线程

    一个进程最多可以创建多少个线程?

    这个问题跟两个东西有关系:

    • 进程的虚拟内存空间上限,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。
    • 系统参数限制,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。

    在前面我们知道,在 32 位 Linux 系统里,一个进程的虚拟空间是 4G,内核分走了1G,留给用户用的只有 3G

    那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是我们可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。

    2.7 进程崩溃、线程崩溃

    一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么系统要让进程崩溃呢,这主要是因为在进程中,各个线程的地址空间是共享的,既然是共享,那么某个线程对地址的非法访问就会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃

  • 相关阅读:
    云计算技术 — 混合云 — 技术架构
    深入解析Kotlin类与对象:构造、伴生、单例全面剖析
    pyqt5 pygraph ‘MouseClickEvent‘ object is not subscriptable
    数据结构之顶层父类的创建
    range方法在Python2和Python3中的不同​​​​​​​
    分布式ID系统设计(2)
    2023蓝帽杯半决赛misc题目复现
    实验6 8255并行接口实验【微机原理】【实验】
    【C++】list
    流程控制知识大闯关
  • 原文地址:https://blog.csdn.net/qq_41945053/article/details/126339769