读锁不能直接升级:释放重查与写锁降级要分清顺序
线程已经拿着读锁,发现缓存缺失,接着直接lock写锁,结果把自己等住。它还没有释放读资格,写锁又要求读者离开,逻辑正好形成一条自我等待。ReentrantReadWriteLock允许写者重入,也允许写者取得读锁,却不提供这种读锁直接升级。
这件事不能靠“公平锁”选项解决。公平性决定竞争政策,不会把当前线程持有的读资格自动转换成写资格;把线程数调成一,也不能让一条违反锁合同的路径变得安全。修正需要改变动作顺序:释放读锁,取得写锁,再检查条件。
释放与重取之间,状态可能已经改变
缓存读取是一个典型场景:读锁内发现value为空,释放以后另一个线程先完成填充。如果拿到写锁后不重查,当前线程还会重复加载,甚至覆盖对方刚写下的版本。锁升级错误修成不死锁,只完成了一半。
下面的Java 17程序用门闩固定这次交错,同时演示安全降级。保存为LockUpgrade.java,执行java LockUpgrade.java。最开始用限时tryLock验证升级失败,没有真的调用一个会永久等待的示例。
import java.util.Objects;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
public class LockUpgrade {
static final class Cache {
private final ReentrantReadWriteLock lock=new ReentrantReadWriteLock();
private String value;
void set(String next) {
lock.writeLock().lock();
try { value=Objects.requireNonNull(next); }
finally { lock.writeLock().unlock(); }
}
String getOrLoad(Supplier<String> loader,Runnable afterMiss) {
lock.readLock().lock();
try { if(value!=null)return value; }
finally { lock.readLock().unlock(); }
afterMiss.run(); // Test seam after releasing the read lock.
lock.writeLock().lock();
try {
if(value==null)value=Objects.requireNonNull(loader.get());
lock.readLock().lock(); // Downgrade before releasing the write lock.
} finally { lock.writeLock().unlock(); }
try { return value; }
finally { lock.readLock().unlock(); }
}
}
static void await(CountDownLatch latch) {
try {
if(!latch.await(3,TimeUnit.SECONDS))throw new IllegalStateException("coordination timeout");
}catch(InterruptedException e){Thread.currentThread().interrupt();throw new RuntimeException(e);}
}
public static void main(String[] args)throws Exception {
ReentrantReadWriteLock lock=new ReentrantReadWriteLock();
lock.readLock().lock();
try {
boolean acquired=lock.writeLock().tryLock(40,TimeUnit.MILLISECONDS);
if(acquired)lock.writeLock().unlock();
System.out.println("read to write acquired="+acquired);
}finally{lock.readLock().unlock();}
lock.writeLock().lock();
lock.readLock().lock();
lock.writeLock().unlock();
try{System.out.println("downgraded read holds="+lock.getReadHoldCount()+", write locked="+lock.isWriteLocked());}
finally{lock.readLock().unlock();}
Cache cache=new Cache();AtomicInteger loads=new AtomicInteger();
CountDownLatch sawMiss=new CountDownLatch(1),otherUpdated=new CountDownLatch(1);
ExecutorService worker=Executors.newSingleThreadExecutor();
try {
Future<String> result=worker.submit(()->cache.getOrLoad(()->{loads.incrementAndGet();return "unnecessary";},
()->{sawMiss.countDown();await(otherUpdated);}));
await(sawMiss);cache.set("other writer");otherUpdated.countDown();
System.out.println("after reacquire="+result.get(3,TimeUnit.SECONDS)+", loader calls="+loads.get());
if(loads.get()!=0)throw new AssertionError("missing write-lock recheck");
Cache empty=new Cache();
System.out.println("initial load="+empty.getOrLoad(()->"built",()->{}));
try { new Cache().getOrLoad(()->null,()->{}); }
catch(NullPointerException expected){System.out.println("null load=rejected");}
} finally {
otherUpdated.countDown();worker.shutdownNow();
if(!worker.awaitTermination(3,TimeUnit.SECONDS))throw new IllegalStateException("worker active");
}
}
}
read to write acquired=false
downgraded read holds=1, write locked=false
after reacquire=other writer, loader calls=0
initial load=built
null load=rejected
读锁内尝试写锁得到false;写锁内先取得读锁再释放写锁,则保留一份读资格。受控缓存实验里,读取者释放读锁后暂停,主线程填入other writer,读取者重新拿到写锁时看到已有值,不再调用loader。
loader calls为零不是性能数字,而是验证写锁内重查确实存在。若删掉第二次value==null判断,这个断言会失败,其他线程更新的值也可能被unnecessary覆盖。测试安排的是指定执行次序,不依赖循环竞争碰巧撞上一次。
降级顺序保护哪一段观察
写锁持有时取得读锁,再释放写锁,当前线程可以继续在读锁下使用刚确认的状态。其他读者可以加入,写者则仍需等读资格释放。若先释放写锁再取得读锁,中间另一写者可以修改状态,后续读取就未必是刚构造的那份结果。
这里返回的是不可变字符串,读锁下取到引用以后即可离开作用域。若返回一个可变对象,解锁以后调用方继续追踪内部字段,不能再借此前的读锁保证整段操作一致。要么在锁内生成稳定值视图,要么让对象自己的访问也遵守明确并发合同。
缓存值设置与读取使用同一把锁,且字段保持私有。只给getOrLoad加锁、其他入口直接给value赋值,就破坏了示例的前提。读写锁保护的是所有相关路径共同遵守的协议,而不是某个方法名声明“这是缓存”就成立。
loader在写锁内运行,因此同一实例的其他写入与读取可能被它阻塞。本例加载只返回一个短字符串;真实远端加载若耗时很长,应考虑分离计算与条件提交,或使用能合并加载的组件。释放写锁去加载也会重新打开竞争窗口,不能照搬后还假设不会重复。
读锁还允许重入。若上层已经持有同一把读锁,再调用一个内部也加读锁的方法,内部只释放自己的一次持有,并不代表当前线程已经没有读资格。随后尝试写锁仍可能自锁。不能为了升级而循环unlock别人的持有次数,应重新安排最外层操作边界,让需要修改的路径从一开始就拥有清楚的锁模式。
失败路径不能遗失持锁状态
代码在读命中时直接return,finally仍释放读锁;加载失败时写锁同样在finally中释放。降级后的读锁只在确实取得后进入对应释放区,不在外层finally凭猜测调用unlock。尝试把所有锁清理集中成几条无条件unlock,反而容易让异常路径抛出另一项状态错误。
加载结果在本模型里必须非空,null会拒绝,而不是把“加载完成”与“依旧缺失”混在一起。需要缓存合法空结果时,可以用显式结果对象描述,不应简单去掉非空检查后继续推断每次读空都必须加载。业务状态与锁状态是两套不同信息。
还要避免在持锁区调用归属不明的回调。回调可能再次进入同一组件、获取其他锁或执行长时间I/O,形成更复杂的等待关系。示例afterMiss专为测试设置,且发生在读锁已经释放以后;真实公共接口不必暴露这项钩子。
公平模式和可重入性质也不能替你避免多锁循环等待。两个组件分别持有各自写锁再调用对方,即使每把锁单独使用都正确,整体仍可能死锁。应统一锁顺序或把跨组件调用移到清楚的边界外,再重新核对所读版本是否仍可采用。
最后将写锁内的第二次检查保留为回归重点,将降级顺序保留为独立测试。一个用例证明“没有挂住”,另一个证明“没覆盖别人的更新”,两者回答不同问题。升级失败、释放重查与安全降级分别落实以后,缓存路径才同时具备可继续执行和可解释的数据含义。