软件事务内存导论(十一)-STM的局限性

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();
6 }
7 }.execute();
8 }

在完成上述定义之后,访问目录和计算文件大小的函数就相对容易多了,我们只需要把程序中的AtomicLong替换成托管引用就好。

01 private void findTotalSizeOfFilesInDir(final File file) {
02 try {
03 if (!file.isDirectory()) {
04 new Atomic() {
05 public Object atomically() {
06 totalSize.swap(totalSize.get() + file.length());
07 return null;
08 }
09 }.execute();
10 } else {
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() {
17 public void run() {
18 findTotalSizeOfFilesInDir(child); }
19 });
20 }
21 }
22 }
23 if(updatePendingFileVisits(-1) == 0) latch.countDown();
24 } catch(Exception ex) {
25 System.out.println(ex.getMessage());
26 System.exit(1);
27 }
28 }

最后,我们还需要写一些创建executor服务池和使程序运行起来的代码:

01 private long getTotalSizeOfFile(final String fileName)
02 throws InterruptedException {
03 service = Executors.newFixedThreadPool(100);
04 updatePendingFileVisits(1);
05 try {
06 findTotalSizeOfFilesInDir(new File(fileName));
07 latch.await(100, TimeUnit.SECONDS);
08 return totalSize.get();
09 } finally {
10 service.shutdown();
11 }
12 }
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);
19 }
20 }

由于我怀疑这段代码跑起来之后可能有问题,所以如果在程序中抓到事务失败所导致的异常,我就会结束掉整个应用程序。

根据事务的定义,如果变量的值在事务提交之前发生了改变,那么事务将会自动重做。在本例中,多个线程会同时竞争修改这两个可变变量,从而导致程序运行变慢或失败。我们可以在多个不同的目录上分别运行上述示例代码来进行观察,下面就列出了该示例程序在我的电脑上计算/etc和/usr这两个目录的输出结果:

1 Total file size for /etc
2 Total Size: 2266408
3 Time taken: 0.537082
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
8 ...

从输出结果来看,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编程模型。 

时间: 2024-09-18 22:40:37

软件事务内存导论(十一)-STM的局限性的相关文章

软件事务内存导论

前言 软件事务内存 用Akka/Multiverse STM实现并发 创建事务 创建嵌套事务 配置Akka事务 阻塞事务 提交和回滚事件 集合与事务 处理写偏斜异常 STM的局限性 文章转自 并发编程网-ifeve.com

软件事务内存导论(二)软件事务内存

1.1    软件事务内存 将实体与状态分离的做法有助于STM(软件事务内存)解决与同步相关的两大主要问题:跨越内存栅栏和避免竞争条件.让我们先来看一下在Clojure上下文中的STM是什么样子,然后再在Java里面使用它. 通过将对内存的访问封装在事务(transactions)中,Clojure消除了内存同步过程中我们易犯的那些错误(见 <Programming Clojure>[Hal09]和<The Joy of Clojure>[FH11]).Clojure会敏锐地观察和

软件事务内存导论(三)用Akka/Multiverse STM实现并发

用Akka/Multiverse STM实现并发 上面我们已经学习了如何在Clojure里使用STM,我猜你现在一定很好奇如何在Java代码中使用STM.而对于这一需求,我们有如下选择: 直接在Java中使用Clojure STM.方法非常简单,我们只需将事务的代码封装在一个Callable接口的实现中就行了,详情请参见第7章. 喜欢用注解(annotation)的开发者可能会更倾向于使用Multiverse的STM API. 除了STM之外,如果我们计划使用角色(actor),那么还可以考虑选

软件事务内存导论(五)创建嵌套事务

1.1    创建嵌套事务 在之前的示例中,每个用到事务的方法都是各自在其内部单独创建事务,并且事务所涉及的变动也都是各自独立提交的.但如果我们想要将多个方法里的事务调整成一个统一的原子操作的时候,上述做法就无能为力了,所以我们需要使用嵌套事务来实现这一目标. 通过使用嵌套事务,所有被主控函数调用的那些函数所创建的事务都会默认被整合到主控函数的事务中.除此之外,Akka/Multiverse还提供 了很多其他配置选项,如新隔离事务(new isolated transactions)等.总之,使

软件事务内存导论(六)配置Akka事务

配置Akka事务 默认情况下,Akka为其相关的运行参数都设定了默认值,我们可以通过代码或配置文件akka.conf来更改这些默认设置.如果想了解如何指定或修改该配置文件位置的详细信息,请参阅Akka的文档. 针对单个事务,我们可以利用TransactionFactory在程序代码中更改其设置.下面就让我们用这种方式先后在Java和Scala中更改一些设置来为你展示如何实现设置的变更. 在Java中对事务进行配置 01 public  class  CoffeePot  { 02     pri

软件事务内存导论(九) 集合与事务

集合与事务 在我们努力学习这些示例的过程中,很容易就会忘记我们所要处理的值都必须是不可变的.只有实体才是可变的,而状态值则是不可变的.虽然STM已经为我们减轻了很多负担,但如果想要在维护不可变性的同时还要兼顾性能的话,对我们来说也将是一个非常严峻的挑战. 为了保证不可变性,我们采取的第一个步骤是将单纯用来保存数据的类(value classes)及其内部所有成员字段都置为final(在Scala中是val).然后,我们需要传递地保证我们自己定义的类里面的字段所使用的类也都 是不可变的.可以说,将

软件事务内存导论(四)创建事务

创建事务 我们创建事务的目的是为了协调针对多个托管引用的变更.事务将会保证这些变更是原子的,也就是说,所有的托管引用要么全部被提交要么全部被丢弃,所以在事务之外我们将不会看到有任何局部变更(partial changes)出现.此外,我们也可以用创建事务的方式来解决对单个ref先读后写所引发的相关问题. Akka是用Scala开发出来的,所以如果我们工作中用的是Scala的话,就可以直接幸福地享用Akka简洁明了的API了.对于那些日常工作中不能使用Scala开发的程序员,Akka同样也提供了一

软件事务内存导论(七)阻塞事务

阻塞事务--有意识地等待 我们经常会遇到这样一种情况,即某事务T能否成功完成依赖于某个变量是否发生了变化,并且由于这种原因所引起的事务运行失败也可能只是暂时性的.作为对这种暂时性失败的响应,我们可能会返回一个错误码并告诉事务T等待一段时间之后再重试.然而在事务T等待期间,即使其他任务已经更改了事务T所依赖的数据,事务T也没法立即感知到并重试了.为了解决这一问题,Akka为我们提供了一个简单的工具--retry(),该函数可以先将事务进行回滚,并将事务置为阻塞状态直到该事物所依赖的引用对象发生变化

软件事务内存导论(十)处理写偏斜异常

处理写偏斜异常 在6.6节中,我们曾经简单讨论了写偏斜(write skew)以及Clojure STM是如何解决这个问题的.Akka同样提供了处理写偏斜问题的支持,但是需要我们配置一下才能生效.OK,一听到配置这个词可能让你觉得有些提心吊 胆,但实际操作起来其实起来还是蛮简单的.下面就让我们首先了解一下Akka在不进行任何配置情况下的默认行为. 让我们回顾一下之前曾经见到过的那个多个账户共享同一个联合余额最低限制例子.首先我们创建了一个名为Portfolio的类来保存支票账户余额和 储蓄账户余