原创

StampedLock 乐观读要先复制再验证:用指定交错检查一致性

这两个结果来自同一个坐标对象:安全读返回 (15,30),把 validate 放在前面的读却返回 (15,40)。写入方法每次都维护 y=2*x,没有任何一次合法状态是 (15,40)。问题发生在读者拼接了两次状态,先验证成功并不能保护后续读取。

StampedLock 的乐观读不是一把已经拿到的读锁。tryOptimisticRead 给出一个可供验证的票据,期间写线程仍然可以进入。正确顺序是先把相关字段复制到局部变量,再验证票据;通过以后,才能把局部副本当成一致的一次观察使用。失败就丢掉那组候选值,在真正读锁内重新读取。

让冲突发生在指定位置

很多并发演示靠两条线程随机跑几万次,偶尔发现一次坏值。这里用两个门闩精确安排交错,避免把“这次没看到问题”当作正确性证明。测试钩子只存在于最小程序,用来暂停读线程;正常 snapshot 不会执行外部业务回调。

程序使用 Java 17,没有第三方依赖。保存为 OptimisticPoint.java 并执行 java OptimisticPoint.java。一共观察三种读取:没有写入、复制之后发生写入、验证之后才开始交错读取。

import java.util.concurrent.*;
import java.util.concurrent.locks.StampedLock;

public class OptimisticPoint {
    record Snapshot(int x, int y, boolean fallback) {}
    static final class Point {
        final StampedLock lock = new StampedLock();
        private int x = 10, y = 20;
        void move(int delta) {
            long stamp = lock.writeLock();
            try {
                int nextX = Math.addExact(x, delta);
                int nextY = Math.multiplyExact(nextX, 2);
                x = nextX;
                y = nextY;
            } finally { lock.unlockWrite(stamp); }
        }
        Snapshot snapshot() { return snapshot(() -> {}); }
        Snapshot snapshot(Runnable afterCopy) {
            long stamp = lock.tryOptimisticRead();
            int localX = x;
            int localY = y;
            afterCopy.run(); // Test seam; production snapshot uses the empty hook.
            boolean fallback = !lock.validate(stamp);
            if (fallback) {
                stamp = lock.readLock();
                try { localX = x; localY = y; }
                finally { lock.unlockRead(stamp); }
            }
            return new Snapshot(localX, localY, fallback);
        }
        Snapshot wrongValidateFirst(Runnable betweenFields) {
            long stamp = lock.tryOptimisticRead();
            if (!lock.validate(stamp)) throw new IllegalStateException("test setup");
            int localX = x;
            betweenFields.run();
            return new Snapshot(localX, y, false); // Intentionally wrong.
        }
    }
    static void await(CountDownLatch latch) {
        try {
            if (!latch.await(3, TimeUnit.SECONDS)) throw new IllegalStateException("test coordination timeout");
        } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); }
    }
    public static void main(String[] args) throws Exception {
        Point p = new Point();
        System.out.println("quiet=" + p.snapshot());
        ExecutorService worker = Executors.newSingleThreadExecutor();
        try {
            CountDownLatch copied = new CountDownLatch(1), changed = new CountDownLatch(1);
            Future<Snapshot> read = worker.submit(() -> p.snapshot(() -> { copied.countDown(); await(changed); }));
            await(copied);
            p.move(5);
            changed.countDown();
            Snapshot safe = read.get(3, TimeUnit.SECONDS);
            System.out.println("interleaved=" + safe);
            if (!safe.fallback() || safe.y() != safe.x() * 2) throw new AssertionError("safe snapshot");

            CountDownLatch firstField = new CountDownLatch(1), moved = new CountDownLatch(1);
            Future<Snapshot> wrong = worker.submit(() -> p.wrongValidateFirst(() -> { firstField.countDown(); await(moved); }));
            await(firstField);
            p.move(5);
            moved.countDown();
            Snapshot mixed = wrong.get(3, TimeUnit.SECONDS);
            System.out.println("validated-too-early=" + mixed);
            System.out.println("mixed=" + (mixed.y() != mixed.x() * 2));
            long write = p.lock.writeLock();
            try { System.out.println("reentrant-write=" + (p.lock.tryWriteLock() != 0)); }
            finally { p.lock.unlockWrite(write); }
        } finally {
            worker.shutdownNow();
            if (!worker.awaitTermination(3, TimeUnit.SECONDS)) throw new IllegalStateException("worker did not stop");
        }
    }
}
quiet=Snapshot[x=10, y=20, fallback=false]
interleaved=Snapshot[x=15, y=30, fallback=true]
validated-too-early=Snapshot[x=15, y=40, fallback=false]
mixed=true
reentrant-write=false

第一条 quiet 在没有写者干扰时完成,fallback 为 false。第二条先让读者复制旧坐标,再让主线程移动对象,票据因此失效;读者走读锁分支,得到新坐标 (15,30)。程序不要求“乐观读必须拿到最新值”,这里只要求返回值属于验证支持的一致观察。

第三条故意犯错:先 validate,再读 x,暂停,让写者把状态变成 (20,40),最后读 y。读者于是拿到旧 x 和新 y。wrongValidateFirst 只用于展示失败,不是可以放进业务工具箱的另一种实现。

票据验证的对象是读取区间

将 validate 移到前面,看起来像是先检查锁可用再访问,实际上改变了它要证明的区间。它只能说明从获得票据到此次验证之间,没有破坏这个乐观读条件的写操作;它不会为验证之后的任意动作颁发通行证。

在正确分支里,验证之后只使用 localX 和 localY。若 validate 成功以后又访问 p.x 或跟随另一个可变引用读取新字段,新的读取就不在此前那次验证覆盖的候选副本里。重构时把一段计算挪进 getter,尤其容易不经意再次回到共享状态。

票据为零也不能按成功处理。写锁已被持有时,tryOptimisticRead 可能返回零,validate 会失败,代码自然进入有读锁的重读路径。本例不靠循环忙试取得一个非零值,也不把读失败转换成某个默认坐标。

StampedLock 的文档对乐观读取有专门的内存语义约束。使用它的前提是写入都遵守同一把锁的协议,并在字段复制后成功验证。不能把这个技巧拆成一个与锁无关的“先读普通字段,再随便检查一个版本号”,也不能夹杂一条不加锁的写入后宣称整体仍由票据保证。

候选值可能不一致,验证前不要做不可撤回动作

示例先复制两个 int,不在验证前计算并发布结果。读取更复杂的数据时,写者可能正在改变引用和索引之间的关系;候选下标短暂越界、候选引用为空,都可能让你在走到 validate 前就抛异常。文档并没有承诺未验证的读取天然可安全做任何计算。

因此乐观区应尽量短,只复制可以安全读取的必要值。涉及数组结构、链表遍历或彼此依赖的对象图,应仔细证明中间态是否可访问;证明不清楚时,直接使用读锁更容易维护。不要为了在代码里出现“乐观”两个字,把本来明确的锁协议变成难以审核的引用追踪。

同样不要在候选读取期间发送消息、写文件或修改其他对象。验证失败意味着这份候选观察不能被采用,但先前副作用不会自动撤回。程序的门闩钩子仅为测试调度服务,不能照搬成真实读取回调接口;实际业务的回调还可能再次获取锁、持有时间无法控制。

Point.move 在写锁内先算完两个新值,再赋给字段。这样算术溢出不会留下只更新 x 的状态。锁保证互斥,不会替多步写入提供异常回滚;即使读端使用正确票据,写端若在异常路径破坏不变量,读者仍可能正常验证并接受坏状态。

读锁路径不是可重入锁的替代品

程序最后在线程持有写锁时再次尝试 writeLock,立即返回失败。这里使用 tryWriteLock,所以实验不会永久挂住;把它换成无超时的阻塞写锁,就可能让线程等自己释放一个永远到不了的锁。

StampedLock 不按传统的线程持有计数提供可重入能力。已有写操作调用另一段“为了稳妥再加一次锁”的公共方法,可能制造这种自锁。内部方法应明确要求由调用方持有哪类票据,或在结构上把已加锁与未加锁的入口分开,而不是随处重复加锁。

票据也不能与其他锁实例互换使用。读写解锁都要匹配取得的模式与有效票据,并在 finally 中释放。转换方法返回的新票据要重新保存,转换失败则按失败协议处理;旧票据不会因为变量名字还叫 stamp 就自动具有新模式。

验证失败也不等于两个局部值一定错误。写者可能取得写锁后又把原值写回,或者只改了与本次结果无关的受保护状态;票据仍可能失效。读者按协议重读即可,不应为了省掉一次重读,再拿候选值去猜“这次修改应该没有影响”。那会把锁的明确合同换成一份随业务字段增长而失效的例外清单。

反过来,验证成功不代表返回时对象还保持那组值。验证之后写者可以立即修改共享对象,但局部快照仍然是一份有效观察。需要让后续判断与修改构成同一个原子动作时,仅返回快照不足以实现;必须在相应锁或版本更新协议里重新核对条件,不能把一次成功乐观读当作长期预订。

什么时候值得保留这段复杂度

如果读取很短、状态容易安全复制、写入干扰不多,乐观读可以让许多读取避开真正的读锁路径。反过来,复制对象图很昂贵、验证经常失败、读取本来就要调用外部组件时,这个实现可能比普通读锁更难解释。这里没有根据三条输出给出吞吐结论,输出只能证明指定交错下的协议行为。

实际评估应分别观察验证失败比例、重读成本与写者等待,并使用目标负载。如果每次失败都复制一份大对象再丢掉,少拿一次读锁未必抵消额外工作;若整体状态已经能作为不可变版本发布,也可以直接读取一个版本引用,避免逐字段乐观观察。

测试中所有门闩和Future都带有限等待,线程池在 finally 中中断并等待终止。超时属于实验协调失败,不能当作锁正确性的证据;它的作用是让代码改坏以后尽快暴露,而不是把挂住的演示留在后台。保留 safe 与 mixed 两个断言,能在以后调整字段读取位置时确认真正受保护的是哪一组副本。

正文到此结束
评论插件初始化中...
Loading...
本文目录