Skip to content

条件变量与并发同步

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],用filluse指针管理插入和取出位置,count记录槽位占用数。
    • 条件:生产者等待count==MAX(满),消费者等待count==0(空)。
    • 优点
    • 支持多槽缓冲区,减少上下文切换。
    • 允许多个生产者和消费者并发操作,提升性能。
    • 实现细节
    • put:将数据插入fill位置,更新fillcount
    • get:从use位置取出数据,更新usecount

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语义和假唤醒要求开发者谨慎设计,避免竞态条件和死锁。结合实践和参考资料,可进一步掌握条件变量在复杂并发场景中的应用。