在多线程计算中,在同步期间会发生ABA问题,当一个位置被读取两次,两次读取具有相同的值,并且“值相同”用于指示“什么都没有改变”时。
但是,另一个线程可以在两次读取之间执行并更改值,执行其他工作,然后将值改回,因此,即使第二个线程的工作违反了该假设,也使第一个线程认为“什么都没有改变”。
当多个线程(或进程)访问共享数据时,会发生ABA问题。以下是将导致ABA问题的事件序列:
- 进程 P1 从共享内存中读取值 A,
- 进程 P1 被抢占,从而允许进程 P2 运行,
- 进程 P2 在抢占之前将共享内存值 A 修改为值 B ,然后又修改回 A,
- 进程 P1 再次开始执行,发现共享内存值未更改并继续。
尽管 P1 可以继续执行,但是由于共享内存中的“隐藏”修改,因此行为可能不正确。
实现无锁数据结构时,会遇到ABA问题的常见情况:
如果从列表中删除一个项目,将其删除,然后分配一个新项目并将其添加到列表中;
由于MRU内存分配,分配的对象通常与删除的对象位于同一位置。因此,指向新项目的指针通常等于指向旧项目的指针,从而导致ABA问题。
以下是一个 lock-free 的栈的实现,但是它有 ABA的问题。
/* Naive lock-free stack which suffers from ABA problem.*/
class Stack {
std::atomic<Obj*> top_ptr;
//
// Pops the top object and returns a pointer to it.
//
Obj* Pop() {
while (1) {
Obj* ret_ptr = top_ptr;
if (!ret_ptr) return nullptr;
// For simplicity, suppose that we can ensure that this dereference is safe
// (i.e., that no other thread has popped the stack in the meantime).
Obj* next_ptr = ret_ptr->next;
// If the top node is still ret, then assume no one has changed the stack.
// (That statement is not always true because of the ABA problem)
// Atomically replace top with next.
if (top_ptr.compare_exchange_weak(ret_ptr, next_ptr)) {
return ret_ptr;
}
// The stack has changed, start over.
}
}
//
// Pushes the object specified by obj_ptr to stack.
//
void Push(Obj* obj_ptr) {
while (1) {
Obj* next_ptr = top_ptr;
obj_ptr->next = next_ptr;
// If the top node is still next, then assume no one has changed the stack.
// (That statement is not always true because of the ABA problem)
// Atomically replace top with obj.
if (top_ptr.compare_exchange_weak(next_ptr, obj_ptr)) {
return;
}
// The stack has changed, start over.
}
}
};
看以下事件序列:
-
假设栈初始的时候是这样的: top -> A -> B -> C
-
Thread 1 calls Pop(), Pop()实现的代码片段如下:
ret_ptr = A;
next_ptr = ret_ptr->next; // next_ptr is B
// <----- 被抢占
if (top_ptr.compare_exchange_weak(A, B)) {
return A;
}
注意,如上注释所示,在执行 compare_exchange_weak()之前,thread_1 被 thread_2 抢占执行。
- Thread 2 抢占 Thread 1 后,做了下面这些事:
Pop(); // the stack is top->B->C
Pop(); // the stack is top->C
delete B;
Push(A); // the stack is top->A->C
- Thread 1 拿回 CPU 继续运行
compare_exchange_weak(A, B)
此时,在 compare_exchange_weak
内部做的比较(即比较 栈顶 与 ret_ptr), 会发现是相等的,因为现在栈顶就是 A.
所以,Thread 1 就把栈顶设为了 B.
但是,B指针已经被 delete 了,在C++中,访问这样的野指针会导致未定义行为。 这将导致 crash,数据损坏,或者貌似工作正常。这样的bug比较很难定位。
变通的办法 (workaround)
常见的解决方法是添加额外的“标签”或“标记”位。例如,在指针上使用比较和交换的算法可能会使用地址的低位来指示已成功修改指针的次数(注:这里指的是 Tagged Pointer 的方法)。因此,即使地址相同,下一次比较和交换也将失败,因为标记位将不匹配。由于第二个A与第一个A略有不同,因此有时称为ABA’.
这样的标记状态引用也用于事务性存储器中。尽管可以使用带标记的指针来实现,但是如果可以使用双倍宽度的CAS,则首选单独的标记字段。
如果“标签”字段回绕,那么针对ABA的保证将不再有效。但是,已经观察到,在当前现有的CPU上,并且使用60位标签,只要程序寿命(即,不重新启动程序)限制为10年,就不可能进行环绕。另外,有人说,出于实际目的,通常有40-48位的标签就足够了,以保证不会缠绕。由于现代CPU(特别是所有现代x64 CPU)倾向于支持128位CAS操作,因此可以针对ABA做出可靠保证。
参考文献
- https://en.wikipedia.org/wiki/ABA_problem