C++ 原子操作实战:实现无锁数据结构

当今,高并发编程已成为软件开发中不可或缺的一部分。无论是大型互联网应用、分布式系统,还是金融交易平台,都需要面对大量并发请求的挑战。在高并发场景下,多个线程或进程同时访问和修改共享资源,这就引发了数据一致性和同步的问题 。传统上,我们使用锁机制来解决这些问题,比如在 C++ 中,常见的锁有互斥锁(std::mutex)、读写锁(std::shared_mutex)等。

随着并发程度的不断提高,传统锁机制逐渐暴露出其性能瓶颈。当一个线程获取锁时,如果其他线程也试图获取同一把锁,这些线程就会被阻塞,进入等待状态。线程阻塞会导致上下文切换频繁发生,而上下文切换是一项开销较大的操作,它需要保存和恢复线程的执行环境,包括寄存器状态、程序计数器等,这会消耗大量的 CPU 时间,降低系统的整体性能。

假设有一个简单的多线程程序,多个线程需要对一个共享变量进行累加操作。使用互斥锁来保护这个共享变量的访问:

复制代码
#include <iostream>
#include <mutex>
#include <thread>


std::mutex mtx;
int shared_variable = 0;


void increment() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();
        ++shared_variable;
        mtx.unlock();
    }
}


int main() {
    std::thread t1(increment);
    std::thread t2(increment);


    t1.join();
    t2.join();


    std::cout << "Final value: " << shared_variable << std::endl;
    return 0;
}

在这个例子中,每次对shared_variable的操作都需要先获取锁,操作完成后再释放锁。当并发线程数增加时,锁竞争会变得非常激烈,大量时间会花费在锁的获取和释放上,而不是实际的计算,导致程序的执行效率低下。

除了性能问题,锁机制还可能引发死锁。当多个线程相互等待对方释放锁时,就会陷入死锁状态,导致程序无法继续执行。死锁的排查和解决往往非常困难,给开发和维护带来极大的挑战。

为了应对这些问题,无锁数据结构应运而生。无锁数据结构不依赖于传统的锁机制,而是利用原子操作来实现线程安全。原子操作是指在执行过程中不会被其他线程打断的操作,它保证了在多线程环境下数据访问的原子性、一致性和可见性。通过使用原子操作,无锁数据结构能够避免锁竞争带来的性能开销,提高系统的并发性能。接下来,我们将深入探讨原子操作的原理和在 C++ 中的实现。

一、原子操作:无锁编程的基石

原子操作,从概念上来说,就像是程序世界里坚不可摧的 "原子" ,是不可分割的最小操作单元。在多线程环境中,它一旦开始执行,就会一气呵成,直至完成,绝不会被其他线程的调度机制所打断。这种不可中断性确保了操作的完整性和一致性,是实现多线程安全的关键要素。

例如,在一个简单的变量赋值操作中,如果这个操作是原子的,那么无论有多少个线程同时尝试进行赋值,都不会出现赋值操作进行到一半被打断,从而导致数据不一致的情况。就好比自动售货机的交易过程,当你投币购买商品时,售货机内部的一系列操作,包括检查货币、出货、找零等,是一个不可分割的整体,不会在中途被打断去处理其他用户的请求。原子操作在多线程编程中就扮演着这样 "可靠交易员" 的角色,保证了数据操作的可靠性。

1.1 原子操作的必要性

在多线程编程的复杂世界里,当多个线程同时访问和修改共享资源时,数据一致性和完整性就像走钢丝一样,稍有不慎就会出现问题。数据竞争,作为多线程编程中最常见的问题之一,就像隐藏在暗处的陷阱,随时可能让程序陷入混乱。

假设有两个线程同时对一个共享变量进行加一操作。如果没有原子操作的保障,就可能出现以下情况:线程 A 读取了变量的值,假设为 10,还没来得及进行加一操作并将结果写回,线程 B 也读取了这个值 10。然后线程 A 和线程 B 分别进行加一操作并写回,最终变量的值只增加了 1,变成了 11,而不是我们期望的 12。这种数据不一致的问题,会像多米诺骨牌一样,引发一系列的连锁反应,导致程序出现各种难以调试的错误。

而原子操作,就像是为共享资源加上了一把 "隐形的锁",它保证了对共享数据的操作是原子性的,避免了数据竞争的发生。当一个线程执行原子操作时,其他线程无法同时对同一数据进行干扰,从而确保了数据在多线程环境下的一致性和完整性,让程序能够稳定可靠地运行。

1.2 C++11 中的原子操作 API

C++11 为我们带来了强大的std::atomic类模板,它就像是一个专门为多线程环境打造的 "原子操作工具箱",为我们提供了丰富的工具来实现高效的原子操作。

使用std::atomic非常简单,首先可以声明一个原子变量,例如std::atomic<int> counter(0); ,这里声明了一个初始值为 0 的原子整型变量counter 。它支持多种常见的成员函数,每个函数都有着独特的功能。

  • load函数用于原子性地读取变量的值,就像从一个安全的容器中取出物品,不会受到其他线程的干扰。例如int value = counter.load(); ,将counter的值原子性地读取到value变量中。
  • store函数则用于原子性地写入一个新值,确保写入操作的完整性。如counter.store(42); ,将42原子性地写入counter 。
  • fetch_add函数可以实现原子性的加法操作,并返回原来的值,它在多线程计数器等场景中非常实用。比如int old_value = counter.fetch_add(5); ,将counter的值增加 5,并返回增加之前的旧值old_value 。

利用这些成员函数,我们可以轻松实现简单的原子操作。例如,在一个多线程的计数器场景中:

复制代码
#include <iostream>
#include <atomic>
#include <thread>


std::atomic<int> counter(0);


void increment() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1);
    }
}


int main() {
    std::thread t1(increment);
    std::thread t2(increment);


    t1.join();
    t2.join();


    std::cout << "Final counter value: " << counter.load() << std::endl;
    return 0;
}

在这个示例中,多个线程可以同时调用increment函数,对counter进行原子性的累加操作,而不用担心数据竞争的问题。std::atomic类模板的出现,极大地简化了多线程编程中原子操作的实现,为我们构建高效、安全的无锁数据结构奠定了坚实的基础。

二、无锁队列:实战案例解析

2.1 无锁队列原理

无锁队列是一种在多线程环境下实现高效数据传输的数据结构,它的设计巧妙地利用了原子操作和 CAS(Compare-And-Swap)机制,从而避免了传统锁机制带来的性能瓶颈 。其基本原理基于链表结构,通过维护两个关键指针:头指针(head)和尾指针(tail),来实现数据的入队和出队操作。

想象一下,有一个繁忙的火车站候车大厅,旅客们就像数据元素,而无锁队列则是旅客排队上车的通道。在这个通道中,head 指针指向队伍的最前面,就像是第一个准备上车的旅客;tail 指针指向队伍的末尾,如同最后一个加入排队的旅客。当有新的旅客(数据)到来时,他们会在队伍的尾部(tail)加入,这就是入队操作;而当火车到站时,最前面的旅客(head 指向的元素)会先上车离开,这对应着出队操作。

在多线程环境下,就好比有多个工作人员同时在处理旅客的排队和上车事宜。传统的锁机制就像是给这个通道加上了一把锁,一次只允许一个工作人员进行操作,其他工作人员必须等待。而无锁队列则不同,它利用原子操作和 CAS 机制,允许多个工作人员同时工作。例如,当一个工作人员要将新的旅客加入队列时,他会先检查 tail 指针指向的位置(预期值),如果发现这个位置没有被其他工作人员修改(当前值等于预期值),他就会将新旅客插入到这个位置(更新为新值),这就是 CAS 操作。如果在检查过程中发现 tail 指针已经被其他工作人员修改了(当前值不等于预期值),那么这个工作人员会重新获取最新的 tail 指针位置,然后再次尝试插入操作。这种机制保证了在多线程并发访问时,队列操作的正确性和高效性,就像多个工作人员可以在不冲突的情况下同时处理旅客的排队和上车,大大提高了火车站的运转效率。

2.2 关键代码实现

接下来,让我们深入到代码层面,看看如何在 C++ 中实现一个无锁队列。

a. 数据结构定义

首先,我们需要定义链表节点和无锁队列的整体结构。链表节点包含数据和指向下一个节点的原子指针,这样可以确保在多线程环境下对节点指针的操作是原子性的,避免数据竞争。无锁队列则包含头指针和尾指针,它们都是原子类型,以保证多线程访问的安全性。

复制代码
#include <atomic>
#include <memory>


template<typename T>
class LockFreeQueue {
private:
    // 定义链表节点
    struct Node {
        T data;
        std::atomic<Node*> next;
        Node(const T& d) : data(d), next(nullptr) {}
    };


    std::atomic<Node*> head;
    std::atomic<Node*> tail;


public:
    // 构造函数,初始化头指针和尾指针指向一个哑元节点
    LockFreeQueue() {
        Node* dummy = new Node(T());
        head.store(dummy, std::memory_order_relaxed);
        tail.store(dummy, std::memory_order_relaxed);
    }


    // 析构函数,释放队列中的所有节点
    ~LockFreeQueue() {
        while (Node* oldHead = head.load()) {
            head.store(oldHead->next);
            delete oldHead;
        }
    }


    // 入队操作
    void enqueue(const T& data);


    // 出队操作
    bool dequeue(T& result);
};

在这段代码中,Node结构体定义了链表节点,data成员存储数据,next成员是一个指向Node类型的原子指针,用于指向下一个节点。LockFreeQueue类包含head和tail两个原子指针,分别表示队列的头和尾。构造函数中创建了一个哑元节点,将head和tail都指向它,这样可以简化边界条件的处理。析构函数则负责释放队列中的所有节点,确保内存不泄漏。

b. 入队操作

入队操作的核心步骤是将新节点插入到队列的尾部,并更新tail指针。为了保证在多线程环境下的正确性,我们使用循环 CAS 操作,不断尝试插入新节点,直到成功为止。

复制代码
template<typename T>
void LockFreeQueue<T>::enqueue(const T& data) {
    // 创建新节点
    Node* newNode = new Node(data);
    Node* oldTail;
    Node* nullNext = nullptr;


    while (true) {
        // 读取当前的tail指针
        oldTail = tail.load(std::memory_order_acquire);


        // 尝试将新节点插入到oldTail的next指针位置
        if (oldTail->next.compare_exchange_weak(nullNext, newNode, std::memory_order_release)) {
            // 插入成功,更新tail指针
            tail.compare_exchange_weak(oldTail, newNode, std::memory_order_acq_rel);
            break;
        } else {
            // 插入失败,说明tail指针已被其他线程修改,尝试更新本地的oldTail指针
            tail.compare_exchange_weak(oldTail, oldTail->next.load(), std::memory_order_acq_rel);
        }
    }
}

在入队操作中,首先创建一个新节点newNode,用于存储要入队的数据。然后进入一个无限循环,在每次循环中,先读取当前的tail指针,赋值给oldTail。接着使用compare_exchange_weak函数尝试将oldTail的next指针指向newNode。如果当前oldTail的next指针为空(即等于预期值nullNext),则将其更新为newNode,插入成功,跳出循环;如果插入失败,说明oldTail的next指针已被其他线程修改,此时通过compare_exchange_weak函数更新oldTail为其当前的next指针,然后继续下一轮循环,再次尝试插入。当插入成功后,再使用compare_exchange_weak函数将tail指针更新为newNode,完成入队操作。

c. 出队操作

出队操作相对复杂一些,需要从队列头部移除节点,并处理一些边界条件,如队列为空的情况。同样,我们使用 CAS 操作来确保多线程环境下的正确性。

复制代码
template<typename T>
bool LockFreeQueue<T>::dequeue(T& result) {
    Node* oldHead;
    Node* oldTail;
    Node* nextHead;


    while (true) {
        // 读取当前的head指针
        oldHead = head.load(std::memory_order_acquire);
        // 读取当前的tail指针
        oldTail = tail.load(std::memory_order_acquire);
        // 读取oldHead的下一个节点
        nextHead = oldHead->next.load(std::memory_order_acquire);


        // 如果head和tail相等,且nextHead为空,说明队列为空
        if (oldHead == oldTail) {
            if (nextHead == nullptr) {
                return false;
            }
            // tail落后,尝试推进tail指针
            tail.compare_exchange_weak(oldTail, nextHead, std::memory_order_acq_rel);
            continue;
        }


        // 尝试推进head指针
        if (head.compare_exchange_weak(oldHead, nextHead, std::memory_order_acq_rel)) {
            // 推进成功,取出数据
            result = nextHead->data;
            // 删除旧的head节点
            delete oldHead;
            return true;
        }
    }
}

在出队操作中,首先进入一个无限循环。在每次循环中,读取当前的head指针、tail指针以及head指针的下一个节点nextHead。如果head和tail相等,且nextHead为空,说明队列为空,返回false;如果head和tail相等,但nextHead不为空,说明tail指针落后,使用compare_exchange_weak函数将tail指针推进到nextHead,然后继续下一轮循环。如果head和tail不相等,尝试使用compare_exchange_weak函数将head指针推进到nextHead。如果推进成功,取出nextHead中的数据赋值给result,删除旧的head节点,返回true;如果推进失败,说明head指针已被其他线程修改,继续下一轮循环,再次尝试推进head指针。通过这样的方式,实现了在多线程环境下安全、高效的出队操作。

📚 往期精选 · 助力C/C++成长之路

梳理了几篇实用文章,覆盖Linux C/C++多元技术路径与成长场景,供大家学习参考:

🔹 明晰方向
若你希望理性看待C++的行业价值与发展空间,推荐阅读:👉为什么很多人劝退学C++,但大厂核心岗位还是要C++?------厘清认知,锚定技术信心。

🔹 后端深耕
聚焦Linux C/C++后端方向?这份👉《【大厂标准】Linux C/C++后端进阶学习路线》提供清晰路径与学习框架,助你系统构建能力体系。

🔹 音视频入门
对流媒体开发感兴趣?👉《音视频流媒体高级开发 - 学习路线》梳理核心技术脉络,帮你搭建扎实的知识结构。

🔹 Qt****全场景实践
无论是桌面应用还是嵌入式开发,👉《C++ Qt学习路线一条龙!(桌面开发 & 嵌入式开发)》提供从入门到实战的完整学习闭环。

🔹 内核探索
向往操作系统底层?👉《Linux内核学习指南,硬核修炼手册》分享深度学习思路与实践方法,适合沉心钻研的你。

🔹 面试赋能
备战技术面试时,👉《C++高频八股文面试题1000题(三)》可作为知识点复盘与巩固的实用参考。
每一段成长都值得认真对待。愿这些内容成为你技术路上的温暖陪伴,稳步向前,静待花开 🌱

三、内存序与性能优化

在 C++ 的多线程编程领域,内存序是一个至关重要却又容易被忽视的概念,它如同交响乐的指挥,掌控着原子操作在内存访问中的顺序和可见性,确保多线程程序的正确性和高效性 。C++11 为我们提供了多种内存序选项,每个选项都有着独特的功能和适用场景。

std::memory_order_relaxed就像是一个自由散漫的舞者,它是最宽松的内存序选项。在使用这个选项时,它仅仅保证原子操作本身的原子性,而对内存访问顺序没有任何同步保证。不同线程间的操作可以任意重排,只保证同一个线程内的原子操作有先后顺序。这就好比在一场自由舞蹈表演中,舞者可以自由发挥,不受任何特定顺序的限制。例如,在一个简单的计数器场景中,如果只关心原子变量的值,而不关心操作顺序对其他变量的影响,就可以使用std::memory_order_relaxed来减少同步开销,提升程序的执行效率。代码示例如下:

复制代码
#include <atomic>
#include <thread>
#include <iostream>


std::atomic<int> counter(0);


void increment() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}


int main() {
    std::thread t1(increment);
    std::thread t2(increment);


    t1.join();
    t2.join();


    if (counter.load(std::memory_order_relaxed) == 2000) {
        std::cout << "Counter reached the expected value." << std::endl;
    } else {
        std::cout << "Counter did not reach the expected value." << std::endl;
    }
    return 0;
}

std::memory_order_acquire则像是一个严谨的收藏家,它用于加载操作,会阻止后续的读写操作被重排到该加载操作之前。当一个线程使用std::memory_order_release存储一个值,另一个线程使用std::memory_order_acquire加载这个值时,会建立一个同步关系,保证在std::memory_order_release之前的所有写操作,对执行std::memory_order_acquire之后的所有操作可见。例如,在生产者 - 消费者模型中,消费者线程使用std::memory_order_acquire来加载数据,确保它能看到生产者线程之前写入的所有数据。代码示例如下:

复制代码
#include <atomic>
#include <thread>
#include <iostream>


std::atomic<bool> ready(false);
std::atomic<int> data(0);


void producer() {
    data.store(42, std::memory_order_relaxed);
    ready.store(true, std::memory_order_release);
}


void consumer() {
    while (!ready.load(std::memory_order_acquire));
    std::cout << "Data: " << data.load(std::memory_order_relaxed) << std::endl;
}


int main() {
    std::thread t1(producer);
    std::thread t2(consumer);


    t1.join();
    t2.join();


    return 0;
}

std::memory_order_release就像一个负责的快递员,它用于存储操作,确保之前的内存操作不会重排到该操作之后。当一个线程对某个变量进行写操作并使用std::memory_order_release内存序时,它保证了该写操作之前的所有内存操作都对其他线程可见。在上面的生产者 - 消费者示例中,生产者线程使用std::memory_order_release来存储数据,保证了数据的可见性。

std::memory_order_acq_rel结合了acquire和release的特性,用于读改写操作,它既保证了读取操作不会被重排到该操作之前,也保证了写入操作不会被重排到该操作之后。而std::memory_order_seq_cst是最强的顺序一致性,它保证所有线程看到一致的操作顺序,就像所有线程在按一个统一的顺序执行操作,但性能开销较大,因为它需要更多的同步操作来保证全局的顺序一致性。

在无锁队列的实现中,合理选择内存序至关重要,它直接关系到程序的性能和正确性 。不同的操作需要根据其具体的语义和对数据一致性的要求,选择合适的内存序选项。

在入队操作中,我们需要保证新节点的插入和tail指针的更新这两个操作的原子性和可见性。当尝试将新节点插入到队列尾部时,使用std::memory_order_release内存序来更新oldTail的next指针,这是因为我们希望确保在插入新节点之前的所有内存操作(如对新节点数据的初始化)都对其他线程可见。代码如下:

复制代码
if (oldTail->next.compare_exchange_weak(nullNext, newNode, std::memory_order_release)) {
    // 插入成功,更新tail指针
    tail.compare_exchange_weak(oldTail, newNode, std::memory_order_acq_rel);
    break;
}

在更新tail指针时,使用std::memory_order_acq_rel内存序,这是因为这个操作既涉及到读取oldTail(获取操作),又涉及到更新tail(释放操作),需要保证读取到的oldTail是最新的,并且更新tail的操作对其他线程可见。

而出队操作中,读取head指针、tail指针以及head指针的下一个节点nextHead时,使用std::memory_order_acquire内存序,这是为了确保读取到的值是最新的,即其他线程对这些指针的更新操作对当前线程可见。例如:

复制代码
oldHead = head.load(std::memory_order_acquire);
oldTail = tail.load(std::memory_order_acquire);
nextHead = oldHead->next.load(std::memory_order_acquire);

当尝试推进head指针时,使用std::memory_order_acq_rel内存序,因为这既涉及到读取当前head指针的值(获取操作),又涉及到更新head指针(释放操作),需要保证操作的原子性和可见性。

复制代码
if (head.compare_exchange_weak(oldHead, nextHead, std::memory_order_acq_rel)) {
    // 推进成功,取出数据
    result = nextHead->data;
    // 删除旧的head节点
    delete oldHead;
    return true;
}

如果在无锁队列实现中,对内存序的选择不当,可能会导致数据不一致、线程安全问题以及性能下降。例如,如果在入队操作中,不使用std::memory_order_release内存序来更新oldTail的next指针,那么其他线程可能无法及时看到新插入的节点,导致数据丢失或访问错误;如果在出队操作中,不使用std::memory_order_acquire内存序来读取指针,可能会读取到旧的值,从而导致错误的出队操作。因此,深入理解内存序的概念,并根据无锁队列的具体操作合理选择内存序,是实现高效、正确的无锁数据结构的关键。

四、开发中常见问题与解决方案

1. ABA 问题

在无锁数据结构的实现中,ABA 问题就像是隐藏在暗处的 "幽灵",常常给开发者带来意想不到的麻烦 。它的产生与 CAS 操作密切相关,是多线程环境下数据一致性的潜在威胁。

假设我们有一个无锁栈,栈顶指针最初指向节点 A。当线程 1 想要弹出节点 A 时,它首先读取栈顶指针指向 A,然后准备进行 CAS 操作来更新栈顶指针。就在这个短暂的时间间隔内,线程 2 介入了。线程 2 从栈中弹出节点 A,将其修改后又重新压入栈中,此时栈顶指针又回到了指向 A 的状态。接着,线程 1 执行 CAS 操作,它发现栈顶指针仍然指向 A(预期值和当前值相同),于是 CAS 操作成功,线程 1 认为一切正常,将节点 A 弹出。但实际上,这个节点 A 已经不是它最初读取的那个节点 A 了,其内容已经被线程 2 修改过,这就导致了数据不一致和程序逻辑错误。

这种看似无害的指针值变化,从 A 到 B 再回到 A,却可能引发严重的问题,尤其是在对数据一致性要求严格的场景中,如金融交易系统、分布式数据库等。ABA 问题就像一个伪装者,它巧妙地利用了 CAS 操作只关注值是否相等,而不关心值的变化过程这一特性,在不经意间破坏了程序的正确性,让开发者在调试时难以察觉和定位问题。

解决方案:

为了应对 ABA 问题这个难缠的 "对手",开发者们想出了许多巧妙的解决方案,每一种方案都针对 ABA 问题的根源,从不同角度提供了有效的防御机制 。

a. 带标记的指针

带标记的指针是一种直观且常用的解决方案,它就像是给指针贴上了一个独一无二的 "身份标签" 。通过将指针与一个版本号或标记组合成一个复合值,每次对指针进行修改时,同时递增版本号。在进行 CAS 操作时,不仅要比较指针的值,还要比较版本号是否匹配。这样一来,即使指针的值在表面上看起来没有变化(回到了原来的值),但如果版本号不一致,CAS 操作就会失败,从而避免了 ABA 问题的发生。

在 C++ 中,可以通过结构体和std::atomic来实现带标记的指针。定义一个结构体,包含指针和版本号:

复制代码
struct TaggedPointer {
    Node* ptr;
    uintptr_t version;
};

然后,使用std::atomic<TaggedPointer>来管理这个复合结构。在入栈和出栈操作中,每次更新指针时递增版本号:

复制代码
class LockFreeStack {
private:
    std::atomic<TaggedPointer> top;
public:
    void push(Node* new_node) {
        TaggedPointer old_top = top.load(std::memory_order_relaxed);
        TaggedPointer new_top;
        do {
            new_node->next = old_top.ptr;
            new_top.ptr = new_node;
            new_top.version = old_top.version + 1;
        } while (!top.compare_exchange_weak(old_top, new_top,
                                           std::memory_order_release,
                                           std::memory_order_relaxed));
    }


    Node* pop() {
        TaggedPointer old_top = top.load(std::memory_order_relaxed);
        TaggedPointer new_top;
        do {
            if (old_top.ptr == nullptr) return nullptr;
            new_top.ptr = old_top.ptr->next;
            new_top.version = old_top.version + 1;
        } while (!top.compare_exchange_weak(old_top, new_top,
                                           std::memory_order_acquire,
                                           std::memory_order_relaxed));
        return old_top.ptr;
    }
};

这样,在无锁栈的操作中,通过版本号的递增和比较,有效避免了 ABA 问题,确保了数据的一致性和操作的正确性。

b. Hazard Pointer

Hazard Pointer(危险指针)是一种更复杂但功能强大的解决方案,它就像是给正在被访问的节点设置了一个 "保护罩" 。每个线程都维护一组危险指针,这些指针指向该线程当前正在访问的节点。当一个线程想要删除某个节点时,它首先检查该节点是否被其他线程的危险指针所指向。如果是,说明该节点正在被其他线程访问,不能立即删除,需要等待这些线程完成对该节点的访问后,才能安全地删除节点。通过这种方式,Hazard Pointer 避免了指针被复用的情况,从而消除了 ABA 问题的隐患。

实现 Hazard Pointer 需要精心设计数据结构和算法,每个线程需要有自己的危险指针数组,并提供相应的管理函数,如设置危险指针、释放危险指针等。在无锁链表的删除操作中,线程首先将待删除节点的指针设置为危险指针,然后检查该节点是否仍然被其他线程的危险指针指向,如果没有,则可以安全删除节点;如果有,则等待直到该节点不再被其他线程的危险指针指向。这种机制在复杂的数据结构,如无锁链表、队列等中表现出色,能够有效保障数据的安全性和一致性,但实现和维护相对复杂,需要仔细考虑各种边界情况和并发访问的冲突。

c. RCU 机制

RCU(Read - Copy - Update)机制是一种适用于高并发读场景的解决方案,它的工作原理就像是一场有条不紊的接力赛 。在 RCU 机制中,当进行写操作时,并不会立即修改原始数据,而是先创建一个数据的副本,在副本上进行修改。然后,通过一定的同步机制,在确保所有正在进行的读操作都完成后,才将原始数据替换为修改后的副本。这样,在读操作期间,不会受到写操作的干扰,因为读操作始终访问的是原始数据。而写操作也不会影响读操作的正确性,因为写操作是在副本上进行的。

在 C++ 中,可以通过智能指针和引用计数等技术来实现 RCU 机制。当进行写操作时,使用智能指针创建数据的副本,并对副本进行修改。在读操作时,通过引用计数确保原始数据在所有读操作完成之前不会被释放。例如,在一个多线程访问的共享链表中,写操作创建新的链表节点副本,修改副本后,等待所有读操作完成(可以通过引用计数判断),然后将新的链表节点插入到链表中,完成更新操作。这种机制在高并发读的场景下表现出良好的性能,因为读操作不需要获取锁,也不会受到 ABA 问题的影响,但写操作相对复杂,需要额外的同步和管理机制来确保数据的一致性和正确性。

相关推荐
卢傢蕊2 小时前
Linux系统安全
linux·运维·系统安全
米码收割机2 小时前
【AI】OpenClaw问题排查
开发语言·数据库·c++·python
CppBlock2 小时前
HPX vs TBB vs OpenMP:并行任务模型对比
c++·算法
returnthem2 小时前
Docker基本命令
linux·运维·服务器
17(无规则自律)2 小时前
Leetcode第六题:用 C++ 解决三数之和
c++·算法·leetcode
Albert Edison2 小时前
【ProtoBuf 语法详解】enum 类型
java·linux·服务器
wengqidaifeng2 小时前
备战蓝桥杯----C/C++组 (一)所需C++基础知识(上)
c语言·数据结构·c++·蓝桥杯
tankeven2 小时前
HJ126 小红的正整数计数
c++·算法
i学长的猫2 小时前
PM2 管理 Cloudflared 隧道 Neo-mac 及后台运行
linux·编辑器·vim