条件变量与并发同步
1. 核心问题:如何等待一个条件?
- 背景:在多线程程序中,线程常需等待特定条件满足后继续执行(如父线程等待子线程完成)。
- 问题:
- 自旋等待:通过循环检查条件,效率低下,浪费CPU资源。
- 需求:线程应休眠直到条件满足,避免忙等待。
- 解决方案:使用条件变量,允许线程在条件不满足时休眠,并在条件满足时被唤醒。
- 关键点:
- 条件变量提供显式队列,线程可加入队列等待条件。
- 其他线程改变状态时,通过信号唤醒等待线程。
- 条件变量与互斥锁配合使用,确保状态检查和等待的原子性。
2. 条件变量的定义与操作
- 定义:
- 声明:
pthread_cond_t c;
(需初始化)。 - 条件变量表示线程等待的条件队列,与共享状态变量(如
done
)结合使用。 - 核心操作:
pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m)
:- 线程休眠,等待条件变量
c
上的信号。 - 需持有互斥锁
m
,调用时原子地释放锁并休眠,唤醒后重新获取锁。
- 线程休眠,等待条件变量
pthread_cond_signal(pthread_cond_t *c)
:- 唤醒至少一个等待在条件变量
c
上的线程。
- 唤醒至少一个等待在条件变量
pthread_cond_broadcast(pthread_cond_t *c)
:- 唤醒所有等待在条件变量
c
上的线程(用于覆盖条件)。
- 唤醒所有等待在条件变量
- 语义:
- Mesa语义:信号仅唤醒线程,不保证条件仍然满足,需重新检查。
- Hoare语义:唤醒线程立即执行,条件保证满足,但实现复杂,实际少用。
- 使用规则:
- 总是使用while循环检查条件:因Mesa语义和假唤醒(spurious wakeup),需在唤醒后重新验证条件。
- 信号和等待时持有锁:确保状态修改和唤醒操作的原子性,避免竞态条件。
3. 父线程等待子线程(Join)
- 问题:父线程需等待子线程完成。
- 自旋方案:
- 使用共享变量
done
,父线程循环检查。 - 问题:自旋浪费CPU,效率低。
- 条件变量方案:
- 结构:使用互斥锁
m
、条件变量c
和状态变量done
。 - 流程:
- 子线程完成时,设置
done=1
,通过thr_exit()
发出信号(pthread_cond_signal
)。 - 父线程在
thr_join()
中检查done
,若为0则调用pthread_cond_wait
休眠。
- 子线程完成时,设置
- 优点:父线程休眠而非自旋,节省CPU。
- 关键点:
- 状态变量
done
记录子线程完成状态,防止信号丢失(无等待线程时)。 - 互斥锁保护状态检查和等待操作,避免竞态条件。
- 状态变量
- 错误示例:
- 无状态变量:
- 若子线程先发出信号而无线程等待,父线程可能永久休眠。
- 教训:状态变量记录条件,信号仅提示状态变化。
- 无锁信号:
- 若不加锁,父线程检查
done
后可能被中断,子线程修改状态并发出信号,导致父线程错过信号。 - 教训:信号和等待需持有锁,确保原子性。
- 若不加锁,父线程检查
4. 生产者/消费者(有界缓冲区)
- 问题:生产者线程将数据放入共享缓冲区,消费者线程从中取出数据。
- 场景:
- 网络服务器:生产者放入HTTP请求,消费者处理。
- UNIX管道:如
grep | wc
,内核缓冲区连接生产者和消费者。 - 初始实现:
- 缓冲区:单一整数
buffer
,用count
标记空(0)或满(1)。 - put/get:生产者调用
put
放入数据,消费者调用get
取出数据。 - 问题:无同步机制,存在数据竞争。
- 条件变量实现:
- 错误版本1(单条件变量+if):
- 使用单一条件变量
cond
和互斥锁mutex
。 - 生产者等待
count==0
(空),消费者等待count==1
(满)。 - 问题:多个消费者时,Mesa语义导致唤醒线程可能发现条件不满足。
- 原因:使用
if
检查条件,唤醒后未重新验证,导致消费者访问空缓冲区。
- 使用单一条件变量
- 改进版本1(单条件变量+while):
- 将
if
改为while
,唤醒后重新检查条件。 - 问题:仍使用单一条件变量,消费者可能唤醒另一个消费者而非生产者,导致所有线程休眠。
- 将
- 改进版本2(双条件变量+while):
- 使用两个条件变量:
empty
(生产者等待)、fill
(消费者等待)。 - 生产者信号
fill
,消费者信号empty
,避免错误唤醒。 - 优点:正确处理多线程场景,确保生产者唤醒消费者,反之亦然。
- 使用两个条件变量:
- 最终版本(多槽缓冲区):
- 缓冲区:循环数组
buffer[MAX]
,用fill
和use
指针管理插入和取出位置,count
记录槽位占用数。 - 条件:生产者等待
count==MAX
(满),消费者等待count==0
(空)。 - 优点:
- 支持多槽缓冲区,减少上下文切换。
- 允许多个生产者和消费者并发操作,提升性能。
- 实现细节:
put
:将数据插入fill
位置,更新fill
和count
。get
:从use
位置取出数据,更新use
和count
。
- 缓冲区:循环数组
5. 覆盖条件(内存分配)
- 问题:多线程内存分配库,线程等待足够内存释放。
- 实现:
- 结构:记录可用内存
bytesLeft
,配有互斥锁m
和条件变量c
。 - 分配:线程调用
allocate(size)
,若bytesLeft<size
,等待。 - 释放:线程调用
free(ptr, size)
,增加bytesLeft
,发出信号。 - 问题:
- 单线程唤醒(
pthread_cond_signal
)可能唤醒不合适的线程(如需更多内存的线程)。 - 例:线程Ta需100字节,Tb需10字节,释放50字节可能唤醒Ta而非Tb,导致Ta继续休眠。
- 解决方案:
- 使用
pthread_cond_broadcast
唤醒所有等待线程。 - 覆盖条件:广播确保所有可能满足条件的线程被唤醒。
- 缺点:可能唤醒不需要的线程,增加上下文切换开销。
- 适用场景:
- 当无法精确确定应唤醒的线程时,广播是保守且有效的选择。
- 若广播导致性能问题,需检查程序设计是否可优化(如使用多个条件变量)。
6. 通用建议
- 条件变量使用原则:
- 使用while循环:因Mesa语义和假唤醒,总是重新检查条件。
- 信号和等待时持有锁:确保状态修改和唤醒的原子性。
- 使用状态变量:记录条件状态,防止信号丢失。
- 多条件变量:为不同类型的等待线程分配专用条件变量,避免错误唤醒。
- 避免常见错误:
- 不检查状态变量:可能导致线程永久休眠。
- 无锁信号:可能引发竞态条件。
- 单一条件变量:多线程场景下可能唤醒错误线程。
- 性能优化:
- 多槽缓冲区:减少上下文切换,支持并发操作。
- 广播 vs 单信号:广播适用于覆盖条件,但需评估性能开销。
总结
条件变量是多线程编程中解决等待条件问题的重要工具,通过与互斥锁和状态变量配合,确保线程在条件不满足时休眠,并在条件满足时被唤醒。父线程等待子线程、生产者/消费者和内存分配等示例展示了条件变量的典型应用。关键设计点包括使用while循环检查条件、信号和等待时持有锁、必要时使用多条件变量或广播。Mesa语义和假唤醒要求开发者谨慎设计,避免竞态条件和死锁。结合实践和参考资料,可进一步掌握条件变量在复杂并发场景中的应用。