【操作系统】二、进程管理

    技术2025-11-28  39

    文章目录

    1. 进程和线程2. 进程状态3. 进程调度算法4. 进程的同步与互斥4.1 基本概念多线程同步机制 5. 实现同步互斥的基本方法5.1 软件同步机制5.2 硬件同步机制5.3 信号量实现 6. PV操作7. 同步经典问题7.1 生产者和消费者问题7.2 读者和写者问题7.3 哲学家进餐问题 8. 进程间通信8.1 共享内存8.2 消息传递8.3 文件映射8.4 管道8.5 消息队列8.6 套接字 9. 线程安全9.1 实现线程安全的方式

    1. 进程和线程

    进程:资源分配的基本单位。计算机中已运行程序的实体,同一程序可产生多个进程,以允许同时有多位用户运行同一程序。

    线程:独立调度的基本单位。一个进程中可以有多个线程,共享进程资源。一个标准线程由 线程 ID,当前指令指针 PC,寄存器集合和堆栈 组成。线程是进程中的一个实体,同属一个进程的线程共享进程环境,包括:进程代码段、进程的公有数据等。

    打开电脑的任务管理器,每个运行的应用都是一个进程: 【线程的组成部分】

    线程共享进程的:进程代码段,进程公有数据、进程当前目录等信息。

    除了以上共享资源,每个线程还有:

    线程 ID:线程 ID 在本进程中是唯一的,进程以此来标识线程。寄存器组的值:由于线程并发运行,当切换线程时,必须保存原有线程的寄存器集合的状态,以便将来该线程在被重新切换时能得到恢复。线程的堆栈:线程函数可以调用函数,而被调用函数中又是可以层层嵌套的,所以线程必须拥有自己的函数堆栈,使得函数调用可以正常执行,不受其他线程的影响。在一个进程中的线程共享堆区,栈是独有的。线程优先级:由于线程需要像进程那样能够被调度,那么就必须要有可供调度使用的参数,这个参数就是线程的优先级。错误返回码:由于同一个进程中有很多个线程在同时运行,可能某个线程进行系统调用后设置了errno值,而在该线程还没有处理这个错误,另外一个线程就在此时被调度器投入运行,这样错误值就有可能被修改。所以,不同的线程应该拥有自己的错误返回码变量。线程的信号屏蔽码:由于每个线程所感兴趣的信号不同,所以线程的信号屏蔽码应该由线程自己管理。但所有的线程都共享同样的信号处理器。

    【进程和线程的区别】

    资源:进程是 资源分配 的基本单位,线程不拥有资源(只有一点点必不可少的资源),同类的多个线程共享进程的堆和方法区资源,但每个线程都有自己的程序计数器、虚拟机栈和本地方法栈。系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多(所以线程也被称作轻量级进程)。调度:线程是 独立调度 的基本单位,同一进程中的线程切换不会引起进程切换,不同进程中的线程切换会引起进程切换。内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的。进程初始化时,会在堆区分配空间,运行过程中也可以继续向系统申请堆,但是用完了要记得还给操作系统,否则会内存泄漏;线程初始化时,会在栈区分配空间,用于保存线程的运行状态和局部自动变量,各个线程的栈相互独立,因此栈是线程安全的。操作系统在切换线程时会自动切换栈,且栈空间不需要显式的分配和释放。系统开销:创建 / 撤销 / 切换进程的开销远大于线程。由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。通信:进程间通信需要进程同步或互斥的辅助(进程间通信,InterProcessCommunication,IPC),同一进程中的多个线程共享进程地址空间,因此线程间可以直接读 / 写进程数据段进行通信。影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。

    引入线程后,进程只作为除 CPU 以外系统资源的分配单元,线程则作为处理器的分配单元。

    【如何选择使用线程还是进程?】

    计算密集型工作使用进程:计算工作依赖 CPU,进程的数量应等于 CPU 数量。I/O 密集型工作使用线程:I/O 经常阻塞,速度远小于 CPU 运行速度,应多开辟线程。

    【进程和程序的区别】

    静态和动态:进程是程序及其数据在计算机上的一次运行活动,是动态概念。进程的运行实体是程序,是由程序、数据、进程控制块三部分组成的。程序是一组有序的指令集合,是一种静态概念。存在时间长短:进程是程序的一次执行过程,动态创建和消亡,具有一定生命期,是暂时存在的。程序是代码集合,可以长期保存。是否可创建:一个进程可以执行一个或几个程序,一个程序也可以构成多个进程。进程可以创建进程,而程序不可能形成新的程序。

    【多进程和多线程的区别】

    多进程:操作系统中同时运行的多个程序,比如电脑同时运行杀毒软件、微信和音乐播放器。多线程:在同一个进程中同时运行的多个任务,比如杀毒软件里同时执行电脑体检、病毒查杀和垃圾清理。

    将进程分解为线程可以有效地利用 多处理器 和 多核计算机。例如,当我们使用 Microsoft Word 时,实际上是打开了多个线程。这些线程一个负责显示,一个负责接收输入,一个定时进行存盘…这些线程一起运转,让我们感觉到输入和显示同时发生,而不用键入一些字符等待一会儿才显示到屏幕上。在不经意间,Word还能定期自动保存。

    进程控制块(Process Control Block, PCB): 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。

    2. 进程状态

    3 个基本状态:就绪,运行,阻塞。 5 个状态:创建,就绪,运行,阻塞,终止

    只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度。

    创建(create):申请一个空白的进程控制块(PCB),并向 PCB 中填写用于控制和管理进程的信息。就绪(ready):等待被调度。进程已获得除 CPU 以外的所有资源,等待分配 CPU,一旦获得CPU,便可立即执行。如果系统中有许多处于就绪状态的进程,通常将它们按照一定的策略排成一个队列,该队列称为就绪队列。【就绪态的进程属于有执行资格,没有执行权的进程】运行(running):占用 CPU 资源运行,处于此状态的进程数小于等于 CPU 数。对任何一个时刻而言,在单处理机的系统中,只有一个进程处于执行状态而在多处理机系统中,有多个进程处于执行状态。【运行态的进程属于既有执行资格,又有执行权的进程】阻塞(waiting):正在执行的进程由于发生某事件(如 I/O 请求、申请缓冲区失败等)暂时无法继续执行,此时操作系统把处理机分配给了另外一个就绪的进程。阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。终止(terminated):当进程到达自然结束点,或出现了无法克服的错误,或被操作系统所终结,或被其他有终止权的进程所终结,将进入终止状态。进入终止态的进程以后不能在再执行,但是操作系统中仍然保留了一个记录,其中保存状态码和一些计时统计数据,供其他进程进行收集。一旦其他进程完成了对其信息的提取之后,操作系统将删除其进程,即将其 PCB 清零,并将该空白的 PCB 返回给系统。

    3. 进程调度算法

    调度的基本准则包括:CPU 利用率,系统吞吐量,周转时间,等待时间,响应时间等。

    系统吞吐量:表示单位时间内 CPU 完成作业的数量。周转时间:作业完成时刻减去作业到达时刻等待时间:进程等待处理器响应的时间之和响应时间:用户从提交请求到系统首次产生响应所用的时间

    【进程调度算法】

    先来先服务 first-come first-serverd(FCFS) 非抢占式的调度算法,按照请求的顺序进行调度。有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。

    短作业优先 shortest job first(SJF) 平均等待时间、平均周转时间最少 非抢占式的调度算法,按估计运行时间最短的顺序进行调度。长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。

    最短剩余时间优先 shortest remaining time next(SRTN) 最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。

    时间片轮转 将所有就绪进程按先来先服务(FCFS)原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。时间片轮转算法的效率和时间片的大小有很大关系:因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间;而如果时间片过长,那么实时性就不能得到保证。

    优先级调度 为每个进程分配一个优先级,按优先级进行调度。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。

    多级反馈队列 一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,…。进程在第一个队列没执行完,就会被移到下一个队列(也就是说分配给该进程的时间片会越来越大)。这种方式下,之前的进程只需要交换 7 次。每个队列优先权也不同,最上面的优先权最高(时间片越小的队列优先级越高)。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。 从不同的需求来看,批处理系统 中没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间),常用的进程调度算法是 先来先服务、短作业优先和最短剩余时间优先。交互式系统 有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。常用的进程调度算法是时间片轮转、优先级调度和多级反馈队列。实时系统 要求一个请求在一个确定时间内得到响应,实时分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。

    4. 进程的同步与互斥

    进程的同步和互斥是在 进程并发 下存在的概念,有了进程的并发,才产生了资源的竞争与协作,从而就要通过进程的同步、互斥、通信来解决资源的竞争与协作问题。

    在多道程序设计系统中,同一时刻可能有许多进程,这些进程之间存在两种基本关系:竞争关系 和 协作关系。进程的同步、互斥、通信都是基于这两种基本关系而存在的。

    4.1 基本概念

    互斥:为了解决进程间 竞争关系(间接制约关系) 而引入进程互斥。当一个进程进入临界区使用临界资源时,另一个进程必须等待,当占用临界资源的进程退出临界区后,另一进程才允许去访问此临界资源。

    【比如 A、B 进程都需要使用打印机,如果进程 A 需要打印时,系统已将打印机分配给进程 B,则进程 A 必须阻塞。一旦进程 B 将打印机释放,系统便将进程 A 唤醒,并将其由阻塞状态变为就绪状态】

    同步:为了解决进程间 松散的协作关系(直接制约关系) 引入进程同步。为了完成某个目而建立的两个或多个进程,这些进程需要在某些位置协调工作次序,信息传递而产生的制约关系,直接制约关系是由于进程间的相互合作而引起的。

    【比如 A、B 进程,A 产生数据,B 计算结果,AB 公用一个缓存区。缓存区为空时,B 不能运行,等待 A 向缓存区传递数据后 B 才能运行,缓存区满时,A 不能运行,等待 B 取走数据后,A 才能运行。此时 AB 为直接制约关系】

    通信:为了解决进程间 紧密的协作关系 而引入进程通信。

    临界资源:一次仅允许一个进程使用的资源称为临界资源。

    临界区(Critical Section):对临界资源进行访问的那段代码称为临界区,每次只允许一个进程进入临界区。多个进程涉及到同一个临界资源的的临界区称为 相关临界区,临界区是一种轻量级的同步机制,与互斥和事件这些内核同步对象相比,临界区是 用户态 下的对象,即只能在同一进程中实现线程互斥。因无需在用户态和核心态之间切换,所以工作效率比较互斥来说要高很多。虽然临界区同步速度很快,但却只能用来同步本进程内的线程,而不可用来同步多个进程中的线程。

    将进入并访问临界资源的代码分为 4 个部分:

    进入区。为了进入临界区使用临界资源,在进入区要检查可否进入临界区,如果可以进入临界区,则应设置正在访问临界区的标志,以阻止其他进程同时进入临界区。临界区。进程中访问临界资源的那段代码,又称临界段。退出区。将正在访问临界区的标志清除。剩余区。代码中的其余部分。 do { entry section; //进入区 critical section; //临界区 exit section; //退出区 remainder section; //剩余区 } while (true)

    【竞争关系】

    多个进程间 彼此无关,不受对方的影响,但是它们共用了一套资源,从而出现多个进程竞争资源的问题。

    资源竞争引发的问题:

    死锁:一组进程如果都获得了部分资源,还想要得到其他进程所占有的资源,最终所有的进程将陷入死锁。饥饿:一个进程由于其他进程总是优先于它而被无限期拖延。

    解决资源竞争:通过进程互斥,使若干个进程要使用同一共享资源时,保证任何时刻最多允许一个进程使用,其他要使用该资源的进程必须等待,直到占有资源的进程释放该资源。

    【协作关系】

    多个进程共同完成某项任务,但每个进程都独立地以不可预知的速度推进,故需要在某些协调点上协调每个进程的工作。比如当某一进程到达协调点,但尚未获得其他进程发来的消息,此时应主动阻塞自己,直到获得必要的消息后再次唤醒继续执行。

    协作关系可分为:1)并不知道对方是谁的 “松散式协作”(同步),比如共享一个缓冲区协作;也可以是 2)知道对方名字的 “紧密式协作”(通信)。

    协作的优点:有利于共享信息、有利于加快计算速度、有利于实现模块化程序设计。

    解决进程协作:通过进程同步,可以协调多个进程的活动。进程互斥属于特殊的进程同步,即对资源使用次序上的一种协调。

    进程通信:进程之间互相交换信息的工作称为进程通信 IPC (InterProcess Communication)(主要是指大量数据的交换)

    【同步机制遵循的规则】

    空闲让进:当无进程处于临界区时,表明临界资源处于空闲状态,应允许一个请求进入临界区的进程立即进入自己的临界区,以有效利用临界资源。忙则等待:当已有进程进入临界区时,表明临界资源正在被访问,因而其他试图进入临界区的进程都必须等待,以保证对临界资源的互斥访问。有限等待:对要求访问的临界资源的进程,应保证在有限时间内能进入自己的临界区,以免陷入“死等” 状态。让权等待:当进程不能进入自己的临界区时,应立即释放处理机,以免陷入"忙等" 状态。

    多线程同步机制

    临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。 用于实现“排他性占有”,范围是单一进程,属于局部对象,非核心对象,可以实现线程间互斥,不能来实现同步。互斥量:为协调共同对一个共享资源的单独访问而设计的。属于核心对象,可以在不同进程的不同线程之间实现“排他性占有”,可以有名称,因此可以被其他进程开启,但只能被拥有它的线程释放。信号量:为控制一个具有有限数量用户资源而设计。 属于核心对象,可以有名称,因此可以被其他进程开启,可以被任何一个线程释放,既能实现线程间互斥也能实现线程间同步。事件:用来通知线程有一些事件已发生,从而启动后继任务的开始。属于核心对象,可以用于实现线程的同步与互斥,可以有名称,因此可以被其他进程开启。

    临界区和互斥量 都有“线程所有权”的概念,所以它们 不能用来实现线程间的同步的,只能用来实现互斥。而 事件和信号量都可以实现线程和进程间的互斥和同步。

    就使用效率来说,临界区的效率是最高的,因为它不是内核对象,而其它的三个都是核心对象,要借助操作系统来实现,效率相对来说就比较低。

    ps:进程的同步方式是线程的同步方式的子集。

    1. 临界区 Critical Section

    在任意时刻只允许一个线程对共享资源进行访问。如果有多个线程试图同时访问临界区,那么 在有一个线程进入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。

    临界区包含两个操作原语:

    EnterCriticalSection():进入临界区LeaveCriticalSection():离开临界区

    Enter 语句执行后必须确保与之匹配的 Leave 能够执行,否则临界区的共享资源永远不会被释放。

    特点:临界区同步速度很快(处于用户态),但只能用来同步本进程内的线程,而不可用来同步多个进程中的线程。

    2. 互斥量 Mutex

    互斥量跟临界区很相似,只有拥有互斥对象的线程才具有访问资源的权限,由于互斥对象只有一个,因此就决定了任何情况下此共享资源都不会同时被多个线程所访问。当前占据资源的线程在任务处理完后应将拥有的互斥对象交出,以便其他线程在获得后得以访问资源。

    互斥量包含的操作原语:

    CreateMutex():创建一个互斥量OpenMutex():打开一个互斥量ReleaseMutex():释放互斥量WaitForMultipleObjects():等待互斥量对象

    特点:互斥量与临界区的作用非常相似,但互斥量比临界区复杂。因为互斥量是可以命名的,也就是说它可以跨越进程使用。使用互斥不仅仅 能够在 同一应用程序不同线程 中实现资源的安全共享,而且可以在 不同应用程序的线程 之间实现对资源的安全共享。所以创建互斥量需要的资源更多。如果只为了在进程内部是用的话使用临界区会带来速度上的优势并能够减少资源占用量 。

    临界区和互斥量的区别

    临界区只能用于对象在 同一进程里线程间的互斥访问;互斥量可以用于对象进程间或线程间的互斥访问。临界区是 非内核对象,只在 用户态 进行锁操作,速度快;互斥量是 内核对象,在 核心态 进行锁操作,速度慢。临界区和互斥量在 Windows 平台都下可用;Linux下只有互斥量可用。临界区通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;互斥量是为协调共同对一个共享资源的单独访问而设计的。

    互斥量和信号量的联系

    mutex互斥量是semaphore信号量的一种特殊情况(n=1时)。也就是说,完全可以用信号量替代互斥量。

    3. 信号量 Semaphores

    信号允许多个线程同时使用共享资源,它指出了同时访问共享资源的线程最大数目。它 允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。

    信号量 S 是一个整型变量,S 大于等于 0 时代表可供并发进程使用的资源实体数,S 小于 0 时则表示正在等待使用共享资源的进程数。信号量 S 支持加法操作 up 和减法操作 down,即 PV 操作。

    信号量包含的操作原语:

    CreateSemaphore():创建一个信号量OpenSemaphore():打开一个信号量ReleaseSemaphore():释放信号量WaitForSingleObject():等待信号量

    用 CreateSemaphore() 创建信号量时要同时指出允许的最大资源计数和当前可用资源计数。一般是将当前可用资源计数设置为最大资源计数,每增加一个线程对共享资源的访问,当前可用资源计数就会减 1,只要当前可用资源计数是大于 0 的,就可以发出信号量信号。但是当前可用计数减小到 0 时则说明当前占用资源的线程数已经达到了所允许的最大数目,不再允许其他线程的进入,此时的信号量信号将无法发出。线程在处理完共享资源后,应在离开的同时通过 ReleaseSemaphore() 将当前可用资源计数加 1。在任何时候当前可用资源计数决不可能大于最大资源计数。

    4. 事件 Event

    事件对象也可以通过通知操作的方式来保持线程的同步。并且可以实现不同进程中的线程同步操作。

    事件包含的操作原语:

    CreateEvent():创建一个事件OpenEvent():打开一个事件SetEvent():回置事件WaitForSingleObject():等待一个事件WaitForMultipleObjects():等待多个事件

    5. 管程 Monitor

    管程即监视器,它监视的是进程或线程的同步操作。具体来说,管程就是 一组子程序、变量和数据结构的组合。言下之意,把需要同步的代码用一个管程的构造框起来,即将需要保护的代码置于 begin monitor 和 end monitor 之间,即可获得同步保护,也就是 任何时候只能有一个线程活跃在管程里面。在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。

    使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。【C 语言不支持管程】

    管程引入了条件变量以及相关的操作:wait() 和 signal() 来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。

    同步操作的保证是由编译器来执行的,编译器在看到begin monitor和end monitor时就知道其中的代码需要同步保护,在翻译成低级代码时就会将需要的操作系统原语加上,使得两个线程不能同时活跃在同一个管程内。

    在管程中使用两种同步机制:锁用来进行互斥,条件变量用来控制执行顺序。从某种意义上来说,管程就是锁+条件变量。

    条件变量就是线程可以在上面等待的东西,而另外一个线程则可以通过发送信号将在条件变量上的线程叫醒。因此,条件变量有点像信号量,但又不是信号量,因为不能对其进行up和down操作。

    管程最大的问题就是对编译器的依赖,因为我们需要将编译器需要的同步原语加在管程的开始和结尾。此外,管程只能在单台计算机上发挥作用,如果想在多计算机环境下进行同步,那就需要其他机制了,而这种其他机制就是消息传递。

    6. 消息传递

    消息传递是通过同步双方经过互相收发消息来实现,它有两个基本操作:发送send和接收receive。他们均是操作系统的系统调用,而且既可以是阻塞调用,也可以是非阻塞调用。而同步需要的是阻塞调用,即如果一个线程执行receive操作,就必须等待受到消息后才能返回。也就是说,如果调用receive,则该线程将挂起,在收到消息后,才能转入就绪。

    消息传递最大的问题就是消息丢失和身份识别。由于网络的不可靠性,消息在网络间传输时丢失的可能性较大。而身份识别是指如何确定收到的消息就是从目标源发出的。其中,消息丢失可以通过使用TCP协议减少丢失,但也不是100%可靠。身份识别问题则可以使用诸如数字签名和加密技术来弥补。

    7. 栅栏

    栅栏顾名思义就是一个障碍,到达栅栏的线程必须停止下来,知道出去栅栏后才能往前推进。该院与主要用来对一组线程进行协调,因为有时候一组线程协同完成一个问题,所以需要所有线程都到同一个地方汇合之后一起再向前推进。

    例如,在并行计算时就会遇到这种需求,如下图所示:

    5. 实现同步互斥的基本方法

    5.1 软件同步机制

    在进入区设置和检查一些标志来标明是否有进程在临界区,如果已有进程在临界区,则在进入区通过循环检查进行等待,进程离开临界区后则在退出区修改标志。

    算法一:单标志法 设置一个公用整型变量 turn,用于指示被允许进入临界区的进程编号,比如 turn = 0 ,则允许 P0 进程进入临界区。该算法可确保每次只允许一个进程进入临界区。 两个进程必须交替进入临界区,如果某个进程不进入临界区,另一个进程也不进入临界区,会造成资源利用的不充分(违背 “空闲让进”)。

    算法二:双标志法先检查 该算法的基本思想是在每一个进程访问临界区资源之前,先查看一下临界资源是否正在被访问,若被访问则该进程需等待,否则进程进入自己的临界区。为此,设置了一个数据 flag[i],如果 flag[i] 值为 FLASE,则表示 i 进程未进临界区,值为 TRUE,表示 i 进程已进入临界区。 优点是不要交替进入,可连续使用;缺点是 pi 和 pj 可能同时进入临界区(违背 ”忙则等待“)。即在检查对方 flag 之后和切换自己的 flag 之间有一段时间,结果都检查通过。这里问题出在检查和修改操作不能一次进行。

    算法三:双标志法后检查 算法二先检测进程状态标志后,再置自己标志,由于在检测和放置中可插入另一个进程到达时的检测操作,会造成两个进程分别检测后。同时进入临界区。为此,算法三采用先设置自己标志为TRUE,再检测对方状态标志,若对方标志为TURE,则进程等待,否则进入临界区。 当两个进程几乎同时想要进入临界区时,他们分别将自己的标志值 flag 设置为 TRUE,并且同时检测对方的状态(执行while语句),发现对方也要进入临界区,预算对方互相谦让了,结果谁也进不了临界区,从而导致"饥饿"现象。

    算法四:Peterson算法 为了防止两个进程为进入临界区而无限等待,又设置变量 turn,指示不允许进入临界区的进程编号,每个进程在设置自己的标志后再设置 turn 标志,不允许另一个进程进入。这时,在同时检测另一个进程状态标志和不允许进入标志,这样可以保证两个进程同时要求进入临界区,只允许一个进程进入临界区。 本算法的基本思想是算法一和算法三的结合。利用 flag 解决临界资源的互斥访问,而利用 turn 解决“饥饿”现象。该算法满足解决临界区问题的 3 个标准:互斥访问、进入、有限等待。

    5.2 硬件同步机制

    1. 关中断

    通过硬件实现临界区最简单的办法就是关CPU的中断。从计算机原理我们知道,CPU进行进程切换是需要通过中断来进行。如果屏蔽了中断那么就可以保证当前进程顺利的将临界区代码执行完,从而实现了互斥。这个办法的步骤就是:屏蔽中断–执行临界区–开中断。但这样做并不好,这大大限制了处理器交替执行任务的能力。并且将关中断的权限交给用户代码,那么如果用户代码屏蔽了中断后不再开,那系统岂不是跪了?

    2. 硬件指令方式

    利用 Test-and-Set 指令实现互斥利用 Swap 指令实现互斥

    5.3 信号量实现

    信号量(Semaphores):信号允许多个线程同时使用共享资源,它指出了同时访问共享资源的线程最大数目。它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。

    信号量 S 是一个整型变量,S 大于等于零时代表可供并发进程使用的资源实体数,但 S 小于零时则表示正在等待使用共享资源的进程数。 可以对信号量 S 执行加减操作,即 PV 操作。

    信号量包含的操作原语:

    CreateSemaphore():创建一个信号量OpenSemaphore():打开一个信号量ReleaseSemaphore():释放信号量WaitForSingleObject():等待信号量

    用 CreateSemaphore()创建信号量时要同时指出允许的最大资源计数和当前可用资源计数。一般是将当前可用资源计数设置为最大资源计数,每增加一个线程对共享资源的访问,当前可用资源计数 就会减1,只要当前可用资源计数是大于0的,就可以发出信号量信号。但是当前可用计数减小到0时则说明当前占用资源的线程数已经达到了所允许的最大数目, 不能在允许其他线程的进入,此时的信号量信号将无法发出。线程在处理完共享资源后,应在离开的同时通过 ReleaseSemaphore()函数将当前可 用资源计数加 1。在任何时候当前可用资源计数决不可能大于最大资源计数。

    信号量操作: P和V操作分别来自荷兰语Passeren和Vrijgeven,分别表示占有和释放。P V操作是操作系统的原语,意味着具有原子性。

    P 操作首先减少信号量,表示有一个进程将占用或等待资源,然后检测S是否小于 0,如果小于 0 则阻塞,如果大于 0 则占有资源进行执行。V 操作是和 P 操作相反的操作,首先增加信号量,表示占用或等待资源的进程减少了 1 个。然后检测 S 是否小于 0,如果小于 0 则唤醒等待使用 S 资源的其它进程。

    如果信号量 S 大于 0 ,执行 -1 操作;如果信号量 S 等于 0,进程睡眠,等待信号量大于 0;对信号量 S 执行 +1 操作,唤醒睡眠的进程让其继续执行。

    如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。

    6. PV操作

    与信号量对应的就是 PV 操作:

    P 操作:申请资源

    信号量 S 减 1;若 S 减 1 后仍大于等于零,则进程继续执行;若 S 减 1 后小于零,则该进程被阻塞后进入与该信号相对应的队列中,然后转入进程调度。

    V 操作:释放资源

    信号量 S 加 1 ;若 S 加 1 后结果大于零,则进程继续执行;若 S 加 1 后结果小于等于零,则从该信号的等待队列中唤醒一个等待进程,然后再返回原进程继续执行或转入进程调度。

    7. 同步经典问题

    7.1 生产者和消费者问题

    问题描述:生产者-消费者问题是一个经典的进程同步问题,该问题最早由Dijkstra提出,用以演示他提出的信号量机制。本作业要求设计在同一个进程地址空间内执行的两个线程。生产者线程生产物品,然后将物品放置在一个空缓冲区中供消费者线程消费。消费者线程从缓冲区中获得物品,然后释放缓冲区。当生产者线程生产物品时,如果没有空缓冲区可用,那么生产者线程必须等待消费者线程释放出一个空缓冲区。当消费者线程消费物品时,如果没有满的缓冲区,那么消费者线程将被阻塞,直到新的物品被生产出来

    这里生产者和消费者是既同步又互斥的关系,首先只有生产者生产了,消费着才能消费,这里是同步的关系。但他们对于临界区的访问又是互斥的关系。因此需要三个信号量empty和full用于同步缓冲区,而mut变量用于在访问缓冲区时是互斥的。

    7.2 读者和写者问题

    问题描述:一个数据文件或记录,统称数据对象,可被多个进程共享,其中有些进程只要求读称为"读者",而另一些进程要求写或修改称为"写者"。

    规定:允许多个读者同时读一个共享对象,但禁止读者、写者同时访问一个共享对象,也禁止多个写者访问一个共享对象,否则将违反Bernstein并发执行条件。

    通过描述可以分析,这里的读者和写者是互斥的,而写者和写者也是互斥的,但读者之间并不互斥。由此我们可以设置3个变量,一个用来统计读者的数量,另外两个分别用于对读者数量读写的互斥,读者和读者写者和写者的互斥。

    7.3 哲学家进餐问题

    问题描述:有五个哲学家,他们的生活方式是交替地进行思考和进餐。哲学家们公用一张圆桌,周围放有五把椅子,每人坐一把。在圆桌上有五个碗和五根筷子,当一个哲学家思考时,他不与其他人交谈,饥饿时便试图取用其左、右最靠近他的筷子,但他可能一根都拿不到。只有在他拿到两根筷子时,方能进餐,进餐完后,放下筷子又继续思考。

    根据问题描述,五个哲学家分别可以看作是五个进程。五只筷子分别看作是五个资源。只有当哲学家分别拥有左右的资源时,才得以进餐。如果不指定规则,当每个哲学家手中只拿了一只筷子时会造成死锁,从而五个哲学家都因为吃不到饭而饿死。因此我们的策略是让哲学家同时拿起两只筷子。因此我们需要对每个资源设置一个信号量,此外,还需要使得哲学家同时拿起两只筷子而设置一个互斥信号量。

    8. 进程间通信

    进程通信和进程同步的区别:

    进程同步:控制多个进程按一定顺序执行进程通信:进程间传输信息

    进程通信是一种手段,而进程同步是一种目的。也可以说,为了能够达到进程同步的目的,需要让进程进行通信,传输一些进程同步所需要的信息。

    进程通信和线程通信的区别:

    线程通信一般是指 同一进程内的线程 进行通讯,由于在同一进程内共享地址空间,因此交互比较容易,全局变量之类的都能起到作用。进程通信一般是指 不同进程间的线程 进行通讯,由于地址空间不同,因此需要使用操作系统相关机制进行“中转”,比如共享文件、管道、SOCKET。

    进程通信与同步的目的:

    数据传输:进程将数据发送给另一个进程共享数据:多个进程需要操作共享数据通知事件:一个进程需要向另一个进程发送消息,通知他们某种事件资源共享:多个进程共享同样的资源,需要内核提供锁和同步机制进程控制:某些进程需要控制另一个进程的执行

    进程通信分类:

    根据交换信息量的多少和效率的高低,进程通信分为低级通信和高级通信。

    低级通信:只能传递状态和整数值(控制信息)。特点是传送信息量小,效率低,每次通信传递的信息量固定,若传递较多信息则需要进行多次通信。编程比较复杂,由用户直接实现通信的细节,容易出错。高级通信:提高信号通信的效率,传递大量数据,减轻程序编制的复杂度。有三种高级通信方式:1)共享内存模式,2)消息传递模式,3)共享文件模式。

    Linux 下进程间通信方式:

    共享内存:多个进程可以访问同一块内存空间,是最快的 IPC 形式。往往与其他通信机制结合使用。管道(Pipe)和有名管道(named pipe):管道用于具有亲缘关系的进程间的通信,有名管道还允许无亲缘关系进程间的通信;信号(Signal):用于通知接收进程某种事件的发生,可用于进程间通信,还可以发送信号给进程本身。信号量(Semaphore):进程间、同一进程不同线程之间的同步手段。消息队列(Message):消息的链表,克服了信号量承载信息量少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。套接字(Socket):可用于不同机器之间的进程通信。

    Linux 下线程间通信方式: 互斥量(互斥体),信号量,条件变量 Windows 下进程间通信方式: 共享内存,消息队列,管道,信号量,Socket Windows 下线程间通信方式: 临界区,互斥量,信号量,事件(Event)

    临界区(Critical section)和互斥量(Mutex)的区别:

    临界区只能用来同步进程内的线程,而不可以用来同步多个进程中的线程;互斥量可以被跨进程使用来进行同步数据操作(信号量、事件也可以)。临界区是非内核对象,只在用户态进行锁操作,速度快;互斥量是内核对象,在核心态进行锁操作,速度慢。临界区和互斥量在 Windows 平台下都可用,Linux 下只有互斥量可用。

    8.1 共享内存

    共享内存(Shared-memory):相互通讯的进程有共享存储区,进程间可以通过直接读写共享存储区的变量来交互数据。共享内存属于间接通信。

    共享内存是最快捷的 IPC(直接对内存存取),通常与其他机制结合使用,比如共享内存区的同步、互斥需要使用信号量来实现。另外,数据的发送方不关心数据由谁接收,数据的接收方也不关心数据是由谁发送的,所以存在安全隐患。

    8.2 消息传递

    消息传递(message-passing):通过操作系统的相应系统调用进行消息传递通讯,分为直接通信和间接通信两种方式。

    直接通信方式:点到点的发送。基本思想是进程在发送和接收消息时直接指明接收者或发送者进程 ID。缺点是必须指定接收进程 ID。间接通信方式:以信箱为媒介进行传递,可以广播。基本思想是系统为每个信箱设一个消息队列,消息发送和接收都指向该消息队列。优点是很容易建立双向通讯链(只要对信箱说明为读写打开)。缺点是必须有一个通讯双方共享的一个逻辑消息队列。

    8.3 文件映射

    文件映射(Memory-Mapped Files):能使进程把文件内容当作进程地址区间的一块内存那样来对待。因此,进程不必使用文件 I/O 操作,只需简单的指针操作就可读取和修改文件的内容。

    文件映射是在多个进程间共享数据的非常有效方法,有较好的安全性。但文件映射只能用于本地机器的进程之间,不能用于网络中,而开发者还必须控制进程间的同步。

    8.4 管道

    管道通信(Pipe):管道是一种信息流缓冲机构,以先进先出(FIFO)方式组织数据传输。管道可分为:

    匿名管道(Anonymous Pipe):只适用于父子进程之间通信。管道能够把信息从一个进程的地址空间拷贝到另一个进程的地址空间。命名管道(Named Pipe / FIFO):命名管道有自己的名字和访问权限的限制,就像一个文件一样(存在于文件系统),可以用于不相关进程间的通信。进程通过使用管道的名字获得管道。常用于客户-服务器应用程序中作为汇聚点,在客户进程和服务器进程之间传递数据。

    特点:1)管道是一个单向通信信道(半双工通信),具有固定的读端和写端。如果进程间要进行双向通信,通常需要定义两个管道。2)调用 pipe 函数创建管道,管道可以看作是一种特殊的文件,可调用 read(), write() 函数对其进行读写操作。但又不属于任何文件系统,并只存在于内存中。3)匿名管道只能用于具有亲缘关系的进程之间的通信(父子进程或者兄弟进程间)。

    8.5 消息队列

    消息队列:消息队列是消息的链接表,存放在内核中。一个消息队列由一个标识符(队列 ID)进行标识。

    特点:1)消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。2)消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。3)消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

    消息队列相比命名管道,有以下优点:

    消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难;避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法;读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。

    8.6 套接字

    套接字(Socket):套接字可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个 逻辑上的概念,即网络环境中进程间通信的 API(应用程序编程接口)。与其它通信机制不同的是,套接字可用于不同主机间的进程通信。 Socket 意思是 “插头”,实际上服务器就像一个大插排,上面有很多插头,客户端就像一个一个的插头,每个线程表示一条电源线,客户端将电线插头插到服务器插排的插座上,就可以开始通信了。

    表示方法:Socket =(IP地址:端口号)

    每一个传输层连接唯一地被通信两端的两个端点(即两个套接字)所确定。例如:如果 IP 地址是 210.37.145.1,而端口号是 23,那么得到套接字就是(210.37.145.1:23)

    套接字类型:

    流套接字(SOCK_STREAM):流套接字用于提供面向连接、可靠的数据传输服务。该服务将保证数据能够实现无差错、无重复送,并按顺序接收。流套接字之所以能够实现可靠的数据服务,原因在于其使用了传输控制协议,即TCP(The Transmission Control Protocol)协议。数据报套接字(SOCK_DGRAM):数据报套接字提供一种无连接的服务。该服务并不能保证数据传输的可靠性,数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP( User DatagramProtocol)协议进行数据的传输。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。原始套接字(SOCK_RAW):原始套接字与标准套接字(标准套接字指流套接字和数据报套接字)的区别在于:原始套接字可以读写内核没有处理的IP数据包,而流套接字只能读取 TCP 协议的数据,数据报套接字只能读取 UDP 协议的数据。因此,如果要访问其他协议发送的数据必须使用原始套接。

    工作流程:要通过互联网进行通信,至少需要一对套接字,其中一个运行于客户端(Client Socket),另一个运行于服务器端(Server Socket)。根据连接启动的方式以及本地套接字要连接的目标,套接字之间的连接过程可以分为三个步骤:

    服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态。客户端请求:客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端接字提出连接请求。连接确认:当服务器端套接字监听到客户端套接字的连接请求,就会响应客户端套接字的请求,建立一个新的线程,并把服务器端套接字的描述发送给客户端。一旦客户端确认了此描述,连接就建立好了。而服务器端套接字继续处于监听状态,接收其他客户端套接字的连接请求。

    套接字调用分类:根据套接字的不同类型,可以将套接字调用分为面向连接服务和无连接服务。

    面向连接服务的主要特点:1)数据传输过程必须经过建立连接、维护连接和释放连接3个阶段;2)在传输过程中,各分组不需要携带目的主机的地址;3)可靠性好,但由于协议复杂,通信效率不高。面向无连接服务的主要特点:1)不需要连接的各个阶段;2)每个分组都携带完整的目的主机地址,在系统中独立传送;3)由于没有顺序控制,所以接收方的分组可能出现乱序、重复和丢失现象;4)通信效率高,但可靠性不能确保。

    9. 线程安全

    线程安全不是指线程的安全,而是指内存的安全。线程安全问题都是由全局变量及静态变量引起的。多线程之所以不安全,是因为共享着资源,如果没有资源变量共享,那么多线程一定是安全的。

    目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的。而在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。

    在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为放进去的数据,可能被别的线程“破坏”。

    线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时进行保护,其他线程不能进行访问,直到该线程读取完其他线程才可使用。不会出现数据不一致或者数据污染。

    当多个线程访问某个方法时,不管通过怎样的调用方式、或者说这些线程如何交替地执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以说这个类是线程安全的。

    线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。

    9.1 实现线程安全的方式

    数据私有(位置隔离),多副本(数据隔离),只读(只读标记),锁(加锁标记)和 CAS。

    1. 数据私有:通过改变数据的位置保证线程安全(位置隔离)

    为每个线程分配属于它自己的内存空间,通常称为 栈内存,其它线程无权访问。如果一些数据只有某个线程会使用,其它线程不能操作也不需要操作,这些数据就可以放入线程的栈内存中。较为常见的就是 局部变量,局部变量会在每个线程的栈内存中都分配一份,线程间互不影响。

    局部变量虽然达到了安全的目的,但是数据的使用范围也受到限制。

    2. 多副本:数据隔离

    为了多个方法都能使用某个变量,此时将位于方法内部的局部变量变为 类的成员变量。类的成员变量不能分配在线程的栈内存中,应分配在公共的堆内存中。数据位于公共区域,就会存在潜在的安全风险。

    为了让公共区域堆内存的数据对于每个线程都是安全的,就让 每个线程都拷贝一份数据,各线程只处理自己的这一份拷贝数据而不要影响其他线程的数据,即 ThreadLocal 类。

    class StudentAssistant { ThreadLocal<String> realName = new ThreadLocal<>(); ThreadLocal<Double> totalScore = new ThreadLocal<>();

    对于 ThreadLocal 类型的成员变量,每个线程在运行时都会拷贝一份存储到自己的本地。其实就是将堆内存的数据复制 N 份(仍在存储在公共区域堆内存里),每个线程认领一份,同时规定互相不能影响对方数据。

    “线程本地” 是从逻辑从属关系来说的,意思是这 N 份数据和线程一一对应,但实际的存储位置仍在公共区域堆内存中。

    3. 只读:只读标记

    常量 或 只读变量,都是只能读取,不能修改,对于多线程是安全的。

    比如 Java 中用 final 关键字声明变量,就不可以再改变该变量的值。通常 final 定义的变量为常量:

    // 常量 final double PI=3.14; // 全局常量,用public static final修饰 public static final double PI = 3.14;

    4. 锁:加锁标记

    如果公共区域(堆内存)的数据会被多个线程操作,为了确保数据的安全(或一致)性,需要在数据旁边放一把锁,要想操作数据就需要先获取锁。

    1)Lock:使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。

    public void testLock () { lock.lock(); try{ int j = i; i = j + 1; } finally { lock.unlock(); } }

    tryLock() 与 Lock() 的区别:

    Lock 获取锁时,如果拿不到锁,就一直处于等待状态,直到拿到锁tryLock 有一个 Boolean 返回值,如果没有拿到锁,直接返回 false 停止等待,它不会像Lock 那样去一直等待获取锁

    2)synchronized 关键字:用于控制线程同步的,保证在多线程环境下,代码不被多个线程同时执行,确保我们数据的完整性。当 synchronized 锁住一个对象之后,别的线程如果想要获取锁对象,那么就必须等这个线程执行完释放锁对象之后才可以,否则一直处于等待状态。

    public void testLock () { synchronized (anyObject){ int j = i; i = j + 1; } }

    使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的Class对象;使用静态代码块时,锁住的是synchronized关键字后面括号内的对象。下面是同步代码块示例。

    无论使用锁还是 synchronized,本质都是一样,通过锁来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法。

    5. CAS(Compare And Swap)

    前一种锁在操作数据前必须获取锁,这实际上属于一种悲观锁,即假设我的数据一定会被意外修改,所以每次操作前都要加锁。

    CAS 属于一种乐观锁,基本思想是线程离开时记录当前数据的状态(值),继续执行时比较记录值和当前值,如果一致说明数据没被改动,可以接着执行;如果不一致则直接从头重新开始处理。乐观锁持乐观态度,就是假设我的数据不会被意外修改,如果修改了,就放弃并从头再来。

    CAS 适合并发量不高的情况,也就是数据被意外修改的可能性比较小的情况,如果并发量很高,数据一定会被修改,每次都要放弃,然后从头再来,这样反而花费的代价更大了,不如直接加锁。


    参考资料: CS-Notes 进程与线程 操作系统4————进程同步 进程的同步、互斥、通信的区别,进程与线程同步的区别 5个步骤,教你瞬间明白线程和线程安全 Peterson算法的简单分析

    Processed: 0.016, SQL: 9