一、从无锁编程谈起
在前文探讨多线程编程时,我们提及了基于CAS的无锁编程技术。实现无锁编程的一个重要前提,是标准库(以及底层系统)提供的原子操作类型,它们本质上是对硬件比较-交换(Compare-And-Swap)指令的封装与向上抽象。在C++标准库中,提供了两个核心接口来实现CAS操作:compare_exchange_strong 和 compare_exchange_weak。
需要注意的是,不同平台或编程语言中,CAS的接口和底层实现机制可能存在差异,这一点必须明确。
二、强与弱的比较交换
compare_exchange_strong 和 compare_exchange_weak,从字面意思就很好理解。强比较交换是严格的比较,不会出现伪失败,性能相对稍差;弱比较交换则更为宽容,允许出现伪失败,但性能通常稍好。这种设计是必要的,不同的应用场景需要匹配合适的工具,而不是强行用一把钥匙开所有的锁。
两者的核心区别在于:强比较交换只有在比较值真正不同时才会失败;而弱比较交换即使在比较值相同时,也可能因故失败。
先看看它们在C++中的函数声明:
bool compare_exchange_weak( T& expected, T desired,
std::memory_order success,
std::memory_order failure );
这里有一个容易让非母语者困惑的地方。我们先明确这个函数的行为逻辑:
- 比较当前原子变量的值是否等于调用者提供的预期值 (
expected)。
- 如果相等,则将原子变量的值设置为调用者提供的新值 (
desired)。
- 如果不相等,则将原子变量的当前值写入
expected 参数,并返回 false。
这里出现了 expected 和 desired 两个与“期望”相关的参数,哪个才是步骤1中的“预期值”呢?查看接口说明便能明白:
expected - pointer to the value expected to be found in the atomic object
desired - the value to store in the atomic object if it is as expected
从英文语义来看,expected 是一种客观的预测或合理的期望,即它代表我们“预期”原子对象中应该存在的值;而 desired 更强调主观愿望,代表在符合预期的情况下,我们“期望”存储进去的新值。结合上面的英文说明,expected 是调用者提供的期望值,而 desired 是匹配成功后的目标存储值。
尽管不同平台存在细微差别,但在软件层面通常会进行“透明化”处理,上层开发者无需感知。目前开发者接触的主要环境还是x86平台。ARM平台主要用于移动端,在这些场景下,直接使用CAS的机会相对较少。
在x86平台上,提供了 CMPXCHG 指令,这是一个原子操作指令。原子操作意味着该指令的执行过程是不可分割的。因此,在这种底层实现上,强弱两个版本的差异不大。这不禁让人联想到x86的内存序问题,确实,内存序也影响着CAS指令的行为。常见的内存序及其对编译器和处理器优化的影响如下:
memory_order_seq_cst:全局顺序一致,要求最严格,对性能影响最小
memory_order_acq_rel:获取-释放语义,要求中等,影响中等
memory_order_relaxed:仅保证原子性,要求最低,对性能影响最大(允许更多优化)
而在AMD等采用LL/SC(Load-Linked/Store-Conditional)机制且默认采用弱内存序的平台上,由于指令处理的并行行为,就可能发生伪失败。这属于硬件架构的固有特性,软件层面难以完全规避。
一般来说,在非循环场景或严格要求成功率的场景下,推荐使用 compare_exchange_strong;在循环(例如自旋锁实现)中,为了性能考虑,可以使用 compare_exchange_weak。在x86平台上,二者性能几乎无差别;但在ARM等平台上,出于性能考虑,常选择 compare_exchange_weak 并配合循环处理可能的伪失败。
三、伪失败、ABA问题与活锁
在CAS应用场景中,ABA问题和活锁是经常被讨论的难点(关于多核CPU编程中的ABA问题与死锁/活锁,可查阅相关文章)。这里我们重点分析一下“伪失败”。
所谓伪失败,即 spurious failure,意思就是“本不应该失败的失败”。在应该成功赋值的情况下,因为伪失败被判定为失败,导致预期操作没有执行。伪失败是 compare_exchange_weak 的典型特征,用于与 compare_exchange_strong 进行明确区分(这种设计思路有点像布隆过滤器)。你可以通俗地将其理解为“人有失手,马有失蹄”。
伪失败的产生原因主要有:
- 硬件架构:主要是基于LL/SC架构的平台,如PowerPC、MIPS、RISC-V等。
- 缓存一致性:多核处理器中的缓存一致性问题。
- 中断与异常:执行过程中被中断或异常事件打断。
- 多线程访问冲突:多个线程几乎同时对同一内存位置进行访问操作,这可以看作是硬件冲突在软件层的映射。
从以上分析可以看出,伪失败与硬件关系密切。像x86这类平台,基本无需考虑此问题。
四、应用与示例代码
下面看看cppreference上提供的一个简单例程:
#include <atomic>
#include <iostream>
std::atomic<int> ai;
int tst_val = 4;
int new_val = 5;
bool exchanged = false;
void valsout()
{
std::cout << "ai= " << ai
<< " tst_val= " << tst_val
<< " new_val= " << new_val
<< " exchanged= " << std::boolalpha << exchanged
<< "\n";
}
int main()
{
ai = 3;
valsout();
// tst_val != ai ==> tst_val is modified
exchanged = ai.compare_exchange_strong(tst_val, new_val);
valsout();
// tst_val == ai ==> ai is modified
exchanged = ai.compare_exchange_strong(tst_val, new_val);
valsout();
return 0;
}
运行结果如下:
ai= 3 tst_val= 4 new_val= 5 exchanged= false
ai= 3 tst_val= 3 new_val= 5 exchanged= false
ai= 5 tst_val= 3 new_val= 5 exchanged= true
如果你对CAS相关的实际应用感兴趣,可以在GitHub或其他技术网站上找到大量更复杂的算法实现和锁设计示例,这里就不再赘述。
五、总结
从一个技术点的不同层面去剖析,往往能获得全新的认知。这就像“盲人摸象”的故事,每个人听来都觉得简单可笑。但在实际的技术开发工作中,我们却常常不自觉地陷入类似的片面认知。随着技术视野的不断拓宽和经验的持续积累,你会越发认识到自身知识的局限性。这恰恰印证了哲学上所说的“波浪式前进,螺旋式上升”的发展规律。深入研究C/C++标准库的实现细节,是提升对并发编程理解的重要途径。