关于Ticket-taking自旋锁内存序强度及缓存行布局的技术问询
Ticket自旋锁:内存序优化与缓存行设计分析
一、初始实现的内存序是否过强?
初始代码中,加锁函数对ticket的自增使用__ATOMIC_ACQ_REL确实过强,原因如下:
ticket的原子自增仅用于分配唯一票号,不需要与临界区的内存操作建立同步关系:- 原子操作本身已经保证了票号不会重复分配;
- 后续自旋等待
serving时使用的__ATOMIC_ACQUIRE,会与解锁操作的__ATOMIC_RELEASE形成完整的同步链——当线程看到serving等于自己的票号时,Acquire语义会确保之前解锁线程的临界区操作全部可见,同时禁止临界区的读写重排到该加载操作之前。
- 因此,
ticket的自增只需要__ATOMIC_RELAXED内存序即可,Acquire/Release语义在这里是多余的,会带来不必要的内存屏障开销(尤其在弱内存模型的arm64架构上)。
而自旋加载serving使用__ATOMIC_ACQUIRE是完全正确的,这是保证临界区内存可见性的关键。
二、ticket与serving是否需要分隔到不同缓存行?
针对arm64和amd64架构,分场景讨论:
- 低竞争场景:不需要拆分。两个字段总大小仅16字节(u64)或8字节(u32),会被放在同一个64字节缓存行内。此时伪共享的影响可以忽略,紧凑的结构反而更利于缓存命中。
- 高竞争场景:建议拆分到不同缓存行,避免伪共享问题:
- amd64和arm64的缓存一致性协议(MESI/ARMv8的MOESI)下,若两个字段在同一缓存行,
ticket的多线程写操作会频繁使缓存行失效,导致自旋读serving的线程需要反复从内存重新加载,大幅降低性能。 - 拆分方式可以通过填充字节实现,比如:
typedef struct { u32 ticket; u8 pad[60]; // 填充至64字节缓存行 u32 serving; u8 pad2[60]; } ticket_mutex __attribute__((aligned(64)));
- amd64和arm64的缓存一致性协议(MESI/ARMv8的MOESI)下,若两个字段在同一缓存行,
三、优化后的实现分析
针对低竞争场景的更新实现做了几个关键优化:
- 缩小字段类型:将
u64改为u32,减少内存占用,低竞争场景下票号不会溢出; - 内存序精简:
ticket的自增用__ATOMIC_RELAXED,符合票号分配的语义需求; - x86架构优化:自旋循环中加入
pause指令,减少自旋时的CPU功耗,避免内存总线过度占用,同时规避推测执行带来的性能损耗; - 解锁操作调整:先用
__ATOMIC_RELAXED加载serving再加1,最后用__ATOMIC_RELEASE存储——由于只有持有锁的线程会执行解锁,不存在竞争,这种写法和原子自增的效果一致,但更直观。
内容的提问来源于stack exchange,提问作者Harris M Snyder
相关产品推荐
相关产品推荐

