C++线程编程-设计无锁的并发数据结构

定义和结果

使用互斥元、条件变量以及future 来同步数据的算法和数据结构被称为阻塞的算法和数据结构.调用库函数的应用会中断一个线程的执行,直到另一个线程执行一个动作.这种库函数调用被称为阻塞调用,因为直到阻塞被释放时线程才能继续执行下去.通常,操作系统会完全阻塞一个线程(并且将这个线程的时间片分配给另一个线程),直到另一个线程执行了适当的动作将其解锁,可以是解锁互斥元、通知条件变量或者使得future就绪.

不使用阻塞库函数的数据结构和算法被称为非阻塞的.但是,并不是所有这样的数据结构都是无锁的,因此我们来看一看非阻塞数据结构的各种类型.

即非阻塞不一定就是不适用锁。

例子:使用std::atomic_flag作为自旋锁的基本互斥元,实现自旋锁:

// 使用std::atomic_flag的自选锁互斥元的实现
#include <atomic>

class spinlock_mutex
{
private:
    std::atomic_flag flag;
public:
    spinlock_mutex():flag(ATOMIC_FLAG_INIT){}
    void lock()
    {
        while(flag.test_and_set(std::memory_order_acquire));
    }
    void unlock()
    {
        flag.clear(std::memory_order_release);
    }
};

该代码不调用任何阻塞函数,因此,使用这个互斥元的操作也将是非阻塞的,但它并非是无锁的。
即自旋锁不阻塞。

互斥锁会阻塞等待,挂起线程,放弃cpu;
自旋锁会循环等待,占用cpu;
互斥锁不占用cpu资源,但是有上下文的切换代价;自旋锁占用cpu资源,但是没有上下文切换的代价。
当只有一个核心的时候,自旋锁就是浪费;
当有多个核心的时候,如果占用锁的时间很长,切换的代价小于占用cpu的代价,就可以选择互斥锁,反之可以选择自旋锁。

无锁数据结构

使用无锁的数据结构的最大原因就是实现最大程序的并发。对于基于锁的容器,总是有那么一个线程或这多个线程会阻塞。而无锁的线程就不再需要等待结果,继续执行。

另外,无锁的结构更具有健壮性,当一个持有锁的线程异常终止的时候,可能会因为它正拿着锁的原因导致整。

无锁结果不仅意味着要注意加于操作上的顺序限制和线程可见性,无锁比有锁难得多。

既然要使用无锁,就要依赖于原子操作,以及和原子操作有关的内存顺序。

刚开始的例子使用最简单的内存顺序:memory_order_seq_cst,它是默认的,也是最耗资源的,也是最简单的。
在所有原子操作里面,只有atomic_flag保证是无锁的,其他的可能自己内部封装了锁,这是C++做的封装,如果不想用可以用系统调用。

编写不用锁的线程安全栈

实现不使用锁的线程安全push()

// 实现不是用锁的线程安全push
#include <atomic>
template<typename T>
class lock_free_stack
{
private:
    struct node
    {
        T data;
        node *next;
        node(const T &data_):data(data_){}
    };
    std::atomic<node*> head;
public:
    void push(const T &data)
    {
        // 创建一个新结点
        // 唯一可能引发异常的点,不会修改链表,所以是异常安全的
        const node *new_node = new node(data);// 唯一的可能引发异常的点
        new_node->next = head.load();   // A
        // 如果head的值和new_node->next的值是是一样的,就把head赋值为new_node
        // 如果不相同,说明有别的线程修改了head,所以要将当前结点的next重新指向新的head,
        // 然后再循环比较。
        while(!head.compare_exchange_weak(new_node->next,new_node));    // B
    }
};

pop()实现:
为了防止异常,用共享指针保存data

// 实现不是用锁的线程安全push
#include <atomic>
#include <memory>
template<typename T>
class lock_free_stack
{
private:
    struct node
    {
        std::shared_ptr<T> data;
        node *next;
        node(const T &data_):data(std::make_shared<T>(data_)){}
    };
    std::atomic<node*> head;

public:
    // 使用CAS
    void push(const T &data)
    {
        // 创建一个新结点
        const node *new_node = new node(data);
        new_node->next = head.load();   //A
        // 如果head的值和new_node->next的值是是一样的,就把head赋值为new_node
        // 如果不相同,说明有别的线程修改了head,所以要将当前结点的next重新指向新的head,
        // 然后再循环比较。
        while(!head.compare_exchange_weak(new_node->next,new_node));    //B
        
    }

    std::shared_ptr<T> pop()
    {
        node *old_head = head.load();
        // 要判断old_head是否为空,如果为空,old_head->next是未定义行为。
        while(old_head && !head.compare_exchange_weak(old_head,old_head->next));
        // 返回智能指针,不会有异常,或者返回空指针,但是还是没有释放内存。
        return old_head ? old_head->data:std::make_shared<T>();
    }
};

但是没有回收垃圾,不用的头节点并没有回收。

在无锁数据结构中管理内存

可以用:

  1. 计数操作:统计每一个结点的线程数,当没有线程操作这个节点的时候,这个节点被删除,但是这个很无语,因为一般不会出现线程没操作的情况。
  2. 风险指针:需要申请专利,而且应用也很难,也是利用了引用计数
  3. 智能指针(共享指针):好像共享指针可以很好的实现,但是共享指针内部的操作不一定是无锁的,可能会有效率上的损耗。
  4. 双层引用计数:使用双层引用计数,是书中提到的比较不错的例子,但也是比较烧脑。
// 使用两个引用计数的无锁栈中入栈结点

#include <memory>
#include <atomic>

template<typename T>
class lock_free_stack
{
private:
    struct node;

    struct counted_node_ptr
    {
        int external_count; // 外部引用计数,保证直到你访问时指针仍然是有效的,不会变成悬空指针
        node *ptr;          // 结点指针
    };

    struct node
    {
        std::shared_ptr<T> data;            // 真正的数据
        std::atomic<int> internal_count;    // 内部引用计数
        counted_node_ptr next;
        node(const T &data_):data(std::make_shared<T>(data_)),internal_count(0){}
    };

    std::atomic<counted_node_ptr> head;

    void increase_head_count(counted_node_ptr &old_counter)
    {
        counted_node_ptr new_counter;
        do
        {
            new_counter = old_counter;
            ++new_counter.external_count;   // 外部引用计数加1
        } while (!head.compare_exchange_strong(old_counter,new_counter));
        old_counter.external_count = new_counter.external_count;
    }

public:
    ~lock_free_stack()
    {
        while(pop());
    }

    void push(const T &data)
    {
        counted_node_ptr new_node;
        new_node.ptr = new node(data);
        new_node.external_count = 1;    // 新创建的结点外部只有一个引用,就是head
        new_node.ptr->next = head.load();
        while(!head.compare_exchange_weak(new_node.ptr->next,new_node));
    }

    std::shared_ptr<T> pop()
    {
        counted_node_ptr old_head = head.load();
        for(;;)
        {
            // 因为引用了head,所以要增加外部引用+1,
            // 避免别的线程在这个时间点把head干掉,导致当前线程变成悬空指针
            increase_head_count(old_head);
            
            // 拿到head结点内部的数据指针
            const node *ptr = old_head.ptr;
            if(!ptr)    // 如果数据是空的,直接弹出
            {
                return std::shared_ptr<T>();
            }

            // 再次查看该线程拿到的head是否还是刚刚的head,
            // 如果不是,说明有别的线程干掉了head,该线程需要重新寻找head
            // 找到之后,该线程就把head放到后面去了,自己使用old_head,不影响别的线程继续操作
            // 如果链表不为空,使用CAS来移动指针
            // 如果false,就重新那ptr,所以能保证进入的ptr就是old_head的内部数据
            if(head.compare_exchange_strong(old_head,ptr->next))
            {
                std::shared_ptr<T> res;
                res.swap(ptr->data);    // 先把值保存下来
                // 从列表移除结点-1,当前线程不再使用该结点,再-1
                const int count_increase = old_head.external_count - 2;
                // 如果当前引用的值为0
                if(ptr->internal_count.fetch_add(count_increase) == -count_increase)
                {
                    delete ptr;
                }
                return res;
            }
            // 如果ptr的内部引用计数只有1,也就是只有当前线程,那么可以直接干掉
            // 但是不能return,因为既然是1,说明肯定已经被别的线程干掉了,否则应该是2
            // 所以直接干掉ptr内存就可以了
            // 不需要再返回指针了。
            else if(ptr->internal_count.fetch_sub(1) == 1)
            {
                delete ptr;
            }
        }
    }
};

将内存模型应用至无锁栈

在改变内存顺序前,你需要检查操作以及它们之间的关系。然后就可以寻找提供这些关系的最小内存顺序。为了实现这一点,就必须在不同场景下从线程角度考虑情况。最简单的场景就是一个线程入栈一个数据项,并且稍后另一个线程将那个数据项出栈,我们先考虑这种情况。

使用引用计数和放松原子操作的无锁栈

// 使用引用计数和放松原子操作的无锁栈

#include <memory>
#include <atomic>

template<typename T>
class lock_free_stack
{
private:
    struct node;

    struct counted_node_ptr
    {
        int external_count; // 外部引用计数,保证直到你访问时指针仍然是有效的,不会变成悬空指针
        node *ptr;          // 结点指针
    };

    struct node
    {
        std::shared_ptr<T> data;            // 真正的数据
        std::atomic<int> internal_count;    // 内部引用计数
        counted_node_ptr next;
        node(const T &data_):data(std::make_shared<T>(data_)),internal_count(0){}
    };

    std::atomic<counted_node_ptr> head;

    void increase_head_count(counted_node_ptr &old_counter)
    {
        counted_node_ptr new_counter;
        do
        {
            new_counter = old_counter;
            ++new_counter.external_count;   // 外部引用计数加1
        } while (!head.compare_exchange_strong(old_counter,new_counter,std::memory_order_acquire,std::memory_order_relaxed));
        // 
        old_counter.external_count = new_counter.external_count;
    }

public:
    ~lock_free_stack()
    {
        while(pop());
    }

    void push(const T &data)
    {
        counted_node_ptr new_node;
        new_node.ptr = new node(data);
        new_node.external_count = 1;    // 新创建的结点外部只有一个引用,就是head
        new_node.ptr->next = head.load(std::memory_order_relaxed);
        // 这一句是唯一的原子操作,上面的load虽然是原子的,但是顺序没有强制,
        // 所以head.load可以是最松散顺序。
        // 但是下面的CAS,如果在成功的情况下,最小限制应该是release的。
        // 保证前面的语句不会在CAS后面执行。
        // 如果CAS失败,那么就可以是relaxed的,因为不会做任何改变。
        while(!head.compare_exchange_weak(new_node.ptr->next,new_node,std::memory_order_release,std::memory_order_relaxed));
    }

    std::shared_ptr<T> pop()
    {
        counted_node_ptr old_head = head.load(std::memory_order_relaxed);
        for(;;)
        {
            // 因为引用了head,所以要增加外部引用+1,
            // 避免别的线程在这个时间点把head干掉,导致当前线程变成悬空指针
            increase_head_count(old_head);
            
            // 拿到head结点内部的数据指针
            const node *ptr = old_head.ptr;
            if(!ptr)    // 如果数据是空的,直接弹出
            {
                return std::shared_ptr<T>();
            }

            // 再次查看该线程拿到的head是否还是刚刚的head,
            // 如果不是,说明有别的线程干掉了head,该线程需要重新寻找head
            // 找到之后,该线程就把head放到后面去了,自己使用old_head,不影响别的线程继续操作
            // 如果链表不为空,使用CAS来移动指针
            // 如果false,就重新那ptr,所以能保证进入的ptr就是old_head的内部数据
            if(head.compare_exchange_strong(old_head,ptr->next,std::memory_order_relaxed))
            {
                std::shared_ptr<T> res;
                res.swap(ptr->data);    // 先把值保存下来
                // 从列表移除结点-1,当前线程不再使用该结点,再-1
                const int count_increase = old_head.external_count - 2;
                // 如果当前引用的值为0
                if(ptr->internal_count.fetch_add(count_increase,std::memory_order_release) == -count_increase)
                {
                    delete ptr;
                }
                return res;
            }
            // 如果ptr的内部引用计数只有1,也就是只有当前线程,那么可以直接干掉
            // 但是不能return,因为既然是1,说明肯定已经被别的线程干掉了,否则应该是2
            // 所以直接干掉ptr内存就可以了
            // 不需要再返回指针了。
            else if(ptr->internal_count.fetch_sub(1,std::memory_order_relaxed) == 1)
            {
                ptr->internal_count.load(std::memory_order_acquire);
                delete ptr;
            }
        }
    }
};

设计无锁数据结构的准则

使用std::memory_order_seq_cst作为原型

最先设计的时候,使用最强顺序作为原型,因为最强顺序就是代码的顺序,容易理解,在保证代码正确之后,再放松顺序。

使用无锁内存回收模式

无锁代码最大的问题之一就是管理内存。
三种方法:

  1. 等待直到没有线程访问该数据结构,并且删除所有等待删除的对象
  2. 使用风险指针来确定线程正在访问一个特定的对象
  3. 引用计数对象,只有直到没有显著引用时才删除它们

在所有的情况下,关键的想法就是使用一些方法来记录有多少线程在访问一个特定的对象,并且只删除不再被引用的对象。有很多方法可以回收无锁数据结构的内存。例如,使用垃圾回收器是很理想的方案。当你不再使用结点的时候,垃圾回收期可以释放结点。在这种情况下写程序就简单一些。
另一个方法就是回收结点,并且当数据结构被销毁的时候才完全释放它们。因为结点是重复使用的,内存永远不会失效。这样避免未定义行为的困难就不存在了。缺点就是另一个问题变得更常见。这就是所谓的ABA问题。

当心ABA问题

ABA问题时任何基于CAS算法都必须提防的问题。
问题描述:

  1. 线程1读取一个原子变量x,并且发现它的值为A
  2. 线程1基于这个值执行了一些操作,例如解引用它或者做一些查找操作
  3. 线程1被系统阻塞了
  4. 另一个线程在x上执行了一些操作,将它的值改为B
  5. 第三个线程更改了于值A相关的值,因此线程1持有的数值就不再有效了
  6. 第三个线程基于新值将x的值改回A,如果这是一个指针,那么就可能是一个新的对象,此对象刚好与先前的对象使用了相同的地址
  7. 线程1重新取得x,并在x上执行CAS,与A进行比较,CAS操作成功(因为值确实是A),但是这个A的值是错误的。第二个步中读取的值不再有效,但是线程1不知道,并且将破坏数据结构。

写无锁程序很容易遇到这样的问题,最常用的避免方法就是在变量x上使用一个ABA计数器。每次修改值的时候,与值绑定的计数器就会+1;所以即使值是一样的,如果计数器不一样,也会CAS失败。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

橙子砰砰枪

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值