引语
由于笔者觉得教材与网上资料对操作系统进程与线程的教程略显生涩抽象,在此笔者将尽力把进程线程的以及同步带来的问题以简单的方式高度概括并类比,对于笔者来说是一个总结温习的过程,希望也能帮助到各位读者加深理解。
以一个小白的视角来接触进程,进程和程序的关系会是什么?线程又与进程有什么区别?
用抽象的方式来概括,程序就像餐厅的菜谱,进程则是正在运行的一个或多个餐厅,而线程就像餐厅中的厨师。
程序是”静态“的,是一段存储在磁盘上的代码与数据;而进程是”动态“的,它是程序一次执行的实例,多个餐厅可以公用一个菜谱,并且每个餐厅都是独立的实体互不干扰。
线程(厨师)是进程(餐厅)内的执行单元,它们一起共享一个进程(餐厅)内的资源,各个厨师协同完成餐厅的任务。
从上面的描述可以看出进程与线程是一个”实体“,以面向对象的角度来看,它们有属于自己的生命周期与执行状态;如果多个厨师一起工作但只有一个锅(单核CPU)呢,这就产生了资源的竞争与并发;如果一个厨师接下来要做的菜依赖于前一个厨师的产品呢,这就产生了同步的问题...
接下来笔者将以上面的问题为线索,重新温习进程与线程。
进程与线程的诞生
进程的诞生: 在早期的单道批处理系统中,计算机只能在一个时间段内完成一个任务。当任务涉及IO操作时,CPU会陷入空闲等待,造成资源浪费;此外,若某个任务出现异常或卡死,整个系统也会随之瘫痪。随着多道批处理系统的出现,进程应运而生,这些问题得到了有效解决。进程允许在同一时间段内,一个任务进行IO操作的同时,另一个任务利用CPU进行计算。每个进程拥有独立的物理内存和逻辑地址空间,彼此隔离,即使某个进程崩溃也不会影响其他进程。因此,进程被称为资源分配的最小单位。
线程的诞生: 然而,对于某些复杂的应用场景,比如使用微信时同时进行视频通话和文字聊天,如果采用多进程实现,会造成大量资源的浪费。这就像为了完成一道菜的不同工序,分别开设多个餐厅一样不经济。于是,线程作为更轻量级的执行单元被引入。线程隶属于进程,共享进程的资源(如地址空间和全局变量),但其创建和销毁的成本远低于进程,上下文切换的开销也更小。因此,线程成为调度的基本单位,适用于需要高效并发的场合。不过,多线程编程也带来了新的挑战,例如数据竞争和死锁等问题,需谨慎设计。
进程与线程的状态
进程的状态: 进程具有五种主要状态:创建、就绪、执行、阻塞和终止。为了完善进程的生命周期,还引入了挂起状态(包括挂起就绪和挂起阻塞)。
-
创建状态 :进程刚被创建,尚未分配资源。
-
就绪状态 :进程已准备好运行,但等待CPU调度。
-
执行状态 :进程正在占用CPU运行。
-
阻塞状态 :进程因等待某些事件(如I/O操作)而暂停。
-
终止状态 :进程完成任务或因错误退出。
-
挂起状态 :
当系统资源不足或需要人为干预时,进程的一部分或全部数据可能被换出到外存,进入挂起状态。挂起状态分为两种:
-
挂起就绪 :进程已准备好运行,但被换出到外存。
-
挂起阻塞 :进程因等待事件而被换出到外存。
-
进程的状态转换由操作系统内核原语控制。例如,当一个进程的时间片耗尽时,它会从执行状态转为就绪状态;当它等待I/O完成时,则进入阻塞状态。
线程的状态: 线程的状态与进程类似,包括创建、就绪、执行、阻塞和终止,但线程通常不支持挂起状态。这是因为线程的设计目标是轻量化,挂起会导致额外的资源开销。此外,由于线程共享同一进程的资源,挂起某个线程可能会引发资源不一致问题,从而破坏线程间的协作。
根据实现方式,线程可分为三类:
-
用户级线程 :由线程库(如POSIX线程库)管理,切换开销小,但无法利用多核CPU的优势。
-
内核级线程 :由操作系统内核直接调度,支持真正的并发,但切换开销较大。
-
混合线程 :结合用户级线程和内核级线程的优点,用户级线程由线程库管理,而内核级线程负责调度。这种模式既能减少上下文切换开销,又能充分利用多核CPU。
进程与线程的“标志“
进程控制块(PCB): 进程控制块标志着一个进程在物理意义上的存在。它包含了进程的所有关键信息,包括:
-
进程标识符(PID) :唯一标识一个进程。
-
CPU状态信息 :如寄存器值、程序计数器(PC)、栈指针(SP)等,用于保存和恢复进程的执行状态。
-
内存管理信息 :如页表、基址寄存器、界限寄存器等,用于虚拟内存管理。
-
资源分配信息 :如打开的文件列表、分配的I/O设备等。
-
调度信息 :如进程优先级、状态(就绪、运行、阻塞等)。
PCB就像餐厅的营业执照、运营日志、员工排班表等,共同标志着一个进程的存在。为了高效管理,操作系统通常使用线性表、链表或索引表等数据结构组织PCB。
线程控制块(TCB): 线程控制块志着一个线程的存在。与PCB不同,TCB的资源依赖于所属进程,因此其内存占用更小,上下文切换开销更低。TCB的主要内容包括:
-
线程标识符(TID) :唯一标识一个线程。
-
CPU状态信息 :如寄存器值、程序计数器(PC)、栈指针(SP)等。
-
堆栈信息 :每个线程拥有独立的用户栈和内核栈。
-
调度信息 :如线程优先级、状态(就绪、运行、阻塞等)。
-
所属进程指针 :指向该线程所属的进程控制块(PCB)。
由于线程共享进程的资源,TCB的设计更加轻量化,适合多线程环境下的高效并发。
同步:让多个线程“排队”干活
什么是同步?
想象一下,两个厨师(线程)要同时用一口锅(共享资源)。如果一个厨师正在炒菜,另一个厨师也冲进来抢锅,可能会导致混乱甚至把菜弄坏。为了避免这种情况,我们可以在厨房门口加一把锁:当一个厨师在用锅时,锁上门;等他炒完菜再开锁,让另一个厨师进来。
同步 就是给共享资源加一把“锁”,确保同一时间只有一个线程能访问它。这样可以避免多个线程“打架”,从而保证程序的正确性。
为什么要引入同步?
如果没有同步,操作系统中的并发任务就像一群没有规则的孩子,想干什么就干什么,完全不可预测。这种“无序”的状态叫异步 ,虽然效率高,但容易出问题。
举个例子:
class Counter { private int count = 0; public void increment() { count++; // 这里有潜在的线程安全问题 } }
假设两个线程 A 和 B 同时调用 increment()
方法,本来希望 count
最后等于 2,但由于 count++
是分三步完成的(读取、修改、写回),线程 A 和 B 可能会互相“踩脚”,结果最后 count
只有 1。
为了避免这种混乱,我们需要用同步机制来“排好队”,让线程按顺序访问共享资源。
同步的实现方式
同步的实现就像给共享资源配备不同的“管理工具”。以下是一些常见的方法:
-
硬件同步机制
-
好比直接关掉厨房的电闸,确保只有一个厨师能用电。
-
例如:关中断、测试与设置指令(TAS)、交换指令。
-
-
信号量(Semaphore)
-
就像餐厅里的“等位牌”,只有拿到牌子的人才能进入厨房。
-
整型信号量:只能控制一个资源。
-
记录型信号量:可以控制多个资源。
-
-
管程(Monitor)
-
管程就像是一个“智能厨房”,所有厨师必须通过固定的入口进入,并且一次只能有一个厨师在里面工作。
-
在 Java 中,
synchronized
关键字就是基于管程实现的。
-
同步的三个经典问题
1. 生产者-消费者问题
-
场景 :一家餐厅有一个有限大小的餐盘区。厨师(生产者)负责做菜放到餐盘区,服务员(消费者)负责从餐盘区端菜给顾客。
-
挑战 :
-
互斥 :厨师和服务员不能同时操作同一个餐盘。
-
同步 :餐盘空了服务员不能端菜,餐盘满了厨师不能放菜。
-
边界条件 :厨师和服务员需要协调好餐盘的数量。
-
2. 读者-写者问题
-
场景 :一个图书馆里有一本热门书。很多人想看这本书(读者),但也有人想修改这本书的内容(写者)。
-
挑战 :
-
互斥 :写者在修改书的时候,读者不能看。
-
饥饿 :如果总是优先满足读者,写者可能一直等不到机会。
-
优先级 :要不要让写者优先于读者?
-
3. 哲学家进餐问题
-
场景 :五个哲学家围坐在一张圆桌旁,每人左右各有一根筷子。哲学家们一边思考一边吃饭,但吃饭时需要同时拿起两根筷子。
-
挑战 :
-
互斥 :每根筷子只能被一个哲学家拿走。
-
死锁 :如果每个人都先拿左边的筷子,再试图拿右边的筷子,所有人都会卡住。
-
饥饿 :某些哲学家可能因为筷子分配不公而长期吃不上饭。
-
死锁:大家一起“卡住”了
死锁是什么?
死锁就像一群人同时抢两样东西,比如两个小朋友争抢玩具车和玩具枪:
-
A 小朋友拿了玩具车,等着拿玩具枪;
-
B 小朋友拿了玩具枪,等着拿玩具车。
结果两个人都卡住了,谁也玩不了。
在计算机中,死锁是指多个线程互相等待对方释放资源,导致大家都无法继续运行。
死锁的四个条件
死锁的发生需要满足以下四个条件:
-
互斥等待 :每个资源只能被一个线程占用,就像玩具车和玩具枪只能被一个人拿着。
-
占有并等待 :线程占有一些资源,同时还在等待其他资源,就像 A 小朋友拿了玩具车又想去拿玩具枪。
-
不可剥夺 :资源不能被强行抢走,只能由线程主动释放,就像玩具不能被别人硬抢。
-
循环等待 :形成一个“你等我、我等你”的循环,就像 A 等 B、B 等 A。
如何避免死锁?
-
破坏死锁条件
-
比如规定所有人必须先拿玩具车再拿玩具枪,避免循环等待。
-
-
使用超时机制
-
如果等太久还没拿到资源,就放弃当前的操作,比如 A 小朋友等玩具枪超过一定时间就放下玩具车。
-
-
使用死锁检测工具
-
让系统帮忙检查是否有死锁发生,类似于找老师来调解小朋友之间的争执。
-
进程与线程的分类
进程的类型
进程就像是一个“餐厅”,每个餐厅都有自己的独立空间和资源(如厨房、厨师、餐具等)。根据餐厅的运营模式,我们可以将进程分为以下几种类型:
1 用户进程
-
比喻 :用户进程就像普通的“街边餐厅”,专门为顾客(用户)提供服务。
-
特点 :
-
由用户启动的应用程序生成,比如打开浏览器、播放音乐等。
-
它们运行在用户空间,不能直接访问系统的底层资源。
-
如果某个用户进程崩溃了,不会影响其他餐厅(进程)。
-
2 系统进程
-
比喻 :系统进程就像“后台管理办公室”,负责维护整个餐厅街的秩序(操作系统的核心功能)。
-
特点 :
-
由操作系统启动,用于管理硬件资源、调度任务等。
-
它们运行在内核空间,拥有更高的权限。
-
比如内存管理、文件系统管理等任务都是由系统进程完成的。
-
3 守护进程(Daemon Process)
-
比喻 :守护进程就像“夜班保安”,默默地在后台工作,确保一切正常运行。
-
特点 :
-
通常在后台运行,不与用户直接交互。
-
例如,定时清理垃圾文件、监控网络连接等任务。
-
线程的类型
线程就像是餐厅里的“厨师”,它们共享餐厅的资源(进程的资源),但每个厨师有自己的任务。根据厨师的工作方式,线程可以分为以下几种类型:
1 用户级线程
-
比喻 :用户级线程就像“学徒厨师”,他们的工作完全由餐厅经理(线程库)安排,老板(操作系统)并不知道他们的存在。
-
特点 :
-
由线程库(如POSIX线程库)管理,操作系统看不到这些线程。
-
切换开销小,因为不需要进入内核态。
-
缺点是无法利用多核CPU的优势,因为操作系统只看到一个进程。
-
2 内核级线程
-
比喻 :内核级线程就像“正式厨师”,他们的工作由老板(操作系统)亲自安排。
-
特点 :
-
由操作系统直接管理,支持真正的并发执行。
-
可以利用多核CPU的优势,让多个线程同时运行。
-
缺点是切换开销较大,因为需要进入内核态。
-
3 混合线程
-
比喻 :混合线程就像“团队协作模式”,既有学徒厨师(用户级线程),也有正式厨师(内核级线程)。
-
特点 :
-
用户级线程由线程库管理,而内核级线程由操作系统管理。
-
结合了用户级线程的轻量化和内核级线程的高效调度。
-
适合复杂的多线程环境,既能减少切换开销,又能充分利用多核CPU。
-