进程与线程管理问题总结

进程

1.并发和并⾏有什么区别?

image-20240808184225337

2.进程与程序的关系的类⽐

到了晚饭时间,⼀对⼩情侣肚⼦都咕咕叫了,于是男⽣⻅机⾏事,就想给⼥⽣做晚饭,所以他就在⽹上找

了辣⼦鸡的菜谱,接着买了⼀些鸡⾁、辣椒、⾹料等材料,然后边看边学边做这道菜。

image-20240808184351568

突然,⼥⽣说她想喝可乐,那么男⽣只好把做菜的事情暂停⼀下,并在⼿机菜谱标记做到哪⼀个步骤,把

状态信息记录了下来。然后男⽣听从⼥⽣的指令,跑去下楼买了⼀瓶冰可乐后,⼜回到厨房继续做菜。

**这体现了,**CPU 可以从⼀个进程(做菜)切换到另外⼀个进程(买可乐),在切换前必须要记录当前进程

中运⾏的状态信息,以备下次切换回来的时候可以恢复执⾏。

所以,可以发现进程有着「运⾏ - 暂停 - 运⾏」的活动规律。

3.进程有哪些状态

所以,在⼀个进程的活动期间⾄少具备三种基本状态,即运⾏状态、就绪状态、阻塞状态。

image-20240808184447657

上图中各个状态的意义:

运⾏状态(Runing):该时刻进程占⽤ CPU;

就绪状态(Ready):可运⾏,由于其他进程处于运⾏状态⽽暂时停⽌运⾏;

阻塞状态(Blocked):该进程正在等待某⼀事件发⽣(如等待输⼊/输出操作的完成)⽽暂时停⽌运

⾏,这时,即使给它CPU控制权,它也⽆法运⾏;

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

创建状态(new):进程正在被创建时的状态;

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

image-20240808184520390

再来详细说明⼀下进程的状态变迁:

NULL -> 创建状态:⼀个新进程被创建时的第⼀个状态;

创建状态 -> 就绪状态:当进程被创建完成并初始化后,⼀切就绪准备运⾏时,变为就绪状态,这个

过程是很快的;

就绪态 -> 运⾏状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运⾏

该进程;

运⾏状态 -> 结束状态:当进程已经运⾏完成或出错时,会被操作系统作结束状态处理;

运⾏状态 -> 就绪状态:处于运⾏状态的进程在运⾏过程中,由于分配给它的运⾏时间⽚⽤完,操作

系统会把该进程变为就绪态,接着从就绪态选中另外⼀个进程运⾏;

运⾏状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;

阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;

如果有⼤量处于阻塞状态的进程,进程可能会占⽤着物理内存空间,显然不是我们所希望的,毕竟物理内

存空间是有限的,被阻塞状态的进程占⽤着物理内存就⼀种浪费物理内存的⾏为。

所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次

运⾏的时候,再从硬盘换⼊到物理内存

那么,就需要⼀个新的状态,来描述进程没有占⽤实际的物理内存空间的情况,这个状态就是挂起状态

这跟阻塞状态是不⼀样,阻塞状态是等待某个事件的返回

另外,挂起状态可以分为两种:

阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;

就绪挂起状态:进程在外存(硬盘),但只要进⼊内存,即刻⽴刻运⾏;

这两种挂起状态加上前⾯的五种状态,就变成了七种状态变迁(留给我的颜⾊不多了),⻅如下图:

image-20240808184801902

导致进程挂起的原因不只是因为进程所使⽤的内存空间不在物理内存,还包括如下情况:

通过 sleep 让进程间歇性挂起,其⼯作原理是设置⼀个定时器,到期后唤醒进程。

⽤户希望挂起⼀个程序的执⾏,⽐如在 Linux 中⽤ Ctrl+Z 挂起进程

4.进程控制块process control blockPCB)包含什么信息

进程描述信息:

进程标识符:标识各个进程,每个进程都有⼀个并且唯⼀的标识符;

⽤户标识符:进程归属的⽤户,⽤户标识符主要为共享和保护服务;

进程控制和管理信息:

进程当前状态,如 new、ready、running、waiting 或 blocked 等;

进程优先级:进程抢占 CPU 时的优先级;

资源分配清单:

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

CPU 相关信息:

CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程

重新执⾏时,能从断点处继续执⾏。

5.每个 PCB 是如何组织的呢?

通常是通过链表的⽅式进⾏组织,把具有相同状态的进程链在⼀起,组成各种队列。⽐如:

将所有处于就绪状态的进程链在⼀起,称为就绪队列

把所有因等待某事件⽽处于等待状态的进程链在⼀起就组成各种阻塞队列

另外,对于运⾏队列在单核 CPU 系统中则只有⼀个运⾏指针了,因为单核 CPU 在某个时间,只能运

⾏⼀个程序。

那么,就绪队列和阻塞队列链表的组织形式如下图:

image-20240808185234086

除了链接的组织⽅式,还有索引⽅式,它的⼯作原理:将同⼀状态的进程组织在⼀个索引表中,索引表项

指向相应的 PCB,不同状态对应不同的索引表。

⼀般会选择链表,因为可能⾯临进程创建,销毁等调度导致进程状态发⽣变化,所以链表能够更加灵活的

插⼊和删除。

6.操作系统如何控制进程的(创建,终止,阻塞,唤醒)

01创建进程

操作系统允许⼀个进程创建另⼀个进程,⽽且允许⼦进程继承⽗进程所拥有的资源,当⼦进程被终⽌时,

其在⽗进程处继承的资源应当还给⽗进程。同时,终⽌⽗进程时同时也会终⽌其所有的⼦进程。

注意:Linux 操作系统对于终⽌有⼦进程的⽗进程,会把⼦进程交给 1 号进程接管。本⽂所指出的进程终

⽌概念是宏观操作系统的⼀种观点,最后怎么实现当然是看具体的操作系统。

创建进程的过程如下:

  • 为新进程分配⼀个唯⼀的进程标识号,并申请⼀个空⽩的 PCB,PCB 是有限的,若申请失败则创建失败;

  • 为进程分配资源,此处如果资源不⾜,进程就会进⼊等待状态,以等待资源;

  • 初始化 PCB;如果进程的调度队列能够接纳新进程,那就将进程插⼊到就绪队列,等待被调度运⾏;

02 终⽌进程

进程可以有 3 种终⽌⽅式:正常结束、异常结束以及外界⼲预(信号 kill 掉)。

终⽌进程的过程如下:

  • 查找需要终⽌的进程的 PCB;

  • 如果处于执⾏状态,则⽴即终⽌该进程的执⾏,然后将 CPU 资源分配给其他进程;

  • 如果其还有⼦进程,则应将其所有⼦进程终⽌;

  • 将该进程所拥有的全部资源都归还给⽗进程或操作系统;

  • 将其从 PCB 所在队列中删除;

03 阻塞进程

当进程需要等待某⼀事件完成时,它可以调⽤阻塞语句把⾃⼰阻塞等待。⽽⼀旦被阻塞等待,它只能由另

⼀个进程唤醒。

阻塞进程的过程如下:

  • 找到将要被阻塞进程标识号对应的 PCB;

  • 如果该进程为运⾏状态,则保护其现场,将其状态转为阻塞状态,停⽌运⾏;

  • 将该 PCB 插⼊到阻塞队列中去;

    进程的阻塞和唤醒是⼀对功能相反的语句,如果某个进程调⽤了阻塞语句,则必有⼀个与之对应的唤醒语

    句。

7.什么是CPU 上下⽂切换

⼤多数操作系统都是多任务,通常⽀持⼤于 CPU 数量的任务同时运⾏。实际上,这些任务并不是同时运⾏

的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运⾏,于是就造成同时运⾏的错觉。

任务是交给 CPU 运⾏的,那么在每个任务运⾏前,CPU 需要知道任务从哪⾥加载,⼜从哪⾥开始运⾏。

所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器

CPU 寄存器是 CPU 内部⼀个容量⼩,但是速度极快的内存(缓存)。我举个例⼦,寄存器像是你的⼝

袋,内存像你的书包,硬盘则是你家⾥的柜⼦,如果你的东⻄存放到⼝袋,那肯定是⽐你从书包或家⾥柜

⼦取出来要快的多。

再来,程序计数器则是⽤来存储 CPU 正在执⾏的指令位置、或者即将执⾏的下⼀条指令位置。

所以说,CPU 寄存器和程序计数是 CPU 在运⾏任何任务前,所必须依赖的环境,这些环境就叫做 CPU

上下⽂

既然知道了什么是 CPU 上下⽂,那理解 CPU 上下⽂切换就不难了。

CPU 上下⽂切换就是先把前⼀个任务的 CPU 上下⽂(CPU 寄存器和程序计数器)保存起来,然后加载新

任务的上下⽂到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运⾏新任务。

系统内核会存储保持下来的上下⽂信息,当此任务再次被分配给 CPU 运⾏时,CPU 会重新加载这些上下

⽂,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运⾏。

上⾯说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下⽂切换

分成:进程上下⽂切换、线程上下⽂切换和中断上下⽂切换

8.进程的上下⽂切换到底是切换什么呢?

进程是由内核管理和调度的,所以进程的切换只能发⽣在内核态。

所以,进程的上下⽂切换不仅包含了虚拟内存、栈、全局变量等⽤户空间的资源,还包括了内核堆栈、寄

存器等内核空间的资源。

通常,会把交换的信息保存在进程的 PCB,当要运⾏另外⼀个进程的时候,我们需要从这个进程的 PCB

取出上下⽂,然后恢复到 CPU 中,这使得这个进程可以继续执⾏,如下图所示:

image-20240808190801753

进程的上下⽂开销是很关键的,我们希望它的开销越⼩越好,这样可以使得进程可以把更

多时间花费在执⾏程序上,⽽不是耗费在上下⽂切换。

9.发⽣进程上下⽂切换有哪些场景?

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为⼀段段的时间⽚,这些时间⽚再被轮流分配

给各个进程。这样,当某个进程的时间⽚耗尽了,进程就从运⾏状态变为就绪状态,系统从就绪队列

选择另外⼀个进程运⾏;

  • 进程在系统资源不⾜(⽐如内存不⾜)时,要等到资源满⾜后才可以运⾏,这个时候进程也会被挂

起,并由系统调度其他进程运⾏;

  • 当进程通过睡眠函数 sleep 这样的⽅法将⾃⼰主动挂起时,⾃然也会重新调度;

  • 当有优先级更⾼的进程运⾏时,为了保证⾼优先级进程的运⾏,当前进程会被挂起,由⾼优先级进程

来运⾏;

  • 发⽣硬件中断时,CPU 上的进程会被中断挂起,转⽽执⾏内核中的中断服务程序;

线程

10.为什么使⽤线程?

在早期的操作系统中都是以进程作为独⽴运⾏的基本单位,直到后⾯,计算机科学家们⼜提出了更⼩的能

独⽴运⾏的基本单位,也就是线程。

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

进程之间如何通信,共享数据?维护进程的系统开销较⼤,如创建进程时,分配资源、建⽴ PCB;终⽌进程时,回收资源、撤销

PCB;进程切换时,保存当前进程的状态信息;

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

实体之间可以并发运⾏;

实体之间共享相同的地址空间;

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

11.什么是线程

线程是进程当中的⼀条执⾏流程。

同⼀个进程内多个线程之间可以共享代码段、数据段、打开的⽂件等资源,但每个线程各⾃都有⼀套独⽴

的寄存器和栈,这样可以确保线程的控制流是相对独⽴的。

image-20240808202709756

12.线程的优缺点

线程的优点:

  • ⼀个进程中可以同时存在多个线程;

  • 各个线程之间可以并发执⾏;

  • 各个线程之间可以共享地址空间和⽂件等资源;

线程的缺点:

  • 当进程中的⼀个线程崩溃时,会导致其所属进程的所有线程崩溃。举个例⼦,对于游戏的⽤户设计,则不应该使⽤多线程的⽅式,否则⼀个⽤户挂了,会影响其他同个进程的线程。

13.线程与进程的⽐较

线程与进程的⽐较如下:

  • 进程是资源(包括内存、打开的⽂件等)拥有的单位,线程是 CPU 调度的单位;

  • 进程拥有⼀个完整的资源平台,⽽线程只独享必不可少的资源,如寄存器和栈;

  • 线程同样具有就绪、阻塞、执⾏三种基本状态,同样具有状态之间的转换关系;

  • 线程能减少并发执⾏的时间和空间开销;

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

  • 线程的创建时间⽐进程快,因为进程在创建的过程中,还需要资源管理信息,⽐如内存管理信息、⽂

件管理信息,⽽线程在创建的过程中,不会涉及这些资源管理信息,⽽是共享它们;

  • 线程的终⽌时间⽐进程快,因为线程释放的资源相⽐进程少很多;

  • 同⼀个进程内的线程切换⽐进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着

同⼀个进程的线程都具有同⼀个⻚表,那么在切换的时候不需要切换⻚表。⽽对于进程之间的切换,

切换的时候要把⻚表给切换掉,⽽⻚表的切换过程开销是⽐较⼤的;

  • 由于同⼀进程的各线程间共享内存和⽂件资源,那么在线程之间数据传递的时候,就不需要经过内核

了,这就使得线程之间的数据交互效率更⾼了;

所以,不管是时间效率,还是空间效率线程⽐进程都要⾼

14.什么是线程上下文切换

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

量等资源。

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

  • 当进程只有⼀个线程时,可以认为进程就等于线程;

  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下⽂切换

时是不需要修改的;

另外,线程也有⾃⼰的私有数据,⽐如栈和寄存器等,这些在上下⽂切换时也是需要保存的。

15.线程上下⽂切换的是什么?

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

  • 当两个线程不是属于同⼀个进程,则切换的过程就跟进程上下⽂切换⼀样;

  • 当两个线程是属于同⼀个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不

动,只需要切换线程的私有数据、寄存器等不共享的数据

所以,线程的上下⽂切换相⽐进程,开销要⼩很多。

16.⽤户线程如何理解?存在什么优势和缺陷?

⽤户线程是基于⽤户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB 也是在

库⾥⾯来实现的,对于操作系统⽽⾔是看不到这个 TCB 的,它只能看到整个进程的 PCB。

所以,⽤户线程的整个线程管理和调度,操作系统是不直接参与的,⽽是由⽤户级线程库函数来完成线程

的管理,包括线程的创建、终⽌、同步和调度等。

⽤户级线程的模型,也就类似前⾯提到的多对⼀的关系,即多个⽤户线程对应同⼀个内核线程,如下图所

image-20240808205323556

⽤户线程的优点

  • 每个进程都需要有它私有的线程控制块(TCB)列表,⽤来跟踪记录它各个线程状态信息(PC、栈指

针、寄存器),TCB 由⽤户级线程库函数来维护,可⽤于不⽀持线程技术的操作系统;

  • ⽤户线程的切换也是由线程库函数来完成的,⽆需⽤户态与内核态的切换,所以速度特别快;

⽤户线程的缺点

  • 由于操作系统不参与线程的调度,如果⼀个线程发起了系统调⽤⽽阻塞,那进程所包含的⽤户线程都

不能执⾏了。

  • 当⼀个线程开始运⾏后,除⾮它主动地交出 CPU 的使⽤权,否则它所在的进程当中的其他线程⽆法

运⾏,因为⽤户态的线程没法打断当前运⾏中的线程,它没有这个特权,只有操作系统才有,但是⽤

户线程不是由操作系统管理的。

  • 由于时间⽚分配给进程,故与其他进程⽐,在多线程执⾏时,每个线程得到的时间⽚较少,执⾏会⽐

较慢;

17.那内核线程如何理解?存在什么优势和缺陷?

内核线程是由操作系统管理的,线程对应的 TCB ⾃然是放在操作系统⾥的,这样线程的创建、终⽌和管理

都是由操作系统负责。

内核线程的模型,也就类似前⾯提到的⼀对⼀的关系,即⼀个⽤户线程对应⼀个内核线程,如下图所示:

image-20240808205629233

内核线程的优点

在⼀个进程当中,如果某个内核线程发起系统调⽤⽽被阻塞,并不会影响其他内核线程的运⾏;

分配给线程,多线程的进程获得更多的 CPU 运⾏时间;

内核线程的缺点

在⽀持内核线程的操作系统中,由内核来维护进程和线程的上下⽂信息,如 PCB 和 TCB;

线程的创建、终⽌和切换都是通过系统调⽤的⽅式来进⾏,因此对于系统来说,系统开销⽐较⼤;

以上,就是内核线程的优缺点了。

18.轻量级进程如何理解?

轻量级进程(Light-weight processLWP)是内核⽀持的⽤户线程,⼀个进程可有⼀个或多个 LWP**,每**

LWP 是跟内核线程⼀对⼀映射的,也就是 LWP 都是由⼀个内核线程⽀持。

另外,LWP 只能由内核管理并像普通进程⼀样被调度,Linux 内核是⽀持 LWP 的典型例⼦。

在⼤多数系统中,LWP****与普通进程的区别也在于它只有⼀个最⼩的执⾏上下⽂和调度程序所需的统计信

。⼀般来说,⼀个进程代表程序的⼀个实例,⽽ LWP 代表程序的执⾏线程,因为⼀个执⾏线程不像进程

那样需要那么多状态信息,所以 LWP 也不带有这样的信息。

在 LWP 之上也是可以使⽤⽤户线程的,那么 LWP 与⽤户线程的对应关系就有三种:

1 : 1 ,即⼀个 LWP 对应 ⼀个⽤户线程;

N : 1 ,即⼀个 LWP 对应多个⽤户线程;

M : N ,即多个 LMP 对应多个⽤户线程

调度

19.什么是调度程序

进程都希望⾃⼰能够占⽤ CPU 进⾏⼯作,那么这涉及到前⾯说过的进程上下⽂切换。

⼀旦操作系统把进程切换到运⾏状态,也就意味着该进程占⽤着 CPU 在执⾏,但是当操作系统把进程切换

到其他状态时,那就不能在 CPU 中执⾏了,于是操作系统会选择下⼀个要运⾏的进程。

选择⼀个进程运⾏这⼀功能是在操作系统中完成的,通常称为调度程序scheduler)。

20.什么时候调度进程,或以什么原则来调度进程呢?

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

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

  • 从就绪态 -> 运⾏态:当进程被创建时,会进⼊到就绪队列,操作系统会从就绪队列选择⼀个进程运⾏;

  • 从运⾏态 -> 阻塞态:当进程发⽣ I/O 事件⽽阻塞时,操作系统必须选择另外⼀个进程运⾏;

  • 从运⾏态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外⼀个进程运⾏;

因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运⾏,或者是否让当前进程从

CPU 上退出来⽽换另⼀个进程运⾏。

另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断

,把调度算法分为两类:

  • ⾮抢占式调度算法挑选⼀个进程,然后让该进程运⾏直到被阻塞,或者直到该进程退出,才会调⽤另

外⼀个进程,也就是说不会理时钟中断这个事情。

  • 抢占式调度算法挑选⼀个进程,然后让该进程只运⾏某段时间,如果在该时段结束时,该进程仍然在

运⾏时,则会把它挂起,接着调度程序从就绪队列挑选另外⼀个进程。这种抢占式调度处理,需要在

时间间隔的末端发⽣时钟中断,以便把 CPU 控制返回给调度程序进⾏调度,也就是常说的时间⽚机

调度原则

原则⼀:如果运⾏的程序,发⽣了 I/O 事件的请求,那 CPU 使⽤率必然会很低,因为此时进程在阻塞等待

硬盘的数据返回。这样的过程,势必会造成 CPU 突然的空闲。所以,为了提⾼ CPU 利⽤率,在这种发送

I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择⼀个进程来运⾏。

原则⼆:有的程序执⾏某个任务花费的时间会⽐较⻓,如果这个程序⼀直占⽤着 CPU,会造成系统吞吐量

(CPU 在单位时间内完成的进程数量)的降低。所以,要提⾼系统的吞吐率,调度程序要权衡⻓任务和短

任务进程的运⾏完成数量。

原则三:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运⾏时间和进程等待时间,这

两个时间总和就称为周转时间。进程的周转时间越⼩越好,如果进程的等待时间很⻓⽽运⾏时间很短,那

周转时间就很⻓,这不是我们所期望的,调度程序应该避免这种情况发⽣。

原则四:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更

快的在 CPU 中执⾏。所以,就绪队列中进程的等待时间也是调度程序所需要考虑的原则。

原则五:对于⿏标、键盘这种交互式⽐较强的应⽤,我们当然希望它的响应时间越快越好,否则就会影响

⽤户体验了。所以,对于交互式⽐较强的应⽤,响应时间也是调度程序需要考虑的原则。

总结成如下:

CPU 利⽤率:调度程序应确保 CPU 是始终匆忙的状态,这可提⾼ CPU 的利⽤率;

系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,⻓作业的进程会占⽤较⻓的 CPU 资

源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;

周转时间:周转时间是进程运⾏和阻塞时间总和,⼀个进程的周转时间越⼩越好;

等待时间:这个等待时间不是阻塞状态的时间,⽽是进程处于就绪队列的时间,等待的时间越⻓,⽤

户越不满意;

响应时间:⽤户提交请求到系统第⼀次产⽣响应所花费的时间,在交互式系统中,响应时间是衡量调

度算法好坏的主要标准。

21.单核 CPU 系统中常⻅的调度算法有哪些

01 先来先服务调度算法
image-20240808211515402

顾名思义,先来后到,每次从就绪队列选择最先进⼊队列的进程,然后⼀直运⾏,直到进程退出或被阻

塞,才会继续从队列中选择第⼀个进程接着运⾏。

这似乎很公平,但是当⼀个⻓作业先运⾏了,那么后⾯的短作业等待的时间就会很⻓,不利于短作业。

FCFS 对⻓作业有利,适⽤于 CPU 繁忙型作业的系统,⽽不适⽤于 I/O 繁忙型作业的系统。

02 最短作业优先调度算法

最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运⾏时间最短的进

程来运⾏,这有助于提⾼系统的吞吐量。

image-20240808211546667

这显然对⻓作业不利,很容易造成⼀种极端现象。

⽐如,⼀个⻓作业在就绪队列等待运⾏,⽽这个就绪队列有⾮常多的短作业,那么就会使得⻓作业不断的

往后推,周转时间变⻓,致使⻓作业⻓期不会被运⾏

03 ⾼响应⽐优先调度算法

前⾯的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和⻓作业。

那么,⾼响应⽐优先

Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和⻓作业。每次进⾏进程调度时,先计算「响应⽐优先级」,然后把「响应⽐优先级」最⾼的进程投⼊运⾏,「响应

⽐优先级」的计算公式:

image-20240808211637367

从上⾯的公式,可以发现:

如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应⽐」就越⾼,这样短作业的

进程容易被选中运⾏;

如果两个进程「要求的服务时间」相同时,「等待时间」越⻓,「响应⽐」就越⾼,这就兼顾到了⻓

作业进程,因为进程的响应⽐可以随时间等待的增加⽽提⾼,当其等待时间⾜够⻓时,其响应⽐便可

以升到很⾼,从⽽获得运⾏的机会

04 时间⽚轮转调度算法

最古⽼、最简单、最公平且使⽤最⼴的算法就是时间⽚轮转(Round Robin, RR)调度算法

image-20240808212217134

每个进程被分配⼀个时间段,称为时间⽚(Quantum),即允许该进程在该时间段中运⾏。

如果时间⽚⽤完,进程还在运⾏,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外⼀个进程;

如果该进程在时间⽚结束前阻塞或结束,则 CPU ⽴即进⾏切换;

另外,时间⽚的⻓度就是⼀个很关键的点:

如果时间⽚设得太短会导致过多的进程上下⽂切换,降低了 CPU 效率;

如果设得太⻓⼜可能引起对短作业进程的响应时间变⻓。

⼀般来说,时间⽚设为 20ms~50ms 通常是⼀个⽐较合理的折中值。

05 最⾼优先级调度算法

前⾯的「时间⽚轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,⼤家的运⾏时间都⼀

样。

但是,对于多⽤户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪

队列中选择最⾼优先级的进程进⾏运⾏,这称为最⾼优先级(Highest Priority FirstHPF)调度算法

进程的优先级可以分为,静态优先级和动态优先级:

  • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运⾏时间优先级都不会变化;

  • 动态优先级:根据进程的动态变化调整优先级,⽐如如果进程运⾏时间增加,则降低其优先级,如果

进程等待时间(就绪队列的等待时间)增加,则升⾼其优先级,也就是随着时间的推移增加等待进程

的优先级

该算法也有两种处理优先级⾼的⽅法,⾮抢占式和抢占式:

  • ⾮抢占式:当就绪队列中出现优先级⾼的进程,运⾏完当前进程,再选择优先级⾼的进程。

  • 抢占式:当就绪队列中出现优先级⾼的进程,当前进程挂起,调度优先级⾼的进程运⾏。

但是依然有缺点,可能会导致低优先级的进程永远不会运⾏。

06 多级反馈队列调度算法

多级反馈队列(Multilevel Feedback Queue)调度算法是「时间⽚轮转算法」和「最⾼优先级算法」的

综合和发展。

「多级」表示有多个队列,每个队列优先级从⾼到低,同时优先级越⾼时间⽚越短。

「反馈」表示如果有新的进程加⼊优先级⾼的队列时,⽴刻停⽌当前正在运⾏的进程,转⽽去运⾏优

先级⾼的队列;

image-20240808213839948

来看看,它是如何⼯作的:

  • 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从⾼到低,同时优先级越⾼时间⽚越

  • 新的进程会被放⼊到第⼀级队列的末尾,按先来先服务的原则排队等待被调度,如果在第⼀级队列规

定的时间⽚没运⾏完成,则将其转⼊到第⼆级队列的末尾,以此类推,直⾄完成;

  • 当较⾼优先级的队列为空,才调度较低优先级的队列中的进程运⾏。如果进程运⾏时,有新进程进⼊

较⾼优先级的队列,则停⽌当前运⾏的进程并将其移⼊到原队列末尾,接着让较⾼优先级的进程运

⾏;

可以发现,对于短作业可能可以在第⼀级队列很快被处理完。对于⻓作业,如果在第⼀级队列处理不完,

可以移⼊下次队列等待被执⾏,虽然等待的时间变⻓了,但是运⾏时间也变更⻓了,所以该算法很好的

顾了⻓短作业,同时有较好的响应时间。

进程间通信

22.什么是进程间通信

每个进程的⽤户地址空间都是独⽴的,⼀般⽽⾔是不能互相访问的,但内核空间是每个进程都共享的,所

以进程之间要通信必须通过内核。

image-20240808214857020

23.什么是管道

如果你学过 Linux 命令,那你肯定很熟悉「 | 」这个竖线。

ps auxf | grep mysql

上⾯命令⾏⾥的「 | 」竖线就是⼀个管道,它的功能是将前⼀个命令( ps auxf )的输出,作为后⼀个命

令( grep mysql )的输⼊,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需

要创建两个管道才⾏。

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

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

在使⽤命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:

mkfifo mypipe

myPipe 就是这个管道的名称,基于 Linux ⼀切皆⽂件的理念,所以管道也是以⽂件的⽅式存在,我们可以

⽤ ls 看⼀下,这个⽂件的类型是 p,也就是 pipe(管道) 的意思:

prw-rw-r-- 1 daic daic    0 8月   8 21:56 myPipe

接下来,我们往 myPipe 这个管道写⼊数据:

daic@daic:~$ echo "hello" > myPipe 


你操作了后,你会发现命令执⾏后就停在这了,这是因为管道⾥的内容没有被读取,只有当管道⾥的数据

被读完后,命令才可以正常退出。

于是,我们执⾏另外⼀个命令来读取这个管道⾥的数据:

daic@daic:~$ cat < myPipe 
hello

可以看到,管道⾥的内容被读取出来了,并打印在了终端上,另外⼀⽅⾯,echo 那个命令也正常退出了。

我们可以看出,管道这种通信⽅式效率低,不适合进程间频繁地交换数据。当然,它的好处,⾃然就是简

单,同时也我们很容易得知管道⾥的数据已经被另⼀个进程读取了。

24.那管道如何创建呢,背后原理是什么?

匿名管道的创建,需要通过下⾯这个系统调⽤:

int pipe(int fd[2])

这⾥表示创建⼀个匿名管道,并返回了两个描述符,⼀个是管道的读取端描述符 fd[0] ,另⼀个是管道的

写⼊端描述符 fd[1] 。注意,这个匿名管道是特殊的⽂件,只存在于内存,不存于⽂件系统中

image-20240808220008331

其实,所谓的管道,就是内核⾥⾯的⼀串缓存。从管道的⼀段写⼊的数据,实际上是缓存在内核中的,另

⼀端读取,也就是从内核中读取这段数据。另外,管道传输的数据是⽆格式的流且⼤⼩受限。

我们可以使⽤ fork 创建⼦进程,创建的⼦进程会复制⽗进程的⽂件描述符,这样就做到了两个进程各有

两个「 fd[0] 与 fd[1] 」,两个进程就可以通过各⾃的 fd 写⼊和读取同⼀个管道⽂件实现跨进程通信

了。

image-20240808220044675

管道只能⼀端写⼊,另⼀端读出,所以上⾯这种模式容易造成混乱,因为⽗进程和⼦进程都可以同时写

⼊,也都可以读出。那么,为了避免这种情况,通常的做法是:

⽗进程关闭读取的 fd[0],只保留写⼊的 fd[1];⼦进程关闭写⼊的 fd[1],只保留读取的 fd[0]

image-20240808220116136

所以说如果需要双向通信,则应该创建两个管道。

到这⾥,我们仅仅解析了使⽤管道进⾏⽗进程与⼦进程之间的通信,但是在我们 shell ⾥⾯并不是这样的。

在 shell ⾥⾯执⾏ A | B 命令的时候,A 进程和 B 进程都是 shell 创建出来的⼦进程,A 和 B 之间不存在

⽗⼦关系,它俩的⽗进程都是 shell。

image-20240808220200338

所以说,在 shell ⾥通过「 | 」匿名管道将多个命令连接在⼀起,实际上也就是创建了多个⼦进程,那么在

我们编写 shell 脚本时,能使⽤⼀个管道搞定的事情,就不要多⽤⼀个管道,这样可以减少创建⼦进程的系

统开销。

我们可以得知,对于匿名管道,它的通信范围是存在⽗⼦关系的进程。因为管道没有实体,也就是没有管

道⽂件,只能通过 fork 来复制⽗进程 fd ⽂件描述符,来达到通信的⽬的。

另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了⼀个类型为管

道的设备⽂件,在进程⾥只要使⽤这个设备⽂件,就可以相互通信

不管是匿名管道还是命名管道,进程写⼊的数据都是缓存在内核中,另⼀个进程读取数据时候⾃然也是从

内核中获取,同时通信数据都遵循先进先出原则,不⽀持 lseek 之类的⽂件定位操作

25.什么是消息队列

对于这个问题,消息队列的通信模式就可以解决。⽐如,A 进程要给 B 进程发送消息,A 进程把数据放在

对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进

程发送消息也是如此。

再来,消息队列是保存在内核中的消息链表,在发送数据时,会分成⼀个⼀个独⽴的数据单元,也就是消

息体(数据块),消息体是⽤户⾃定义的数据类型,消息的发送⽅和接收⽅要约定好消息体的数据类型,

所以每个消息体都是固定⼤⼩的存储块,不像管道是⽆格式的字节流数据。如果进程从消息队列中读取了

消息体,内核就会把这个消息体删除。

消息队列⽣命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会⼀直存在,⽽前⾯

提到的匿名管道的⽣命周期,是随进程的创建⽽建⽴,随进程的结束⽽销毁。

消息这种模型,两个进程之间的通信就像平时发邮件⼀样,你来⼀封,我回⼀封,可以频繁沟通了。

但邮件的通信⽅式存在不⾜的地⽅有两点,⼀是通信不及时,⼆是附件也有⼤⼩限制,这同样也是消息队

列通信不⾜的点。

消息队列不适合⽐较⼤数据的传输,因为在内核中每个消息体都有⼀个最⼤⻓度的限制,同时所有队列所

包含的全部消息体的总⻓度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB ,

它们以字节为单位,分别定义了⼀条消息的最⼤⻓度和⼀个队列的最⼤⻓度。

消息队列通信过程中,存在⽤户态与内核态之间的数据拷⻉开销,因为进程写⼊数据到内核中的消息队列

时,会发⽣从⽤户态拷⻉数据到内核态的过程,同理另⼀进程读取内核中的消息数据时,会发⽣从内核态

拷⻉数据到⽤户态的过程。

26.什么是共享内存

消息队列的读取和写⼊的过程,都会有发⽣⽤户态与内核态之间的消息拷⻉过程。那共享内存的⽅式,就

很好的解决了这⼀问题。

现代操作系统,对于内存管理,采⽤的是虚拟内存技术,也就是每个进程都有⾃⼰独⽴的虚拟内存空间,

不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是⼀样的,其实

访问的是不同的物理内存地址,对于数据的增删查改互不影响。

共享内存的机制,就是拿出⼀块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写⼊的东⻄,

另外⼀个进程⻢上就能看到了,都不需要拷⻉来拷⻉去,传来传去,⼤⼤提⾼了进程间通信的速度。

image-20240808221330663

27.什么是信号量

⽤了共享内存通信⽅式,带来新的问题,那就是如果多个进程同时修改同⼀个共享内存,很有可能就冲突

了。例如两个进程都同时写⼀个地址,那先写的那个进程会发现内容被别⼈覆盖了。

为了防⽌多进程竞争共享资源,⽽造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只

能被⼀个进程访问。正好,信号量就实现了这⼀保护机制。

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

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

⼀个是 P 操作这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占⽤,进程需

阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使⽤,进程可正常继续执⾏。

另⼀个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进

程,于是会将该进程唤醒运⾏;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

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

接下来,举个例⼦,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1 。

image-20240808221622124

具体的过程如下:

  • 进程 A 在访问共享内存前,先执⾏了 P 操作,由于信号量的初始值为 1,故在进程 A 执⾏ P 操作后

信号量变为 0,表示共享资源可⽤,于是进程 A 就可以访问共享内存。

  • 若此时,进程 B 也想访问共享内存,执⾏了 P 操作,结果信号量变为了 -1,这就意味着临界资源已

被占⽤,因此进程 B 被阻塞。

  • 直到进程 A 访问完共享内存,才会执⾏ V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程

B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执⾏ V 操作,使信号量恢复到初始

值 1。

==可以发现,信号初始化为 1 ,就代表着是互斥信号量,==它可以保证共享内存在任何时刻只有⼀个进程在访

问,这就很好的保护了共享内存。

另外,在多进程⾥,每个进程并不⼀定是顺序执⾏的,它们基本是以各⾃独⽴的、不可预知的速度向前推

进,但有时候我们⼜希望多个进程能密切合作,以实现⼀个共同的任务。

例如,进程 A 是负责⽣产数据,⽽进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A

必须先⽣产了数据,进程 B 才能读取到数据,所以执⾏是有前后顺序的。

那么这时候,就可以⽤信号量来实现多进程同步的⽅式,我们可以初始化信号量为 0 。

image-20240808221859929

具体过程:

如果进程 B ⽐进程 A 先执⾏了,那么执⾏到 P 操作时,由于信号量初始值为 0,故信号量会变为-1,表示进程 A 还没⽣产数据,于是进程 B 就阻塞等待;

接着,当进程 A ⽣产完数据后,执⾏了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作

的进程 B;

最后,进程 B 被唤醒后,意味着进程 A 已经⽣产了数据,于是进程 B 就可以正常读取数据了。

可以发现,信号初始化为 0 ,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执⾏。

28.什么是信号

上⾯说的进程间通信,都是常规状态下的⼯作模式。对于异常情况下的⼯作模式,就需要⽤「信号」的⽅

式来通知进程。

信号跟信号量虽然名字相似度 66.66%,但两者⽤途完全不⼀样,就好像 Java 和 JavaScript 的区别。

在 Linux 操作系统中, 为了响应各种各样的事件,提供了⼏⼗种信号,分别代表不同的意义。我们可以通

过 kill -l 命令,查看所有的信号:

daic@daic:~$ kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX	

运⾏在 shell 终端的进程,我们可以通过键盘输⼊某些组合键的时候,给进程发送信号。例如

Ctrl+C 产⽣ SIGINT 信号,表示终⽌该进程;

Ctrl+Z 产⽣ SIGTSTP 信号,表示停⽌该进程,但还未结束;

如果进程在后台运⾏,可以通过 kill 命令的⽅式给进程发送信号,但前提需要知道运⾏中的进程 PID

号,例如:

kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,⽤来⽴即结束该进程;

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

信号是进程间通信机制中唯⼀的异步通信机制,因为可以在任何时候发送信号给某⼀进程,⼀旦有信号产

⽣,我们就有下⾯这⼏种,⽤户进程对信号的处理⽅式。

**1.**执⾏默认操作。Linux 对每种信号都规定了默认操作,例如,上⾯列表中的 SIGTERM 信号,就是终⽌进

程的意思。

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

**3.**忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应⽤

进程⽆法捕捉和忽略的,即 SIGKILL 和 SEGSTOP ,它们⽤于在任何时候中断或结束某⼀进程。

29.什么是socket

前⾯提到的管道、消息队列、共享内存、信号量和信号都是在同⼀台主机上进⾏进程间通信,那要想跨⽹

络与不同主机上的进程之间通信,就需要 Socket 通信了。

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

我们来看看创建 socket 的系统调⽤:

int socket(int domain, int type, int protocal)

三个参数分别代表:

  • domain 参数⽤来指定协议族,⽐如 AF_INET ⽤于 IPV4、AF_INET6 ⽤于 IPV6、

AF_LOCAL/AF_UNIX ⽤于本机;

  • type 参数⽤来指定通信特性,⽐如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM

表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;

  • protocal 参数原本是⽤来指定通信协议的,但现在基本废弃。因为协议已经通过前⾯两个参数指定完

成,protocol ⽬前⼀般写成 0 即可;

根据创建 socket 类型的不同,通信的⽅式也就不同:

  • 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;

  • 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;

  • 实现本地进程间通信: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据

报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以

AF_UNIX 也属于本地 socket;

01针对 TCP 协议通信的 socket 编程模型是什么
image-20240808225125908
  • 服务端和客户端初始化 socket ,得到⽂件描述符;

  • 服务端调⽤ bind ,将绑定在 IP 地址和端⼝;

  • 服务端调⽤ listen ,进⾏监听;

  • 服务端调⽤ accept ,等待客户端连接;

  • 客户端调⽤ connect ,向服务器端的地址和端⼝发起连接请求;

  • 服务端 accept 返回⽤于传输的 socket 的⽂件描述符;

  • 客户端调⽤ write 写⼊数据;服务端调⽤ read 读取数据;

  • 客户端断开连接时,会调⽤ close ,那么服务端 read 读取数据的时候,就会读取到了 EOF ,待

  • 处理完数据后,服务端调⽤ close ,表示连接关闭。

这⾥需要注意的是**,服务端调⽤ accept 时,连接成功了会返回⼀个已完成连接的 socket,后续⽤来传**

输数据。

成功连接建⽴之后,双⽅开始通过 read 和 write 函数来读写数据,就像往⼀个⽂件流⾥⾯写东⻄⼀样

02针对 UDP 协议通信的 socket 编程模型
image-20240808225320061

UDP 是没有连接的,所以不需要三次握⼿,也就不需要像 TCP 调⽤ listen 和 connect,但是 UDP 的交互

仍然需要 IP 地址和端⼝号,因此也需要 bind。

对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送⽅和接收⽅,甚⾄都不存在客户端和服务端

的概念,只要有⼀个 socket 多台机器就可以任意通信,因此每⼀个 UDP 的 socket 都需要 bind。

另外,每次通信时,调⽤ sendto 和 recvfrom,都要传⼊⽬标主机的 IP 地址和端⼝

03针对本地进程间通信的 socket 编程模型

本地 socket 被⽤于在同⼀台主机上进程间通信的场景:

本地 socket 的编程接⼝和 IPv4 、IPv6 套接字编程接⼝是⼀致的,可以⽀持「字节流」和「数据报」

两种协议;

本地 socket 的实现效率⼤⼤⾼于 IPv4 和 IPv6 的字节流、数据报 socket 实现;

对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。

对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。

本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端⼝,⽽是

绑定⼀个本地⽂件,这也就是它们之间的最⼤区别。

多线程同步

30.什么是竞争与协作

如果⼀个程序只有⼀个执⾏流程,也代表它是单线程的。当然⼀个程序可以有多个执⾏流程,也就是所谓

的多线程程序,线程是调度的基本单位,进程则是资源分配的基本单位。

所以,线程之间是可以共享进程的资源,⽐如代码段、堆空间、数据段、打开的⽂件等资源,但每个线程

都有⾃⼰独⽴的栈空间。

image-20240808230044923

多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。

image-20240808230350968

上⾯展示的情况称为竞争条件(race condition,当多线程相互竞争操作共享变量时,由于运⽓不好,

即在执⾏过程中发⽣了上下⽂切换,我们得到了错误的结果,事实上,每次运⾏都可能得到不同的结果,

因此输出的结果存在不确定性(indeterminate

由于多线程执⾏操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为**临界区(**critical

section**),它是访问共享资源的代码⽚段,⼀定不能给多线程同时执⾏。**

31.什么是互斥与同步

我们希望这段代码是互斥(mutualexclusion)的,也就说保证⼀个线程在临界区执⾏时,其他线程应该

被阻⽌进⼊临界区,说⽩了,就是这段代码执⾏过程中,最多只能出现⼀个线程。

image-20240808231027665

另外,说⼀下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使⽤互斥的⽅式

来避免资源竞争造成的资源混乱。

互斥解决了并发进程/线程对临界区的使⽤问题。这种基于临界区控制的交互作⽤是⽐较简单的,只要⼀个

进程/线程进⼊了临界区,其他试图想进⼊临界区的进程/线程都会被阻塞着,直到第⼀个进程/线程离开了

临界区。

我们都知道在多线程⾥,每个线程并不⼀定是顺序执⾏的,它们基本是以各⾃独⽴的、不可预知的速度向

前推进,但有时候我们⼜希望多个线程能密切合作,以实现⼀个共同的任务。

所谓同步,就是并发进程**/**线程在⼀些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通

信息称为进程**/**线程同步

注意,同步与互斥是两种不同的概念:

同步就好⽐:「操作 A 应在操作 B 之前执⾏」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执

⾏」等;

互斥就好⽐:「操作 A 和操作 B 不能在同⼀时刻执⾏」;

32.互斥与同步的实现和使用

在进程/线程并发执⾏的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。

为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和⽅法,主要的⽅法有两种:

锁:加锁、解锁操作;

信号量:P、V 操作;

这两个都可以⽅便地实现进程/线程互斥,⽽信号量⽐锁的功能更强⼀些,它还可以⽅便地实现进程/线程同

步。

01锁

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

任何想进⼊临界区的线程,必须先执⾏加锁操作。若加锁操作顺利通过,则线程可进⼊临界区;在完成对

临界资源的访问后再执⾏解锁操作,以释放该临界资源。

image-20240808232035450

根据锁的实现不同,可以分为「忙等待锁」和「⽆忙等待锁」。

02信号量

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

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

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

P 操作:将 sem 减 1 ,相减后,如果 sem < 0 ,则进程/线程进⼊阻塞等待,否则继续,表明 P

操作可能会阻塞;

V 操作:将 sem 加 1 ,相加后,如果 sem <= 0 ,唤醒⼀个等待中的进程/线程,表明 V 操作不

会阻塞;

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

33.操作系统是如何实现 PV 操作的呢?

image-20240808232700642

PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执⾏ PV 函数时是具有原⼦性的

信号量不仅可以实现临界区的互斥访问控制,还可以线程间的事件同步。

我们先来说说如何使⽤

01信号量实现临界区的互斥访问

为每类共享资源设置⼀个信号量 s ,其初值为 1 ,表示该临界资源未被占⽤。

只要把进⼊临界区的操作置于 P(s) 和 V(s) 之间,即可实现进程/线程互斥:

image-20240808232823238

此时,任何想进⼊临界区的线程,必先在互斥信号量上执⾏ P 操作,在完成对临界资源的访问后再执⾏ V

操作。由于互斥信号量的初始值为 1,故在第⼀个线程执⾏ P 操作后 s 值变为 0,表示临界资源为空闲,

可分配给该线程,使之进⼊临界区。

、若此时⼜有第⼆个线程想进⼊临界区,也应先执⾏ P 操作,结果使 s 变为负值,这就意味着临界资源已被

占⽤,因此,第⼆个线程被阻塞。

并且,直到第⼀个线程执⾏ V 操作,释放临界资源⽽恢复 s 值为 0 后,才唤醒第⼆个线程,使之进⼊临界

区,待它完成临界资源的访问后,⼜执⾏ V 操作,使 s 恢复到初始值 1。

对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示:

如果互斥信号量为 1,表示没有线程进⼊临界区;

如果互斥信号量为 0,表示有⼀个线程进⼊临界区;

如果互斥信号量为 -1,表示⼀个线程进⼊临界区,另⼀个线程等待进⼊

通过互斥信号量的⽅式,就能保证临界区任何时刻只有⼀个线程在执⾏,就达到了互斥的效果。

02信号量实现事件同步

同步的⽅式是设置⼀个信号量,其初值为 0 。

image-20240808233311326

妈妈⼀开始询问⼉⼦要不要做饭时,执⾏的是 P(s1) ,相当于询问⼉⼦需不需要吃饭,由于 s1 初始值

为 0,此时 s1 变成 -1,表明⼉⼦不需要吃饭,所以妈妈线程就进⼊等待状态。

当⼉⼦肚⼦饿时,执⾏了 V(s1) ,使得 s1 信号量从 -1 变成 0,表明此时⼉⼦需要吃饭了,于是就唤醒

了阻塞中的妈妈线程,妈妈线程就开始做饭。

接着,⼉⼦线程执⾏了 P(s2) ,相当于询问妈妈饭做完了吗,由于 s2 初始值是 0,则此时 s2 变成

-1,说明妈妈还没做完饭,⼉⼦线程就等待状态。

最后,妈妈终于做完饭了,于是执⾏ V(s2) , s2 信号量从 -1 变回了 0,于是就唤醒等待中的⼉⼦线

程,唤醒后,⼉⼦线程就可以进⾏吃饭了。

03生产者消费者问题
image-20240808233518715

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

  • ⽣产者在⽣成数据后,放在⼀个缓冲区中;

  • 消费者从缓冲区取出数据处理;

  • 任何时刻,只能有⼀个⽣产者或消费者可以访问缓冲区;

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

  • 任何时刻只能有⼀个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥

  • 缓冲区空时,消费者必须等待⽣产者⽣成数据;缓冲区满时,⽣产者必须等待消费者取出数据。说明

⽣产者和消费者需要同步

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

  • 互斥信号量 mutex :⽤于互斥访问缓冲区,初始化值为 1;

  • 资源信号量 fullBuffers :⽤于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0

(表明缓冲区⼀开始为空);

  • 资源信号量 emptyBuffers :⽤于⽣产者询问缓冲区是否有空位,有空位则⽣成数据,初始化值为 n

(缓冲区⼤⼩);

具体的实现代码:

image-20240808233757895

如果消费者线程⼀开始执⾏ P(fullBuffers) ,由于信号量 fullBuffers 初始值为 0,则此时 fullBuffers 的

值从 0 变为 -1,说明缓冲区⾥没有数据,消费者只能等待。

接着,轮到⽣产者执⾏ P(emptyBuffers) ,表示减少 1 个空槽,如果当前没有其他⽣产者线程在临界区执

⾏代码,那么该⽣产者线程就可以把数据放到缓冲区,放完后,执⾏ V(fullBuffers) ,信号量 fullBuffers

从 -1 变成 0,表明有「消费者」线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。

消费者线程被唤醒后,如果此时没有其他消费者线程在读数据,那么就可以直接进⼊临界区,从缓冲区读

取数据。最后,离开临界区后,把空槽的个数 + 1。

34.哲学家就餐问题是什么

对于互斥访问有限的竞争问题(如 I/O 设备)⼀类的建模过程⼗分有⽤。

先来看看哲学家就餐的问题描述:

5 个⽼⼤哥哲学家,闲着没事做,围绕着⼀张圆桌吃⾯;

巧就巧在,这个桌⼦只有 5 ⽀叉⼦,每两个哲学家之间放⼀⽀叉⼦;

哲学家围在⼀起先思考,思考中途饿了就会想进餐;

奇葩的是,这些哲学家要两⽀叉⼦才愿意吃⾯,也就是需要拿到左右两边的叉⼦才进餐

吃完后,会把两⽀叉⼦放回原处,继续思考

那么问题来了,如何保证哲 学家们的动作有序进⾏,⽽不会出现有⼈永远拿不到叉⼦呢?

01方案一

我们⽤信号量的⽅式,也就是 PV 操作来尝试解决它,代码如下:

image-20240809083316341

上⾯的程序,好似很⾃然。拿起叉⼦⽤ P 操作,代表有叉⼦就直接⽤,没有叉⼦时就等待其他哲学家放回

叉⼦。

image-20240809083401860

不过,这种解法存在⼀个极端的问题:假设五位哲学家同时拿起左边的叉⼦,桌⾯上就没有叉⼦了,

这样就没有⼈能够拿到他们右边的叉⼦,也就说每⼀位哲学家都会在 P(fork[(i + 1) % N ]) 这条语句阻塞

了,很明显这发⽣了死锁的现象

02方案二

既然「⽅案⼀」会发⽣同时竞争左边叉⼦导致死锁的现象,那么我们就在拿叉⼦前,加个互斥信号量,代

码如下

image-20240809083949782

上⾯程序中的互斥信号量的作⽤就在于,只要有⼀个哲学家进⼊了「临界区」,也就是准备要拿叉⼦时,

其他哲学家都不能动,只有这位哲学家⽤完叉⼦了,才能轮到下⼀个哲学家进餐。

image-20240809084010156

⽅案⼆虽然能让哲学家们按顺序吃饭,但是每次进餐只能有⼀位哲学家,⽽桌⾯上是有 5 把叉⼦,按道理

是能可以有两个哲学家同时进餐的,所以从效率⻆度上,这不是最好的解决⽅案。

03⽅案三

那既然⽅案⼆使⽤互斥信号量,会导致只能允许⼀个哲学家就餐,那么我们就不⽤它。

另外,⽅案⼀的问题在于,会出现所有哲学家同时拿左边⼑叉的可能性,那我们就避免哲学家可以同时拿

左边的⼑叉,采⽤分⽀结构,根据哲学家的编号的不同,⽽采取不同的动作。

即让偶数编号的哲学家「先拿左边的叉⼦后拿右边的叉⼦」,奇数编号的哲学家「先拿右边的叉⼦后拿左

边的叉⼦」。

image-20240809084211638

上⾯的程序,在 P 操作时,根据哲学家的编号不同,拿起左右两边叉⼦的顺序不同。另外,V 操作是不需

要分⽀的,因为 V 操作是不会阻塞的。

image-20240809084230092

⽅案三即不会出现死锁,也可以两⼈同时进餐。

04方案四

在这⾥再提出另外⼀种可⾏的解决⽅案,我们⽤⼀个数组 state 来记录每⼀位哲学家在进程、思考还是饥

饿状态(正在试图拿叉⼦)。

那么,⼀个哲学家只有在两个邻居都没有进餐时,才可以进⼊进餐状态。

第 i 个哲学家的左邻右舍,则由宏 LEFT 和 RIGHT 定义:

LEFT : ( i + 5 - 1 ) % 5

RIGHT : ( i + 1 ) % 5

⽐如 i 为 2,则 LEFT 为 1, RIGHT 为 3。

具体代码实现如下

image-20240809084520952

上⾯的程序使⽤了⼀个信号量数组,每个信号量对应⼀位哲学家,这样在所需的叉⼦被占⽤时,想进餐的

哲学家就被阻塞。

注意,每个进程/线程将 smart_person 函数作为主代码运⾏,⽽其他 take_forks 、 put_forks 和

test 只是普通的函数,⽽⾮单独的进程/线程。

image-20240809084942138

35.读者写者问题是什么

它为数据库访问建⽴了⼀个模型,读者只会读取数据,不会修改数据,⽽写者即可以读也可以修改数据。

读者-写者的问题描述:

「读-读」允许:同⼀时刻,允许多个读者同时读

「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写

「写-写」互斥:没有其他写者时,写者才能写

01方案一

使⽤信号量的⽅式来尝试解决:

信号量 wMutex :控制写操作的互斥信号量,初始值为 1 ;

读者计数 rCount :正在进⾏读操作的读者个数,初始化为 0;

信号量 rCountMutex :控制对 rCount 读者计数器的互斥修改,初始值为 1;

image-20240809092622359

上⾯的这种实现,是读者优先的策略,因为只要有读者正在读的状态,后来的读者都可以直接进⼊,如果

读者持续不断进⼊,则写者会处于饥饿状态。

02⽅案⼆

那既然有读者优先策略,⾃然也有写者优先策略:

只要有写者准备要写⼊,写者应尽快执⾏写操作,后来的读者就必须阻塞;

如果有写者持续不断写⼊,则读者就处于饥饿;

在⽅案⼀的基础上新增如下变量:

信号量 rMutex :控制读者进⼊的互斥信号量,初始值为 1;

信号量 wDataMutex :控制写者写操作的互斥信号量,初始值为 1;

写者计数 wCount :记录写者数量,初始值为 0;

信号量 wCountMutex :控制 wCount 互斥修改,初始值为 1;

image-20240809092719653

注意,这⾥ rMutex 的作⽤,开始有多个读者读数据,它们全部进⼊读者队列,此时来了⼀个写者,执⾏

了 P(rMutex) 之后,后续的读者由于阻塞在 rMutex 上,都不能再进⼊读者队列,⽽写者到来,则可以

全部进⼊写者队列,因此保证了写者优先。

同时,第⼀个写者执⾏了 P(rMutex) 之后,也不能⻢上开始写,必须等到所有进⼊读者队列的读者都执⾏

完读操作,通过 V(wDataMutex) 唤醒写者的写操作。

03方案三

既然读者优先策略和写者优先策略都会造成饥饿的现象,那么我们就来实现⼀下公平策略。

公平策略:

优先级相同;

写者、读者互斥访问;

只能⼀个写者访问临界区;

可以有多个读者同时访问临界资源;

image-20240809093036427

看完代码不知你是否有这样的疑问,为什么加了⼀个信号量 flag ,就实现了公平竞争?

对⽐⽅案⼀的读者优先策略,可以发现,读者优先中只要后续有读者到达,读者就可以进⼊读者队列, ⽽

写者必须等待,直到没有读者到达。

没有读者到达会导致读者队列为空,即 rCount==0 ,此时写者才可以进⼊临界区执⾏写操作。

⽽这⾥ flag 的作⽤就是阻⽌读者的这种特殊权限(特殊权限是只要读者到达,就可以进⼊读者队列)。

⽐如:开始来了⼀些读者读数据,它们全部进⼊读者队列,此时来了⼀个写者,执⾏ P(falg) 操作,使得

后续到来的读者都阻塞在 flag 上,不能进⼊读者队列,这会使得读者队列逐渐为空,即 rCount 减为

0。

这个写者也不能⽴⻢开始写(因为此时读者队列不为空),会阻塞在信号量 wDataMutex 上,读者队列

中的读者全部读取结束后,最后⼀个读者进程执⾏ V(wDataMutex) ,唤醒刚才的写者,写者则继续开始

进⾏写操作。

死锁

36.什么是死锁

在多线程编程中,我们为了防⽌多线程竞争共享资源⽽导致数据错乱,都会在操作共享资源之前加上互斥

锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。

那么,当两个线程为了保护两个不同的共享资源⽽使⽤了两个互斥锁,那么这两个互斥锁应⽤不当的时

候,可能会造成两个线程都在等待对⽅释放锁,在没有外⼒的作⽤下,这些线程会⼀直相互等待,就没办

法继续运⾏,这种情况就是发⽣了死锁

举个例⼦,⼩林拿了⼩美房间的钥匙,⽽⼩林在⾃⼰的房间⾥,⼩美拿了⼩林房间的钥匙,⽽⼩美也在⾃

⼰的房间⾥。如果⼩林要从⾃⼰的房间⾥出去,必须拿到⼩美⼿中的钥匙,但是⼩美要出去,⼜必须拿到

⼩林⼿中的钥匙,这就形成了死锁。

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

  • 互斥条件;

    互斥条件是指多个线程不能同时使⽤同⼀个资源

    ⽐如下图,如果线程 A 已经持有的资源,不能再同时被线程 B 持有,如果线程 B 请求获取线程 A 已经占

    ⽤的资源,那线程 B 只能等待,直到线程 A 释放了资源。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 持有并等待条件;

持有并等待条件是指,当线程 A 已经持有了资源 1,⼜想申请资源 2,⽽资源 2 已经被线程 C 持有了,所

以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放⾃⼰已经持有的资源 1

image-20240809093742453
  • 不可剥夺条件;

不可剥夺条件是指,当线程已经持有了资源 ,在⾃⼰使⽤完之前不能被其他线程获取,线程 B 如果也想使

⽤此资源,则只能在线程 A 使⽤完并释放后才能获取。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 环路等待条件;

⽐如,线程 A 已经持有资源 2,⽽想请求资源 1, 线程 B 已经获取了资源 1,⽽想请求资源 2,这就形成

资源请求等待的环形图。

image-20240809093930653

37.如何模拟死锁问题的产生

⾸先,我们先创建 2 个线程,分别为线程 A 和 线程 B,然后有两个互斥锁,分别是 mutex_A 和

mutex_B,代码如下:

pthread_mutex_t mutex_A = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_t mutex_B = PTHREAD_MUTEX_INITIALIZER;

int main()

{

 pthread_t tidA, tidB;

 

 //创建两个线程

 pthread_create(&tidA, NULL, threadA_proc, NULL);

 pthread_create(&tidB, NULL, threadB_proc, NULL);

 

 pthread_join(tidA, NULL);

 pthread_join(tidB, NULL);

 

 printf("exit\n");

 

 return 0;

}

接下来,我们看下线程 A 函数做了什么。

//线程函数 A

void *threadA_proc(void *data)

{

 printf("thread A waiting get ResourceA \n");

 pthread_mutex_lock(&mutex_A);

 printf("thread A got ResourceA \n");

 

 sleep(1);

 

 printf("thread A waiting get ResourceB \n");

 pthread_mutex_lock(&mutex_B);

 printf("thread A got ResourceB \n");

 pthread_mutex_unlock(&mutex_B);

 pthread_mutex_unlock(&mutex_A);

 return (void *)0;

}

可以看到,线程 A 函数的过程:

先获取互斥锁 A,然后睡眠 1 秒;

再获取互斥锁 B,然后释放互斥锁 B;

最后释放互斥锁 A;

//线程函数 B

void *threadB_proc(void *data)

{

 printf("thread B waiting get ResourceB \n");

 pthread_mutex_lock(&mutex_B);

 printf("thread B got ResourceB \n");

 

 sleep(1);

 

 printf("thread B waiting get ResourceA \n");

 pthread_mutex_lock(&mutex_A);

 printf("thread B got ResourceA \n");

 

 pthread_mutex_unlock(&mutex_A);

 pthread_mutex_unlock(&mutex_B);

 return (void *)0;

}

可以看到,线程 B 函数的过程:

先获取互斥锁 B,然后睡眠 1 秒;

再获取互斥锁 A,然后释放互斥锁 A;

最后释放互斥锁 B;

然后,我们运⾏这个程序,运⾏结果如下:

thread B waiting get ResourceB

thread B got ResourceB

thread A waiting get ResourceA

thread A got ResourceA

thread B waiting get ResourceA

thread A waiting get ResourceB

// 阻塞中。。。

可以看到线程 B 在等待互斥锁 A 的释放,线程 A 在等待互斥锁 B 的释放,双⽅都在等待对⽅资源的释

放,很明显,产⽣了死锁问题。

38.如何避免死锁问题的发生

前⾯我们提到,产⽣死锁的四个必要条件是:互斥条件、持有并等待条件、不可剥夺条件、环路等待条

件。

那么避免死锁问题就只需要破环其中⼀个条件就可以,最常⻅的并且可⾏的就是使⽤资源有序分配法,来

破环环路等待条件

那什么是资源有序分配法呢?

线程 A 和 线程 B 获取资源的顺序要⼀样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,

线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺

序申请⾃⼰想要的资源。

我们使⽤资源有序分配法的⽅式来修改前⾯发⽣死锁的代码,我们可以不改动线程 A 的代码。

我们先要清楚线程 A 获取资源的顺序,它是先获取互斥锁 A,然后获取互斥锁 B。

所以我们只需将线程 B 改成以相同顺序的获取资源,就可以打破死锁了。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

线程 B 函数改进后的代码如下:

//线程 B 函数,同线程 A ⼀样,先获取互斥锁 A,然后获取互斥锁 B

void *threadB_proc(void *data)

{

 printf("thread B waiting get ResourceA \n");

 pthread_mutex_lock(&mutex_A);

 printf("thread B got ResourceA \n");

 

 sleep(1);

 

 printf("thread B waiting get ResourceB \n");

 pthread_mutex_lock(&mutex_B);

 printf("thread B got ResourceB \n");

 

 pthread_mutex_unlock(&mutex_B);

 pthread_mutex_unlock(&mutex_A);

 return (void *)0;

}

执⾏结果如下,可以看,没有发⽣死锁。

thread B waiting get ResourceA

thread B got ResourceA

thread A waiting get ResourceA

thread B waiting get ResourceB

thread B got ResourceB

thread A got ResourceA

thread A waiting get ResourceB

thread A got ResourceB

exit
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值