去年读了一下 TAOMP,但是一直没有时间好好整理一下笔记,懒癌要治一下……所以我准备把这本书每个部分切分一下,这样可能还能挤个几篇。
现在网上的资料真的是铺天盖地,往往都是所有人对同一个知识点反复咀嚼(让人想到那个啥……)。不能说这样不好吧,我还是喜欢读一些更原始更系统的东西。由道入术,由术入道,仅此而已。
本篇对应 TAOMP 第 9 章。
锁本质上是线性化。构建一个并发数据结构最简单的方法,就是直接对这个结构使用一个粗粒度(Fine-grained)锁。而优化粗粒度锁的方法大致有这样几种:
- Fine-grained synchronization
- Optimistic synchronization
- Lazy synchronization
- Nonblocking synchronization
链表的无锁并发是最后一种(这里使用的术语是非阻塞,但它并不是无锁的同义词)。
在设计一个并发数据结构之前,需要仔细考虑它的应用。比如,它是写频繁还是读频繁?还是检测包含比较频繁?在 TAOMP 中举的例子是用链表实现一个集合,而它通常使用contains
比读写要多。
并发推理
我的数学老师以前常常告诉我:要抓住不变的出发点推导出结论。这里也是这个意思。在推理之前,无干扰性(freedom from interference)是很重要的:比如,我们假设链表只能被内部方法修改,而不能有外部线程删除节点(这种算法应该出现在C++里)。
这里,一个集合具有(也要求)满足这样的不变性(invariant):
- 哨兵节点(指的是head 和 tail)不能增加或者删除
- 链表按照关键字排序,且关键字唯一
抽象映射(abstract map)描述了实现和抽象表示的映射关系。对象的抽象表示(abstract value)指的是集合,而具体实现(concrete representation)指的是链表。这里,抽象映射是这样的:一个元素包含在集合中当且仅当它对于 head 是可达的。
因此,并发推理就是基于每个具体的方法,比如contains
,独立地分析其不变性,同时要求其他方法保持不变性。
最后,在并发编程中需要保证安全性和活性。这个说法很学究,通俗来说,安全性就是保证程序正常运行;活性不能有死锁或者饿死。一般来说,保证安全性意味着我们必须要实现线性一致性——这是一个在分布式中很常见的术语,意味着操作是被逐个顺序执行的。这个只是为了证明算法的正确性。
活性比较重要,它需要算法提供演进保证。一般来说,我们可以提供两个非阻塞级别:无锁和无等待(当然还有其他的级别,比如无死锁)。这里的定义非常重要:
- A method is wait-free if it guarantees that every call finishes in a finite number of steps.
- A method is lock-free if it guarantees that some call always finishes in a finite number of steps.
比如说,一个典型的无锁算法是使用 CAS 的,但是它有可能某些线程总是会被其他线程抑制,抢不到机会。但是无等待算法的任何线程都能在有限步完成。
细粒度同步
细粒度锁的思想很简单:一把锁拆分成若干个锁,然后快速获取和释放。因此对应到链表中,拆分每个锁对应一个节点。因为在链表中每个节点的添加,删除至多影响2个节点,我们只获取两个节点的锁。整个流程是,每次锁定节点前,必须从head开始,逐个加锁。且只有得到了父节点的锁才能加锁子节点。这种技术被称为交叉上锁,也叫做锁耦合(lock coupling):

假设现在x->a->b->c,两个线程分别需要删除a和b,所以分别获得了x和a的锁(如果只能获得一个锁,那么显然是获得修改对象的锁,即父节点)。现在令x指向b,a指向c,如果a在x指向b之后发生,那么最终删除b的操作被覆盖了。
但为什么要进行交叉上锁,而不是直接遍历到指定节点?也就是说,为什么不能随机上锁?因为需要保证节点的可达性。可能还没锁住a的时候,a被删除了,然后锁住a添加节点就会丢失。同时,如果锁住了b和c,就不需要锁住 a,因为对a的任何操作不影响b和c。所以交叉上锁其实是一个逻辑的递推式:保证a不被删除,就可以保证b,保证c。
既然如此,那能不能弄一个标记,标识它是否删除呢?这就是后面惰性同步的内容了。
从更学术的角度来看,交叉上锁保证了可线性化(有兴趣可以研究这个证明,总之就是可以把操作看做是瞬间发生的)。为了保证演进,所有操作都是升序加锁。这可以保证该算法是无饥饿的,即不产生死锁。
可以看到,因为删除节点必须要用到前驱结点的指针和后继结点的指针,所以要锁定两个节点。那么,可不可以对于添加操作只锁定一个节点呢?答案是可以的。
细粒度锁虽然可以并发,但是它需要不断的获取和释放锁。如果正在删除比较靠前的位置,就会阻塞整个链表。
乐观同步
乐观锁的思想就不多说了,就是比较上锁前后的变化来决定是否进行操作。我觉得乐观锁和悲观锁有点像死锁的检测和预防之间的区别。书里的这个小故事很有意思:
A tourist takes a taxi in a foreign town. The taxi driver speeds through a red light. The tourist, frightened, asks “What are you are doing?” The driver answers: “Do not worry, I am an expert.” He speeds through more red lights, and the tourist, on the verge of hysteria, complains again, more urgently. The driver replies, “Relax, relax, you are in the hands of an expert.” Suddenly, the light turns green, the driver slams on the brakes, and the taxi skids to a halt. The tourist picks himself off the floor of the taxi and asks “For crying out loud, why stop now that the light is finally green?” The driver answers “Too dangerous, could be another expert crossing.”
前面说到,先遍历到指定节点再锁定是错误的。解决方法是,我们使用一个validate
函数验证节点是否可达。乐观锁的关键在于,这个validate
必须在加锁之后。也就是说,必须遍历链表两次(加上开始的搜索)。只要我们发现此时前驱节点依然是可达的,且指向这里的另一个节点,那么它们就都没有被删除。
private boolean validate(Entry pred, Entry curr) {
Entry entry = head;
while (entry.key <= pred.key) {
if (entry == pred)
return pred.next == curr;
entry = entry.next;
}
return false;
}

假设在 head 到指定节点之间遍历时,当前所在节点恰好被删除,但由于当前节点依然指向有效的后继,依然可以继续遍历。唯一的问题是这个节点有可能被GC回收。
乐观同步不是无饥饿的,因为有可能线程会被其他线程不停地阻塞。
惰性同步
乐观同步比细粒度同步已经要好了,但是仍然有一个问题是需要对contains
加锁。你可能怀疑这里加锁的必要性,举个例子:假设一个contains
线程现在遍历到了b,另一个线程准备删除b,同时增加我们想要搜索的值到b原先的位置。由于contains
线程没有锁,b被删除,期望值被添加,但我们接下来会访问b的后继,也就是d。这样,就跳过了期望的值。不过,如果你对contains
不要求精确的话,或许可以采用……
惰性同步可以让contains
方法变为无等待的,同时add
和remove
即使被阻塞也只需要遍历一次链表。它增加了一个不变性约束:所有未被标记的节点一定是可达的,反之则不可达。简单来说,就是有没有删除。remove
先标记该节点,从逻辑上删除,然后更新其前驱,从物理上删除。这里虽然论文里也是叫做 Lazy List,但是我总觉得它和一般的惰性策略差别很大。这里并不是推迟删除,而是增加了一个逻辑删除的步骤,这可以避免乐观锁里进行遍历。判断一个节点是否被删除的检查方法如下:
private boolean validate(Node<T> pred, Node<T> curr) {
return !pred.marked && !curr.marked && pred.next == curr;
}

可以看到,不必验证父节点的可达性,只需要验证他的标记即可。但是,为什么需要验证curr
的标记呢?在乐观实现中,也只需要检查pred
的可达性,以及pred
是否指向curr
。考虑curr
被删除的情况。此时一定pred
不指向curr
,除非pred
也被删除了。或者,除非逻辑删除和物理删除被划分为两步,在add
或者remove
中可以看到一个被逻辑删除但是还没有物理删除的节点,否则这里并不需要检查curr
的标记。不过这只是我个人的想法,具体的正确性倒是无法验证。
惰性同步的contains
是无等待的。不过,整个算法依然会有阻塞的情况。Traffic Jam 描述了这样的情况:一个线程的阻塞(比如缺页,缓存没有命中等等)会延迟其他所有的线程。这是使用互斥算法不可避免的问题。
无锁同步
可以看到,想要实现真正的无阻塞算法,无锁是必不可少的。无锁算法的特点,是往往使用 CAS 操作,而不是获取锁。CAS 操作的大行其道,就源自于本书作者Maurice Herlihy证明了,CAS原语具有无限的共识数。什么是共识数呢?就是这种类型的对象,所能并发且满足对象可以同步的最大线程数:
Consensus numbers is the maximum number of threads for which objects of the class can solve an elementary synchronization problem called consensus.
这么说有点晦涩,但其实是分布式系统中很常见的共识问题。只不过,由于多线程具有共享内存模型(且多线程追求更高的效率,不能使用选举这样的方式)。但共享内存的缺点在于,它的读和写都是孤立的操作,因此当我们读完一个值时,这个值却可能已经被修改了。我们无法确认这一点——除非再读一次。这个过程可以无限循环,所以原子寄存器(其实就是指内存)的共识数为1。这说明只用内存不可能进行线程同步。这个结论可能是并发编程最重要的结论(也许可以去掉之一)。
注意这里指的是不使用锁的情况。有兴趣可以读一下知乎的推荐:
Maged Michael基于Tim Harris在2001年的工作,提出了一个基于Lock-Free List算法,也被叫做Harris-Michael算法。这里的算法只是一种简单的变种。
假设现在我们将惰性同步中的锁去掉,使用 CAS 修改元素的 next 指针。那么有可能出现论文中这样一个例子:

假设A打算删除10,B插入20。由于没有锁,它们可以同时进行 CAS(因为B可以先进入临界区,然后A再修改10的标记)。所以,我们需要让10被标记删除和更新next两件事不可分割。Java提供了AtomicMarkableReference<V>
类来将引用和一个标记绑定。AtomicMarkableReference<V>
可以根据期望的引用和标记来更新标记和引用。
当然,对于删除操作而言,这两个操作还是分开的,因为标记在当前节点上,但是next在前驱结点(除非你把标记放在前驱,但这样好像就又变复杂了)。只是我们在判断是否删除时,我们可以同时判断这两个条件。以上图为例,一旦A把10改为逻辑删除,B就不能更新指针了。
为什么AtomicMarkableReference
的get(boolean[] markHolder)
方法参数是一个数组?因为传入的markHolder
会包含它的mark
值。Java 既没有元组,也没有可输出参数,所以只能这么写喽。
这里有可能出现前驱指向一个被标记的节点的时刻。但这不会产生问题,因为添加操作会用一个外循环不断CAS,然后重新取前驱(也就是图中的H)。
看了一下示例的代码,整个逻辑是很清楚的(不过这个goto的写法我不是很喜欢),只是在遍历查找键对应的节点之外增加了需要物理删除节点的步骤。这一点,你可以理解为懒惰策略中的分摊,或者是多线程协作。也就是说,我们不管在add
,remove
遍历的时候,都可以将remove
线程剩下的物理删除完成。
比起前面的惰性同步,我觉得这个无锁算法可能更能体现Lazy的精髓。
public Window find(Node head, int key) {
Node pred = null, curr = null, succ = null;
boolean[] marked = {false}; // is curr marked?
boolean snip;
retry: while (true) {
pred = head;
curr = pred.next.getReference();
while (true) {
succ = curr.next.get(marked);
while (marked[0]) {
snip = pred.next.compareAndSet(curr, succ, false, false); // 这里是线程协作的操作
if (!snip) continue retry;
curr = pred.next.getReference();
succ = curr.next.get(marked);
}
if (curr.key >= key)
return new Window(pred, curr);
pred = curr;
curr = succ;
}
}
}
contains
和惰性同步代码是相似的,都是无等待的。
public boolean contains(T item) {
boolean marked;
int key = item.hashCode();
Node curr = this.head;
while (curr.key < key)
curr = curr.next;
Node succ = curr.next.get(marked);
return (curr.key == key && !marked[0])
}
实现这种无锁算法强演进的代价是:
- 对标记和引用整体的CAS增加了开销;
- 由于
add
和remove
都需要均摊删除操作,所以会产生争用,从而重新遍历节点。
因此,使用什么样的算法不是绝对的,取决于add
和remove
的相对频率,任意线程延迟的可能性,实现整体CAS的开销等等。有一篇不错的文章介绍了该算法:
他讲到了该算法的几个问题。首先,用AtomicMarkableReference
虽然简单但是性能不好,每次执行CAS操作的时候,都要创建新的对象。
public boolean compareAndSet(V expectedReference,
V newReference,
boolean expectedMark,
boolean newMark) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedMark == current.mark &&
((newReference == current.reference &&
newMark == current.mark) ||
casPair(current, Pair.of(newReference, newMark)));
}
因为硬件层的CAS操作一定是一个值,而不能扩展为一个类,或者说多个值。所以casPair
只能更新为一个新的内部类Pair
,然后每次去改这个类的地址。
在Java中,另一种可能的实现是使用运行时类型信息(RTTI)创建的一种Harris算法的变体。所谓RTTI,其实就是多态——对于原先的Node
扩展为UnMarked
和Marked
两个子类。
我看了一下他的实现是非常hack的,因为指针保存在父类上,所以在运行时直接CAS为另一个子类。这里还有第3种策略我没有看到他提到,也就是 JUC 中使用的,通过指向一个中间虚节点的引用来表示被标记了。可以看到在ConcurrentSkipListMap
中使用到了这个技巧。
另一个问题是,CAS操作必然要面对ABA问题。不过,在Harris算法中节点总是新建的,因此可以避免ABA问题。不过,这里还是值得略微思考一下,它意味着一个更加有意义的结论:GC,或者说自动内存管理,可以避免引用的ABA问题。这是为什么呢?
进行CAS的引用一定是可达的,因为此时它正在被引用。此时GC不回收这部分内存,这样一来新创建的对象不和之前的地址相同。但是在非自动回收内存的语言中(好处是可以使用steal a bit技巧),我们经常将内存管理直接和数据结构耦合在一起,就有可能会出现ABA问题。一般的策略是加tag。一种无锁数据结构的内存管理技术是Hazard Pointer。参考 Oceanbase 吴镝大神的文章:
lock free数据结构内存回收技术-hazard pointer
Hazard Pointer其实是读写锁的一个应用,即线程本身私有的Hazard Pointers可以被自身修改,而被其他所有线程读取。
Each reader thread owns a single-writer/multi-reader shared pointer called “hazard pointer.” When a reader thread assigns the address of a map to its hazard pointer, it is basically announcing to other threads (writers), “I am reading this map. You can replace it if you want, but don’t change its contents and certainly keep your deleteing hands off it.”
这个基于链表的无锁集合算法,没有在Java里找到对应的实现,可能是因为ConcurrentLinkedQueue
包含了这里无锁链表集合的功能:
Lock-Free Concurrent Linked List in Java
PS:感觉掘金的标签设计及其不合理……不能自定义不说可用的标签也很少……