目录
进程同步 Process Synchronization (PS)
-
定义
- 一种机制,任务是:协调进程的执行,以确保在任何时刻,没有两个进程可以同时访问相同的共享数据和资源。
-
问题
- 数据不一致:多个进程对共享数据的并发访问可能会导致这一点。
- 竞态条件(Race Condition): 是一种特殊的数据不一致性情况。多个进程并发地访问和修改共享数据,最终数据的值取决于哪个进程最后完成。
-
对应方案
- 维护数据一致性:需要机制来确保协作进程的有序执行。
- 防止竞态条件的出现:并发进程必须同步
临界区 Critical-Section
-
定义
- 一种代码段。在这段代码中,进程会访问共享资源(例如共享变量、文件等)。
-
代码形式
do { entry section critical section - CS exit section remainder section – RS } while(TRUE)
-
流行度
- 每个并发线程 / 进程都有。
-
特性
- 不能在多个线程 / 进程之间交错执行:
- 含义:同一时刻,只能有一个进程在执行临界区内的代码。
- 目的:避免竞态条件。
- 解释:
- Critical-Section 以“读取-更新-写入”的方式引用一个变量;
- 如果临界区允许交错执行,就会出现:
- 如此,只能禁止交错执行:
- 即使我们失去了一些效率,但我们获得了正确性。
- 不能在多个线程 / 进程之间交错执行:
临界区问题的解决方案(简介)
-
原则(三个)
- 互斥 Mutual Exclusion:一次只能有一个进程 / 线程进入临界区
- 进展 Progress:如果没有进程处于临界区,并且一些进程希望进入临界区,那么选择下一个进入临界区的进程不能被无限期推迟。
- 直观理解:需要有“进展”。不能“互相谦让”,导致谁都进不去。
- 有限等待 Bounded Waiting:任何进程 / 线程都不应无限期等待进入临界区。
- 目的:否则进程 / 线程可能会遭受“饥饿”
- 解释:
- “等待进入”:一种状态。即发起请求后,实际进入前。
- “饥饿”(Starvation)现象:即一个进程一直得不到所需的资源。
-
各种解决方案简介(按类型分)
- 软件解决方案:仅通过软件算法来实现进程同步,不依赖于特殊的硬件指令。
- 硬件解决方案:依赖于特殊的机器指令进行“锁定”(lock)
- 操作系统和编程语言提供的解决方案:
- 示例:Java 中有特定的函数和数据结构可以用于同步。
临界区问题的解决方案(详解)
-
软件解决方案(Peterson算法)
- 用途: 实现互斥(Mutual Exclusion),允许多个进程共享一个资源,而不会发生冲突。
- 特点:
- 仅使用共享内存进行通信。
- 最初的 Peterson算法 只适用于两个进程。
- 核心思想:设计临界区的“进入区”和“退出区”代码。
- 具体内容:进程共享一些公共变量以同步它们的操作。
turn
: 一个整数,表示轮到哪个进程进入临界区。turn = 0
表示轮到进程P0。turn = 1
表示轮到进程P1。flag[2]
: 一个布尔数组,flag[i]
表示进程Pi是否想要进入临界区。flag[i] = true
表示进程Pi想要进入临界区。flag[i] = false
表示进程Pi不想进入临界区。- 需要同时使用 turn 和 flag[2] 来保证互斥、进展 和 有限等待。
- 代码:
do { flag[i] = TRUE; turn = j; while ( flag[j] && turn == j); CRITICAL SECTION flag[i] = FALSE; REMAINDER SECTION } while (TRUE);
i
: 当前进程的编号(0或1)。j
: 另一个进程的编号(如果i
是0,则j
是1;如果i
是1,则j
是0)。- 代码解释:
flag[i] = TRUE;
: 进程Pi将自己的flag
设置为true
,表示它想要进入临界区。turn = j;
: 进程Pi将turn
设置为j
,表示“谦让”,让另一个进程优先进入临界区。while ( flag[j] && turn == j);
: 这是 Peterson算法 的关键。进程Pi 会在这里等待,直到以下两个条件之一成立:flag[j] == false
:另一个进程不想进入临界区。turn == i
:轮到进程Pi进入临界区。
CRITICAL SECTION
: 进程Pi 进入临界区,执行访问共享资源的代码。flag[i] = FALSE;
: 进程Pi 将自己的flag
设置为false
,表示它已经离开了临界区。REMAINDER SECTION
: 进程Pi 执行其他非临界区代码。
- 方案的正确性:
- 正确,因为三个原则都得到了遵守。
-
硬件解决方案
-
使用“锁”
- 流程:在进入临界区之前尝试获取需要访问的共享资源的锁,如果成功获取锁,则进入临界区;离开临界区时释放该锁。
-
禁用中断
- 方案前提:单处理器环境
- 别名:TEST AND SET SOLUTION
- 流程:
- 初始时,锁值设置为 0。
- 锁值 = 0:表示临界区当前为空,没有进程在其中。
- 锁值 = 1:表示临界区当前被占用,有一个进程在其中。
- 优点:
- 实现起来简单
- 缺点:
- 不完全“正确”:
- 可以满足“互斥”,但是不能保证“有限等待”:
- 可能会出现一个进程一直获取不到锁的情况。
- 可以满足“互斥”,但是不能保证“有限等待”:
- 不完全“正确”:
-
COMPARE AND SWAP SOLUTION
- 方案前提:多处理器环境
- 解释:
- 因为多处理器环境可以提供特殊的原子硬件指令。
- “原子”和数据库中的原子性 (Atomicity) 的含义一致。
- 流程:
- 全局变量 lock 被初始化为 0。
- 唯一可以进入 CS 的 进程Pi 是“找到” lock = 0 的那个;
- 这个 Pi 通过将 lock 设置为 1 来排除所有其他 Pj。
- 解释:
- “找到”与设置:使用 CAS (Compare And Swap) 指令来做这两件事;
- CAS 指令的功能:
- 读取变量值;
- 比较变量的当前值与 期望值A 是否相等。
- 如果相等,则将变量值更新为 新值B ,并返回 true(表示操作成功)。
- 如果不相等,则不进行任何操作,并返回 false(表示操作失败)。
-
优点(硬件解决方案)
- 适用于任意数量的进程,无论是在单处理器还是多处理器系统上。
- 简单易于验证。
- 可以支持多个临界区。
-
缺点(硬件解决方案)
- 机制有一些缺陷,也有陷入一些困境;
- 忙等待(Busy-waiting): 当一个进程等待进入临界区时,它会不断地检查锁的状态,这会浪费CPU时间。
- 饥饿(Starvation): 可能会出现一个进程长时间等待,始终无法进入临界区。
- 死锁(Deadlock): 可能会出现多个进程互相等待对方释放资源,导致所有进程都无法继续执行的情况。
-
-
操作系统与编程语言解决方案:
-
互斥锁 / 互斥 (Mutex Locks / Mutual exclusion)
- 定义:一种用于获取和释放对象的编程标志 (programming flag)。
- 作用:保证一致性,确保同一时刻只有一个进程可以访问共享资源。
- 操作:
- 加锁(Lock): 进程在进入临界区之前尝试获取互斥锁。如果互斥锁已经被其他进程占用,则当前进程会阻塞(等待)。
- 解锁(Unlock): 进程在离开临界区之后释放互斥锁,以便其他进程可以获取锁。
- 实现:
- 内核级别: 禁用中断
- 目的:防止共享数据结构的损坏
- 软件级别: 忙等待机制
- 流程:进程在无限循环中执行,等待锁变量的值指示可用性
- 内核级别: 禁用中断
- “自旋锁”(Spinlocks)
- 命名由来:进程在等待锁变为可用时会“自旋”(循环检查锁的状态),而不是单纯的阻塞。
- 示例:Peterson算法 中的 `while ( flag[j] && turn == j);`
- 特点:
- 没有阻塞,自然没有上下文切换;
- 只能用于多处理器系统:
- 在单处理器系统中,如果一个进程自旋等待锁,那么持有锁的进程根本没有机会运行,锁永远不会被释放。
- 应用场景:锁被持有的时间预计非常短。
- 预计很短,所以:“自旋”的开销 < 切换上下文的开销
-
信号量 (Semaphore)
- 核心:共享一个简单的非负整数值(信号量);
- 信号量的含义:
- 当前信号量的值指示了还允许多少几个进程进入自己的临界区。
- 示例:如果将信号量初始化为k,则可以允许最多k个进程同时进入临界区。
- 操作(只有三种):
- 初始化(信号量);
- (信号量)减量:
- 操作:
wait()
- 功能:可能阻塞进程;
- 操作:
- (信号量)增量:
- 操作:
signal()
- 功能:可能解除阻塞。
- 操作:
- 使用信号量解决临界区问题:
- 代码:
do { wait(S); CRITICAL SECTION signal(S); RS } while(true)
- 解释:
wait(S);
: 尝试获取信号量。如果信号量的值为0,则进程阻塞,直到其他进程释放信号量。CRITICAL SECTION
: 进程进入临界区,执行访问共享资源的代码。signal(S);
: 释放信号量,允许其他进程进入临界区。RS
: 进程执行其他非临界区代码。
- 代码:
- 信号量的类型:
- 计数信号量(Counting Semaphore / CS):
- 值域: 可以是任意非负整数。
- 用途: 用于控制对具有多个实例的资源的访问。例如,一个打印机池可能有多个打印机,可以用计数信号量来表示可用打印机的数量。
- 初始值: 通常初始化为可用资源的数量。
- 二进制信号量(Binary Semaphore / BS):
- 值域: 只能是 0 或 1。
- 用途: 类似于互斥锁,用于实现多个进程对临界区的互斥访问。
- 初始值: 通常初始化为 1,表示临界区空闲。
- 计数信号量(Counting Semaphore / CS):
- 工作流程:
- 二进制信号量被初始化为 1。
- WAIT() 操作:
- 检查信号量的值:
- 如果值为 0,则执行 wait() 的进程被阻塞。
- 如果值为 1,则将其更改为 0,进程继续执行。
- 检查信号量的值:
- SIGNAL() 操作:
- 检查是否有进程因为等待该信号量而阻塞。
- 即因信号量值等于 0,而在 WAIT() 处被阻塞。
- 如果是这样,则唤醒一个被阻塞的进程。
- 如果没有,那么信号量的值将被设置为1。
- 检查是否有进程因为等待该信号量而阻塞。
- 互斥锁与二进制信号量的区别:
- 互斥锁(Mutex): 哪个进程加的锁,就必须由哪个进程解锁。
- 二进制信号量(Binary Semaphore): 理论上,一个进程可以锁定二进制信号量,而另一个进程可以解锁它。
- 信号量可能导致的问题:
- 饥饿(Starvation);
- 死锁(Deadlock)。
-
经典的同步问题
-
有界缓冲区问题(Bounded-Buffer Problem)/ 生产者-消费者问题(Producer-Consumer Problem)
- 描述: 一组生产者进程生成数据,放入一个缓冲区;一组消费者进程从缓冲区取出数据进行消费。缓冲区的大小是有限的。
- 同步要求:
- 生产者不能在缓冲区满时放入数据。
- 消费者不能在缓冲区空时取出数据。
- 多个生产者或多个消费者不能同时访问缓冲区。
- 工程背景:缓冲区访问控制。
- 解决方案:
- 使用三个信号量;
mutex
信号量: 用于互斥访问缓冲区, 初始化为1.empty
信号量: 记录空闲缓冲区的数量, 初始化为n (缓冲区大小).full
信号量: 记录已填充的缓冲区的数量, 初始化为0.
-
读者-写者问题(Readers-Writers Problem)
- 描述: 多个进程共享一个数据集。一些进程只读取数据(读者),另一些进程需要写入数据(写者)。
- 同步要求:
- 多个读者可以同时读取数据。
- 写者必须独占地访问数据,任何其他读者或写者都不能同时访问。
- 工程背景:一个数据集在多个并发进程之间共享。
- 解决方案:
- 将锁分类,读锁和写锁。
- 获取读写锁时需要指定模式 (读或写)。
-
哲学家就餐问题(Dining-Philosophers Problem)
- 描述: 五个哲学家围坐在一张圆桌旁,每人面前有一盘意大利面,两人之间有一根筷子。哲学家要么思考,要么吃饭。吃饭时,哲学家需要拿起左右两边的筷子。
- 同步要求:
- 避免死锁:所有哲学家都拿起左边的筷子,然后等待右边的筷子,导致所有人都无法吃饭。
- 避免饥饿:一个哲学家可能一直无法拿到两根筷子。
- 工程背景:如何在多个进程之间分配多个资源。
- 可能的解决方案:
- 限制同时饥饿的哲学家数量 (例如, 最多4个).
- 只有当两根筷子都可用时才允许拿起 (在临界区内完成).
- 奇数编号的哲学家先拿左边的筷子, 偶数编号的哲学家先拿右边的筷子.