Lec 24 并发与同步
我们已经学习OS通过时间片轮转让多个进程共享CPU,现在进一步.在单个进程内,划分多个线程,由于现代CPU是多核的,我们系统尽可能并行执行多个线程,为了能够正确地配合执行任务,必须同步(Synchronization)通信
同步
我们可以将计算任务分配给多线程执行。
- 多个独立(independent)的顺序线程,它们竞争共享资源
- 多个协作(cooperating)的顺序线程,他们相互通信
同步模型有两种:
- 基于共享内存模型(shared memory),所有线程共享同一地址空间,通过写入某个内存地址,另外一个线程读取该地址即可通信
- 优点是实现简单,就是load/store操作
- 缺点是容易踩脚,数据竞争或冲突。
- 基于消息传递模型(Message Passing),各线程地址空间不同,需发送/接收显式消息。
- 优点:不容易踩脚
- 缺点:通信开销大,实现复杂。
每当系统中存在并行进程时,就需要同步,
- fork-join :并行进程可能需要等待多个事件发生
- 生产者-消费者:消费者进程必须等到生产者进程生成数据
- 互斥:操作系统必须确保资源在给定时间内仅由一个进程使用

IMPORTANT
线程安全(Thread-safe),是指并行程序的输出和在单处理器上某一个串行执行结果一致,换句话说,即使并发执行,结果也可预测、正确。
进程之间如何通信?
Solution: 大致有几种:
- 共享内存。
- 同步指令(需要硬件支持)。比如锁、信号量、原子操作
- 系统调用
引例:有界缓冲区问题
单字符缓冲区
有两个线程:Producer(生产者):执行一系列操作,生成一个字符 c,然后发送给消费者;Consumer(消费者):接收字符 c,然后执行一系列操作。

每个线程内部是顺序执行的,但跨线程之间没有同步机制,可能出现以下问题:消费者在数据还没被生产时就尝试读取;生产者在数据还没被消费完就覆盖已有数据。
我们使用符号 ≺("precede")来表示先后约束
- 约束1:先生产再消费:
Send(i) ≺ Receive(i),即生产者必须先发送第i个字符,消费者才能接收。 - 约束2:不能覆盖未消费的旧数据:
Receive(i) ≺ Send(i+1),即生产者在发送第i+1个字符前,消费者必须完成对第i个字符的接收。
FIFO缓冲区

使用 FIFO 缓冲区放松约束。使用大小为 n 的 FIFO 缓冲区:允许生产者最多领先消费者 n 步;新约束变为Receive(i) ≺ Send(i+n) 。在生产者发送第 i+n 个字符前,消费者必须接收完第 i 个字符。
通常来说,会把这个buffer实现成环形缓冲区(Ring buffer),原理缓冲区收尾相连,写满后从头写,用两个指针:in:生产者写的位置; out:消费者读的位置。
示例:
假设缓冲区大小为 3,初始时 in == out == 0:
- 生产者写入
c0→in = 1; - 写入
c1→in = 2; - 写入
c2→in = 0(回绕); - 缓冲区满了,必须等待消费者读取至少一个;
- 消费者读取
c0→out = 1,此时缓冲区腾出一个位置; - 生产者继续写入下一个字符到位置
0。 
// Shared memory
char buf[N]; // The buffer
int in = 0, out = 0;
// Producer
void send(char c) {
buf[in] = c;
in = (in + 1) % N;
}
// Consumer
char rcv() {
char c;
c = buf[out];
out = (out + 1) % N;
return c;
}代码有什么问题? 不能保证precedence限制,比如rcv()均可能在任何send()之前调用。我们将会更改这个代码,满足这些约束条件,为此我们将引入一种新的编程结构——信号量(Semaphores),用于实现适当的进程间同步。
信号量
Dijkstra于1962年提出,信号量(Semaphores)是特殊整型变量,始终
semaphore s = K; // initialize s to K信号量操作
wait(semaphore s):
wait until s > 0 # 如果 s == 0,则阻塞
s = s - 1 # 获取资源(或占用一个槽)signal(semaphore s):
s = s + 1 # 释放资源(或归还一个槽)语义保证: 当信号量初始化为 K,这确保了“最多允许 K 个并发”,或说“最多允许领先 K 步”
抽象
信号量做资源分配,我们可以抽象地理解这个场景。由K个资源组成的资源池,必须保证最多有K个资源被使用。 主要思想就是, 将信号量看成是资源池的剩余资源数,将其作为不变量。生产者消费者的代码改动如下,可以至多有K个消费者占用资源,生产者负责
// shared memory
char buf[N];
int in = 0, out = 0;
samaphore chars = 0; // 当前最多可以有0个消息可以被访问
// producer
void send(char c) {
buf[in] = c;
in = (in + 1) % N;
signal(chars);
}
// consumer
char recv() {
char c;
wait(chars);
c = buf[out];
out = (out + 1) % N;
return c;
}代码有什么问题?
能保证
正确的实现如下
// shared memory
char buf[N];
int in = 0, out = 0;
samaphore chars = 0; // 当前最多可以有0个消息可以被访问
samaphore spaces = N; // 当前最多可以有K个空间可以防止消息
// producer
void send(char c) {
wait(spaces);
buf[in] = c;
in = (in + 1) % N;
signal(chars); //
}
// consumer
char recv() {
char c;
wait(chars);
c = buf[out];
out = (out + 1) % N;
singal(spaces);
return c;
}对于单个生产者和消费者, 用信号量管理的资源:字符、空间都采用FIFO机制。 但如果是多个生产者和消费者呢? 将会遇到互斥的问题
互斥
编写并发程序时,尤其是在修改共享数据时,对于某些代码段(称为临界区 critical sections), 我们希望确保任何两次执行都不会重叠,这种约束称为互斥(mutual exclusion)
解决方案:将临界区嵌入包装器(例如“事务”)中,以保证其原子性,即,使它们看起来像是单个、瞬时的操作。
下面是用信号量也可以实现互斥的例子
semaphore mutex = 1;
void debit(int amount) {
wait(mutex); // wait for exclusive access
int bal = account.balance;
bal = bal - amount;
account.balance = bal;
signal(mutex); // 离开临界区(解锁)
}锁控制了临界区的使用。使用锁,需要考虑粒度大小,比如
- 所有账户共用一把锁?
- 为每个账户分配一把?
- 还是以004解决的账户共用一把锁?
如果我们考虑多消费者、多生产者的模型,就需要考虑buffer的临界区问题了,因此我们需要再一次改动前面的代码
// shared memory
char buf[N];
int in = 0, out = 0;
samaphore chars = 0;
samaphore spaces = N;
samaphore lock = 1; // 只有一个能访问
// producer
void send(char c) {
wait(spaces);
wait(lock);
buf[in] = c;
in = (in + 1) % N;
singal(lock);
signal(chars);
}
// consumer
char recv() {
char c;
wait(chars);
wait(lock);
c = buf[out];
out = (out + 1) % N;
signal(lock);
singal(spaces);
return c;
}综上我们发现信号量的强大,我们仅仅同个一个原语就能能保证互斥和先后关系。先后关系有
实现
信号量本身是共享数据,实现wait 和 signal操作需要 读/修改/写 三步骤,这些序列必须作为临界区执行。那么,如何在不使用信号量的情况下保证这个临界区的互斥呢?
实现方式是:
- 使用特殊的原子指令(比如,Test-and-Set指令),由硬件直接支持,这是最常见的方法。
- 使用系统调用实现他们。 仅适用于单核处理器,其中内核不可中断。
示例: 基于TAS实现锁
bool lock = false;
void acquire_lock() {
while (test_and_set(lock));
}
void release_lock() {
lock = false;
}同步的阴暗面: 死锁
死锁问题
示例,A给B转账,但B又同时给A转账。
void transfer(int account1, int account2, int amount) {
wait(lock[account1]);
wait(lock[account2]);
balance[account1] = balance[account1] - amount;
balance[account2] = balance[account2] + amount;
signal(lock[account2]);
signal(lock[account1]);
}
Thread 1: wait(lock[6031]);
Thread 2: wait(lock[6004]);
Thread 1: wait(lock[6004]); // cannot complete
// until thread 2 signals
Thread 2: wait(lock[6031]); // cannot complete
// until thread 1 signals
No thread can make progress a Deadloc死锁的四个必要条件
- 互斥(Mutual Exclusion),每个资源(如筷子)一次只能由一个线程(哲学家)占有。
- 保持并等待(Hold and Wait),线程持有一部分资源,同时等待另一部分。
- 非抢占(No Preemption),资源不能被强行回收, 只能由占有者显式释放
- 循环等待(Circular Wait)