Scala 32:异步失败、超时与共享状态
收到超时之后,副作用还可能发生
调用方收到超时,只能说明某个等待或组合结果已经失败;它不自动证明原工作停止。本章让一个慢任务先进入执行,再被门闩阻塞。定时器使另一个 Promise 失败,组合结果观察到超时后,慢任务仍未完成。随后释放门闩,副作用计数从零变成一。
这个实验把“调用方不再等待”和“任务已取消”分成两个可观察事实。若慢任务代表外部写入,超时之后直接重新提交可能产生重复操作。是否需要重试、如何避免重复、取消请求是否被下游接受,都需要额外协议。
同一章还重现一个共享计数错误:两个任务都读到零,再各自写回一,最终结果是一。换成原子递增后结果是二。这说明异步结果组合不会替共享可变字段提供原子性,Future 类型也不是线程安全容器。
工具冻结为 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11。所有情形都在 snippets/32/Chapter32.scala 中运行,证据包含命令、退出码和有限输出。超时和丢失更新都是预期的错误场景,测试通过表示这些错误被准确重现并由断言确认。
Promise控制完成,Future用于观察
Promise[A] 提供完成结果的能力,promise.future 则供其他代码观察和组合。二者分离之后,可以让定时器持有写入完成状态的能力,让调用方只拿到结果句柄。
本章创建 Promise[Int](),定时器稍后对它调用 tryFailure(new TimeoutException("deadline"))。使用 tryFailure 可以把“是否成功完成”作为布尔结果处理,而不是在已经完成时再抛一次完成冲突。当前实验只有一个定时完成者,选择它主要是避免把竞争完成写成未声明异常。
慢任务与超时 Future 都具有相同结果类型,随后通过 Future.firstCompletedOf 组合。这个 API 接受完成结果,无论它是成功还是失败。因此超时异常也可以成为组合的完成结果,而不是等待所有输入都成功才返回。
冻结源码通过一个新的 Promise 与完成处理器实现这个组合,并用原子引用协调谁取得完成机会。这里只核对了这个入口,没有把它称为通用取消机制。代码中也没有向未胜出的输入发送取消操作。
如果业务要求“第一个成功结果”,应另外考虑失败是否被忽略、所有输入都失败时返回什么。firstCompletedOf 的名字强调完成顺序,不保证选择成功。把失败竞争理解成成功竞争,会让错误恢复策略与实际行为不一致。
时间阈值与因果顺序分别怎样验证
慢任务先对 started 门闩发信号,再等待 release。主线程确认它已进入任务体后,才安排三十毫秒后的超时。这让实验不依赖任务尚未启动时的猜测,能明确讨论一个已经开始的任务。
三十毫秒是定时器的安排参数,不是测得的精确延迟。线程调度与环境负载可能使实际执行晚于该值。本文没有断言异常恰好在第三十毫秒出现,而是用五秒外层等待限制实验最长等待时间。
更关键的因果关系由关闭的门闩保证:主线程释放之前,慢任务不能执行副作用。因此当超时结果被观察到时,副作用计数必定仍为零;它不是靠机器足够慢或者碰巧没有切换线程来成立。
收到超时后,实验检查 !slow.isCompleted 与计数零,再释放门闩,等待原 Future,确认其结果与计数都为一。这个后半段使结论具有区分力:若只看到一次超时异常,就不能证明原工作随后继续完成。
外层 Await 保护与业务超时 Promise 都可能使用超时异常类型,所以测试还通过慢任务状态、门闩和后续结果约束解释。修改实验时应保留这些中间断言;只捕获一个同名异常并打印“超时成功”,容易把保护机制触发误认为被测机制正常。
失败传播不意味着撤销已经发生的动作
Future 失败保存的是某次异步计算的失败结果。计算在失败之前可能已经修改内存、发送请求或写入文件。这些动作不会因为结果转为失败而自动回滚。
recover 可以生成替代结果,recoverWith 可以连接另一个异步计算,但它们都需要明确恢复策略。如果第一个请求是否落地未知,恢复中直接重新提交可能与原请求重叠。这里的主要问题是外部动作的语义,不是选择哪个组合方法更短。
同样,zip 或一条依赖链最终失败,也不保证所有已经创建的独立 Future 都停止。上一章已经区分创建时机,本章在超时后继续执行的例子进一步说明:组合只表达结果关系,不自动拥有底层任务生命周期。
要实现取消,需要被取消操作提供可执行的机制,例如取消令牌、可关闭连接或支持取消的客户端句柄。上层发出取消请求之后,还应区分“请求已发送”“工作已观察到请求”“外部副作用已停止”。本章没有实现这些机制,因此不宣称完成了可取消 Future 抽象。
如果接口只有 Future,没有取消句柄,调用方仍可停止等待并忽略迟到结果,但这只是结果消费策略。资源、线程与外部请求可能继续占用,系统容量评估必须计入这些工作。
重试之前先定义一次操作的身份
超时后的重试最需要明确的是:再次调用是否代表同一业务动作。读取最新数据通常比重复扣减更容易处理,但即使读取也可能产生计费、限流或审计副作用,不能仅按方法名判断无副作用。
对不能重复执行的动作,可以使用业务操作标识和下游去重协议。标识应贯穿原请求与重试,而不是每次重试都生成全新身份。这样下游才有条件把多次提交识别为同一意图。
本章只用原子计数表示副作用,没有连接真实外部服务。计数从零变成一证明本地任务继续执行,不证明某个远程服务具有幂等或取消能力。实际接入需要对客户端和服务端契约分别测试。
错误模型也可以显式区分确定失败与结果未知。输入校验失败通常可以确定未提交;网络超时可能无法确定下游是否已经处理。把两者都压成一个布尔 false,会让调用者失去选择正确恢复策略的信息。
重试次数、退避与截止时间则属于进一步策略。它们应围绕这个操作身份和结果不确定性设计,而不是只在异常匹配中递归调用自己。一个正确的异步组合仍可能执行错误的业务重试。
用屏障稳定重现丢失更新
共享计数反例拆开 unsafe += 1 的逻辑:
1 | |
两个任务都先读取当前值,再到达同一个双参与者屏障。初始值是零,且屏障释放前没有写入,所以两个局部变量都保存零。屏障释放之后,无论两次写入的顺序怎样,写回值都是一。
这个安排把通常依赖调度碰撞的竞态变成受控反例。最终等待两个 Future 都完成,断言 unsafe == 1。它明确表明两次递增意图只留下了一次增量,而不是因为其中一个任务没执行。
屏障提供的是阶段协调,不会把屏障之后的两次读改写自动合并为一个原子动作。这里读操作故意放在屏障前,让错误交错必然出现。若只是把屏障随意加在循环外面,可能仍然无法稳定观察丢失更新。
var 被闭包捕获后仍指向共享可变状态。把更新放入两个 Future,只改变执行安排,没有消除共享。即使每个 Future 最终都成功返回,也不能据此证明组合操作保持了业务不变量。
可见性与原子性解决不同问题
可见性涉及一个线程的更新何时能被另一个线程按照内存模型观察;原子性涉及一个操作是否可以被其他线程的操作插入。读、加一、写回是三个逻辑步骤,只让某个字段可见并不能自动把三步变成不可分割动作。
因此把计数声明为 volatile 并不是一般性的复合递增修复。两个线程仍可能读取相同旧值,再写回相同新值。本章没有另外声称执行了 volatile 变体,而是由受控交错解释为什么单独强化可见性不足以排除这个形状。
AtomicInteger.incrementAndGet 为所需递增提供原子操作。修复实验让两个任务经过屏障后调用它,等待完成,最终值是二。这里不能改写成先 get 再 set(old + 1),否则又把原子对象上的多个方法拆成非原子的复合操作。
原子字段也不自动保护多个字段之间的不变量。如果余额与版本必须一起更新,分别使用两个原子变量未必足够。需要把一致性单位定义清楚,再选择锁、单一原子状态、串行处理或更高层事务。
本章验证的是单个计数的原子递增,不测量竞争性能,也没有证明一种同步方式总比另一种更快。首先让不变量成立,再按真实负载测量成本,能避免把错误实现的速度当成优化收益。
尽量减少共享状态的范围
一种常见替代是让各个任务返回自己的结果,最后在单一组合步骤中汇总。若任务可以独立产生数值,Future.sequence 后求和通常比多个任务共同修改外部计数更容易推理。
但这种改写也应保持语义。若计数用于实时限流或任务开始前的容量预留,等全部完成后再汇总就改变了约束时机。消除共享状态不能只看代码更纯,还要确认状态代表的是结果统计还是执行控制。
不可变值可以减少别名修改风险,但外层不可变集合里仍可能装着可变对象。第 30 篇 Java 视图、本系列型变章节都涉及相同边界。并发审查应沿整个对象图看哪些位置会写入,而不是只搜索有没有 var 关键字。
共享对象还有发布问题。把对象放进异步闭包时,应确认初始化完成以及所用同步机制满足发布要求。本文任务通过标准并发工具提交,并没有构造不安全发布反例,因此只强调检查方向,不把它列为已经验证的实验结果。
当确实需要共享状态时,把更新集中到少数入口、明确锁或原子协议,比在各个回调里零散修改更容易保持一致。测试则应覆盖能够破坏不变量的交错,而不只是重复运行大量随机任务期待偶然暴露问题。
超时测试要避免把调度偶然性当成规则
使用 Thread.sleep 可以制造延迟,但不能可靠证明另一个线程已到达某一行。机器负载变化之后,睡眠时间与任务进度之间可能失去关系。当前实验使用开始门闩与释放门闩分别表达两种状态。
定时器只负责完成超时 Promise,没有承担证明慢任务所处位置的职责。这使测试即使在定时器略晚执行时仍然保持逻辑:慢任务被门闩挡住,超时最终先成为可观察完成结果。
竞态测试同样不依赖“多跑一万次总能出错”。屏障刻意安排两个读取先于两个写入,从操作序列直接推出一。测试能稳定失败于正确性差异,比统计偶发现象更适合教学与回归。
这并不意味着受控测试覆盖了所有线程交错。它只证明一个具体错误交错存在,并验证所选修复排除了该单字段递增问题。更复杂算法仍需要不变量分析、更多场景或专门并发测试工具。
实验退出前会释放门闩、关闭定时器和工作池,并等待终止。失败路径也保留清理,防止一个断言失败后留下阻塞进程。清理成功是实验可重复运行的一部分,不应只验证业务输出而忽略线程生命周期。
将并发故障拆成可以回答的问题
看到最终值错误时,可以先区分任务有没有执行、是否读取同一旧值、是否存在非原子组合、结果读取是否在完成之后。当前反例通过 Future 完成等待、屏障和局部旧值排除了“少执行一个任务”的解释。
看到超时后仍出现写入时,则检查原工作是否有取消入口、上层是否真的调用、下层是否响应,以及写入是否早已提交。当前实验明确没有取消入口,因此后续副作用是模型预期,而不是 recover 失效。
看到恢复成功而日志仍有失败,也要区分原 Future 与恢复后的 Future。原始失败结果不会被原地改写成成功;新的派生结果承载恢复值。保留两者标识有助于解释监控中同时出现的失败与正常响应。
这些问题分别对应调度、同步、生命周期和错误建模。它们可以出现在同一个请求中,但不必用一个含糊的“异步有问题”概括。实验先隔离机制,工程再组合机制,定位时就能沿对应边界缩小范围。
多层超时应如何传递剩余预算
真实调用经常经过入口、业务服务和外部客户端,每层都可能设置自己的超时。如果每层重新获得完整的五秒预算,整个请求可能明显超过入口期望;如果外层先返回,而内层仍持续工作,又会积累已经无人等待的任务。
一种可审查的设计是传递截止时间或剩余预算,让下层知道当前操作还允许等待多久。这个设计仍不能凭空取消已提交工作,但能减少在预算耗尽之后继续启动新任务的机会。检查预算与提交之间的竞争也应纳入接口语义。
实验中的三十毫秒定时与五秒保护并不是这样的生产预算链。前者是被测的失败触发,后者只是防止测试挂住。把两者都标为“请求超时”会掩盖用途,因此日志与源码保留各自位置,正文也不把五秒解释成下游服务配置。
定时任务本身同样需要资源管理。如果业务结果提前完成,长期积累未取消的定时任务可能增加调度器负担。当前只有一个短寿命实验,结束时关闭调度器;生产封装若为每个请求安排定时器,应研究正常完成之后如何撤销不再需要的定时工作。
迟到结果的处理也是业务决策
停止向调用方返回结果之后,迟到成功可能仍具有业务价值。例如查询结果可用于缓存,但写入结果可能需要补充审计。直接丢弃或继续处理都不是通用正确答案,应结合操作身份与所有权决定。
如果选择记录迟到结果,观察者应避免再次执行原本的业务动作。对已经完成的写入再调用一次相同写入函数,不能算作“记录结果”。应保存原任务完成信息,区分观察与重放。
若超时之后触发了补偿,迟到成功还可能与补偿竞争。仅靠一个本地 Future 无法证明跨服务操作的最终顺序,需要下游状态机或幂等协议支持。本章保留本地计数反例,是为了让这类设计问题有一个明确起点,而不是提供未经验证的分布式事务方案。
复跑、手算与执行练习
本章为 LAB_VERIFIED:
1 | |
原始记录在 examples/scala-lab/evidence/20261002-ch32-r2/32.json 与 32.log。输出确认超时已被观察、随后原任务副作用计数为一、非原子计数为一、原子计数为二。退出零表示所有预期行为与清理断言成立。
手算题:两个线程先读零,再各自写入旧值加一,最终可能得到二吗?在本实验强制的交错下不会,因为两次写入都是一。若没有屏障,可能出现其他交错,但不能用偶然得到二证明递增安全。
第二题:firstCompletedOf 返回失败以后,原 Future 是否也必须失败?不必。本实验的原 Future 后来成功得到一。完成结果的选择没有向其他任务发送取消协议。
执行练习把修复中的 incrementAndGet 改成 get 加 set,在读取与写入之间加入同样屏障,观察错误重新出现。再给慢任务加入一个显式取消标记,在副作用前检查它,分别验证取消请求发出、任务观察到标记和副作用未发生。不要把这个协作式小样例推广成能中断任意阻塞外部调用。
参考
Future固定源码的firstCompletedOf实现说明组合完成机制;JDK21 AtomicInteger定义原子操作;JDK21 CyclicBarrier定义实验屏障。前篇是Future执行模型,下篇把完成时间与资源关闭时间放在一起检查。
