多处理器编程的艺术 第二版 第七章 笔记

简介

架构会影响性能。我们这次设计适合今天的多处理器的互斥协议。

1. 

反复测试锁称为自旋(spinning)或忙等待(busy-waiting),延迟较短时是合理的
自旋(spinning)仅适用于多处理器,因为在单处理器上,线程自旋会占用 CPU 资源,阻止其他线程运行,导致效率低下。在多处理器上,自旋线程可在其他核心运行,不阻塞其他线程,适合锁延迟短的场景。
2. 
另一种选择是挂起自己,请求操作系统在你的处理器上调度另一个线程,这称为阻塞(blocking),预期锁延迟较长
 

7.1 Welcome to the real world

lock.lock()应该放在try外面,以防止本身报错,finally释放本来就没拿到的锁。

两个原因导致我们的peterson不靠谱

1. 

The first culprits are compilers that reorder instructions to enhance
performance

2. 

the vast
majority of writes do not need to take effect in shared memory right away,往共享变量缓存的写会延迟一会

内存屏障可解。使用高共识的原语操作来制作锁可能会好使一些?

7.2 Volatile fields and atomic objects

作为经验之谈,任何不在critical section区域的对象域都应该标记上volatile,没有这个东西,读到的可能是陈旧的值,写也要等一会。

volatile不能让组合的操作原子化,x++(x=x+1)依然可能会出问题。AtomicInteger这种类会提供更多的好用的原子操作。

7.3

 

7.4 Exponential back-off

通过延时来减少争用,但是也有一些缺点,对于关键区的使用不足,以及不公平。

 

假设有一个共享计数器(counter),由 BackoffLock 保护:

  • 不公平的 BackoffLock
    • 线程 A 获取锁,递增 counter,counter 的值在 A 所在处理器的缓存中(Modified 状态)。
    • A 释放锁后立即再次获取锁(因不退避或退避时间短),继续访问 counter。
    • 因为 counter 数据仍在 A 的缓存中,A 可以直接操作,缓存命中,无需总线通信。

7.5 Queue locks

看起来back-off锁的极限到这里了?我们考虑利用队列,好处多多。

  • 可移植性强:比退避锁更易适配不同系统。
  • 降低缓存一致性流量:线程在不同位置自旋,减少缓存竞争。
  • 高效利用临界区:线程由前驱直接通知,无需猜测访问时机。
  • 高公平性:提供先来先服务的公平性,类似Bakery算法。

7.5.1 Array-based locks

可以通过将flag放在不同的缓存行避免被写入的时候跟自己无关结果还要去重新去读。

然而,ALock 的缺点是空间效率不高。它需要一个已知的上界 n,表示最大并发线程数,并且每个锁需要分配一个大小为 n 的数组。同步 L 个不同对象需要 O(Ln) 的空间,即使一个线程一次只访问一个锁也是如此。

 7.5.2 The CLH queue lock

如果要实现L+N的空间复杂度,似乎只要把mypred和myNode改成static就行了(保证每一个线程在任何锁都只有一个node)。

如果没有缓存,就会有点膈应,但是有的话就很好了。

llm说现在大多数NUMA已经有缓存一致了。

 

7.5.3 The MCS queue lock

这个锁有显式的链表(在QNode类中有体现)

这一次,自己的QNode的值由前一个拿到锁的人来改,自己出CS之后需要给自己的后继者修改值。

 

当Thread B离开的时候,直接打断掉next的QNode,而自己这个Node当然也已经被A.next给打断了,所以没有人在用,就可以重用了。

每个锁只自选自己本地的值,所以就算无缓存也很好。

一个缺点是释放锁需要自旋。另一个缺点是它需要比 CLHLock 算法更多的读、写和 compareAndSet() 调用。

 7.6 A queue lock with timeouts

不可能简单的就退出,因为后面的节点已经连接上来了,放弃了就完犊子。
惰性方法:当线程超时,它将自己的节点标记为 已放弃。其队列中的后继者(如果有)会注意到它自旋的节点已被放弃,并开始自旋于被放弃节点的前驱。这种方法还有一个额外的好处:后继者可以回收被放弃的节点。
协议:
当一个 QNode 的 pred 字段为 null 时,关联的线程要么尚未获取锁,要么已释放锁。当 pred 字段指向一个特殊的静态 QNode AVAILABLE 时,关联的线程已释放锁。最后,如果 pred 字段指向其他 QNode,则关联的线程已放弃锁请求,因此拥有后继节点的线程应等待被放弃节点的前驱。
这个协议其实设计的有点恶心,但是压缩了使用的空间。

 

7.7 Hierarchical locks

本地线程vs远程线程

7.7.1 A hierarchical back-off lock

难以选择退避时间、释放-获取延迟高、可能引发缓存一致性流量风暴

7.7.2 Cohort locks

 

自己的集群能拿到全局锁,自己能拿到在本集群的锁。

释放锁的时候,如果本集群就有人要,那就不用释放集群拿到的全局锁了。无需跨集群交流。

防止饥饿,就得设置一个协议。

lock支持一个新的方法alone(cohort detection),如果返回false,就有别的线程要,如果是true,就没有(但是可能会判断错,也就是说有人要,我们给他判断成了没人要)

thread-oblivious: 释放锁的线程不一定是刚刚拿到锁的线程(意思应该是,我们传递了锁,也不知道是谁释放锁了)

我们的cohort锁的全局锁刚好就是这个thread-oblivious的性质,我们希望它要实现这一点功能。

获取:先拿集群锁,然后看看全局锁是不是已经拿到了。

释放:1. 如果有人要锁而且没超过限制,那就直接在本地传递了 2.如果没人要锁或者已经超过限制,那就给别的集群的锁了

 

7.7.3 A cohort lock implementation

 7.8 A composite lock

一种高级锁算法,能够有backoff和queue的优点。

去拿node,拿不到就back off,拿到了就插入到queue。

 

AtomicStampedReference<QNode>

  • 链表中某个节点在数组索引 5 处,线程 1 读取该索引并准备更新。
  • 线程 2 删除该节点(标记索引 5 为“空闲”),然后插入新节点,恰好又分配到索引 5。
  • 线程 1 看到索引 5 仍然“有效”,执行 CAS 操作,但实际上节点的内容或链表结构已改变。

tail要不是null,要不就是最后一个插入queue的node。

waiting:已经进入队列,拥有者要不已经进关键区,要不就还在等

released:拥有者已经离开cs,释放锁。

不再尝试进入的两种情况:

FREE:已经拿到node但是还没放到queue

ABORTED:进入了queue还是不要了

tryLock:

1. QNode node = acquireQNode(backoff, startTime, patience);
上面这个从waitingarray中获取node

2. QNode pred = spliceQNode(node, startTime, patience);

然后放进queue

3. waitForPredecessor(pred, node, startTime, patience);

总算等到在queue的头部了

 

对于上面的acquireQNode的逻辑
1. 随机选一个,然后cas(free, waiting)

2 不成功, 检查状态(成功就直接返回)

3.1 检查状态是ABORTED或者RELEASED, 线程需要清理这个node,以防止出现同步矛盾

只有当它是tail才要clean。如果是ABORTED就让tail是这个node的前一个(这个节点是已经进入了queue然后被放弃的节点,就相当于自己跑了,让前一个节点去当尾巴),是RELEASED的话tail就是null

3.2 是WAITING,那说明别人还要用这个node,那就backoff一会

 

对于上面的spliceQNode

tail.compareAndSet(currTail, node, currStamp[0], currStamp[0]+1) 检查 tail 是否还是 currTail(尾节点没变)且标记是 currStamp[0](版本没变)。如果是,就原子地将 tail 更新为 node(新节点),并将标记加 1,表示插入成功。如果检查失败,说明尾节点或版本已被其他线程修改,插入失败(需要重试)。

 

 

对于waitForPredecessor

如果前一个哥们已经是null,那么这个线程的node就是排第一了,那就存到thread-local回头unlock能用,然后进去cs。

如果前一个哥们不是null,while里面检查是不是RELEASE,如果不是RELEASE(是RELEASE说明前一个哥们离开cs,就到我们了),检查是不是ABORTED。

操作一:如果是ABORTED,那就设置自己的前一个是前一个的前一个,然后给前一个设置成FREE(相当于给前一个哥们踢出队列了)

操作二:检查timeout,如果超时,就设置成ABORTED然后抛出异常。

要不然的话,就更新前一个哥们的状态继续转圈圈。

等到变成RELEASE就给他变成FREE,记录自己的node到thread-local的myPred然后进入cs。

 

unlock:状态改成RELEASE,然后myNode改成null走人

For L locks and n threads, theCompositeLockclass requires only O(L) space
in the worst case, as compared to theTOLockclass’s O(L ·n).

 

7.9 A fast path for threads running alone

快速路径

 

22if (super.tryLock(time, unit)) {
23while ((tail.getStamp() & FASTPATH ) != 0){};
24return true;
25}

 

自己通过慢速path拿到了锁,还有可能别的线程走了快速路径,一直检查直到所有的都不是1

 

 

 

 

 
posted @ 2025-05-04 05:46  映空城  阅读(12)  评论(0)    收藏  举报