free技术详解 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 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 中的 compare_exchange_weak 方法

bool CAS(T* p, T old, T new) {

if (*p != old) {

return false;

}

*p = new;

return true;

}

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

#include

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 _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

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 _top;

};

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

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

public class Stack {

private static class Node {

T val;

Node next;

Node(T val) {

this.val = val;

}

}

private AtomicReference> _top;

void push(T val) {

Node newObj = new Node<>(val);

while (true) {

Node next = this._top.get();

newObj.next = next;

if (this._top.compareAndSet(next, newObj)) {

return;

}

}

}

T pop() {

while (true) {

Node ret = this._top.get();

if (ret == null) {

return null;

}

Node 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 newNode = new Node(e);

// 从 tail 开始迭代寻找当前应该从哪将 e 加入

// 1)本身并发操作导致 tail 在变化

// 2)经过多次操作后的 LinkedList 允许 tail 指向非尾部

for (Node t = tail, p = t;;) {

Node 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 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 h, Node 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
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值