死锁

老师,有个问题,我在实际工作当中发现,不同线程查询账户生成的对象,地址是不同的,这意味着,我锁账户没效果 作者回复: 没有效果,必须是一个对象


class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 锁定转出账户
    synchronized(this) {              
      // 锁定转入账户
      synchronized(target) {           
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

我们先看看现实世界里的一种特殊场景。如果有客户找柜员张三做个转账业务:账户 A 转账户 B 100 元,此时另一个客户找柜员李四也做个转账业务:账户 B 转账户 A 100 元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本 A,李四拿到了账本 B。张三拿到账本 A 后就等着账本 B(账本 B 已经被李四拿走),而李四拿到账本 B 后就等着账本 A(账本 A 已经被张三拿走),他们要等多久呢?他们会永远等待下去…因为张三不会把账本 A 送回去,李四也不会把账本 B 送回去。我们姑且称为死等吧。

如何预防死锁

并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。

方案一(账本管理员)

可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。例如,张三同时申请账本 A 和 B,账本管理员如果发现文件架上只有账本 A,这个时候账本管理员是不会把账本 A 拿下来给张三的,只有账本 A 和 B 都在的时候才会给张三。这样就保证了“一次性申请所有资源”。

对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java 里面的类)来管理这个临界区,我们就把这个角色定为 Allocator。它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。账户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。具体的代码实现如下。


class Allocator {
  private List<Object> als =
    new ArrayList<>();

/*
apply()方法有锁,只有A线程执行完转账过后,才会执行finally中的actr.free(this, target)方法,这时B线程执行apply()才会返回true
*/
  // 一次性申请所有资源
  synchronized boolean apply(
    Object from, Object to){
    if(als.contains(from) ||
         als.contains(to)){
      return false;  
    } else {
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

class Account {
  // actr应该为单例(这个自己实现)
  private Allocator actr;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 一次性申请转出账户和转入账户,直到成功
    while(!actr.apply(this, target))
      ;
    try{
      // 锁定转出账户
      synchronized(this){              
        // 锁定转入账户
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}
/*
我的想法是,如果Account对象中只有转账业务的话,while(actr.apply(this, target)和对象锁synchronized(Account.class)的性能优势几乎看不出来,synchronized(Account.class)的性能甚至更差;但是如果Account对象中如果还有其它业务,比如查看余额等功能也加了synchronized(Account.class)修饰,那么把单独的转账业务剥离出来,性能的提升可能就比较明显了。
作者回复: 是的,有时候性能更差,毕竟要synchronized三次。但是有些场景会更好,例如转账操作很慢,而apply很快,这个时候允许a->b,c->d并行就有优势了。
有性能优势,锁Account.class会把所有用户的转账操作都变成串行,while的这种方式只限制有关联关系的账户,比如账户c也要转账,如果c和a或b之间发生转账就会进入while等待,但如果c要转账到d就不受a和b的影响
*/
  • 我觉得while(!actr.apply(this, target));和synchronized(Account.class)也使用中,Account只有一个转账操作其实两者差不多。但是如果Account中还有修改密码,查询余额等也需要加锁的操作,那就不一样了。加锁Account看似简单易懂也能保证安全,但是效率不敢恭维你来个相关操作我就全给你锁上,而前者转账对应转账的操作,修改密码对应修改密码的操作,看余额对应余额操作彼此独立,不用出现A操作等待B操作完场的这种状况。
  • 在并行执行 A->B B->C 的转账过程中,A->B的转账过程,会在while(!actr.apply(this, target)); 这个循环中,阻塞B->C的转账线程。
    然而在并行执行 A->B C->D的转账过程中,是不会相互阻塞的。所谓的锁了所有账户的说法,我不是很理解。
    反观 synchronized(Account.class), 不仅在 A->B C->D的转账过程中不能并行。甚至会串行其他需要获得Account.class这个锁的所有操作。例如查看余额
  • 老师你好,我有个疑惑,Account实例难道永远是单例的么?现实中往往针对转账操作会new 一个线程私有的Account对象吧?那这样锁是无效的,还是说老师您说的场景就是要确保项目一启动,每一个Account都只能是单例的,可能理解有误,请老师指证!作者回复: 不是单例,但是同一个账户只有一个实例

-啥不把als和apply(),free()都设为static呢?感觉作用是一样的,代码简洁些 (这个方法我深感同身受,之前做项目,static一个变量,所有人都能看到,本来是打算一个人选择一个年度进行操作的,反而,这个人改了,其他人的年度也改了)

方案三(按照顺序进行锁)

//方案二
class Account {
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    Account left = this        ①
    Account right = target;    ②
    if (this.id > target.id) { ③
      left = target;           ④
      right = this;            ⑤
    }                          ⑥
    // 锁定序号小的账户
    synchronized(left){
      // 锁定序号大的账户
      synchronized(right){ 
        if (this.balance > amt){
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}
/*
* 上节最后说到,不能用可变对象做锁,这里为何又synchronized(left)?
* 作者回复: 保护的是对象里面的成员,这俩对象变也只能是里面成员变,相对于里面的成员来说,这俩对象是永远不会变的。你可以这样理解。不是绝对不能用于可变对象,只是一条最佳实践。
*/
  • 虽然上面两种锁的方式都是串行化了,但是具体还是有一点区别的:synchronized(Account.class)的方式相当于A->B 转账,C->D转账 先后执行,而 actr.apply(this, target)的方式则是apply-->转账-->free这样的串行方式执行,但是在转账中是可以A->B,C->D转账线程并行执行的,正如文中提到的apply方法耗时很少 所以比如一次转账耗时200ms,apply+release方式执行要20ms,所以用synchronized的方式A->B,C->D则需要耗时400ms,而appy的方式则要200+20*2=240ms,并且同时转账的人越多 apply方式的转账并行度越高 比synchronized的方式的优势越明显。 但是有一个不明白的地方,对于已经通过apply获取锁的线程,感觉没有必要对转账的账户锁定了,因为其他的线程想对相同的账户进行转账 调用apply方式是没法返回true的(已经有线程对list加入账户了)
  • 考虑现实场景,做出如下假设:转账操作会相对apply方法耗时。那在高并发下synchronized(Account.class)会使得所有转账串行化,使用apply方法能提高转账的吞吐量。但apply方法也有问题,在同一个账户转账操作并发量高的场景下,apply方法频繁失败,转账的线程会不断的阻塞唤醒阻塞唤醒,开销大。也许应该改进一下由Allocator负责在有资源的情况下唤醒调用apply的线程?
  • 如果有人没有理解透彻,看着例子来写生产代码,那么并发情况下会出问题,如果并发小,一直没出问题,会以为代码没问题,真正出问题的时候都分析不出来哪里错了。并发情况下,这些代码的加锁对象并不是同一个,所以是有问题的。 不同的线程都获取到了账户A的实例对象,但这些实例对象不是同一个
  • 希望所有读者都能看透这个,多线程对账户A,B实例加锁时一定要保证是同一个实例对象,就像在数据库表中通过select * from account where account_id = ? for update 加锁一样,锁住的是同一条账户记录。

    学员方案(MQ消息中间件)

    void transfer(Account target, int amt){
      boolean isTransfer = false;
    // 锁定转出账户
      synchronized(this){
            if (this.balance > amt) {
            this.balance -= amt;
            isTransfer = true;
      } 
      if (!isTransfer) {
           return;
      }
        // 锁定转入账户
        synchronized(target){ 
            target.balance += amt;
        }
    }
    /*
    反映到现实中的场景:服务员A拿到账本1先判断余额够不够,够的话先扣款,再等待其他人操作完账本2,才增加它的额度。
    但是这样转账和到账就存在一个时差,现实生活中也是这样,转账不会立马到账,短信提醒24小时内到账,所谓的最终一致性。 
    老师帮忙看看这样实现会不会有啥其他问题?
    作者回复: 实际工作中也有这么做的,只不过是把转入操作放到mq里面,mq消费失败会重试,所以能保证最终一致性。
    */

课后思考

我们上面提到:破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));这个方法,那它比 synchronized(Account.class) 有没有性能优势呢?

  • synchronized(Account.class) 锁了Account类相关的所有操作。相当于文中说的包场了,只要与Account有关联,通通需要等待当前线程操作完成。while死循环的方式只锁定了当前操作的两个相关的对象。两种影响到的范围不同。
  • while循环是不是应该有个timeout,避免一直阻塞下去?
    作者回复: 你考虑的很周到!

标签: none

评论已关闭