无锁并发数据结构

互斥量是一个强大的工具,可以在并发情况下保护数据,防止数据竞争的发生,但同时,使用互斥量难免会影响并发的性能。如果能写一个无锁安全的并发数据结构,就能更好地发挥并发。

原子量实现的简单自旋锁#

class SpinLock {
public:
    SpinLock() : m_flag(ATOMIC_FLAG_INIT) { }

    void lock() {
        while(m_flag.test_and_set(memory_order_acquire));
    }

    void unlock() {
        m_flag.clear(memory_order_release);
    }

private:
    atomic_flag m_flag;
};

通过原子量的不断自循环来实现锁定,直到有其他线程调用了unlock()后,才会退出循环,这种不断自选来维持线程的锁定的操作就被形象地称为自旋锁。

概念和意义#

通常情况下,可以使用互斥量、条件变量和future等来实现数据的同步工作,当线程执行到特定位置后,将会执行一个阻塞操作,然后该线程将被挂起,其时间片由其他线程分享,然后在之后的某一时刻解除阻塞,比如互斥量解锁、条件变量达成或者是future处于了就绪状态等。与有阻塞相对的就是使用一种非阻塞的结构,比如上面自己实现的自旋锁,它不会将当前线程挂起然后让出时间片,而是不断地循环,直到有外部力量打破循环。非阻塞不代表是无锁地,像我们实现的自旋锁就通过了一个原子量来实现了一把锁,且一次可以锁住一个线程。
无锁结构意味着线程可以并发地访问数据结构,一个线程写入数据时,允许另外的线程可以并发地访问数据,但是不可以有多个数据同时进行写入操作。不仅如此,当其中的一个线程被挂起时,其他线程必须能够正常地执行自己的工作,而无须等待挂起的线程。
无锁结构的三种类型:

无阻碍 —— 如果其他线程都暂停了,任何给定的线程都将在一定时间内完成操作。
无锁 —— 如果多个线程对一个数据结构进行操作,经过一段时间后,其中一个线程将完成其操作。
无等待 —— 即使其他线程也在对该数据进行操作,每个线程都可以在一定时间内完成操作。

实践中,无阻碍的情况往往是使用的很少的,其他线程都在暂停是很罕见的,而且这往往透露着bad smell。
如果我们在操作数据的"CAS"操作时,这里通常会有一个类似自选锁的循环,其他线程更改了该数据时,此线程会尝试恢复数据,当其他线程挂起时,当前线程执行成功,而执行失败时,会进行不断自选,因此这是个“无阻塞-有锁”结构。而这可能会造成部分线程的“饥饿”,一些线程在“错误”的时间将会不断地尝试执行,但无法成功,而有些线程始终无法得到执行的机会。因此需要一种更好的“无锁-无等待”的数据结构。
绝对的“无等待”是难以做到的,线程间对共享数据的操作往往会出现冲突,一般会进行若干次的尝试,比如对atomic的compare_exchange_weak进行循环。通过这种循环的“比较/交换”的操作,保证所有线程都可以在一定时间内完成任务,可能有的线程循环的多,有的线程循环的少,但都会在有限次循环后结束,我们也认为这是无等待的。

优缺点

  • 使用无锁结构的主要原因:最大化并发。使用基于锁的容器,会让线程阻塞或等待,并且互斥锁削弱了结构的并发性。无锁数据结构中,某些线程可以逐步执行。无等待数据结构中,每一个线程都可以独自向前运行。
  • 使用无锁数据结构的第二个原因就是鲁棒性。当一个线程在持有锁时被终止,那么数据结构将会永久性的破坏。不过,当线程在无锁数据结构上执行操作,在执行到一半终止时,数据结构上的数据没有丢失(除了线程本身的数据),其他线程依旧可以正常执行。
  • 无锁数据结构也非万能的,“无锁-无等待”代码的缺点:虽然提高了并发访问的能力,减少了单个线程的等待时间,但是其可能会将整体性能拉低。首先,原子操作的无锁代码要慢于无原子操作的代码,原子操作就相当于无锁数据结构中的锁。不仅如此,硬件必须通过同一个原子变量对线程间的数据进行同步。

无锁的线程安全栈#

栈可以简单的通过链表来实现,入栈等于放入链表头部,出栈等于删除链表头部,入栈和出栈都可以在有限步骤下完成,例如入栈可以通过三步:

  1. 创建节点node;
  2. node->next指向head->next;
  3. head->next指向node。

如果有多个线程在操作同一个栈时,不加限制的话就会造成指针的混乱和错误。那么来实现一个无锁的push。

template<typename T>
class LockFreeStack {
public:
    void push(const T& data)
    {
        Node* const node = new Node(data);
        node->next = head.load();
        while(!head.compare_exchange_weak(node->next, node));
    }
    
private:
    struct Node {
        T data;
        Node* next{ nullptr };

        explicit Node(const T& d) : data(d) { }
    };

    std::atomic<Node*> head{ nullptr };
};

可以看到,在push中只是将前面提到的指针交换变成了一个原子操作,并使用compare_exchange_weak来自选直到完成交换。需要说明compare_exchange_weak在这里完成的操作,它会比较原子量head和当前期望值node->next是否相等,如果相等的话,就将head更新为node,否则将把node->next重新更新为当前的head值。这就保证了当多个线程在执行入栈第三步可能发生数据竞争时,仍能通过不断的比较和交换来正确地入栈。

出栈也可以在5步内完成:

  1. 获取head;
  2. 读取head->next指向的节点node;
  3. 将head->next指向node->next;
  4. 返回node中的数据;
  5. 删除node。
template<typename T>
class LockFreeStack {
public:
    std::shared_ptr<T> pop()
    {
        Node* old = head.load();
        while(old && !head.compare_exchange_weak(old, old->next));
        return old ? old->data : std::shared_ptr<T>();
    }

private:
    struct Node {
        std::shared_ptr<T> data;
        Node* next{ nullptr };

        explicit Node(const T& d) : data(d) { }
    };

    std::atomic<Node*> head{ nullptr };
};

我们实现了一个简单的pop接口(不可用),将Node中的数据改进为用智能指针来保存,这样可以做到产生异常时,数据不会丢失。在pop中我们没有进行指针的释放,无疑这会造成内存泄露,可惜至少目前为止,我们没有办法,因为在多线程同时在pop时,直接释放指针会出现问题,例如有A和B两个线程在执行pop操作,A和B都读取到了head中的值,因为没有锁或其他的限制,这完全是可以的。但是原子量的CAS一个时刻只能有一个在操作,如果A先进行比较/交换操作,然后把指针释放掉了,那么当B再进行比较时,B在传入old和old->next作为入参时就会出现问题,因为old已经被A释放掉了,那么此时的old就是一个悬垂指针,这会引发未定义的行为。
问题在于,当释放一个节点时,需要确认其他线程没有持有该节点,只有一个线程调用pop()时就可以安心地释放,如果有多个线程在持有相同的节点,就需要等待到合适的时机再释放。因此,我们可以多维护一个待删除的链表来暂时地保存那些已经pop了,但是还不能删除的节点,以及一个原子量用来计数当前正在执行pop的线程数量。

template<typename T>
class LockFreeStack {
public:
    std::shared_ptr<T> pop()
    {
        ++m_pops;         
        Node* old = head.load();
        while(old && !head.compare_exchange_weak(old, old->next));
        shared_ptr<T> res;
        if(old) {
            res.swap(old->data);
        }
        try_delete(old);    // 1
        return res;
    }

private:
    void deleteNodes(Node* node)
    {
        while(node) {
            Node* next = node->next;
            delete node;
            node = next;
        }
    }
    
    void try_delete(Node* node)
    {
        if(m_pops == 1) {
            Node* deleted_node = need_deleted.exchange(nullptr);
            if(!--m_pops) {
                deleteNodes(deleted_node);
            }
            else if(deleted_node != nullptr) {
                pendingNodes(deleted_node);
            }
            delete node;
        }
        else {
            pendingNodes(node);
            --m_pops;
        }
    }

    void pendingNodes(Node* node)
    {
        Node* last = node;
        while(Node* next = node->next) {
            last = next;
        }
        pendingNodes(node, last);
    }

    void pendingNodes(Node* begin, Node* end)
    {
        end->next = need_deleted.load();
        while(!need_deleted.compare_exchange_weak(end->next, begin));
    }

private:
    struct Node {
        std::shared_ptr<T> data;
        Node* next{ nullptr };

        explicit Node(const T& d) : data(d) { }
    };

    std::atomic<Node*> head{ nullptr };

    std::atomic<Node*> need_deleted{ nullptr };

    std::atomic<unsigned> m_pops{ 0 };
};

need_deleted维护了一个待删除节点的列表,m_pops用来计数同一时刻执行pop的线程数量。一进入pop函数,就将m_pops加一,然后将数据传递出去,基于这样一个共识,栈在返回了栈顶数据后,就应该认为栈中的元素被弹出了,以后绝不会再被使用到,那么这个节点就应该被删除。于是我们调用try_delete来尝试删除该节点和待删除列表中的节点。
try_delete函数中,首先判断当前是否只有一个线程在操作pop,如果不是的话,直接将当前要删除的节点加入到待删除列表,然后退出,不要忘记退出时需要将计数减一;如果计数为1,表示只有当前线程在执行pop,那么就可以将待删除列表和当前节点删除掉了,删除列表使用deleteNodes函数,加入待删除列表使用pendingNodes函数。
这种方案在低功耗下可以良好地运作,但是如果功耗过高的话,可能待删除列表永远也得不到机会释放。因此需要一些其他的方案来解决。
可供替代的方案有风险指针引用计数等。

风险指针的基本想法是,一个线程在使用其他线程上已经删除的对象时是有风险的,那么通过维护一个各个线程和对应释放的对象的列表,来记录风险指针,每个线程在要pop对象前,需要先搜索这个存储风险指针的列表,直到拿到自己线程对应的对象指针,表示风险解除,然后才可以执行后续的获取数据和释放指针的操作。
引用计数对每个节点上访问的线程数量进行统计。智能指针中内置了引用计数,但可惜的是智能指针中的引用计数并不保证是无锁的。在扩展头文件<experimental/atomic>中倒是提供了原子智能指针。

无锁数据的建议#

  1. 使用std::memory_order_seq_cst

std::memory_order_seq_cst保证了内存中所有的操作都是有序的,即使追求性能,也要从std::memory_order_seq_cst开始,保证基本操作都能够满足后再考虑放宽内存限制。

  1. 对无锁内存的回收

无锁代码的最大区别就在于内存的管理,当线程对节点进行访问时,线程无法删除节点,为了避免内存的过度使用,节点在能释放的时候应该尽可能的释放。

  • 等待无线程对数据结构进行访问时,删除所有等待删除的对象。
  • 使用风险指针来标识正在访问的对象。
  • 对对象进行引用计数,只有在没有线程对对象进行引用时才能删除。
  1. 小心“ABA”问题

“ABA”问题是这样一种场景:
线程1读取原子变量x,并且发现其值是A。
2). 线程1对这个值进行一些操作,比如,解引用(当其是一个指针的时候),或做查询,或其他操作。
3). 操作系统将线程1挂起。
4). 其他线程对x执行一些操作,并且将其值改为B。
5). 另一个线程对A相关的数据进行修改(线程1持有),让其不再合法。可能会在释放指针指向的内存时,代码产生剧烈的反应(大问题),或者只是修改了相关值而已(小问题)。
6). 再来一个线程将x的值改回为A。如果A是一个指针,那么其可能指向一个新的对象,只是与旧对象共享同一个地址而已。
7). 线程1继续运行,并且对x执行“比较/交换”操作,将A进行对比。这里,“比较/交换”成功(因为其值还是A),不过这是一个错误的A(the wrong A value)。从第2步中读取的数据不再合法,但是线程1无法言明这个问题,并且之后的操作将会损坏数据结构。

作者:cwtxx

出处:https://www.cnblogs.com/cwtxx/p/18718223

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   cwtxx  阅读(10)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示