2022-09-30 C++并发编程(二十九)

C++并发编程(二十九)


前言

无锁数据结构简单理解,就是不用锁完成并发安全的数据结构,其通常使用 CAS 即比较替换结构,原子操作 compare_exchange_weak() ,无锁不是无阻塞,完全无阻塞的乱序逻辑一般是无法满足并发程序使用的逻辑的。

本文我们介绍一个简单的无锁栈,说简单,但要比有锁栈难得多。

无锁栈数据结构的一个难点是内存回收。与有锁栈结构不同,无锁栈的阻塞是靠 compare_exchange_weak() 实现的,而正因如此,我们无法实时的将资源 delete 掉,解决方案或许是干脆想方设法,搞一个无需 delete 资源的数据结构。


一、线程安全的无锁栈

对于无锁栈,push 的实现是比较简单的,new 一个包裹数据的 node 节点内存,将其 next 指针指向 head,然后进入 CAS 比较替换,有一个幸运的线程获取了比较替换的时机,成功的更新了head,其余线程则只能再次更新 node 节点指向,然后争抢进行比较替换的时机。

但pop就没这么简单了。多个线程获取了 head 指向的 node 资源,只有一个线程成功的进行比较替换,并且将 head 指向的 node 从栈上弹出,而其它线程则需要更新他们对 head 的指向,于是资源被泄漏了。

如果我们将资源实时的 delete,那更惨,由于 compare_exchange_weak(oldHead, oldHead->next) 的操作,当 oldHead 被删除,其余所有线程都要解引用一个悬垂的 oldHead,也就是 oldHead->next 操作,导致无法预料的程序崩溃,或者系统崩溃,这是无法接受的。

以下是一个有内存泄漏的无锁栈设计,并且无法用于空栈,因为无法解引用一个空指针。同时,也不是异常安全的.

#include <atomic>
#include <iostream>

template <typename T>
struct lockFreeStack;

template <typename T>
struct node
{
    explicit node(const T &dataVal)
        : data(dataVal)
    {}

  private:
    T data;
    node *next = nullptr;

    friend struct lockFreeStack<T>;
};

template <typename T>
struct lockFreeStack
{
    void push(const T &data)
    {
        const node<T> *newNode = new node(data);
        newNode->next = head.load();
        // 当前值与期望值相等时,修改当前值为设定值,返回true
        // 当前值与期望值不等时,将期望值修改为当前值,返回false
        // 这个函数可能在满足true的情况下仍然返回false,所以只能在循环里使用,否则可以使用它的strong版本
        while (!head.compare_exchange_weak(newNode->next, newNode))
        {}
    }

    void pop(T &result)
    {
        node<T> *oldHead = head.load();
        while (!head.compare_exchange_weak(oldHead, oldHead->next))
        {}
        result = oldHead->data;
    }

  private:
    std::atomic<node<T> *> head;
};

auto main() -> int
{}

以下实现,更改了 data 的储存方式,通过智能指针的包裹,可用于空栈,但依然内存泄漏。

#include <atomic>
#include <iostream>
#include <memory>

template <typename T>
struct lockFreeStack;

template <typename T>
struct node
{
    explicit node(const T &dataVal)
        : data(std::make_shared<T>(dataVal))
    {}

  private:
    std::shared_ptr<T> data;
    node *next = nullptr;

    friend struct lockFreeStack<T>;
};

template <typename T>
struct lockFreeStack
{
    lockFreeStack() = default;

    void push(const T &data)
    {
        node<T> *newNode = new node(data);
        newNode->next = head.load();
        // 当前值与期望值相等时,修改当前值为设定值,返回true
        // 当前值与期望值不等时,将期望值修改为当前值,返回false
        // 这个函数可能在满足true的情况下仍然返回false,所以只能在循环里使用
        while (!head.compare_exchange_weak(newNode->next, newNode))
        {}
    }

    auto pop() -> std::shared_ptr<T>
    {
        node<T> *oldHead = head.load();
        while (oldHead && !head.compare_exchange_weak(oldHead, oldHead->next))
        {}
        return oldHead ? oldHead->data : std::shared_ptr<T>();
    }

  private:
    std::atomic<node<T> *> head = nullptr;
};

auto main() -> int
{
    lockFreeStack<int> lfs;

    lfs.push(3);

    std::cout << *lfs.pop() << std::endl;

    return 0;
}

以下的实现,对内存泄漏进行了改善,使用的是 pop 线程计数的方法,代码不复杂,但理解为什么这么实现非常之复杂。这是一种内存回收技术,对于轻量的栈使用,问题不大,对于巨量的栈 pop 会堆积不可删除内存,最终会将内存撑爆。

#include <atomic>
#include <iostream>
#include <memory>
#include <thread>

namespace noLock
{

template <typename T>
struct lockFreeStack;

template <typename T>
struct node
{
    explicit node(const T &dataVal)
        : data(std::make_shared<T>(dataVal))
    {}

  private:
    std::shared_ptr<T> data;
    node *next = nullptr;

    friend struct lockFreeStack<T>;
};

template <typename T>
struct lockFreeStack
{
    lockFreeStack() = default;

    // 不可拷贝构造
    lockFreeStack(const lockFreeStack &rhs) = delete;

    // 不可拷贝赋值
    auto operator=(const lockFreeStack &rhs) -> lockFreeStack & = delete;

    // 析构
    ~lockFreeStack()
    {
        deleteNodes(head.load());
    }

    // 入栈
    void push(const T &data)
    {
        node<T> *newNode = new node(data);
        newNode->next = head.load();
        // 当前值与期望值相等时,修改当前值为设定值,返回true
        // 当前值与期望值不等时,将期望值修改为当前值,返回false
        // 这个函数可能在满足true的情况下仍然返回false,所以只能在循环里使用
        while (!head.compare_exchange_weak(newNode->next, newNode))
        {}
    }

    // 出栈
    auto pop() -> std::shared_ptr<T>
    {
        ++threadsInPop;

        node<T> *oldHead = head.load();

        while (oldHead && !head.compare_exchange_weak(oldHead, oldHead->next))
        {}

        std::shared_ptr<T> res;

        if (oldHead)
        {
            res.swap(oldHead->data);
        }

        // delete oldHead; 有多个线程引用 head 会引起解引用悬垂指针
        tryReclaim(oldHead);

        return res;
    }

  private:
    // 头节点
    std::atomic<node<T> *> head = nullptr;

    // 回收待删除列表的头节点
    std::atomic<node<T> *> toBeDeletedList = nullptr;

    // pop 出栈线程计数
    std::atomic<unsigned> threadsInPop = 0;

    // 删除节点指向的链表
    static void deleteNodes(node<T> *nodes)
    {
        while (nodes)
        {
            node<T> *next = nodes->next;
            delete nodes;
            nodes = next;
        }
    }

    // 找到 nodes 节点的尾节点并将其指向 toBeDeletedList 节点,
    // toBeDeletedList 节点指向 nodes 节点
    void chainPendingNodes(node<T> *nodes)
    {
        node<T> *last = nodes;
        while (node<T> *const next = last->next)
        {
            last = next;
        }
        // 将 nodes 指向的链表加入到 toBeDeletedList 指向的链表之前
        // 并即将 toBeDeletedList 指向 nodes 节点
        chainPendingNodes(nodes, last);
    }

    // 将 first 指向的链表加入到 toBeDeletedList 指向的链表之前
    // 并即将 toBeDeletedList 指向 first 节点
    void chainPendingNodes(node<T> *first, node<T> *last)
    {
        last->next = toBeDeletedList;
        while (!toBeDeletedList.compare_exchange_weak(last->next, first))
        {}
    }

    // 将 singleNode 指向的节点加入到 toBeDeletedList 指向的链表之前
    // 并即将 toBeDeletedList 指向 singleNode 节点
    void chainPendingNode(node<T> *singleNode)
    {
        chainPendingNodes(singleNode, singleNode);
    }

    // 试图回收
    void tryReclaim(node<T> *oldHead)
    {
        // 若符合条件,则 head 已经更新, 没有其它线程占用 oldHead
        // 可放心 delete oldeHead
        if (threadsInPop == 1)
        {
            node<T> *nodesToDelete = toBeDeletedList.exchange(nullptr);
            // 如果条件成立,可删除待删除链表
            // 因为 toBeDeletedList 已经腾出来,可接受待删除节点了
            if ((--threadsInPop) == 0U)
            {
                deleteNodes(nodesToDelete);
            }
            // 如条件成立,则意味着在测试 if (threadsInPop == 1)  条件后,
            // 有其它线程threadB pop(),
            // 此时有可能 threadB 的 oldHead 已经传给 toBeDeletedList,
            // 如果不巧,还有其它线程 threadC 引用 threadB 的 oldHead
            // 指向的资源, 而 threadB oldHead 资源由 toBeDeletedList
            // 删除,则会发生解引用悬垂指针
            // 所以 nodesToDelete 不能安全删除
            else if (nodesToDelete)
            {
                chainPendingNodes(nodesToDelete);
            }
            delete oldHead;
        }
        // 否则意味着多个节点占用 head
        // 无法删除 oldHead,只能将其添加到待删除链表中
        else
        {
            chainPendingNode(oldHead);
            --threadsInPop;
        }
    }
};

} // namespace noLock

auto main() -> int
{
    noLock::lockFreeStack<int> lfs;

    std::thread t0([&lfs] {
        for (int i = 0; i != 10000000; ++i)
        {
            lfs.push(i);
        }
    });

    std::thread t1([&lfs] {
        for (int i = 0; i != 5000; ++i)
        {
            std::cout << *lfs.pop() << "\n";
        }
    });

    std::thread t2([&lfs] {
        for (int i = 0; i != 5000; ++i)
        {
            std::cout << *lfs.pop() << "\n";
        }
    });

    std::thread t3([&lfs] {
        for (int i = 0; i != 5000; ++i)
        {
            std::cout << *lfs.pop() << "\n";
        }
    });

    std::thread t4([&lfs] {
        for (int i = 0; i != 5000; ++i)
        {
            std::cout << *lfs.pop() << "\n";
        }
    });

    std::thread t5([&lfs] {
        for (int i = 0; i != 5000; ++i)
        {
            std::cout << *lfs.pop() << "\n";
        }
    });

    std::thread t6([&lfs] {
        for (int i = 0; i != 5000; ++i)
        {
            std::cout << *lfs.pop() << "\n";
        }
    });

    t0.join();
    t1.join();
    t2.join();
    t3.join();
    t4.join();
    t5.join();
    t6.join();

    return 0;
}


总结

无锁的数据结构相较有锁结构,非常的繁琐,极度考验逻辑性,很难保证程序逻辑滴水不漏,甚至无从 debug,因为逻辑很复杂,从乱序中梳理出顺序,及其困难。

另外,并发不一定比单线程快,无锁不一定比有锁快,因为任何逻辑缺陷都可能导致无法预知的问题或内耗。如果可能,还是不要轻易的使用未经长期测试的无锁结构。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不停感叹的老林_<C 语言编程核心突破>

不打赏的人, 看完也学不会.

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值