前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >并发编程-原子性

并发编程-原子性

作者头像
ImportSource
发布2018-04-03 16:34:29
1.3K0
发布2018-04-03 16:34:29
举报
文章被收录于专栏:ImportSource

前面我们说了有关stateless的内容,那么如果我们在一个stateless的object中添加一个状态元素会发生什么呢?现在假设我们想要添加一个命中计数器(hit counter),其实就是用来记录处理请求的次数。那么你也许想到了,比较明显的做法就是给这个servlet添加一个long类型的field,然后每次请求都会自动的加1,就像程序清单2.2的UnsafeCountingFactorizer类那样。

强势插入上一期:并发编程-什么是线程安全?

不幸的的是,UnsafeCountingFactorizer这个类不是线程安全的,尽管它在单线程的环境下没什么问题运转良好。就像上一文中提到的那个UnsafeSequence一样,UnsafeCountingFactorizer很可能会导致丢失更新(lost updates)。虽然这个自增操作++count看起来像是一个操作,one action,因为他的语法很紧凑。但其实这个操作不是原子的,也就是说这个操作不是一个(单一且不可分割的)操作。相反,它是由三个独立的操作组成的:读取当前的value,对当前的value加1,然后把新的value写回去。这是一个典型的”读取-修改-写入”(read-modify-write)操作,并且其结果状态依赖于上一个状态,或者叫之前的状态。

程序清单 2.2.在没有同步的情况下统计已处理请求数的Servlet(不要这样做)

图1.1展现了如果两个线程在没有同步措施的情况下同时对一个计数器执行递增操作将会发生的情况。如果该计数器的初始值为9,那么在一种比较糟糕的情况下,每个线程都读取到了9这个值,然后各自都对9加了1,然后各自都把counter设置为了10。这个状况明显不是我们希望发生的,就是一个递增操作丢失了,这个命中数就会差一个。(ImportSource前面的一篇《并发编程-多线程带来的风险》中有图1.1)

你也许会认为在这种web服务中,命中数的少量偏差是可以接受的,在很多时候确实如此。但如果计数器是被用来生成一个序列号或者唯一对象标识符,这时候如果多次调用返回了相同的value,就会导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是非常要命的,以至于它有了一个名字叫做:竞态条件(race-condition)。

2.2.1.竞态条件(Race Conditions )

在UnsafeCountingFactorizer中存在多个竞态条件(race conditions)从而使得结果不可靠。当一个计算的正确与否取决于运行时多个线程的交替执行或相对的时序的时候,竞态条件就发生了;换句话说,当得到一个正确的答案取决于运气的时候,竞态条件就发生了。最常见的竞态条件的类型就是:“先检查后执行”(check-then-act),就是通过一个潜在的可能失效的结果来决定下一步要做什么。

在我们的现实世界中也经常会遇到竞态条件。比如说你打算中午在University Avenue的Starbucks会见一位朋友。但是,当你到了那儿的时候,你发现University Avenue这里有两个Starbucks。在12点10分的时候,你还是没看到你的朋友到Starbucks A。于是你就去了Starbucks B看他是不是在那儿,但他也不在B。那么这种情况下有以下可能:一种可能是你的朋友迟到了,所以没在任何一个Starbucks;还有种可能是你的朋友在你离开Starbucks A后到了Starbucks A;或者你的朋友在Starbucks B,但他去找你了,现在正在去往Starbucks A的路上。现在让我们说一种最糟的情况下,也是最后一种可能性:现在是12点15分,你两都已经去过了两个Starbucks,并且都在怀疑是不是对方放了鸽子。这时候你们该怎么做呢?是回到另外一个Starbucks吗?那么你们要来来回回多少次啊?除非你们约定好一个协议,否则你们两个这天可能就一直在University Avenue上走来走去,郁闷至极。

在“我去看看他是否在另一家Starbucks”这种解决办法的问题在于:当你走在街上,你的朋友也许可能已经离开了你要去的那家Starbucks。你首先看了看Starbucks A,发现“他不在”,然后就去寻找他。然后你在Starbucks B也做了同样的事情,但是不是在同一时间。你在街上的这段时间里,也许系统的状态就已经发生了改变。

Starbucks的这个例子说明了一种竞态条件,就是要想获得一个预期的正确的结果(与朋友会面),必须要取决于事件发生的相对时序(就是当你们到达了Starbucks后,你们会在这个地方等多久再去另外一家……)。一旦你走出前门,你在Starbucks A观察到的“他不在这里”的这个结果便可能变为无效;因为你的朋友他也许这时候就从后门进来了,而你却不知道。这种观察结果的无效其实就描绘了大多数的竞态条件的本质:就是根据一个可能陈旧的观察结果来做出下一步的决定以及计算。

这种类型的竞态条件我们称之为:“先检查后执行”(check-then-act):就是你观察到了一些事情是真的(比如:文件X不存在)然后就根据这个观察到的结果来做下一步的操作(比如:创建X);但事实上你观察到这个“不存在”的结果很可能在你观察到结果到你做下一步操作的时候,这段时间里已经发生了改变,之前的结果已经变为无效,这就会导致一些问题(比如:意料之外的异常、数据被覆盖、文件被破坏等)

2.2.2.例子:延迟初始化的竞态条件( Race Conditions in Lazy Initialization )

使用“先检查后执行”(check-then-act)的一种常见情况就是延迟初始化。

延迟初始化的目标就是直到用到的时候再去初始化对象,同时要确保这个对象只被初始化一次。程序清单2.3的LazyInitRace类描绘了延迟初始化的做法。

getInstance方法首先检查ExpensiveObject是否已经被初始化,如果存在了,那么就返回这个现存的实例,否则就创建一个新的实例,并把这个实例的引用保留起来然后返回它,这样以后的调用就可以避免重复创建了。

程序清单2.3.延迟初始化中的竞态条件(不要这样做)

LazyInitRace就有竞态条件的问题,这会破坏它的正确性。如果线程A和线程B同时执行getInstance方法。A看到instance是null,然后就新建了一个ExpensiveObject。B也检查instance是否为null。这时候instance是否为null,其实已经开始不可预测的依赖于时序,包括调度的变幻莫测以及 A创建ExpensiveObject对象以及set instance字段的时间。当B检测时,如果instance为null,那么getInstance方法的两个调用者可能会得到不同的结果,即使getInstance通常是被认为总要返回同样的instance。

在UnsafeCountingFactorizer中的命中数累加操作是另外一类型的竞态条件问题。就是“读取-修改-写入”操作,比如递增一个计数器,就要基于这个对象的前一个状态来定义这个对象状态的转换。要递增一个计数器,你必须知道它的前一个值,并且要确保在你更新的过程中没有其他人修改或使用这个值。

就像大多数的并发错误一样,竞态条件问题也并不是一定会导致失败:有时候糟糕的时序也是必要的。但竞态条件确实会导致严重的问题。如果LazyInitRace被用来初始化一个应用程序内的一个广泛的注册表,这时候多个调用返回了不同的实例可能会导致注册丢失或者多个活动存在不一致的注册对象的集合的视图。如果UnsafeSequence被用来在持久化框架中生成实体的标识符,那么两个不同的对象可能会有相同的ID,这就违反了标识完整性约束。

2.2.3.复合操作(Compound Actions )

LazyInitRace和UnsafeCountingFactorizer两个都包含了一系列需要在同一个状态上相对于其他操作保证原子性或者不可分割性的操作。为了避免竞态条件,就必须有一种方法来避免其它线程在我们正在修改一个变量的时候使用它,这样我们就能确保其他线程只能在我们开始之前或者在我们结束之后观察或修改这个状态,但就是不能在我们正在进行的时候进行操作。

假设有两个操作A 和 B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A 和 B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。

如果在UnsafeSequence中的递增操作是原子的,那么前面的图1.1描述的竞态条件将不会发生,并且递增操作的每一个执行步骤都会将计数器加1。为了保证线程安全性,“先检查后执行”(check-then-act)的操作(像延迟初始化加载(lazy initialization))以及“读取-修改-写入”(read-modify-write)操作(像递增)必须要是原子的。我们把check-then-act以及read-modify-write一系列统一称之为compound actions,就是复合操作,或者叫组合操作:为了确保线程安全性,一系列的操作的执行都必须是原子操作。在后面的译文中,我们将会介绍锁机制,那种java内建的确保原子性的机制。现在的话,我们就暂时通过现存的线程安全类来修复问题吧,就像程序清单2.4里的CountingFactorizer那样。

程序清单2.4.使用AtomicLong来统计请求数的Servlet

java.util.concurrent.atomic包里包含了原子变量类,用来实现数字以及对象引用上的原子状态转换。用AtomicLong来代替long类型的计数器,我们可以确保访问计数器状态的所有操作都是原子的。因为servlet的状态就是计数器的状态,那么如果计数器是线程安全的话,我们的servlet也就是线程安全的了。

我们在因数分解的servlet中增加一个计数器,并通过使用现有的线程安全的类AtomicLong来管理计数器的状态,从而确保线程安全性。当一个状态被添加到一个无状态的类中的时候,如果这个状态是完全由线程安全的对象管理的话,那么这个添加了状态的无状态类也会是线程安全的。但,在下一文中我们将会发现,当状态变量由一个变为多个时,并不会像状态变量由零变为一个那么的简单。

实际上,应尽可能地使用现有的线程安全对象,像AtomicLong,就可以管理你的class的state。与非线程安全的对象相比,判断线程安全对象的可能状态以及状态的转换要更为容易,这也使得管理和验证线程安全性更容易。

END

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2016-07-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 ImportSource 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档