我们使用 Account.class 类对象作为 「 互斥锁 」,来解决 银行业务里面的转账 问题,虽然这个方案不存在 并发 问题,但是这里面所有账户的操作都是「串行」的。
例如 账户A 转账给 账户B、 账户C 转账给 账户D,这两个操作在现实世界中是可以并行的,但是在这个方案中被 「串行化」 ,导致性能非常低下。
在 互联网环境 中,如果在可能存在 「大量并发」 的场景中为了保证安全性而使用完全串行化的方式来解决问题,那么 性能 就会成为一个不得不解决的问题。
所有我们需要在 保证安全的同时,尝试提升性能 。【锁的粒度越细,对性能的影响就越低】。
向现实世界要答案
现实世界里, 账户转账操作 是支持「并发」的,而且是真正的「并行」,银行所有窗口都可以做转账操作 。
所以只要我们将现实中的解决方案移植到编程领域中,模仿现实世界的转账操作,串行的问题就解决了。
如果在古代,没有信息化,账户的存在是一个真正的「账本」的实体,每个「账户」都有一个「 账本 」,这些 账本统一存放在文件架上 。 银行柜员给我们做转账时,要去文件架上把「 转出账本 」 和 「转入账本」 都拿到手,然后做转账操作。
那么这个柜员在试图拿账本的时候可能遇到以下三种情况:
- 文件架上恰好对应的 「转入账本」 和 「转出账本」 都在 ,那就 同时拿走 。
- 如果文件架上 只存在其中之一 ,那就由柜员先将这个存在的账本拿到手,同时「等待」其他柜员将另一个账本送回来。
- 如果「 转入账本 」 和 「 转出账本 」 都不存在,那么这个柜员就 等待 两个账本都被送回来。
上面这个过程对应的 编程实现 是:使用 两把锁 。「 转出账本 」一把,「 转入账本 」一把。
在 transfer() 方法内部,我们首先尝试「锁定」 转出账户 this (先把转出账户拿到手 ),然后尝试锁定转入账户 target (再把转入账本拿到手),只有当两者都成功时,才执行转账操作。
这个逻辑可以图形化为下图这个流程:
【↑并行转账的示意图。】
详细的代码实现则如下所示:↓
经过这样优化之后:账户A 转 B,账户C 转D这两个转账操作就可以并行了。
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;
}
}
}
}
}
没有免费的午餐
上面的实现看起来很完美,相对于 使用 Account.class 作为互斥锁 ,那种情况下 锁的粒度太大 ,使整个并发程序成为串行化的程序。
而这里我们锁 定两个账户的范围就小多了 ,这样的锁叫做 细粒度锁。
细粒度锁可以提高「并行度」,是性能优化的重要手段。
【 但是编程中没有银弹,引入技术不可能只带来优势而不存在问题。 】
使用细粒度锁需要付出的代价就是可能导致 「死锁」。
所谓「死锁」 :
jcip 中给的例子是 哲学家进餐 ,每个人手边都只有一根筷子,当每个人都拿起了自己手边的那一根时,每个人都将等待其他人放下筷子,最终所有人都会饿死。
极客时间 中给出的例子就是上面的转账例子:
假设柜员张三拿到了账本A,柜员李四拿到了账本B,则他们都将等待另一本账本被放回文件架,这就将形成死锁。
【转账业务中的"死等"】
死锁比较专业的定义是:一组互相竞争「资源」的线程因互相等待,导致永久「阻塞」 的现象。
上面的转账代码是怎么发生死锁的呢:假设线程Thread-1 执行 账户A 转 B 的操作,账户 A.transfer(账户B);同时 Thread-2 线程 执行账户B 转 账户A 的操作,账户B.transfer(账户A)。
当 T1 和 T2 执行完①处 的代码时, T1 获得了账户A 的锁, T2获得了账户B 的锁,
之后当T1试图获取账户B的锁时,也就是执行到②处的代码。发现其已经被锁定,于是等待锁释放,T2同理 。
于是 T1 和 T2将 无限等待 下去,也就是我们所说的 死锁 产生了。
class Account {
private int balance;
// 转账
void transfer(Account taget,int amt) {
// 锁定转出账户
synchronized(this) { ①
// 锁定转入账户
synchronized(target) { ②
if(this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
关于死锁,作者还给了一个 可视化的资源分配图 来说明 锁的占用情况 :其中资源用方形节点表示 ,线程用圆形节点表示 。
资源 中的点指向线程的边表示线程已经获得该资源 ,线程指向资源的边表示线程请求资源 ,但 「尚未得到」 。
转账发生死锁时就是如下的情况,一个"各据山头死等"的尴尬局面。
如何预防死锁
当程序发生死锁时,一般只能重启应用
。因此最好的方法就是提前「 规避 」死锁的发生。
Conffman 总结了死锁发生的「四个」必要条件:
- 互斥,共享资源 X 和 Y 只能 被一个线程占用。
- 占有且等待,线程T1 已经取得 共享资源X ,在等待 共享资源Y 的时候,不释放 自己持有的 共享资源X 。
- 不可抢占,其他线程不能「强行抢占」 线程T1 已经占有的资源。
- 循环等待,线程T1 等待 线程T2 占有的资源,线程T2 等待 线程T1 占有的资源,就是循环等待。
所以我们只要破坏以上4点中的一个,就可以成功避免死锁的发生。
其中,「互斥」 这个条件没法办法破坏,因为我们使用锁的目的就是为了互斥而保证线程的安全性。
其他三个条件都有对应的破解方法:
- 破解 「占有且等待」,可以采用一次性申请所有资源这种做法,这样就不存在等待了。
- 破解「不可抢占」,占用部分资源的线程进一步申请其他资源时,如果无法获取,则主动释放它已经占有的资源,这样「不可抢占」就被破坏了。
- 破解「循环等待」,可以使用按序申请资源来预防。所谓「按序」指的是资源存在「线性顺序」,申请的时候先申请资源序号小的,然后再申请序号大的,这样就不存在循环了。
破解死锁的理论已经清晰了,下一步就是将理论转换为具体代码的实现。
- 破坏占有且等待条件
之前说了,破坏这个条件的措施是 一次性申请所有资源。
继续使用之前举的转账的例子,其中需要的资源有两个:
- 「转出账户」
- 「转入账户」
当这两个账户同时被申请,该如何解决呢?
可以增加一个 「账本管理员」,只允许账本管理员从文件架上拿账本,也就是 柜员无法直接操作文件架,必须通过第三方才能拿到想要的账本。
例如:张三同时申请账本 A 和 B,管理员发现文件架上只存在 账本A,那么这时候管理员不会将 账本A 交给张三,只有当所有申请的资源同时存在时,管理员才会将账本交给张三,这就保证了 —— 「一次性申请所有资源」 。
【经典的解决方案,通过引入第三方来降低问题的复杂性。】
再对应到具体的编程领域,「同时申请」 这个操作是一个 「临界区」,我们需要一个 Java 中的类 来管理这个临界区,我们将这个角色定义为 Allocator 分配者。
它有两个「重要功能」,分别是:
- 同时申请资源 apply()
- 同时释放资源 free()
账户 Account 类里面持有一个 Allocator 的单例(必须是单例,因为只能有一个人来分配资源)。
当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请其需要的 转出账户 和转入账户 这两个资源,成功后再锁定这两个资源;
当转账操作执行完,释放锁之后,通知 Allocator 同时释放 转出账户 和 转入账户 这两个资源,代码如下:
// 分配者
class Allocator {
private List<Object> als = new ArrayList<>();
// 一次性申请所有资源
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 {
// Allocator 应该是单例的
private Allocator actr;
private int balacne;
// 转账
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);
}
}
}
- 破坏「不可抢占」条件
破坏「不可抢占条件」的核心是能够主动释放它占有的资源,这一点 synchronized 是做不到的。
因为 synchronized 申请资源的时候,如果没有申请成功,那么线程直接进入阻塞状态,而当线程进入阻塞状态之后什么也干不了,所以无法释放线程已经占有的资源。
Java 在语言层次没有解决这个问题,但是在 JDK 中存在解决了这个问题的类:java.util.concurrent 包下的 Lock 类可以轻松解决这个问题,但是这个在后面才会具体讲。
- 破坏「循环等待」条件
破坏这个条件,需要对资源进行排序,然后「按序」 申请资源。
这个实现很简单,我们假设每个账户都有一个不同的属性:id。 这个 id 可以作为排序字段,申请的时候,根据 id 按照从小到打的顺序来进行申请。
比如下面代码中,①到⑥ 处的代码对 转出账户(this) 和 转入账户 target 「排序」,然后按照序号从小到大锁定账户,这样就不存在 「循环等待」了。
// 增加 id 字段,根据 id 从小到大申请资源,破除循环等待条件
public class Account {
private int id;
private int balance;
//转账 这里将 id 小的那个账户作为转入账户
void transfer(Account2 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;
}
}
}
}
}
总结
在编程世界中遇到问题时,不应该局限于当下,可以换个思路从现实世界出发,利用现实世界的模型来构思解决方案,这样往往能够让我们的方案更容易理解,也更能够看清楚问题的本质。
但是现实世界的模型有很多「细节」会被我们忽视。 因为现实世界中,人太智能了,以致于有些细节显得抬不重要。
比如转账,为什么在现实世界中会忽视「死锁」 这个问题? 主要是我们会交流,而且是很智能地交流。
而编程世界中,两个线程是不会智能地交流的。 所以在利用现实模型建模时,还要仔细对比现实世界和编程世界里各个角色之间的「差异」。
这一章主要讲了 使用细粒度锁来锁定多个资源时,要注意可能发生的「死锁」问题,这里我们需要将这个意识强化为思维定势,遇到这种特定的场景,马上意识到可能存在「死锁」问题。
当你知道风险之后,才有机会去预防和避免,因此意识技术背后可能引入的问题非常重要。
预防死锁主要是破坏三个条件中的一个,有了思路,实现起来就很简单。但是有时候预防死锁的「成本」也是很高的。
例如上面的破坏占用且等待条件的成本就比破坏循环等待的成本高。 破坏占用且等待条件,我们锁定了所有的账户,而这里还用了死循环 while(!actr.apply(this,target)); 方法 ,不过好在这个 apply 方法基本不耗时。
在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。
所以我们在选择具体方案的时候,还需要评估一下 操作成本 ,从中选择一个成本最低的方案。
课后思考
Q:
使用死循环 while(!actr.apply(this,target)); 和使用 synchronized (Account.class) 相比,谁更有性能优势?为什么?
A:
首先,后者 synchronized (Account.class) 会将程序变为一个串行的程序,导致只有一个线程能操作,其余的所有线程都需要等待当前线程完成。
而while(!actr.apply(this,target)); 只涉及当前操作的两个对象,两种方式的「影响范围」不同。
课后留言个人精选:
- 在实际项目中, 对于死循环等待某个条件成立,增加超时 timeout 非常重要,避免一直阻塞下去。
- 实际开发中多用 数据库事务 + 乐观锁的方式来保证 A 转 B , C 转 B 一起执行时 B 的 account 对象是同一个对象。
Q.E.D.
Comments | 0 条评论