1.1 STM的局限性
STM消除了显式的同步操作,所以我们在写代码时就无需担心自己是否忘了进行同步或是否在错误的层级上进行了同步。然而STM本身也存在一些问题,比如在跨越内存栅栏失败或遭遇竞争条件时我们捕获不到任何有用的信息。我似乎可以听到你内心深处那个精明的程序员在抱怨“怎么会这样啊?”。确实,STM是有其局限性的,否则本书写到这里就应该结束了。STM只适用于写冲突非常少的应用场景,如果你的应用程序存在很多写操作竞争,那么我们就需要在STM之外寻找解决方案了。
下面让我们进一步讨论STM的局限性。STM提供了一种显式的锁无关编程模型,这种模型允许多个事务并发地运行,并且在没有发生冲突时所有事务都能毫无滞碍地运行,所以相对其他编程模型而言STM可以提供更好的并发性和线程安全方面的保障。当事务对相同对象或数据的写访问发生冲突时,只有一个事务能够顺利完成,其他事务都会被自动重做。这种重做机制延缓了写操作冲突时竞争失败的那些写者的执行,但却提升了读者和竞争操作的胜利者的执行速度。当对于相同对象的并发写操作不频繁时,其性能就不会受到太大影响。但是随着冲突的增多,程序整体性能将因此变得越来越差。
如果对相同数据有很高的写冲突概率,那么我们的应用程序轻则写操作变慢,重则会因为重试太多次而导致失败。目前在本章我们所看到的例子都是在展示STM的优势,但是在下面的例子中我们将会看到,虽然STM是易于使用的,但也并非在所有应用场景下都能得到理想的结果。
在4.2节的示例中,当多个线程同时访问多个目录时,我们使用AtomicLong来对文件大小的并发更新操作进行同步。此外,如果需要同时更新多个变量,我们也必须依赖同步才能完成。虽然表面看起来使用STM对这段代码进行重构似乎是个不错的选择,但大量的写冲突却使得STM不适用于这个应用场景。下面就让我们将上述计算目录大小的程序改用STM实现,并观察其运行结果是否如我们所预料的那么差。
在下面的代码中,我们没有使用AtomicLong,而是采用了Akka托管引用作为FileSizeWSTM的属性字段。
1 |
public class FileSizeWSTM { |
2 |
private ExecutorService service; |
3 |
final private Ref<Long> pendingFileVisits = new Ref<Long>(0L); |
4 |
final private Ref<Long> totalSize = new Ref<Long>(0L); |
5 |
final private CountDownLatch latch = new CountDownLatch( 1 ); |
为了保证安全性,pendingFileVisits的增减都需要在事务内完成。而在之前使用AtomicLong时,我们只需要简单调用incrementAndGet()函数和decrementAndGet()函数就行了。但是由于托管引用都是通用的(generic),没有专门针对数字类型的处理方法,所以我们还需要针对pendingFileVisits进行一些额外的加工,即把对于pendingFileVisits的操作封装到一个单独的函数里。
1 |
private long updatePendingFileVisits( final int value) { |
2 |
return new Atomic<Long>() { |
3 |
public Long atomically() { |
4 |
pendingFileVisits.swap(pendingFileVisits.get() + value); |
5 |
return pendingFileVisits.get(); |
在完成上述定义之后,访问目录和计算文件大小的函数就相对容易多了,我们只需要把程序中的AtomicLong替换成托管引用就好。
01 |
private void findTotalSizeOfFilesInDir( final File file) { |
03 |
if (!file.isDirectory()) { |
05 |
public Object atomically() { |
06 |
totalSize.swap(totalSize.get() + file.length()); |
11 |
final File[] children = file.listFiles(); |
12 |
if (children != null ) { |
13 |
for ( final File child : children) { |
14 |
Limitations of STM • 137 |
15 |
updatePendingFileVisits( 1 ); |
16 |
service.execute( new Runnable() { |
18 |
findTotalSizeOfFilesInDir(child); } |
23 |
if (updatePendingFileVisits(- 1 ) == 0 ) latch.countDown(); |
24 |
} catch (Exception ex) { |
25 |
System.out.println(ex.getMessage()); |
最后,我们还需要写一些创建executor服务池和使程序运行起来的代码:
01 |
private long getTotalSizeOfFile( final String fileName) |
02 |
throws InterruptedException { |
03 |
service = Executors.newFixedThreadPool( 100 ); |
04 |
updatePendingFileVisits( 1 ); |
06 |
findTotalSizeOfFilesInDir( new File(fileName)); |
07 |
latch.await( 100 , TimeUnit.SECONDS); |
08 |
return totalSize.get(); |
13 |
public static void main( final String[] args) throws InterruptedException { |
14 |
final long start = System.nanoTime(); |
15 |
final long total = new FileSizeWSTM().getTotalSizeOfFile(args[ 0 ]); |
16 |
final long end = System.nanoTime(); |
17 |
System.out.println( "Total Size: " + total); |
18 |
System.out.println( "Time taken: " + (end - start)/ 1 .0e9); |
由于我怀疑这段代码跑起来之后可能有问题,所以如果在程序中抓到事务失败所导致的异常,我就会结束掉整个应用程序。
根据事务的定义,如果变量的值在事务提交之前发生了改变,那么事务将会自动重做。在本例中,多个线程会同时竞争修改这两个可变变量,从而导致程序运行变慢或失败。我们可以在多个不同的目录上分别运行上述示例代码来进行观察,下面就列出了该示例程序在我的电脑上计算/etc和/usr这两个目录的输出结果:
1 |
Total file size for /etc |
4 |
Total file size for /usr |
5 |
Too many retries on transaction 'DefaultTransaction' , maxRetries = 1000 |
6 |
Too many retries on transaction 'DefaultTransaction' , maxRetries = 1000 |
7 |
Too many retries on transaction 'DefaultTransaction' , maxRetries = 1000 |
从输出结果来看,STM版本对于/etc目录的计算结果与之前使用AtomicLong的那个版本是完全相同的。但是由于会产生过多的重试操作,所以STM版本的运行时间要比后者慢一个数量级。而遍历/usr目录的运行情况则更为糟糕,有相当多的事务超过了默认的最大重试限制。虽然我们的逻辑是一抓到异常就会立即终止整个程序,但由于多个事务是并发运行的,所以在程序真正停止之前我们还是能看到多条错误信息输出到控制台。
有个别评论家曾建议说是否用commute代替alter会对解决这个问题有所帮助。请回忆我们在6.4节中曾讨论过的在Clojure中用来修改托管引用的那三个函数。由于在事务失败之后不会进行重试,所以commute可以提供比alter更高的并发度。此外,commute也不会在没有hold住调用方事务的情况下就单独执行提交操作。然而单纯就计算目录大小这个程序而言,使用commute对性能的提升十分有限。在面对结构复杂的大型目录时,使用该函数也无法在提供良好性能的前提下获得一致性的结果。除了将alter换成commute之外,我们还可以尝试将atom与swap!函数一起使用。虽然atom是不可调整并且同步的操作,但其优点是不需要使用事务。此外,atom仅能在对单个变量(例如计算目录大小示例中用于记录目录大小的变量)的变更时使用,并且变更期间不会遇到任何事务性重试。然而,由于在对atom做变更时会产生对用户透明的同步操作,所以我们依然会遇到同步操作所导致的延迟问题。
由于大量线程会同时尝试更新totalSize变量,所以计算目录大小示例在实际执行过程中会产生非常频繁的写冲突,这也就意味着STM不适合于解决此问题。事实上,当读操作十分频繁且写冲突被控制在合理范围内时,STM的性能还是不错的,同时还能帮程序员免除显式同步的负担。但是在不考虑一般程序中常见的其他导致延时问题的前提下,如果待解决问题中含有大量写冲突,那就请不要使用STM,而是考虑采用我们在第8章中将会讨论的actor模型来避免同步操作。
1.1 小结
STM是一个针对并发问题的非常强大的编程模型,该模型有很多优点:
- STM可以根据应用程序的行为来充分挖掘出其最大的并发潜力。也就是说,用了STM之后,我们可以无需使用过度保守的、需要预先定义的同步操作,而是让STM动态地管理竞争冲突。
- STM是一种锁无关的编程模型,该模型可以提供良好的线程安全性和很高的并发性能。
- STM可以保证实体仅能在事务内被更改。
- STM没有显式锁意味着我们从此无需担心加锁顺序及其他相关问题。
- STM可以帮助我们减轻前期设计的决策负担,有了它我们就无需关心谁对什么东西上了锁,而只需放心地把这些工作交给动态隐式组合锁(implicit lock composition)。
该模型适用于对相同数据存在并发读且写冲突不频繁的应用场景。
如果应用程序的数据访问方式符合STM的适用范畴,则STM就为我们提供了一种处理共享可变性的高效解决方案。而如果我们的应用场景里写冲突非常多,我们可能就会更倾向于使用将在第8章中讨论的基于角色(actor)的模型。但在下一章,还是让我们先学习一下如何在其他JVM上的语言中使用STM编程模型。