系列文章:并发编程原则与技术(一)——线程安全

 
并发编程原则
(一)
线程安全
总述:
现在我们所处的这个时代,多核已经成为不可阻挡的历史潮流,流行多年的“摩尔定律”已经渐露疲态,电子电路的物理极限已经无法突破,在这样的环境下“多核”应运而生,这正好比一次汹涌的浪潮冲击着 IT 技术的各个层面,这次浪潮必将对固有的技术和理念带来深远的影响。
对于程序员来说,多核带来的变化并不像时钟频率增加那样透明。以前我们在进行程序开发以及系统设计时,要考虑诸如可扩展性、健壮性、可伸缩性、可测试性、等等。但是今天我们还要考虑软件是否允许充分发挥了微处理器的全部性能。只有充分挖掘程序的并行性,才能让多处理器物尽其用,才能让你的软件在今后内核不断增加的日子里,得以保持升级。
在现今的多核系统中,我们开发的系统真的有可能,向我们过去期望的那样“并行”的运行,这将使我们欢欣鼓舞,但是在兴奋之余,我们可能不得不去认真地面对一个问题,那就是“线程安全问题”。因为我们系统运行性能得到提升必须要有一个前提,那就是系统运行的结果,不要因为在多个执行线程并行运行下,造成错误比如:数据混乱,状态混乱等等。
我写这个系列文章的目的,就是想论述一些原则,这些原则能够帮助我们不但充分利用多核提供来性能提升,同时也保证我们的系统运行安全。在这个系列文章中我将以 JAVA 语言( JDK 版本为 5.0 )为例,来描述一下并发编程原则,希望我的描述,能够对大家规避并发编程中的危险,提供一些帮助。
 
1、  线程的安全性问题:
1 )、线程安全性焦点:
你也许会感到惊讶,并发编程并不会涉及过多的线程和锁,至少不会多于建筑中的钢梁。当然如果想使建筑坚固,那么就必须正确使用钢梁。同样道理,构建安全、高效、稳定的并发程序的关键也是正确使用线程和锁,而不是到处乱用。编写线程安全的代码,本质上就是管理队状态的访问,而且通常这些状态都是共享的,可变的状态。
通常来讲,一个对象的状态就是他的数据,存储在状态变量中,比如实例域或静态域,另外对象状态还包括其他附属对象的域。例如, HashMap 的状态一部分存储在对象本身中,但同时也存储到很多的 Map.Entry 对象中。一个对象的状态包含了会对它外部表现行为产生影响的数据。
所谓共享,是指一个变量可以被多个线程同时访问;所谓可变,是指变量在其生命周期内可以改变。线程安全要解决的问题,其实就是在不可控的并发访问中对数据进行有效保护。
一个对象是否应该是线程安全的,取决于它是否会被多个线程同时访问。因此线程安全取决于如何使用对象,而不是对象完成了什么。保证对象的安全性需要使用同步来协调对其可变状态的访问;如果做不到这一点,就会导致脏数据和其它不可预期的后果。
无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问,在 Java 中首要的同步机制是 synchronized 关键字,它提供了独占锁。除此之外还包括 valatile 变量,显示锁和原子变量的使用。
  2 )、什么是线程全性:
如果让我给线程安全性下一个完善的定义,那会让我绞尽脑汁且夜不能寐,最终也不会有一个令我满意的结果。如果你不相信,你现在就可以 Google 一下,你会发现很多种不同的定义,而且这些定义都会显得过于复杂。
任何一个合理的“线程安全性”定义,其关键在于“正确性”的概念。如果我们关于线程安全性的定义不够清晰,那是因为缺少一个明确的“安全性”定义(其实这个定义也是众口难调)。
正确性意味一个类对其对象状态变化的一系列的规约。这些规约定义了用于强制对象状态“不变约束”以及描述操作影响的“后验条件”。抓住了这一点,我们就可以利用我们博大精深的语言并且发扬我们中国人的智慧,来高度概括一个线程安全性的定义了。一般的一个类是线程安全的,是指在被多个线程访问时,类可以持续进行正确的行为。
当一个类被多个线程访问时,如果不考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码中不必作其他的协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。也就是说对于线程安全的实例进行顺序或并发的一系列操作,都不会导致实例处于无效的状态。那么最简单的方式就是构建无状态对象,如下面 1.2.1 代码示例:
public class StatelessFactorizer implements Servlet{
 public void service(ServletRequest,req,ServletResponse res){
      ……
 }
}
这个 Servlet 类的代码中没有任何相关的对象状态的实例变量因此它是无状态的。每个线程访问这个类时的瞬时状态,会唯一地存放在本地变量中,这些变量存储在每个线程的栈中,只有执行线程可以访问。并且不会影响其它访问该类的线程。因此无状态的对象永远都是线程安全的。
  3 )、保证操作原子性:
虽然无状态对象可以保证线程安全性,但是在实际中很多对象是需要状态的。特别是当需要为不同的线程记录一些信息时,那么就必须需要状态变量,如下面 1.3.1 代码所示,这个代码中将会动态记录服务被访问的次数。
public class UnsafeCountingFactorizer implements Servlet{
 private long count=0;
public long getCount(){return this.count;}
 public void service(ServletRequest,req,ServletResponse res){
      ++count;
      ……
 }
}
很遗憾, UnsafeCountingFactorizer 类不是线程安全的,因为它很容易遗失更新,虽然 ++count 看上去很紧凑,但是它却不是原子操作,所谓原子操作,就是指不能够再被细分,再被打断的操作,它必须独享 CPU 的一个或几个时钟周期,在时钟周期内其它原子操作不能打断当前原子操作,而且它要么向前执行,要么原地不动,并且原子操作每个对于对象状态的改变,对于后续原子操作来说都是一个新的开始,而不是一个不可见的、处于中间状态的或者是未知的操作。
++count 是一个历经了“读、改、写”操作的复合操作,它的结果状态衍生自它先前的状态。因此在多线程并发访问环境中,一个 ++count 操作的时序会被其它并发线程的 ++count 操作的时序打乱,进而造成对象状态的错误和混乱。这种情况在并发编程开发中被称为“竞态条件”。竞态条件的诱因是:为获取期望的结果,需要依赖相关事件的分时。而这些事件对于对象状态改变或者判断,并不是对象状态的一个暂时的终点,很可能是已经过期的或者是被错误数据或错误引用污染的对象状态。
在上面的 UnsafeCountingFactorizer 类中存在数个竞态条件,导致其结果不可靠。在我们的日常开发中还存在很多产生竞态条件的情形,比如典型的“检查再运行”情况,通常所谓的惰性初始化是这种检查再运行的常见场所。如下面的 1.3.2 代码所示:
public class LazyInit{
 private Object instance=null;
 public Object getInstance(){
    if(instance==null){
      instance=new Object();
    }
    return instance;
 }
}
在上面的代码中我们期望 getInstance() 总是返回相同实例,但是在多线程并发条件下,很可能同时有两个线程同时检测到 instance null ,因此会使两个调用者调用 getInstance() 返回不同的对象实例。如果 instance 代表一个统一的应用级实例,那么很可能上述代码会给多个使用者带来不一致的对象视图。竞态条件并不一定总是会引起失败,它的爆发需要依赖特殊的分时,但是竞态条件的存在总是引起严重问题的重要隐患。
利用 JDK1.5 提供的原子变量(在 java.util.concurent.atomic 包中),可以使一些类变成线程安全类,如下面的 1.3.3 代码所示:
public class UnsafeCountingFactorizer implements Servlet{
 private final AtomicLong count=new AtomicLong();
public long getCount(){return count.get();}
 public void service(ServletRequest,req,ServletResponse res){
      count.incrementAndGet();
      ……
 }
}
AtomicLong 是线程安全的原子操作,确保了计数器状态的正确。当向无状态类中加入一个状态元素时,而这个状态有被线程安全类所管理者,那么这个新类仍然是线程安全的。但是如果是增加了多个状态的情况,可远远不像从 0 增加到 1 那么简单,即使这些状态被线程安全类所管理,类的状态仍然可能是线程不安全的,这个问题我会在下一部分中说明。
  4 )、花絮——非原子的 64 位操作:
    Java 中对于 32 位的基本类型( int float 等)操作,通常会是原子性的,虽然可能会得到一个过期值,但是这些值起码是一个线程设定的真实值,而不是凭空捏造的错误值。但是对于 double long 基本类型,因为它是 64 位的变量,因此对它的操作是非原子性的, JVM 会将对它的操作划分为两个 32 位操作来进行,因此在多线程环境中对这种操作有可能会得到一个莫名其妙的错误值。因此在 Java 中对它们的操作,如果需要保证线程安全,那么就要使用锁来保护起来。
 
2、  锁:
1 )、类的不变约束:
我们在上面说到要保证对对象状态的操作是原子操作,但是有些情况下即使通过原子操作来保护对象状态的改变,仍然会得到一个非线程安全类,如下面 2.1.1 代码所示:
public class UnsafeCountingFactorizer implements Servlet{
 private final AtomicReference<BigInteger> lastnumber
=new AtomicReference<BigInteger> ();
 private final AtomicReference<BigInteger[]> lastfactors
=new AtomicReference<BigInteger[]> ();
 
 public void service(ServletRequest,req,ServletResponse res){
      BigInteger i=new BigInteger(req.getParameter(“i”));
      if(i.equals(lastnumber.get()){
        ……
      }
      else{
        BigInteger[] factors=factor(i);
        lastnumber.set(i);
        lastfactors.set(factors);
        ……
      }
      ……
 }
}
假设按照规定 lastfactors 中的各个因子的乘积应该等于缓存在 lastnumber 中的数值。那么即使是使用了原子引用,并且每个 set 调用都是原子的,也是无法保证会同时更新 lastfactors lastnumber 。因为存在在某一个时序下,某个线程修改了一个变量而另一个还没有开始修改时,另一个线程已经修改了另一个变量的情况。为什么会这样?这是因为大部分多状态类,都会存在“不变约束”。所谓不变约束,其实就是类状态的内部约束,这种约束往往表现为某个变量的值会制约其它几个变量值,或者表现为类状态必须要符合某些逻辑的要求(比如类状态如果代表某个最大值与最小值范围,那么最小值只能大于等于最大值,不能出现最大值小于最小值的情况)。因此考虑到不变约束,在更新一个变量时,要在同一个原子操作中更新受约束的其它几个。也就是说这时原子操作的操作范围扩大了。
  2 )、内部锁:
Java 中提供了强制性的内部锁机制,通过 synchronized 关键字每个 Java 对象都可以隐式地扮演一个用于同步的锁角色,因此在 Java 中是对对象加锁,而不是对代码快加锁。内部锁在 Java 中表现为互斥独占锁,当一个线程获得内部锁后,它会阻塞其它企图获得相同锁的线程,直到它完成它的操作释放锁。如下面的 2.2.1 代码,我们将 UnsafeCountingFactorizer 类的 service 方法用 synchronized 保护起来。
public class SafeCountingFactorizer implements Servlet{
 private final BigInteger lastnumber;
 private final BigInteger[] lastfactors;
 
 public synchronized void service(ServletRequest,req,ServletResponse res){
      BigInteger i=new BigInteger(req.getParameter(“i”));
      if(i.equals(lastnumber.get()){
        ……
      }
      else{
        BigInteger[] factors=factor(i);
        lastnumber.set(i);
        lastfactors.set(factors);
        ……
      }
      ……
 }
}
此时 service 方法成为了原子化方法,它会阻塞多个企图获取同一个对象锁的线程进入,而只会在一个时间允许一个线程获取内部锁,并且进入代码块执行。因此可以保证同时只会有一个线程改变类实例的多个状态,从而保证了类实例的不变约束。
如上所示 synchronized 将会保证复合操作为原子操作,因此复合操作会在完整的运行期间占有锁,以确保其行为是原子的。然而仅仅使用 synchronized 包装复合操作是不够的,还需要同步访问变量的操作。进一步说,就是用锁来协调访问变量时,每次访问变量都需要用同一个锁。
  3 )、活跃度与性能:
代码 2.1.1 通过 synchronized 保证了操作的原子性并且保护了对象状态。但是它存在一个问题,那就是如果将 service 方法整个用 synchronized 保护起来,会影响程序的并发活跃性。因为每次只允许一个线程执行它,当负载过高时,这会影响程序的响应效果。这种 Web 应用的运行方式称为“弱并发”,这对程序运行性能是一种损害。我们可以通过缩小 synchronized 范围来改善这种弱并发性。一般情况下不可以将一个原子操作分解为多个 synchronized 块。不过应该尽量从 synchronized 块中分离出耗时且不影响共享状态的操作。这样即使在耗时操作的执行过程中,也不会阻止其它线程对共享变量的访问。按照这个原则我们将 2.1.1 代码进行如下修改,请看如下 3.1.1 代码:
public class ConcurentSafeCountingFactorizer implements Servlet{
 private final BigInteger lastnumber;
 private final BigInteger[] lastfactors;
 
 public void service(ServletRequest,req,ServletResponse res){
      BigInteger i=new BigInteger(req.getParameter(“i”));
      BigInteger[] factors=null;
      synchronized(this){
       if(i.equals(lastnumber){
        ……
        factors= lastfactors.clone();
       }
     }
      if(factors==null){
        factors=factor(i);
       synchronized(this){
        lastnumber=i;
        factors= lastfactors.clone();
        ……
      }
     }
      ……
 }
}
重新构造后的 ConcurentSafeCountingFactorizer 类达到了线程安全性与并发性之间的平衡。获取和释放锁是需要付出开销的,因此不要将 synchronized 分解的过于琐碎。决定 synchronized 块的大小要权衡各种设计要求,包括安全性(这是决不能妥协的)、简单性和性能。有时简单性与性能会是一对矛盾,正如在 ConcurentSafeCountingFactorizer 所看到的,为了追求控制线程安全的简单性,我们简单的将 service 方法置为 synchronized ,这虽然是最简单的解决方法,但是却伤害了性能与活跃度。但是正如你所见往往都会在简单和性能之间找到一个合理的平衡点。另外注意不要过早为了性能而牺牲简单性,这是对安全性的潜在妥协。
当使用锁时,你应该清楚 synchronized 块中的功能,识别它是否是一个耗时操作,如果是一个耗时操作,而又不影响实例的状态及不变约束,那么就应该将它从 synchronized 块中分离出来。尽量不要将对象状态的不变约束与耗时操作(如:网络 I/O 操作等)相关,这样在执行这些操作时就不需要占用锁了。
 
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值