【Lock】活锁(Livelock)

Posted by 西维蜀黍 on 2021-07-31, Last Modified on 2023-07-18

活锁(Livelock)

Analygy

考虑一个场景:进程P1占有A资源并请求占用资源B,进程P2占有B资源并请求占用资源A。

如果是等待式的请求,两者都会陷入无尽的等待中。这是死锁(deadlock)

如果请求不是等待式的,而是一旦发现资源已经被占有了,就释放所有等待占用和已经占用的资源,并重新开始。此时P1放弃占有资源A重新开始,P2放弃占有资源B重新开始。则P1、P2可能会出现重复不断的开始-回滚循环。这种情况我们称之为活锁(livelock)

相比死锁,活锁更难检测,也更浪费资源(重复不断的开始-回滚循环)。


换个例子,活锁大概就是你低头走在路上,正好快要碰上一个人,你马上往左边垮了一步,但这个人也正好往右边跨了一步,不断循环,最后谁都过不去。

Explanation

You can see in the above image, each of the two given processes needs two resources, and they use the primitive polling enter registry to try to acquire the locks necessary for them. If the attempt fails, the method works again.

  1. Process A hold Y resource
  2. Process B holds resource X
  3. Process A require X resource
  4. Process B require Y resource

Assuming, process A runs first and acquires data resource X and then process B runs and acquires resource Y, no matter which process runs first, none of them further progress.

However, neither of the two processes are blocked. They use up CPU resources repeatedly without any progress being made but also stop any processing block.

Therefore, this situation is not that of a deadlock because there is not a single process that is blocked, but we face the situation something equivalent to deadlock, which is LIVELOCK.

Example

比如说,业务量明明不算大,但转账却好几分钟都没完成。可当你开始检查,正一头雾水的时候,转账忽然就完成了。你看这段代码:

class Account {
    private int balance;
    private final Lock lock = new ReentrantLock();

    // 转账
    // 下面的代码是简化版,千万别模仿,解锁一定要用try-finally包裹
    void transfer(Account tar, int amt) {
        boolean flag = true;

        // 不断尝试加锁两个账户
        while (flag) {
            if(this.lock.tryLock()) {
                if (tar.lock.tryLock()) {
                    // 加锁成功,执行转账
                    this.balance -= amt;
                    tar.balance += amt;

                    // 跳出循环
                    flag = false;
                }
            }

            // 释放锁资源
            tar.lock.unlock();
            this.lock.unlock();
        }
    }
}

如果线程没拿到锁,那并不会进入阻塞状态,而是直接退出。这样一来,线程就能释放出占用的资源,从而破坏了不可抢占条件,避免死锁的发生。

然而,新问题又来了。

现在同时出现两笔交易,账户A转账户B,账户B转账户 A。那么,线程一会占有账户A,线程二则会占有账户B。

结果,线程一拿不到账号B,转账没法执行,线程一结束;线程二也拿不到账号A,转账也没法执行,线程二结束。

到这里,第一轮循环结束,两笔转账都没有完成,新一轮循环开启。但在第二轮循环中,上面的过程又再次重复。如此不断地循环下去,两笔转账却一直没有完成。

简单来说,程序出现了活锁问题

如何避免活锁

增加随机等待时间

程序之所以出现活锁,其实是因为线程的解锁时间是一样的。

比如说,两个线程如果同时解锁,重试时同时加锁,这个过程又不断循环,可不就是陷入死循环吗?

因此,**想要避免活锁发生,我们可以在解锁之前,等待一个随机时间。**由于每个线程的解锁时间都不一样,也就不存在一直让资源的情况。

比如,上面的转账业务,我们可以修改两行代码。

class Account {
    private int balance;
    private final Lock lock = new ReentrantLock();

    // 转账
    // 下面的代码是简化版,千万别模仿,解锁一定要用try-finally包裹
    void transfer(Account tar, int amt) {
        boolean flag = true;

        // 不断尝试加锁两个账户
        while (flag) {
            if(this.lock.tryLock(随机数, TimeUnit.MILLISECONDS)) {
                if (tar.lock.tryLock(随机数, TimeUnit.MILLISECONDS)) {
                    // 加锁成功,执行转账
                    this.balance -= amt;
                    tar.balance += amt;

                    // 跳出循环
                    flag = false;
                }
            }

            // 释放锁资源
            tar.lock.unlock();
            this.lock.unlock();
        }
    }
}

在锁定账户A的时候,线程一会先等待一个随机的时间;在锁定账户B的时候,线程二也会等待一个随机的时间。

这样一来,由于持有锁的时间是随机的,重复的概率很小,两个线程的加解锁操作就错开了,转账也就完成了。

使加锁顺序一致

分析这个例子,可以看到,两个线程获取的锁的顺序不一致,最后导致互相需要对方手中的锁。如果两个线程加锁顺序一致,所需条件就会一样,势必就不会产生活锁了,比如我们以对账户卡号较小的账户先加锁。

Reference