为什么需要公平锁
公平锁和非公平的锁的区别主要还是在于公平锁保证先竞争的的线程会优先获取锁,而非公平锁则无法提供这个保障。
在ReentrantLock的实现中,两者的区别主要是公平锁在获取锁之前,会先去判断,如果有其他线程持有锁或者有其他线程在等待队列中等待这,那么该线程将被放入到队列中。而非公平锁会先尝试去获取锁,只有当锁被某个线程持有时,该线程才会被放入队列中。这样就意味着非公平锁会有更多的机会去抢占锁。
在ReentrantLock的实现中,非公平锁性能会高于公平锁,因为公平锁会增加很多线程切换的开销,而公平锁能避免某些场景下长时间等待依然无法获取锁的情况。因为两种锁各有优劣,我们才需要根据不同场景去选择不同的锁。
但是在基于etcd实现的分布式锁的场景内,因为Jetcd原生支持的就是公平锁,我们实现公平锁和非公平锁的代价是相差无几的。所以在能实现公平锁且性能相差不多的前提下,我们自然得优先去选择实现公平锁。
为什么之前没实现公平锁
看到这里,大家一定很疑惑。既然etcd天然支持的就是公平锁,为什么我在这里还要强调锁是否公平的问题。
在第一版的实现中,我们是按照redis分布式锁非公平锁的思路去实现可重入的场景的,所以就导致了原本可以实现公平锁的cp分布式锁,因为可重入的问题,硬生生地变成了非公平锁。
final Boolean tryLock(long waitTime) {
final long startTime = System.currentTimeMillis();
final long endTime = startTime + waitTime * 1000;
final long lockTime = LOCK_TIME;
final Thread current = Thread.currentThread();
try {
do {
int c = this.getState();
if (c == 0) {
LockLeaseData lockLeaseData = this.getLockLeaseData(lockName, lockTime);
if (Objects.isNull(lockLeaseData)) {
return Boolean.FALSE;
}
Long leaseId = lockLeaseData.getLeaseId();
if (lockEtcdClient.tryLock(leaseId, lockName, endTime - System.currentTimeMillis())) {
log.info("线程获取重入锁成功,cp锁的名称为{}", lockName);
this.setLockOwnerThread(current);
this.setState(c + 1);
return Boolean.TRUE;
}
} else if (lockOwnerThread == Thread.currentThread()) {
if (c + 1 <= 0) {
throw new Error("Maximum lock count exceeded");
}
this.setState(c + 1);
log.info("线程重入锁成功,cp锁的名称为{},当前LockCount为{}", lockName, state);
return Boolean.TRUE;
}
int sleepTime = SLEEP_TIME_ONCE;
if (waitTime > 0) {
log.info("线程暂时无法获得cp锁,当前已等待{}ms,本次将再等待{}ms,cp锁的名称为{}", System.currentTimeMillis() - startTime, sleepTime, lockName);
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
log.info("线程等待过程中被中断,cp锁的名称为{}", lockName, e);
}
}
} while (System.currentTimeMillis() <= endTime);
if (waitTime == 0) {
log.info("线程获得cp锁失败,将放弃获取,cp锁的名称为{}", lockName);
} else {
log.info("线程获得cp锁失败,之前共等待{}ms,将放弃等待获取,cp锁的名称为{}", System.currentTimeMillis() - startTime, lockName);
}
this.stopKeepAlive();
return Boolean.FALSE;
} catch (Exception e) {
log.error("execute error", e);
this.stopKeepAlive();
return Boolean.FALSE;
}
}
这是最初版的设计,我们可以看到在同一jvm内的不同线程和其他jvm内的线程去竞争锁的时候,可能会发生一个情况:线程a已经获取到锁a,此时state>0且lockOwnerThread是线程a,然后线程b去竞争锁a时,会直接跳过去竞争ectd锁的步骤,再根据线程b的等待时间的设置决定是快速失败还是休眠后再循环。然后如果有其他应用的线程c,也尝试去竞争锁a,而在线程c的应用内,锁的state为0,所以线程c会直接去竞争etcd锁。不出意外的话,线程c会比线程b优先竞争到锁a。这是不公平的。
如何实现可重入的公平锁
之前我们因为先判断state是否为0才导致的锁非公平的问题。之所以这么设计,是因为参照了ReentrantLock可重入的实现,然后在redis的非公平的分布式锁中这么写也完全没问题,所以就照搬照抄过来了。
我们需要对锁在可重入场景下的竞争逻辑做下修改,修改后的逻辑为先去判断是否当前线程已持有锁,如果有,则直接重入,锁的state+1。否则直接去竞争etcd锁。这样就避免了非公平竞争的发生。
此外还增加了一个快速失败的情况:在当前jvm内已有其他线程持有锁,如果当前线程竞争的是失败后不重试的锁,则直接快速失败。因为是竞争失败后不再重试,所以在其他线程已有持有的情况下,无须再去竞争etcd锁,这样能减少一部分的开销。
之所以还保留do-while的实现方式,是为了保证在某些情况下如果waitTime没超时,那么即便出现tryLock失败,该线程依然可以继续竞争锁。
以下是do-while中的代码实现:
int c = this.getState();
//当前线程已持有锁
if (c > 0 && lockOwnerThread == Thread.currentThread()) {
if (c + 1 <= 0) {
throw new Error("Maximum lock count exceeded");
}
this.setState(c + 1);
log.info("线程重入锁成功,cp锁的名称为{},当前LockCount为{}", lockName, state);
return Boolean.TRUE;
}
//其他线程已持有锁的情况下,如果不等待获取锁,则直接快速失败
if (c > 0 && waitTime == 0) {
log.info("线程获得cp锁失败,将放弃获取,cp锁的名称为{}", lockName);
return Boolean.FALSE;
}
LockLeaseData lockLeaseData = this.getLockLeaseData(lockName, lockTime);
if (Objects.isNull(lockLeaseData)) {
return Boolean.FALSE;
}
//如果当前应用中没有线程持有锁,则去竞争获取锁
Long leaseId = lockLeaseData.getLeaseId();
if (lockEtcdClient.tryLock(leaseId, lockName, endTime - System.currentTimeMillis())) {
log.info("线程获取重入锁成功,cp锁的名称为{}", lockName);
this.setLockOwnerThread(current);
this.setState(c + 1);
return Boolean.TRUE;
}
更新说明
本次更新,主要将非公平锁改为公平锁,此外还进行了以下几处优化:
1、优化了在同jvm不同线程内需要休眠等待无法及时竞争锁的情况
2、优化了租约被销毁后依然能续期的问题
3、优化了快速失败的场景。如果线程去tryLock时,同一jvm内已有其他线程持有锁,且该线程竞争的是失败后不重试的锁,则直接快速失败
推荐阅读
1、Etcd分布式锁:cp分布式锁的最佳实现
2、Redis分布式锁:基于AOP和Redis实现的简易版分布式锁
3、Redis分布式锁(二):支持锁的续期,避免锁超时后导致多个线程获得锁
4、Redis分布式锁(三):支持锁可重入,避免锁递归调用时死锁
好了,我们下一期再见,欢迎大家一起留言讨论。同时也欢迎点赞~