理解Clojure STM 软件事务性内存

Posted shann09

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了理解Clojure STM 软件事务性内存相关的知识,希望对你有一定的参考价值。

翻译说明:

英文原文来自:http://java.ociweb.com/mark/stm/article.html

原文包含了一些非STM的知识,也包括STM底层实现的内容,这里只是翻译了STM抽象层的内容,自认为这部分比较重要

翻译是基于自己能够理解的方式翻译的,并非逐句翻译,目的是理解STM,理解如何调优STM,有逐句翻译强迫症的同学请不要喷我!

本人是在学习《Clojure编程乐趣》的“压力之下的 Ref”章节,遇到无法理解minHistory和maxHistory的时候才找到这篇文章的。

Ref是类似Atom和Agent的变量,包含一个所有线程共享的值,Ref只能在STM事务中修改,能够修改的函数有:ref-set,alter,commute,获取Ref值的方式是用@读取器或者deref,读取的话不需要在事务内部,但是要读取多个ref的一致性快照的话就需要在事务内部。


Ref是Clojure的STM使用的唯一一种变量类型。


alter和commute的第一个参数是一个Ref,第二个参数是一个能够返回新值的函数,简称参数函数,当参数函数被调用的时候,它会获得如下参数:当前Ref的值、alter或commute接收到的其它参数,参数函数返回的值作为Ref的新值。


大部分情况下使用alter,使用commute的情况是并发修改Ref,修改顺序无关紧要的情况,这与数学里的commutative(交换律)是一样的,加法交换律和乘法交换律都是顺序无关的,使用commute相当于在说:我现在要启用一个事务来修改一个Ref,但是我不担心其它事务会在我这个事务提交前就已经把Ref给改了,因为我在提交的时候还会再获取一下最新的Ref,并以此再计算一遍,然后提交,而这种做法依然能够保证最终结果是正确的。


commute的使用情境如集合中增加元素,或者统计一个集合中的数据,最大、最小、平均值等,假设一个Ref持有一个集合,如果2个并发的事务同时向这个集合添加元素,大多数情况下谁先加进去并不重要,事务A比事务B先运行,但事务B在事务A之前往集合里塞进一个新元素,这时事务A没有什么必要全部重试运行整个事务,只需要使用commute在提交的时候,再获取一次最新的Ref并塞元素进去。再假设Ref持有一个值,这个值是一个集合的最大值,事务A比事务B先运行,但事务B在事务A之前修改了这个最大值,这时事务A也没有理由重试整个事务,只需要使用commute在提交的时候,再获取一次最新的最大值,并对比计算一次然后提交。


使用alter或ref-set,当另外一个事务在当前事务之前提交了改变,当前事务将会会滚重试。commute在满足顺序无关的条件下,能够提升性能。


在一个事务中,传递给commute的参数会被存放在一个有序map里,map的key是Ref,value是包含了相关函数和参数的list,这个map的顺序是根据ref的创建时间来排列的。当事务正在提交的时候,一个写锁会按照map的顺序依次锁住该事务涉及的所有Ref,然后对于使用commute函数修改的Ref,会再次调用函数来修改Ref的值。这种按顺序锁定Ref的做法保证了不会发生死锁的情况。commute则允许第一次取得的Ref和第二次取得的Ref值可以不同。


在一个事务中,一个已经被commute调用了的Ref是无法被alter或者ref-set修改的,因为被commute调用的Ref意味着在提交阶段会被commute再次修改,而alter在commute之后获取并修改这个Ref的话,提交阶段STM无从判定是否应该会滚。(这段还不太理解,需要回头再看)


有时候需要防止其它事务修改一个当前事务会去读取或修改的Ref,也叫写入偏差,可以用ensure函数来解决写入偏差。ensure能保证其它事务无法修改Ref,但并不保证本事务可以修改Ref,因为其它事务可能也同时用ensure阻止了当前事务修改这个Ref。


在深入ClojureSTM之前,得先理解Validators和Watchers。Validators是一种函数,只要可变类型的变量被修改,就会调用这个函数,如果这个函数返回false或者是抛出异常,那么表示这次修改是无效的。每种可变类型的变量都唯一对应一个validator校验函数,使用set-validator!函数可以为一种可变类型的变量指定校验函数。有2种途径可以得知可变类型变量被修改:watch观测函数和watcher代理。watch观测函数必须接收4个参数,一个是唯一的key,一个是可变类型变量,还有旧值和新值。key可以用来表明watch函数的目的,也可以任何其它数据。每一个key对应一个变量,同时对应一个watch函数。我们可以使用add-watch函数来为变量指定观测函数。add-watch函数接收3个参数,变量、key和watch函数,可以用remove-watch函数来为变量解绑观测函数。remove-watch函数接收2个参数,变量和key。watcher代理能够在变量被修改的时候,收到一个action,这个action是一个函数,代理会把当前值和变量传递给action,注意没有传旧值哦。


接下来要从抽象层次来看看Clojure的STM实现,但是只针对Clojure的1.0版本,后续的版本可能会有不一样的实现方式,所以接下来要讨论的东西并不能完全代表Clojure的STM。要知道一点,理解STM内部实现并不是必需的,不理解内部实现也是能够正确使用它的。但是理解内部实现依然很有用。


1.0版本的ClojureSTM实现混杂着java和clojure源码,之后一些Clojure版本完全用java实现STM,近期则有不少为加大Clojure代码占比的工作在默默的进行着,一旦这些工作完成,我们在这里讨论的东西就过时了。不过不要灰心,STM的实现机制并不会有多大的变化。本人的意愿是在Clojure更新STM实现的时候,同步更新本文,帮助大家理解内部实现,不用去看晦涩的源码。


Clojure的STM实现是基于MVCC(multi-version concurrency control和snapshot isolation,也就是说Clojure实现了这两个抽象概念。这两个抽象概念的标准定义和clojure实现,主要区别是clojure用的是内存而不是数据库表。以下是对MVCC的定义,小括号中是标准定义相关的。


MVCC使用时间戳或者是事务性id来实现串行运行,MVCC通过维护一个拥有多个版本的对象(或数据库),确保一个事务不用等待这个对象。对象的每个版本都包含一个改写时间戳,每个事务都包含一个事务时间戳,当事务在读取对象的时候,会去抓取在事务时间戳之前的改写时间戳最新的那个版本。如果事务Ti想要改写一个对象,而事务Tk也想改写这个对象,Ti的事务时间戳必需先于Tk的事务时间戳,Ti才能成功改写对象。也就是说要一个事务要完成写入动作,它的事务时间戳必需是最早的那个。每个对象都有一个读取时间戳,假设事务Ti想要修改对象P,如果事务时间戳先于读取时间戳,Ti被抛弃并重试,否则Ti会创建新版本的P,并且设置这个版本的改写时间戳为事务时间戳,设置对象的读取时间戳为事务时间戳,注意ClojureSTM并没有使用读取时间戳。这种实现方式的一个显著缺点是保存多个版本对象的成本(存储在数据库中),优点则是快速读取,因为读取不会被阻塞,适合完成读取密集型的工作,它还适合用于实现“真隔离快照”,真隔离快照能够使并发操作以很低的消耗执行或者不完全执行。在隔离快照模式下,事务启动的时候会获取快照,就好像这个事务独享对象(数据库)一样,事务运行到提交阶段的时候会做判断,只有该快照没有被其它事务修改的情况下才能成功提交。


隔离快照的一个缺点是会导致写入偏差write skew,写入偏差是指并发事务读取一组对象,并根据这组对象中的一些对象来修改这组对象中的另一些对象,而这些对象之间是有约束关系的。举个例子,有个镇子严格限制每个家庭最多只能拥有3只宠物,宠物只能是猫或狗,李雷有一只狗,他的老婆韩梅梅有一只猫,这时候李雷收养了另一只狗,韩梅梅收养了另一只猫,他们倆同时并发进行,注意,事务只能看到其它已经提交成功的事务,事务看不到其它未提及的事务内部数据的。李雷的事务修改的是他们家拥有的狗的数量,这并没有违反上限3的约束,韩梅梅的事务也一样,都满足提交的条件,结果导致他们拥有4只宠物,因为李雷修改的是狗的数量,韩梅梅修改的是猫的数量,事务提交的时候,是根据其它事务是否修改了本事务要修改的对象来决定的,不管他们夫妻倆提交的先后,总是没有修改对方的要修改的对象,事务总是会成功提交。clojure提供了ensure函数来防止写入偏差的情况。


ClojureSTM实现使用锁和锁自由策略,事务取得锁以后,立即释放锁,而不是整个事务过程都持有锁。锁自由策略用于标记Ref变量是否被事务修改过。用Clojure写并发代码,比显式锁的那套更加简单,clojure创建一个事务使用dosync函数,并传入一组表达式(这组表达式也称作事务体body),不需要指明哪个Ref可能会被事务修改,但是开发者还是得分清哪些代码应该放在dosync内,因为在事务体内读取或修改的一组Ref变量是拥有一致性状态的,在事务体外读取就无法保证一致了,而且clojure事务体外是无法修改Ref变量的。


目前ClojureSTM使用了java的并发类有:
java.util.concurrent.AtomicInteger
java.util.concurrent.AtomicLong
java.util.concurrent.Callable
java.util.concurrent.CountDownLatch
java.util.concurrent.TimeUnit
java.util.concurrent.locks.ReentrantReadWriteLock
使用的clojure类主要有:
clojure.lang.LockingTransaction
clojure.lang.Ref


dosync宏包裹着事务体,dosync宏先调用sync宏,sync宏去调用LockingTransaction的静态方法runInTransaction。sync宏把事务体作为一个匿名函数传给runInTransaction。每一个线程都持有一个LockingTransaction对象,这些对象都是存放在一个ThreadLocal变量里,LockingTransaction对象就是一个事务,创建这个对象就是创建事务,在这个对象里面调用方法就是在事务里面调用方法。ThreadLocal能够保证线程访问到的是线程自己存取的对象。runInTransaction会先进行判断,如果当前线程还未持有LockingTransaction对象,那么就会创建一个,然后在这个事务对象里面运行sync传过来的匿名函数。如果当前线程已经运行了一个事务,即已经持有LockingTransaction对象,那么就会在这个事务对象里运行你们函数。


事务的状态有如下5种
RUNNING 运行中
COMMITTING 提交中
RETRY 重试
KILLED 扑街
COMMITTED 提交成功
当处于重试状态,事务将会尝试一次重试,但是还没开始试。如果开始重试了,状态会变成运行。有2种情况会导致事务扑街:1,在事务中调用abort方法,会设置事务状态为扑街并抛出AbortException异常,事务中止并且不会重试,当前版本的clojure并没有调用abort方法的代码,2,在事务中调用barge方法,会设置事务状态为扑街,但允许事务可以重试。


每一个Ref对象都有一个tvals字段,包含一串这个Ref的历史提交值,tvals的长度不会变短,只会变长。tvals字段的长度是由Ref对象的另外2个字段控制的:minHistory和maxHistory,默认是0和10,不同Ref对象的这2个字段可以各不相同,用ref-min-history和ref-max-history函数可以进行修改。不要忽视这个tvals长度的重要性,请参考下面Faults部分。每个Ref对象都有一个ReentrantReadWriteLock(可重入读写锁)。对于一个Ref对象,可以有任意数量的并发事务持有这个Ref的读取锁,而只能有一个事务持有这个Ref的改写锁。只有一种情况下一个事务的整个生命周期都会持有读取锁,那就是使用ensure修改Ref的时候,这种情况下,一个事务持有读取锁直到Ref在这个事务中被修改或者这个事务提交。不会发生一个事务整个生命周期都持有改写锁的情况。事务在某些情况下获取改写锁,并随即释放,在事务提交的时候再次获取改写锁,并在提交完成后释放,关于锁的更具体的信息,参考下面实现层次ClojureSTM部分的lock字段相关段落。


调用ref-set或alter修改一个Ref的时候,会获得这个Ref的一个事务内部值,对于外部事务是不可见的,提交成功后才变成外部可见。调用的同时还会修改Ref的tinfo字段,该字段描述了修改过这个Ref的事务的顺序以及当前事务状态等信息。事务就是通过读取tinfo来了解Ref是否正在被另外一个事务修改。可以把tinfo想象成一张门票,Ref持有门票就能够进入commit阶段,但是一张门票只能进入一个事务的commit阶段。关于tinfo更具体的信息,参考下面关于实现层次ClojureSTM部分的lock字段相关段落。


每一个LockingTransaction对象都有一个vals字段,该字段维护一个包含事务内部值的map,map的key是Ref对象,map的val是Ref对应的值,值的类型是java.lang.Object。如果事务周期内只对Ref读取,那么值是从tvals字段里获取,多次读取的话,效率就会相对较低。事务周期内,第一次修改Ref的时候,新的值会存放在vals字段里,事务周期内的后续操作就会从vals存取。


当事务内部,在读取一个Ref的时候,这个Ref即没有事务内部值(事务的vals字段没有这个Ref的key),在Ref的tvals字段里也找不到提交时间比当前事务开始的时间更早的值,那么就会发生“fault”故障。故障发生的时候,事务就会重试。
假设一个Ref从没经历过fault故障,之后也不会经历fault故障,而且它的minHistory是3,maxHistory是6,那么tvals的长度就会增长到3以后,保持在3,不会继续增长。
假设一个Ref从没经历过fault故障,之后也不会经历fault故障,而且它的minHistory是0,那么tvals的长度就不会超过1。
假设一个Ref已经经历过fault故障,并且这个Ref的tvals的长度小于maxHistory,这时事务A对该Ref提交了一个修改,那么这个Ref的tvals就会新增一个节点,tvals的长度就可能处于minHistory和maxHistory之间
假设一个Ref已经经历过fault故障,并且这个Ref的tvals的长度等于maxHistory,这时事务A对该Ref提交了一个修改,那么这个Ref的tvals就会新增一个节点,并去掉最早的那个节点,tvals的长度就是maxhistory


barge是用来描述当前事务继续的运行的情况下,另外一个事务是否应该重试。当一个事务A试图barge(闯入)另外一个事务B,只有满足这3个条件才能闯入成功:1,A必需至少已经运行了10毫秒,2,A的事务开始时间必需比B早,也就是说,老的事务优先于新的事务,3,B必需是处于RUNNING运行状态并在A闯入的时候能够成功修改成KILLED扑街状态,也就是说B如果处于提交状态,B就不会被闯入。


重试是指事务抛弃其对Ref的修改,回到事务体开始的地方,重新执行。有4种情况下会发生重试:
1,当事务体用ref-set或alter修改一个Ref的时候,会去获取这个Ref的锁,
a,如果其它事务已经占用了这个Ref的读取或改写锁,那么当前事务就获取不到Ref的改写锁。
b,如果当前事务启动后,已经有其它事务提交了对这个Ref的修改。
c,有另一个事务B正在修改这个Ref,但是还未提交,并且事务B尝试barge闯入其它事务并且尝试失败
2,事务A尝试读取Ref的值,但是:
a,另一个事务B已经闯入事务A,导致事务A的状态不是RUNNING
b,Ref并没有事务内部值,也没有比本事务开始时间更早的历史值,即发生了fault。
3,当事务体用ref-set,alter,commute,ensure修改一个Ref后,另一个事务成功barge闯入当前事务,导致当前事务的状态变成非RUNNING。
4,当前事务正在提交,但是另外一个事务做了一个事务内修改并且尝试闯入当前事务,并且失败
事务不会无限制的重试,在LockingTransaction对象里,有一个RETRY_LIMIT常量,当前版本的clojure是设置为1万,如果重试超过这个数,就会抛出异常。


重试是由一个java.lang.Error的子类RetryEx触发的,这个RetryEx是定义在LockingTransaction类里,不使用Exception的子类来触发的理由是:这样就不会被用户catch Exception块拦截到。重试的代码中包含一个拦截RetryEx的try块,拦截处理只是简单的回到事务开始的地方,这个try块并没有拦截其它的东西,所以如果发生其它的异常,就会中断事务。


在clojureSTM实现中,有不少方法会抛出IllegalStateException,如果在事务内抛出这个异常,就不会重试,下面这些情况会抛出这个异常:
1,当前线程尝试获取LockTransactin,但是这个对象并不存在,比如在事务外部调用ref-set、alter、commute或者ensure的情况
2,尝试获取Ref,但是获取不到值,比如Ref还没初始化
3,在事务内部尝试使用ref-set或alter修改一个已经被commute修改过的Ref
4,Ref的validation函数返回false或抛出异常
在ClojureSTM中不会发生死锁deadlock、活锁livelock、竞争条件race condition。

以上是关于理解Clojure STM 软件事务性内存的主要内容,如果未能解决你的问题,请参考以下文章

Clojure 发布年度调查报告:用于开发企业软件的比例历史最高

Day862.STM软件事务内存 -Java 并发编程实战

Day862.STM软件事务内存 -Java 并发编程实战

ScalaSTM官网翻译

STM 软件事务内存——本质是为提高并发,通过事务来管理内存的读写访问以避免锁的使用

JRuby中的Clojure STM