ABA问题
出现场景
根本原因都是:只比较“值”或“地址”,不检查“是否中间被修改过”
场景一:无锁栈(Lock‐Free Stack)入栈/出栈
用单个指针 top
管理栈顶,入栈/出栈都用 CAS(Compare‐And‐Swap)操作。
示例流程
// 假设节点结构
struct Node {
int value;
Node* next;
};
// 全局栈顶指针
std::atomic<Node*> top;
// 线程 T1 想要将 nodeX 入栈:
do {
old = top.load(); // 读取当前栈顶,假设 old == A
nodeX->next = old; // nodeX->next 指向 A
} while (!top.compare_exchange_weak(old, nodeX));
// CAS 比对 old(A)与 top,若相等就把 top 设为 nodeX
-
T1 读到
top == A
。 -
T2 执行两次操作:
-
弹出 A(
CAS(top, A, A->next)
成功)。 -
再把同一个 A 重新入栈(可能通过内存回收后重分配的相同地址)。此时
top
又变回 A。
-
-
T1 回到 CAS,看到
top
仍然等于它最初读取的 A,于是 CAS 成功,把top
更新为nodeX
,但实际上中间栈结构已被 T2 改动过。
隐患:最终链表可能出现死链、重复节点,甚至访问已释放内存。
场景二:对象池(Memory/Object Pool)中对象重用
高性能系统常用对象池避免频繁申请/释放。
示例流程
// 简化版对象池接口
Object* obj = pool.acquire(); // 取出对象 A
// … do work on obj …
pool.release(obj); // 归还 A,池中可重用
// 另一线程又 acquire() 得到同一个 A
-
线程 T1 调用
acquire()
,拿到对象 A,并暂存指针。 -
在处理过程中,T1 暂不修改 A。
-
线程 T2 执行
acquire()
/release()
多次后,恰好再次从池中拿到同一个内存地址 A,但它可能已经被重置为“全新状态”。 -
T1 之后基于“指针等于 A”判断对象未被更改,却忽略了内部状态已被复位或改写。
隐患:T1 访问了被重置的数据结构,逻辑错误或数据污染。
场景三:循环版本号(Wrap-Around Counter)
用单整型记录版本号,且不检查溢出。
示例流程
std::atomic<uint32_t> version;
// 线程 T1:
uint32_t v1 = version.load(); // v1 = 0xFFFFFFFF (最大值)
sleep(1);
// 中间有很多更新:version 增加多次,最终从 0x00000000 增到 0xFFFFFFFF 再 +1 回绕到 0x0000000A
// 线程 T1 进行 CAS:
bool ok = version.compare_exchange_strong(v1, v1+1);
-
T1 读取到
v1 = 0xFFFFFFFF
。 -
其他线程快速更新若干次,版本号经历了完整回绕,又“恰好”变回
0xFFFFFFFF
。 -
T1 的 CAS 比对旧值
0xFFFFFFFF
依然相等,CAS 成功,误以为版本未变。
隐患:程序以为中间无更新,实际可能丢失多次重要改动。
场景四:无锁队列(Lock‐Free Queue)出队/入队
链式队列用 head
和 tail
双指针管理。
示例流程
struct Node { Node* next; int val; };
std::atomic<Node*> head, tail;
// 简化版 dequeue:
do {
oldHead = head.load(); // 读 head = A
next = oldHead->next; // A->next = B
} while (!head.compare_exchange_weak(oldHead, next));
-
T1 读
head == A
,将要把 A 弹出。 -
T2 快速执行:
-
将 A 弹出(
head
指向 B),再把 A 节点重新插入到队尾(或对象池重用)。 -
此时
head
又变回 A。
-
-
T1 CAS 成功,将
head
指向next
(也是 B),但其实 B 已经不是原来链上的那个 B,可能导致节点丢失或轮回。
场景五:引用计数与延迟回收
引用计数为 0 时立即释放对象,然后复用相同地址。
示例流程
// 假设 Obj 有 atomic<int> refCount
Obj* p = getSharedObj(); // p 指向 A
// T1 看到 refCount>0,自行增加引用:
if (p->refCount.compare_exchange_strong(oldCnt, oldCnt+1)) {
// 以为对象未被释放
}
-
T1 读取
p=A
,oldCnt=1
。 -
在 T1 CAS 之前,另一个线程把引用减到 0,回收 A,再分配给新对象 C(地址仍为 A)。
-
T1 CAS 看到
A.refCount==1
(因为 C 初始化为 1),CAS 成功,错误地建立了对 C 的额外引用。
隐患:访问了已销毁对象或类型不匹配的内存。
解决思路
常用的解决思路可以分为两大类:避免地址/值重用 和 增强原子操作的语义。
1. 指针+版本号(Tagged/Stamped Pointer)
原理
在指针之外额外维护一个小宽度的“版本号”或“标记”(stamp),每次更新指针时,同时把版本号 increment。CAS 比对时,既比较指针,也比较版本号,只有双双匹配才算“没被修改”。
C++ 示例(双宽度 CAS)
**双宽度 CAS:**硬件/库层面提供一次比较交换两个相邻字(例如:64 位机器一次性 CAS 128 位),可同时原子地更新“指针 + 版本号”。大多数高性能框架(like Folly, TBB)底层封装了此功能,你只要用它提供的
atomic< pair<void*,uint64_t> >
即可。
#include <atomic>
#include <cstdint>
#include <cassert>
// 将指针和 16 位版本号打包到一个 64 位原子值里
struct TaggedPtr {
uintptr_t ptr : 48; // 假设低 48 位够存地址
uint16_t tag : 16; // 高 16 位用作版本号
};
static_assert(sizeof(TaggedPtr)==8, "Expect 8 bytes");
std::atomic<uint64_t> atomic_top;
// 入栈示例
void push(Node* newNode) {
uint64_t oldPacked = atomic_top.load();
while (true) {
TaggedPtr oldTP = *reinterpret_cast<TaggedPtr*>(&oldPacked);
newNode->next = reinterpret_cast<Node*>(oldTP.ptr);
// 构造新值:指针指向 newNode,版本号 +1
TaggedPtr newTP{
.ptr = reinterpret_cast<uintptr_t>(newNode),
.tag = uint16_t(oldTP.tag + 1)
};
uint64_t newPacked = *reinterpret_cast<uint64_t*>(&newTP);
if (atomic_top.compare_exchange_weak(oldPacked, newPacked)) {
return;
}
// CAS 失败后 oldPacked 已被更新,重试
}
}
2. 延迟回收/安全回收(Hazard Pointers & Epoch‐Based Reclamation)
原理
即使重用同一地址,先标记出哪些指针正在被其它线程访问,保证“回收”阶段不会释放仍然可能被访问的内存,从根本上杜绝指针重用带来的 ABA。
-
Hazard Pointers:每个线程在读取一个共享指针后,将其地址写入一个全局
hazard-list
;只有当确认无任何线程标记该指针后,才能真正回收它。 -
Epoch‐Based Reclamation (EBR):把所有线程按阶段(epoch)分组,只有当所有活跃线程都跨过某个 epoch 后,才能回收早先 retired 的节点。
-
Epoch(时代):系统维护一个全局递增的整数
global_epoch
,每个线程进入“关键区”(即要访问共享结构、可能读指针)时,都先把自己“贴上”这个时代号,离开后可不管它。 -
延迟回收:当一块内存(节点)不再被逻辑使用时,不马上
delete
,而是打上它“退休时”的时代号,暂存在本地“回收列表”里。 -
安全时机:只有当所有活跃线程的“贴签时代”都超过该节点退休时代时,才说明:没有任何线程还可能拿着这块内存的老指针,于是才能真正释放它。
-
C++ 伪码示例(Epoch-Based)
// 全局时代号
std::atomic<uint64_t> global_epoch{0};
// 每线程数据
struct ThreadState {
uint64_t local_epoch; // 线程当前登记时代
std::vector<Node*> retired; // 本地待回收列表
} thread_state[MAX_THREADS];
// 进入关键区:登记当前时代
void enter_critical(int tid) {
thread_state[tid].local_epoch
= global_epoch.load(std::memory_order_acquire);
}
// 离开关键区:不必清零,可视情况省略或设为 UINT64_MAX
void exit_critical(int tid) {
thread_state[tid].local_epoch = UINT64_MAX;
}
// 标记回收:不立即 delete,而是“退休”并附带时代戳
void retire_node(Node* p, int tid) {
uint64_t retire_epoch = global_epoch.load(std::memory_order_acquire);
p->retired_epoch = retire_epoch;
thread_state[tid].retired.push_back(p);
if (thread_state[tid].retired.size() >= THRESHOLD) {
scan_and_reclaim(tid);
}
}
// 扫描并回收:只有退休时代小于**所有活跃线程**的最小local_epoch时,才 delete
void scan_and_reclaim(int tid) {
// 找出所有线程中最“旧”的纪元
uint64_t min_epoch = UINT64_MAX;
for (int i = 0; i < MAX_THREADS; i++) {
min_epoch = std::min(min_epoch, thread_state[i].local_epoch);
}
auto &L = thread_state[tid].retired;
auto it = L.begin();
while (it != L.end()) {
if ((*it)->retired_epoch < min_epoch) {
delete *it; // 真正安全回收
it = L.erase(it);
} else {
++it;
}
}
}