test and set lock_简化概念下的 lock-free 编程

1 lock-free 是什么

常有人说 lock-free 就是不使用 mutex / semaphores 之类的 无锁(lock-Less) 编程,这句话严格来说并不对。从本质上讲,lock-free 描述的是代码逻辑的一种『属性』。不使用锁的代码大部分情况下具备这个『属性』,所以有时候会混淆 lock-free 和 无锁 两个概念,前者是对代码性质的描述,后者是说代码如何实现。那么,什么是 lock-free ——

对于一个并发实现,无论当前处于什么状态,只要运行足够长的时间,至少有一个 process 能取得进展或完成其操作,则其实现称之为 lock-free(这里的 process 代表并发操作中一条独立的逻辑流,可以是线程,也可以是进程)。这个描述并不直观,下面通过一个反例来说明 —— 这是一个允许多线程访问的 stack 实现,直接使用一个线程不安全的 std::stack 加上 std::mutex 来实现,其入栈操作示例

// C++
std::stack<T> stack;
std::mutex mutex;

void push(const T& obj) {
   mutex.lock();
   stack.push(obj);
   mutex.unlock();
}

假设在一个单核实时可抢占的系统中,有两个线程 A / B / C 同时会访问到 push 方法,其中 A 具备最高优先级,B 优先级次之。考虑如下并发顺序

  • 线程 C 进入的 push 内部,获取到 mutex
  • 线程 A 唤醒,并中断了 C,进入 push 内部,但是因为 锁 已经被 C 占用,所以 A 进入阻塞队列
  • 线程 B 唤醒,因其比 C 的优先级高,并且其不执行 push 而是执行其他任务,所以直到 B 完成或因为其他原因被阻塞之前,C 没有机会继续执行并释放锁,A 继续等待
  • 也就是说,优先级更低的 B 比 优先级更好的 A 更先执行,并且只有 B 在被阻塞的情况 A 才有机会继续

这个现象被称为优先级倒置,其本质原因是因为 push 操作不是 lock free 的,一个线程的挂起可以将整体的 push 操作全部阻塞住了。在一些关键领域,这种现象是不可接受的,有很多方法可以解决这个问题,比如当 A 发现锁被抢占时,将当前获得锁的线程 C 优先级提高到和 A 一样,这样 B 就无法抢占 C。但是根据 lock-free 的定义,无论处于什么状态,都不应该让所有的线程全部被阻塞,而在上述实现中如果线程 C 进入某种一直不会执行的状态,它将阻塞其他所有同一操作的线程。

在这个基础上,很容易理解所有基于『锁』的并发实现,都不是 lock-free 的,因为它们都会遇到同样的问题 —— 如果我们永久暂停当前占有锁的某一个线程 / 进程的执行,将会阻塞其他线程 / 进程的执行。而对于 lock-free 实现,允许部分 process 饿死但保证整体逻辑的持续前进。

这里在看另外一个反例,在这个示例中两个线程 A / B 将并发执行下列代码

// initialize x = 0
while (x == 0) {
   x = 1 - x;
}

如果考虑一下执行顺序

  • A 进入 while 循环,当前 x = 0
  • B 进入 while 循环,当前 x = 0
  • A 执行 x = 1 - x, 当前 x = 1
  • B 执行 x = 1 - x,当前 x = 0
  • 循环继续

这是一个无锁操作,但是也会导致整体逻辑无法向下推进,其同样不是 lock-free 的。

通过上面两个例子,大概能体会到 lock-free 最大的要求是其实现不会锁定整体逻辑,无论是死锁,还是活锁。如果有一个并发实现是 lock-free 的,那么我们就可以不用额外的改进将其使用在一个实时操作系统上,而无须担心调度系统长时间阻塞高优先级任务。那么如何实现一个 lock-free 的操作以及需要注意哪些问题,在此之前,我们将会假定一个条件来限定问题的边界。

2 一个假定和简化

通过 lock-free 的定义,我们知道如果使用锁,比如 mutex,那么其实现必然不是 lock-free的。但如果不能使用锁,将会带来很多问题 —— 诸如 mutex 之类的锁,其不仅仅是在逻辑中创造一个独占区,其还会根据实际运行的系统,会创建内存屏障 / 阻止指令重排 / 建立内存的一致性模型。当我们不再使用锁的时候,这些东西就需要更加小心的去注意。这块的内容很大,在这篇文章中很难全面且细致的覆盖,且为了避免给带来额外的复杂性,文中后续的部分将不会涉及到内存模型的任何概念。这不是说明内存模型不重要,相对的,内存模型在 lock-free 编程中非常重要,没有这方面的知识是不可能写出一个正确的 lock-free 实现。只是即使不涉及这方面的概念,也不影响后续内容的正确性,如果对这方面感兴趣,可以阅读 Memory ordering - Wikipedia / Memory barrier - Wikipedia / Consistency model

3 一个 stack 的 lock-free C++ 实现以及 ABA 问题

一般情况下,实现一个 lock-free 算法需要系统提供一个 atomic RMW (read-modify-write) 操作。常用 RMW 操作包括 test-and-set,fetch-and-add,compare-and-swap(CAS) 以及更进一步的 LL / SC ,在 C++ 11 中的 atomic 库 中有许多类似的操作。大多 lock-free 实现都是使用 CAS 来实现

// 伪代码,整个过程是 atomic,不可被中断
// 可以使用 std::atomic<T*> 中的 compare_exchange_weak 方法
bool CAS(T* p, T old, T new) {
   if (*p != old) {
      return false;
   }
   *p = new;
   return true;
}

下面是一个很简单 stack 的 lock-free 实现

#include <atomic>

class Node {
public:
    int val;
    Node *next;

    Node(int val): val(val), next(NULL) {
    }
};

class Stack {
public:
    void push(Node* new_obj) {
        while (true) {
            Node *next_ptr = this->_top;
            new_obj->next = next_ptr;
            
            // 将栈顶指针指向新节点,CAS 直到成功
            if (this->_top.compare_exchange_weak(next_ptr, new_obj)) {
                return;
            }
        }
    }

    Node* pop() {
        while (true) {
            Node *ret_ptr = this->_top;
            if (!ret_ptr) {
                return NULL;
            }

            Node *next_ptr = ret_ptr->next;

            // 将栈顶指针指向下一节点,CAS 直到成功
            if (this->_top.compare_exchange_weak(ret_ptr, next_ptr)) {
                return ret_ptr;
            }
        }
    }

private:
    std::atomic<Node*> _top;

};

上述代码,直接利用 CAS 的原子性实现了一个可以并发操作的 stack。无论是多个线程同时 push 或者 pop 时,任一线程在任意步骤阻塞 / 挂起,其他线程都会继续执行并最终返回,无非就是多执行几次 while 循环。

但是上面代码还存在一个使用 CAS 操作时非常经典的问题,就是 ABA 问题 —— 我们常常使用 CAS 时会假设,如果 CAS 成功则代表事物没有任何变化。但是有时候这种假设是错的,比如线程 1 到达值的 CAS 操作时,线程 2 开始执行了一系列操作 1)修改值 2)执行其他操作 3)将值修改为原值,线程 1 继续执行时发现值没有变换,然后 CAS 成功。这里虽然 CAS 成功,但是实际上线程 2 已经做了很多事情,如果我们做了没有变化的假设,那么将会发生非预期行为,比如说对于上述 stack 实现

stack 中已有值   A(_top) -> B -> C
                
1. 线程 1 stack.pop 执行到 _top.compare_exchange_weak 暂停
   此时 push 内部状态 next_ptr = B / ret_ptr = A
2. 线程 2 执行 Node *cur = stack.pop()
   此时 stack 内部状态为 B(_top) -> C / cur = A
3. 线程 2 执行 delete stack.pop()
   此时 stack 内部状态为 C(_top)
4. 线程 2 执行 stack.push(cur);
   此时 stack 内部状态为 A(_top) -> C
5. 线程 1 恢复执行 _top.compare_exchange_weak,因为 _top == expected 此时该操作将会成功
   此时 stack 内部状态为 B(_top) 

当执行完第 5 步时,stack 内部的 top 就指向一个已经 delete 的 B 且 C 被丢失 。这里 stack 的 ABA 问题如此明显,是因为在实现时特地让 push / pop 直接操作 Node 对象,如果我们把代码改为

#include <atomic>

class Node {
public:
    int val;
    Node *next;

    Node(int val): val(val), next(NULL) {
    }
};

class Stack {
public:
    void push(int val) {
        Node *new_obj = new Node(val);
        while (true) {
            Node *next_ptr = this->_top;
            new_obj->next = next_ptr;
            
            // 将栈顶指针指向新节点,CAS 直到成功
            if (this->_top.compare_exchange_weak(next_ptr, new_obj)) {
                return;
            }
        }
    }

    bool pop(int* ret) {
        while (true) {
            Node *ret_ptr = this->_top;
            if (!ret_ptr) {
                return false;
            }

            Node *next_ptr = ret_ptr->next;

            // 将栈顶指针指向下一节点,CAS 直到成功
            if (this->_top.compare_exchange_weak(ret_ptr, next_ptr)) {
                *ret = ret_ptr->val;
                return true;
            }
        }
    }

private:
    std::atomic<Node*> _top;

};

这个 stack 实现中 ABA 问题就很难被构造出来,因为每次 push 时都会 new 一个全新的 Node 对象。但是这里 ABA 问题仍然存在 —— 某一次 new Node 返回的指针值恰好和 expected 相同,ABA 问题就会发生。

避免 ABA 问题的方法不止一种,比如 boost 使用 Tagged Pointer 来唯一的标记指针,即指针除了地址以外额外的加入另一个标识,这样即使地址被再次使用用指针的值也不一样(这里是 boost 的 stack 实现);另外一种是延迟回收内存,当程序中还有引用时不释放相应的内存,这样就不会出现和 expected 相同地址的指针,这一点对于有 GC 的语言是天然的,如果使用 Java 来重写上述代码就不会存在 ABA 问题,如下。

public class Stack<T> {
    private static class Node<T> {
        T val;
        Node<T> next;

        Node(T val) {
            this.val = val;
        }
    }

    private AtomicReference<Node<T>> _top;

    void push(T val) {
        Node<T> newObj = new Node<>(val);
        while (true) {
            Node<T> next = this._top.get();
            newObj.next = next;

            if (this._top.compareAndSet(next, newObj)) {
                return;
            }
        }
    }

    T pop() {
        while (true) {
            Node<T> ret = this._top.get();

            if (ret == null) {
                return null;
            }

            Node<T> next = ret.next;

            if (this._top.compareAndSet(ret, next)) {
                return ret.val;
            }
        }
    }
}

4 Java 中的 ConcurrentLinkedQueue 中的简单分析

在 Java 的 Concurrent 包中实现了很多 Non-blocking 结构操作,其中可能数 ConcurrentLinkedQueue 相对简单易懂,作为一个 lock-free 编程入门是一个很好的例子。源代码有近千行,这里就不贴了,这里单独把 offer 和 poll 方法摘出来详细说明下

// 加入队列尾部
public boolean offer(E e) {
    // e 不能为空。item 为 null 有特别意义
    // 在该结构中如果某一节点 item 为 null,说明其已经被移除
    checkNotNull(e); // ** A
    final Node<E> newNode = new Node<E>(e);

    // 从 tail 开始迭代寻找当前应该从哪将 e 加入
    // 1)本身并发操作导致 tail 在变化 
    // 2)经过多次操作后的 LinkedList 允许 tail 指向非尾部
    for (Node<E> t = tail, p = t;;) {
        Node<E> q = p.next;
        // 此时有 3 种情况
        if (q == null) {
            // ** B
            // q 是队列中最后一个元素
            if (p.casNext(null, newNode)) {
                // 将 p 的 next 设置为 新节点
                // ** C
                if (p != t) // 如果此时 tail 指向的不是 p,则尝试将 tail 指向新节点
                    casTail(t, newNode);  // 运行该操作失败,如果失败 tail 不变
                return true;
            }
            // CAS 失败,继续迭代
        }
        else if (p == q)
            // 如果 p.next == p,说明 p 节点已经从队列中移除
            // 如果此时 tail 没有变化,那么从 head 开始重新迭代
            // 如果此时 tail 变化,那么从新的 tail 开始迭代,这样效率更高
            // ** D
            p = (t != (t = tail)) ? t : head;
        else
            // 如果 p 不是 tail 且 tail 发生了变化,从新 tail 处重新开始
            // 否则,p = p.next
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

// 从队列头部移出
public E poll() {
    restartFromHead:
    for (;;) {
        // 从 head 开始迭代寻找第一个元素
        // 1)本身并发操作导致 head 在变化 
        // 2)经过多次操作后的 LinkedList 允许 head 指向非头部
        for (Node<E> h = head, p = h, q;;) {
            E item = p.item;
            // ** E
            // 如果 item 不会空,则其是第一个元素,尝试将 item 置为 null
            if (item != null && p.casItem(item, null)) {
                // 如果 CAS 成功即代表已将节点移除
                if (p != h) // 如果此时 head 指向的不是 p
                    // 更新 head,如果 p.next 不为 null,则 head 指向 p.next
                    // 否则指向 p
                    // 允许更新 head 失败
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
            else if ((q = p.next) == null) {
                // 当前队列为空
                // ** F
                updateHead(h, p); // 允许更新 head 失败
                return null;
            }
            else if (p == q)
                // 如果 p.next == p,说明 p 已经从队列中移除,从新 head 重新开始
                continue restartFromHead;
            else
                // p = p.next 继续迭代寻找
                p = q;
        }
    }
}

final void updateHead(Node<E> h, Node<E> p) {
    // 更新 head
    // ** G
    if (h != p && casHead(h, p))
        h.lazySetNext(h);
}

上述代码中的注释已经比较详细。在 ConcurrentLinkedQueue 中使用了一个单向链表作为队列的存储结构,其中有 head 和 tail 两个字段分别指向虚假的队头和队尾,或者更准确的说,head 字段指向节点必然在真正队列头节点的前面;tail 字段指向节点必然在真正队列尾节点的前面。

1)而真正的队列头结点是从 head 开始第一个 item 不为 null 的节点

2)真正的队列尾部节点是从 tail 或 head 开始第一个 next 为 null 的节点

如下所示

->$->$->$->$->*->*->*->*->*->*->*->*->* 
        ^                    ^
        |                    |
       head                 tail

$ 代表已经移除节点
* 代表队列中当前存在节点

在注释中使用 ** [字母] 的形式列出了 A ~ G 几点有必要进一步说明

A)E)在 A 处要求检查传入参数是否为空,是因为在 E 处的 CAS 操作使用当前 item 是否为空来判断该节点是否已经从队列中移除。所以传入的值不能为 null

B)要理解到 tail 只是一个提高效率的字段,而不是指向真正队尾

C)当成功的加入一个节点的时候,如果 tail 和新节点相差至少一个节点,则尝试更新 tail 且允许失败

D)G)如果一个节点的 next 指向自身,则说明该节点已经被移出队列,这点是在 G 处进行的操作。如果 offer 操作进入 D 处,说明 poll 操作已经让消费节点超越了之前的 tail。如果此时 tail 无变化,说明 head 已经在 tail 右边,继续使用原 tail 将无法找到真正的队尾

F) 如果访问节点 next 为 null,说明已经到达真正的队尾,无可消费节点返回 null

论证代码的正确性,就要准确的理解 offer / poll 中每一次 CAS 操作的含义 1)做了哪些假设 2)如果 CAS 成功了意味着什么。这个实现是基于这篇文章的,论证非常详尽

Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms​www.cs.rochester.edu

5 小结

本文讲述了 lock-free 的定义,通过一个简单 Stack 实现(C++ / Java)来说明如何使用 CAS 操作来实现 lock-free,并在最后分析了一个实际的例子 —— Java 中的 ConcurrentLinkedQueue。旨在作为一个 non-blocking 编程的入门文章,文本一直试图简化相关概念,避免引入太多未知名词。lock-free 作为一个基本的概念,其涉及到编程中的很多领域,比如前面提到的内存模型,比如 lock-free 更进一步的 wait-free,两者都属于 non-blocking,如果希望进一步了解,可以阅读相关资料。Java 的 Concurrent 包是一个很好 non-blocking 入门代码,其中代码简洁易懂、注释详尽,并且相对于 C++ 之类无 GC 的语言不需要关注对象指针带来的 ABA 问题。

最后,我们关注下 lock-free 编程相对于有锁编程,有哪些优势或劣势。

  • 一个在第 1 节提到了,lock-free 操作更加适合实时系统,而不必担心调度系统带来的优先级倒置问题。
  • 如果单独看一个线程,为了实现 lock-free 操作需要在逻辑上做更多的工作,也就会导致单个线程的效率较低。
  • 相对于锁竞争,lock-free 操作大大减少了锁竞争导致上下文切换,进而提高整体吞吐

基于合理的推测,lock-free 在高并发的情况下相对有锁编程的整体性能应该有所改善,但是有一句非常经典的话 —— 在性能优化领域,除非进行测试对比,否则我们什么都不知道。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值