理解获取-释放语义

  • 课程介绍:《Multiprocessor Synchronization》本科课程从理论到实践进展清晰,从共识数理论到原子操作、同步原语和无锁数据结构,以 Java 为例讲解。后将所学知识转化为 C 时,才明白课程用 Java 教学的原因,Java 能帮助写出正确的多线程代码,如 volatile 的作用等。
  • 内存工作原理误区:人们常以为代码像调试时那样按行执行,变量在内存中即时变化,这是线性化的理论框架。但实际计算机内存并非线性化,如在构建无锁 FIFO 队列时,CPU 读写内存通过缓存,可能导致指令重排序,从而引发问题。
  • 队列中的问题及解决:在无锁单生产者单消费者环形队列的实现中存在一个 bug,poll()有时会在没有元素时返回成功。这是因为 CPU 可能在写入队列元素前就提前更新了尾指针,导致消费者误判有元素。通过添加 fence() 可以解决这个问题,它能阻止 CPU 重排序代码,确保内存操作的顺序。但又引入了新的问题,即消费者在读取元素前也要确保尾指针已更新,需要在 poll() 中添加 fence()
  • 内存顺序与 fence:问题的本质是内存顺序,在将元素放入队列时需要更新两个变量,顺序很重要,而实际 CPU 可能会打乱代码顺序,所以需要用 fence 来告诉 CPU 顺序很重要,不能重排序。不同 CPU 表达 fence 的方式不同,它就像高速公路上的里程碑,能阻止指令跨越。
  • 单所有权模式:大多数多线程代码中内存由单个线程拥有,即使线程共享内存,也通常是轮流使用。锁就是一种常见的所有权转移方式,而队列也能实现类似的所有权转移。这引出了“两阶段转移”模式,进而引出“获取”(acquire)和“释放”(release)操作。
  • 获取和释放机制:抽象来看,线程释放共享内存所有权,另一个线程异步获取,这两个操作必须序列化,分别称为获取和释放操作,且需作用于同一变量并为原子操作。对于队列,tail 就是同步变量,生产者更新尾指针是释放,消费者验证尾指针更新是获取。对于锁,通常用一个 state 变量表示锁的状态,清除锁位是释放,设置锁位是获取。
  • 获取和释放语义:这次不仅要考虑内部工作,还要考虑调用代码对共享内存的操作。当前队列实现中 fence() 有全局影响,但可以用更灵活的半 fence,即写释放(write-release)保证前面的代码完成后再进行释放写,读获取(read-acquire)保证后面的代码在获取读后开始。这就是获取和释放语义,可通过 stdatomic API 实现,如给 tail 变量的访问添加不同语义。
  • 补充说明:编译器在优化代码时也会影响内存顺序,fence() 是一种近似的概念,stdatomicatomic_thread_fence 方法要谨慎使用。还有比获取和释放语义更强的顺序保证,如顺序一致(sequentially consistent)语义,但性能更差。在 Intel 和 AMD 处理器上,内存操作按程序顺序执行,无需 fence 等,但仍需注意存储缓冲。总之,使用获取和释放语义时要注意 CPU 架构,以免出现问题。
阅读 9
0 条评论