条件变量
条件变量是一种同步机制,用于线程间的通信。当某些执行状态不满足时,线程可以将自己加入到等待队列中,直到该条件发生并被唤醒。条件变量有两个主要操作
- wait():线程调用此函数进入睡眠状态,等待某个条件的发生。
- signal():线程调用此函数通知等待的线程,某个条件已经发生。
考虑以下程序:
|
|
有几点值得注意:
- 为什么
cond_wait(&c,&m)需要获取锁:
因为 wait 将导致线程睡眠,wait 需要负责处理锁的释放。当等待的条件满足并被唤醒后,wait 会重新获取锁 m,确保线程在继续执行前持有锁。
- 为什么需要一个
done变量:
假设没有 done 变量,程序变成这样:
|
|
如果 main 创建子线程后。子线程立刻运行并执行 signal。此时没有等待唤醒的线程,然后子线程退出,main 进入 join 函数后将永远等待信号的唤醒。
- 为什么使用 wait&join 需要持有锁:
假设不使用锁保护信号的发出和获取,当 main 进入睡眠前的瞬间,子线程发出了信号并发现等待睡眠的队列为空,然后 main 将持续睡眠,再也没有信号将其唤醒。
为了保证安全,在调用 wait&join 时务必持有锁。
生产者和消费者
生产者和消费者模型描述了两类线程或进程:一类线程或进程持续向缓冲区写入数据(生产者),另一类线程或进程持续从缓冲区读取数据(消费者)。
现在可以思考另一个问题:为什么需要使用循环来检查变量而不是 if?
|
|
我们假设有多个消费线程(Tc1,Tc2):
假设 Tc1 检查到缓冲区为空并进入等待状态,Tp 放入数据并发出信号。问题的关键在这:在 Tc1 被唤醒前,Tc2 刚好开始消费数据并清空了缓冲区,接着 Tc1 才被唤醒并开始消费数据。但此时缓冲区已经为空,Tc1可能会崩溃。
又或者条件变量控制了多个等待中的线程,此时因为调度策略而先被唤醒的线程将消费数据,后被唤醒的线程将面对一个空的缓冲区,这种情况被称为虚假唤醒(Spurious Wakeups)。
sequenceDiagram
participant Tc1 as 消费者Tc1
participant Tp as 生产者Tp
participant Tc2 as 消费者Tc2
participant Buffer as 缓冲区
Tc1->>Buffer: 检查缓冲区(空)
Tc1->>Buffer: 进入等待状态(cond_wait)
Tp->>Buffer: 获取锁,写入数据(count=1)
Tp->>Buffer: 发送信号(cond_signal)
Tp->>Buffer: 释放锁
Tc2->>Buffer: 获取锁,检查缓冲区(满)
Tc2->>Buffer: 消费数据(count=0)
Tc2->>Buffer: 释放锁
Tc1->>Buffer: 被唤醒,重新获取锁
Tc1->>Buffer: 检查缓冲区(空)
Tc1-->>Buffer: 尝试消费空缓冲区(崩溃风险)
因此,使用 while 以确保在被唤醒后再次检查是十分有必要的。在此提出一个编码习惯:
在条件变量同步中,必须使用 while 循环而非 if 语句来检查条件。原因如下:
- 虚假唤醒(Spurious Wakeups):某些线程库(如POSIX)允许条件变量在未被显式唤醒时因系统原因返回,即使条件未满足。
- 状态竞态(State Race):线程被唤醒后,共享状态可能已被其他线程修改(例如,生产者唤醒消费者,但数据已被其他消费者抢先消费)。
- Mesa语义特性:条件变量的唤醒仅表示状态可能发生了变化,而非绝对满足条件。
总是使用循环,还可以为广播信号做好准备。
当多个线程等待不同条件时(如内存不足),广播(pthread_cond_broadcast) 是确保正确性的关键:
- 场景示例:
- 线程A申请100字节,线程B申请50字节,均因内存不足等待。
- 线程C归还50字节后,若使用
pthread_cond_signal,可能仅唤醒一个线程(如A),但A仍需100字节(仍不满足),导致线程B无法被唤醒。
- 解决方案:通过广播唤醒所有等待线程,每个线程通过
while循环重新检查自身条件。
同步
同步的本质是通过happens-before关系控制事件的顺序,确保多个进程或线程的协调执行。例如,代码中隐含的依赖关系(如 c = a + b 中 b 必须在 c 之前执行)需要通过同步原语(如锁、条件变量)显式管理,以避免竞态条件。
同步问题可抽象为以下两类:
- 原子性控制:通过互斥锁确保临界区代码的独占访问,防止数据不一致。
- 条件等待:利用条件变量实现线程在特定条件未满足时休眠,并在条件满足时被唤醒。
条件等待的万能框架:
|
|
在解决同步问题时,关键在于理解 “同步成功” 的条件是什么。然后各个线程在条件不满足时等待,直到条件满足方可继续。
首先,代码天然存在一个执行顺序和依赖关系:
|
|
这个简单的程序里 c 必须要在 b 之后执行。但实际任务中,往往存在可以并行的部分。如果将每一个任务抽象为一个节点或函数,绘制依赖图,基本上呈现一个树状结构,从根节点开始,一层一层向下扩展,层与层之间存在依赖,但每一层内部往往可以并行处理。
graph TD
subgraph 层级1
A[根任务]
end
subgraph 层级2
B1[子任务B1]
B2[子任务B2]
B3[子任务B3]
end
subgraph 层级3
C1[子任务C1]
C2[子任务C2]
C3[子任务C3]
C4[子任务C4]
end
subgraph 层级4
D1[叶子任务D1]
D2[叶子任务D2]
end
A --> B1
A --> B2
A --> B3
B1 --> C1
B1 --> C2
B2 --> C3
B3 --> C4
C1 --> D1
C2 --> D1
C3 --> D2
C4 --> D2