基于自旋锁的单次调用实现:性能与内存序优化问询
多线程单次初始化机制的实现优化与内存序分析
需求背景
要实现一种多线程安全的单次初始化机制,确保初始化逻辑即使被多线程反复调用也仅执行一次,本质是用GCC原子操作和自旋锁模拟pthread_once的功能,目标架构覆盖x86_64和aarch64。预期API使用示例如下:
void gets_called_many_times_from_many_threads(void) { static int my_once_flag = 0; if (once_enter(&my_once_flag)) { // 仅执行一次的初始化逻辑 once_commit(&my_once_flag); } // 依赖初始化完成的后续逻辑 }
初始实现与问题
初始实现代码如下:
int once_enter(int *b) { int zero = 0; int got_lock = __atomic_compare_exchange_n(b, &zero, 1, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED); if (got_lock) return 1; while (2 != __atomic_load_n(b, __ATOMIC_ACQUIRE)) { // x86平台可在此插入pause指令 }; return 0; } void once_commit(int *b) { (void) __atomic_store_n(b, 2, __ATOMIC_RELEASE); }
存在的疑问与问题:
- 对CAS操作使用
__ATOMIC_RELAXED内存序的合理性存疑,担心无法保证初始化逻辑与后续代码的内存可见性。 - x86平台下
lock cmpxchg是全内存屏障,在已完成初始化的常见场景下,每次调用once_enter都会触发这个屏障,带来不必要的性能开销。
更新后的实现与设计思路
为优化常见场景的性能,更新实现采用了双重检查锁模式,并统一使用__ATOMIC_SEQ_CST内存序,代码如下:
#ifdef __x86_64__ #define PAUSE() __asm __volatile("pause") #else #define PAUSE() #endif int once_enter(int *b) { if(2 == __atomic_load_n(b, __ATOMIC_SEQ_CST)) return 0; int zero = 0; int got_lock = __atomic_compare_exchange_n(b, &zero, 1, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST); if (got_lock) return 1; while (2 != __atomic_load_n(b, __ATOMIC_SEQ_CST)) { PAUSE(); }; return 0; } void once_commit(int *b) { (void) __atomic_store_n(b, 2, __ATOMIC_SEQ_CST); }
更新的原因:
- 优先保障已初始化场景的性能:首次读取标记位,若已完成初始化(值为2)则直接返回,避免进入CAS逻辑。
- 观察到GCC在x86上对
__ATOMIC_ACQUIRE的读取不会生成内存栅栏指令,但为了简化跨平台逻辑,直接使用__ATOMIC_SEQ_CST。
实现中的问题分析
内存序冗余,性能浪费
- 在已初始化场景下,x86平台的
__ATOMIC_SEQ_CST读取会生成lock orl $0,(%rsp)这类全屏障指令,而实际上仅需要__ATOMIC_ACQUIRE(x86上无需额外屏障,仅保证读取的可见性)就能满足需求,SEQ_CST的全屏障会带来不必要的性能开销。 - aarch64平台上
SEQ_CST的开销更大,远超过ACQUIRE/RELEASE的组合。
- 在已初始化场景下,x86平台的
CAS操作的内存序过度严格
- CAS操作将状态从0改为1时,仅需要保证这个修改对其他线程可见,使用
__ATOMIC_RELEASE作为成功时的内存序即可,失败时用__ATOMIC_RELAXED完全足够,无需SEQ_CST。
- CAS操作将状态从0改为1时,仅需要保证这个修改对其他线程可见,使用
自旋等待的内存序优化空间
- 自旋等待时的读取使用
__ATOMIC_ACQUIRE即可,无需SEQ_CST,因为只需要保证读到状态2时,初始化逻辑的所有修改都已可见。
- 自旋等待时的读取使用
初始实现的CAS内存序隐藏风险
- 初始实现中CAS用
RELAXED确实存在隐患:当线程A成功CAS并进入初始化逻辑,线程B在自旋等待时用ACQUIRE读取状态,虽然commit时用RELEASE能保证初始化逻辑与状态2的同步,但CAS将0改为1的操作是RELAXED,无法保证其他线程看到状态1时的内存可见性(不过因为后续自旋等待的是状态2,这个风险在当前逻辑下被掩盖,但并非正确的内存序设计)。
- 初始实现中CAS用
优化建议的实现版本
兼顾性能与正确性的版本应该如下,针对不同场景使用最小必要的内存序:
#ifdef __x86_64__ #define PAUSE() __asm __volatile("pause") #else #define PAUSE() #endif int once_enter(int *b) { // 已初始化场景:用ACQUIRE保证读取到2时,初始化逻辑的修改都可见 if(2 == __atomic_load_n(b, __ATOMIC_ACQUIRE)) return 0; int zero = 0; // CAS成功时用RELEASE:保证初始化逻辑的修改能被后续读取状态2的线程看到 // CAS失败时用RELAXED:不需要同步任何操作 int got_lock = __atomic_compare_exchange_n(b, &zero, 1, 0, __ATOMIC_RELEASE, __ATOMIC_RELAXED); if (got_lock) return 1; // 自旋等待:用ACQUIRE保证读到2时,初始化逻辑完成 while (2 != __atomic_load_n(b, __ATOMIC_ACQUIRE)) { PAUSE(); }; return 0; } void once_commit(int *b) { // RELEASE内存序:保证初始化逻辑的所有写操作在状态2被写入前完成,且对其他线程可见 (void) __atomic_store_n(b, 2, __ATOMIC_RELEASE); }
这个版本的优势:
- 已初始化场景下,x86平台的
__ATOMIC_ACQUIRE读取无额外屏障,性能最优;aarch64平台仅生成轻量的ldar指令。 - CAS操作使用最小必要的内存序,避免不必要的屏障开销。
- 内存序设计严谨,完全保证初始化逻辑与后续代码的内存可见性,跨平台兼容x86_64和aarch64。
内容的提问来源于stack exchange,提问作者Harris M Snyder
相关产品推荐
相关产品推荐

