Scala 31:Future与ExecutionContext的执行模型
两个Future写进for之后是否并行
下面两种写法都能合并两个结果,却不一定具有相同的任务创建时机:
1 | |
1 | |
第一种在组合之前已调用两个创建函数。第二种需要第一个结果,才进入创建第二个任务的函数。判断并发不能只看是否使用 for,而要把表达式求值、Future 创建、任务开始和结果完成分开。
本章使用命名的双线程池与门闩实际验证这些时刻。独立任务都先报告开始,再等待释放;顺序任务则记录第二段函数何时执行。实验不依赖“睡一小会儿之后应该已经开始”的推测,而是用同步信号建立可以检查的先后关系。
冻结工具仍为 Scala 3.3.7、Scala CLI 1.9.1、JDK 21.0.11。标准库实现核对到 2.13.16 的固定提交。代码在 snippets/31/Chapter31.scala,包含正常组合、顺序依赖、失败传播、恢复与回调线程断言。
Future是某次计算结果的句柄
Future[A] 表示一次可能尚未完成的计算结果。结果可以成功得到 A,也可以失败保存异常。已经持有同一个 Future 时,多次引用它并不意味着重新执行原始计算;如果多次调用创建 Future 的方法,则可能创建多次计算。
1 | |
这里 a 和 b 共享那一次创建的结果。若改成两次 request(),则执行次数由创建函数决定。对会发送请求或写入数据的代码,这种差别直接影响副作用次数。Future 不能只被当作“值外面加了一层异步括号”。
调用 Future(body) 时,计算被交给相应执行上下文安排;并不是等到后续 map、Await 或 for 出现才自动启动。具体开始时间取决于调度,不能保证创建调用返回前或返回后某个固定时刻执行。
冻结源码中 Future.apply 通过 unit.map(_ => body) 建立计算。这说明可以沿转换与执行上下文理解它的启动机制,而不是把它想成保存任意代码、等待另一个解释器统一运行的惰性描述。其他效果库的构造语义要另行研究,不能直接套用 Future 的模型。
这种启动特性也影响错误处理。若先创建两个请求,再组合它们,其中一个失败不会让另一个变成“从未启动”。组合结果失败与已经启动的工作是否继续,是不同问题;下一篇通过超时实验进一步验证这个边界。
ExecutionContext决定工作怎样被安排
创建和转换 Future 通常需要 ExecutionContext。它负责执行提交的任务并处理相应报告,而不是 Future 自身凭空产生线程。本章用 Executors.newFixedThreadPool(2) 建立执行器,再通过 ExecutionContext.fromExecutorService 适配。
线程工厂把工作线程命名为 lab31-1、lab31-2。任务记录自己实际执行时的线程名,断言前缀符合预期。这是运行证据;仅在代码里声明一个线程池变量,不能证明实际回调使用了它。
执行上下文也不等同于“每个 Future 一条新线程”。固定池复用有限线程,其他上下文还可能批量执行任务或采用其他调度策略。给一个 Future 增加多个转换,不应据此推算新增了相同数量的线程。
标准库文档说明回调在提供的执行上下文中运行,但不保证每个回调独立调用一次 execute。实现可以批处理,执行方式也可能立即发生或异步安排。因此本文只对所配置的线程池断言工作线程前缀,不把它提升为所有执行上下文都“必定切线程”的规则。
显式传递上下文还能让依赖关系更清楚。计算密集工作、阻塞外部调用和轻量回调可能需要不同安排。如果一切都隐式使用同一个全局池,代码虽然简短,容量和阻塞影响却更难追踪。选择上下文应基于工作性质和服务约束,不能只依据哪里导入最方便。
用门闩证明两个任务都已开始
并行实验有一个计数为二的 started 门闩与一个关闭的释放门闩。每个任务先对 started 减一,再等待释放。主线程只有观察到两个任务都报告开始,才继续检查它们仍未完成。
这时可以得出几个有限结论:两个任务都进入了任务体;两者都在结果产生之前被阻塞;当前双线程池确实能容纳这两个任务同时到达门闩。不能由此推导 CPU 在同一纳秒执行了两条指令,也不能推导任务在所有负载下都会公平调度。
主线程随后释放门闩,等待 first.zip(second),断言两个整数和为三,并核对记录的线程名。zip 负责把两个结果组成一对;任务已经在之前创建,zip 不是启动它们的唯一原因。
如果池只有一个线程,这种测试结构会使第一个任务占住唯一线程等待,第二个任务无法开始,started 无法归零。实验的五秒等待会失败并进入清理。这个现象反映测试所要求的并发容量,不代表所有单线程 Future 程序都不能工作。
门闩刻意阻塞线程,只用于建立实验时序。业务代码一般不应为了合并 Future 再手工加入这类等待。正常依赖使用组合表达,测试中的门闩则让中间状态可观察。把测试控制装置当成生产实现模板,会引入不必要的阻塞。
flatMap中的创建时机形成真正依赖
顺序实验先创建第一个 Future。它报告开始后等待释放,此时一个原子布尔标记仍为 false。这个标记只在 flatMap 的函数里设为 true,然后才创建第二个 Future。
1 | |
当第一个任务尚未完成时,第二段函数没有成功值可以接收,因此标记保持 false。释放第一个任务后,组合结果为十一,标记变为 true。这是“第二个计算的创建依赖第一个成功结果”的直接证据。
对比先写 val second = Future(...) 再在 flatMap 中引用它:这种写法延后的是结果组合,不一定延后第二个任务本身。代码审查时要沿创建位置追踪,而不是只沿变量被读取的位置追踪。
若第二项确实独立,可以提前创建以允许重叠;若它需要第一项的标识或令牌,则应留在依赖函数中。提前启动所有工作并不总是优化,它也可能造成无用调用、资源占用或违反业务顺序。需要的是与依赖图一致的启动位置。
for 表达式可以按 flatMap 与 map 手工展开。展开之后,哪些表达式处在闭包内部、哪些已经求值就更清楚。语法隐藏了重复的组合代码,却没有改变普通表达式求值规则。
map转换值,flatMap连接后续计算
map 接受成功值并产生普通结果。如果函数返回一个 Future,就会得到嵌套类型;flatMap 接受返回 Future 的函数,并把完成关系连接起来,使外层结果随这个后续计算完成。
这个差别不仅是类型层面的括号数量,也影响失败如何传播。如果第一个 Future 失败,成功转换函数不会得到值;若转换函数自身抛出符合捕获规则的异常,新的结果可以失败;若 flatMap 返回的后续 Future 失败,组合结果也相应失败。
冻结源码的 map 委托给 transform,flatMap 使用 transformWith,仅在成功分支调用用户函数。这里的源码核验范围只到这些组合入口,没有审计所有执行器与 Promise 内部实现。它足以支持成功路径与失败路径的解释,不足以证明任意 Throwable 都会成为普通失败值。
本章失败实验创建一个抛出 IllegalArgumentException 的 Future,随后接 map(_ + 1),最后用 recover 返回负一。实际结果是负一,说明该失败没有被整数加一当作成功值继续处理。
恢复不等于原计算成功。它创建了一个按照错误策略得到的新结果。选择负一只是实验中便于断言的标志;生产接口若负数本来是合法值,应该使用能区分成功与错误的领域模型,避免恢复值掩盖故障。
回调不是可依赖的顺序日志管线
对同一个 Future 注册多个独立回调,不能根据注册顺序假设它们必定依次完成。若业务需要“先写入再发送”,应通过一个返回结果的组合链表达依赖,而不是分别注册两个回调后期待线程调度恰好满足顺序。
onComplete 的主要用途是观察完成并执行相应动作,其回调返回值不会成为一个供后续组合的业务结果。需要转换时通常使用 map、flatMap 或相应恢复组合,使类型和完成关系都保留在链中。
本章针对 Future.successful(1).map(...) 记录回调线程。即使原 Future 已经完成,转换函数仍遵循提供的执行上下文规则。实际记录落在命名池中,但线程编号可能是其中任意一个;断言只要求前缀,而没有错误地固定为一号或二号线程。
这种断言设计体现了并发测试应区分稳定性质和合法不确定性。值结果、依赖顺序和线程池归属可以要求;两个空闲工作线程中具体选哪个通常不应要求。把非契约细节写进测试,容易形成偶发失败。
日志也存在同样问题。两个独立任务打印的行顺序不构成可靠的因果证明。当前实验用门闩建立因果,再把线程名排序后输出,日志只是对已验证状态的摘要,不能反过来代替同步关系。
等待边界与线程池生命周期
Await.result 会阻塞调用线程直到结果可用或等待失败。本章只在测试入口使用有限等待,让进程能够对断言给出确定结果。业务请求处理代码若在同一个小线程池中阻塞等待该池的新任务,可能造成容量耗尽甚至无法推进。
五秒超时是实验防挂保护,不是服务性能指标。测试在五秒内完成,并不说明请求具有五秒服务等级;它只说明当前控制时序和环境没有超过保护阈值。真实延迟指标需要另行测量。
线程池由创建者负责关闭。本章 finally 先释放可能仍在等待的门闩,再调用 shutdown,最后用有限时间等待线程池终止。这样即使某个断言失败,也尽量避免工作线程永久停在门闩上。
关闭执行器并不自动等于所有业务动作撤销。已经运行的任务如何响应中断、外部调用是否支持取消,以及资源由谁关闭,需要额外协议。当前实验没有使用中断作为业务取消机制,也没有把 shutdown 描述成事务回滚。
执行上下文的生命周期可以由应用统一持有,也可以由有界组件持有。关键是所有权清楚:库方法若使用调用者传入的上下文,通常不应在完成一次计算后关闭整个共享池;测试自己创建池,则应在结束时回收。
从同步接口迁移时保留错误和执行次数
把同步 A => B 改成返回 Future[B],影响的不只是返回类型。调用者需要决定何时创建任务、在哪个上下文执行、如何观察失败以及谁管理相关资源。同步 try/catch 包住创建调用,未必能捕获随后异步任务中的失败。
如果函数在创建 Future 之前就执行了有风险的同步表达式,那部分异常仍可能同步抛出;如果表达式在 Future 任务体内,失败则按异步计算规则处理。接口实现应明确边界,测试也应覆盖两种时机,避免调用方用错捕获位置。
重试尤其依赖执行次数模型。重新引用同一个失败 Future 不会重新发送请求;重新调用创建函数才可能再次执行。反过来,不经意地把 val 改成每次创建新 Future 的 def,也可能把一次外部写入变成多次。迁移时应保留副作用计数断言。
本章没有实现网络重试框架。对普通 Future 的执行模型建立清楚之后,才适合设计退避、幂等和取消策略。直接在未知副作用边界上重复调用,可能使错误处理本身扩大影响。
把依赖图与线程占用分开画
并发程序有两张不同的图。结果依赖图描述哪个值需要等待哪个值;资源占用图描述哪些任务占着线程、连接或锁。使用 flatMap 可以表达结果依赖,却不自动保证资源占用合理。一个异步返回的接口内部仍可能执行长时间阻塞调用。
例如第一个任务占住唯一工作线程并同步等待第二个任务,而第二个任务又只能提交到同一个池。结果依赖是前者需要后者,资源关系却是前者不释放后者所需的唯一线程。这种结构即使每个返回值都叫 Future,也无法仅靠类型名解决。
本章选择两个线程,恰好允许两个受控任务同时进入等待区。测试结束后释放门闩,使占用可以解除。如果将它扩展为任意数量任务而不改变容量,部分任务会排队;因此“创建了很多 Future”只能证明提交了很多工作,不保证相同数量都已执行。
线程池容量还不是唯一限制。外部数据库连接池、并发请求配额和内存缓冲也会限制推进。组合 API 的简洁不能代替容量设计。实际应用应在入口控制并发量,观察排队与运行中的任务数量,再决定是否增加线程;否则只是把等待从一处移动到另一处。
成功值与异常值之外还有报告路径
Future 的失败通道可以表达异步计算中按规则捕获的异常,但回调中某些未进入派生结果的异常可能交给执行上下文报告。尤其 onComplete 返回 Unit,调用者不能从它取得一个新的业务 Future 来继续组合。
这说明日志里出现异常与某个业务 Future 失败并非完全同一件事。排查时应记录异常发生在任务体、转换函数、完成观察者还是执行器报告入口。单纯搜索一条堆栈,可能无法判断调用方最终收到什么结果。
对于必须影响业务结果的动作,优先把动作放入有结果的组合链中,让失败能被后续代码明确观察。对于仅用于指标或审计的观察,也应决定观察失败是否影响业务,以及异常由哪里记录。这个选择属于接口契约,不应由一个偶然的回调写法决定。
本章只用普通 IllegalArgumentException 验证失败传播,没有拿虚拟机错误、线程中断或控制异常做统一结论。标准库对不同异常类别的处理需按源码与文档区分。把所有 Throwable 都捕获并包装成普通错误,可能破坏执行环境本来需要的控制行为。
为异步重构选择不会误通过的断言
如果并行实验只断言最后结果等于三,那么顺序实现也可以通过,测试无法区分两种执行模型。当前额外检查两个任务都已开始且都未完成,才使“允许重叠”具有观察点。测试应先明确要排除哪个错误实现,再选择相应中间状态。
顺序实验也不能只断言最后等于十一,因为提前创建第二个独立任务也可能凑出同样结果。标记在 flatMap 函数内部更新,门闩释放之前保持 false,提供了与最终值不同的时序证据。
同样,失败实验如果只打印堆栈而没有断言恢复值,不能确认错误是否走过预期组合。线程实验如果只打印名称而不检查前缀,也不能阻止意外使用全局执行上下文。把观察转为断言,才能在重构改变行为时得到失败,而不只是生成一份看起来合理的日志。
复跑与练习
本章为 LAB_VERIFIED,真实记录在 examples/scala-lab/evidence/20261002-ch31/31.json 与 31.log:
1 | |
日志显示两个任务都进入执行,顺序场景的第二段只在第一个成功后创建,失败恢复结果为负一。线程名摘要来自真实工作线程;具体回调编号不是稳定契约。所有等待有上限,线程池终止也有断言。
手算题:先创建 fa、fb,再写 fa.flatMap(a => fb.map(b => a + b)),是否证明 fb 等 fa 完成才开始?不能,fb 已经创建。把创建表达式移入 flatMap 才改变启动时机,但如果没有数据依赖也可能减少本可利用的重叠。
第二题:Future.successful(1) 已完成,其 map 是否必定在调用者线程执行?不能这样判断,要看执行上下文。本实验选择命名线程池,观察到池内执行;其他上下文的调度应单独验证。
执行练习增加一个计数器,对同一 Future 等待两次,再调用创建函数两次,比较任务体执行次数。随后把一个成功转换改成抛出普通异常,分别观察 map、recover 与原 Future 的状态。答案应区分原结果和派生结果,不能写成“恢复把失败 Future 改成成功”。
参考
Future 2.13.16 固定源码支持本文对 apply、map、flatMap 和回调上下文的解释;官方 Futures 概览提供组合与执行上下文说明;JDK 21 CountDownLatch定义实验同步工具。前篇是Java互操作,下篇继续验证超时之后工作是否仍会发生。
