前言
这一系列笔记以Maurice Herlihy等编著的The Art of Multiprocessor Programming及其PPT的practice部分为基础,主要注重于并发编程的性能。会对其做一定的扩展和省略,其中的Java代码也许会改成C++代码(因为我更熟悉C++)
粗粒度锁同步
大家都会
细粒度锁同步
删除
错误的加锁方式:单个加锁
head->n1->n2->n3->n4->n5->tail
当我们想要删除某个节点时,比如说删除n4,我们一定会做的事情是
n3->next=n5
看起来只写了n3,那么我们是不是可以有这样的加锁策略:读的过程中加锁,因此加锁顺序是head n1 n2 n3,并且拿到下一个节点的锁后释放当前锁。这样到了n3时,我们就只需要对n3加锁(前面的锁已经释放掉了),在锁的保护下改掉n3的next指针即可。
这样做的问题用下图可以清晰地表示。现在有两个线程B和C,B想要删除b,C想要删除c。如果此时C拿到了b的锁,B拿到了a的锁并且读到了b->next是c,C将b的指针重定向到d, B将a的指针重定向到c。这时候c就没有被删除了,因此出现了问题。其实问题就在于B读到c的时候没有对b加锁(因为b->next是b的成员,读取b当然是应该加锁的啦,如果假设b->next是一个原子指针,这只是避免了读写数据竞争,但并没有保证remove"事务"之间的"隔离性")。
正确的加锁方式:前驱锁和目标锁
由上面的讨论应该可以知道,正确的加锁方式是hand over hand的加锁:每次移动都应该持有两个锁。以下面这个链表删除n4为例,重要的步骤是
head->n1->n2->n3->n4->n5->tail
- 获取head锁,获取n1锁
- 释放head锁,获取n2锁
- 释放n1锁,获取n3锁
- 释放n2锁,获取n4锁
- 此时n3和n4被锁住,发现n4是目标节点,于是n3->next = n4->next
插入
插入时会在p后面插入n。实际上我们只需要对p加锁就行了。在p被加锁后,后继节点s的地址被读到(p->next),并且s不可能被删除,因为想要删除s就要拿到p的锁。同时,插入过程中n也不可能被加锁,因为p已经加锁了,其他线程无法绕过p。
实现
struct Node {
int val_;
std::mutex mtx_;
std::shared_ptr<Node> next_;
Node() : val_(0) {