ABA问题及其解决思路C++

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
  1. T1 读到 top == A

  2. T2 执行两次操作:

    • 弹出 A(CAS(top, A, A->next) 成功)。

    • 再把同一个 A 重新入栈(可能通过内存回收后重分配的相同地址)。此时 top 又变回 A。

  3. 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
  1. 线程 T1 调用 acquire(),拿到对象 A,并暂存指针。

  2. 在处理过程中,T1 暂不修改 A。

  3. 线程 T2 执行 acquire()release() 多次后,恰好再次从池中拿到同一个内存地址 A,但它可能已经被重置为“全新状态”。

  4. 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);
  1. T1 读取到 v1 = 0xFFFFFFFF

  2. 其他线程快速更新若干次,版本号经历了完整回绕,又“恰好”变回 0xFFFFFFFF

  3. 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));
  1. T1 读 head == A,将要把 A 弹出。

  2. T2 快速执行:

    • 将 A 弹出(head 指向 B),再把 A 节点重新插入到队尾(或对象池重用)。

    • 此时 head 又变回 A。

  3. 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)) {
    // 以为对象未被释放
}
  1. T1 读取 p=AoldCnt=1

  2. 在 T1 CAS 之前,另一个线程把引用减到 0,回收 A,再分配给新对象 C(地址仍为 A)。

  3. 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; 
        }
    }
}
目前包含功能: 国图农村地籍数据库 自动赋界址线位置; 计算线走向; 删除重复要素: 使用环境: winXP(32、64),Win7(32、64) 系统必备: Microsoft .NET Framework 2.0; ArcEngine Runtime 9.3 arcGIS 9.3 不安装上述工具无法运行 功能介绍: 1、国图农村地籍数据库 自动赋界址线位置: 说明: 本功能只适用于《国图村庄地籍数据库》,城镇地籍数据库未经测试,其他格式数据库不适用。 使用本功能前已经使用国图地籍软件,自动填写过地籍调查表、更新界址、顺序等,并经过要素重复性检查、界址断线检查、界址重复性检查。 鉴于地籍数据库拓扑要求并不严格,不在进行严密的拓扑错误检查,容差在0.01范围内不在指示出拓扑错误。 在界址线图层自动添加一个text型字段“检查”,问题都写在这里。存在问题的界址线需要手动填写位置类别,或者修改后在自动添加。 界址线类别可以通过ArcMap的空间筛选批量添加在界址线图层的界址线类别字段中,并不费事所以就没必要编写代码了。 界址线赋位置之前,界址线图层界址线类别字段必须上好。界址线位置完全根据界址线图层的界址线类别来计算,然后位置与类别共同储存在国图地籍数据库界址标识表中,上好后的位置与类别可通过国图地籍建库软件查看。 2、计算线走向: 说明: 在线要素图层自动建立一个Double类型的“走向”字段。记录线的走向,既起终与正北方的夹角。用于地质、矿产计算断裂走向等方面。 3删除重复要素: 说明: 只是删除完全重合的、线、区要素,相交重叠的并不删除。 4断线 与空间分析功能 目前未完善。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值