You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Scala3中Future引用外部作用域val触发阻塞问题咨询

问题背景

测试代码修改自Scala Cookbook示例,将原示例中硬编码的常量替换为val定义的sleeptime变量,代码片段如下:

val sleeptime = 1000

版本运行差异

  • Scala 2.13.8环境:运行结果符合预期,执行输出如下:
$ scala FuturesExample3
1 - starting calculation ...
2- before onComplete
A ...
B ...
Got the callback, meaning = 42
C ...
D ...
E ...
F ...
  • Scala 3.1.2环境:相同代码编译后运行,onComplete回调始终未触发,输出存在显著差异:
$  scala FuturesExample3
1 - starting calculation ...
2- before onComplete
A ...
B ...
C ...
D ...
E ...
F ...

通过jstack抓取线程栈分析发现:Scala 3.1.2环境中承载Future任务的工作线程,在sleeptime对象上执行object.wait()进入阻塞状态。

完整测试代码

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Failure, Success}

object FuturesExample3 extends App {
  val sleeptime = 1000

  println("1 - starting calculation ...")
  val f = Future {
    sleep(sleeptime*2)
    42
  }

  println("2- before onComplete")
  f.onComplete {
    case Success(value) => println(s"Got the callback, meaning = $value")
    case Failure(e) => e.printStackTrace()
  }

  // do the rest of your work
  println("A ..."); sleep(sleeptime)
  println("B ..."); sleep(sleeptime)
  println("C ..."); sleep(sleeptime)
  println("D ..."); sleep(sleeptime)
  println("E ..."); sleep(sleeptime)
  println("F ..."); sleep(sleeptime)

  def sleep(duration: Long): Unit = { Thread.sleep(duration) }

}
待解答疑问
  1. 为何Scala3生成的字节码在读取外部作用域的val时会产生阻塞?
  2. 这类不符合直觉的行为差异提升了Scala 2.x到3.x的升级难度,该如何理解、规避此类兼容性问题?

问题解答

1. 阻塞产生的根本原因

这个问题和读取外部val的字节码逻辑无关,是Scala 3对App特质、对象初始化语义的改动触发的,具体逻辑链:

  • Scala 2的App靠特殊的延迟初始化逻辑实现,对象里的业务代码不会在类加载阶段执行,等main方法触发后按代码顺序执行,内部val执行到对应行才赋值,不会有额外的锁逻辑。
  • Scala 3完全重写了App的实现:App对象的业务代码默认在类的静态初始化阶段执行,这个阶段JVM会持有该类的初始化锁,所有外部线程如果要访问这个类还没完成初始化的final val字段,就会自动在这个锁上wait,等类初始化完成才会被唤醒。
  • 测试代码刚好踩中了这个机制的阻塞+进程提前退出逻辑:
    1. 主线程持有类初始化锁,按顺序执行代码,先把Future提交到全局线程池(这个线程池里的线程全是daemon线程,不会阻止JVM退出),然后开始执行后续的6次sleep。
    2. Future的工作线程启动后,第一行代码就要读sleeptime字段,这时候类还在初始化阶段(主线程还在跑sleep没跑完初始化流程),于是工作线程直接wait在类锁上,根本没往下执行sleep和计算逻辑。
    3. 等主线程跑完6次sleep,类初始化完成,释放锁,这时候主线程已经执行完所有main方法逻辑,JVM一检查发现没有非daemon线程在跑,直接就退出了。被唤醒的Future线程还没来得及执行任务、触发回调,进程就已经结束了,所以看不到回调输出,jstack抓到的wait就是线程等类初始化锁的状态。

2. 兼容性问题的理解与规避

怎么理解这类差异

这类不兼容改动不是Scala 3的bug,是为了修复Scala 2里存在了十几年的初始化语义漏洞做的调整:Scala 2的App延迟初始化逻辑一直有初始化顺序混乱、多线程下字段可见性无保障的问题,Scala 3对齐了JVM标准的类初始化规则,长期来看语义更严谨,但确实会让依赖旧版App行为的老代码出问题。

具体规避方法

  • 最稳妥的方案:别用App特质当入口,手动写def main(args: Array[String]): Unit方法作为程序入口。手动写main方法不会把业务代码放到静态初始化块里跑,字段初始化顺序和代码编写顺序完全一致,不会出现跨线程等初始化锁的问题。
  • 如果要继续用App,可以选两种改法:
    • 把跨线程访问的val改成lazy val,lazy字段不会触发类初始化锁的阻塞逻辑,第一次访问的时候才会赋值。
    • 把跨线程访问的常量挪到App对象外面,要么定义成顶级常量,要么放到单独的object里,避开内部字段的初始化锁影响。
  • 跨版本升级的通用注意点:
    • 写并发代码别靠主线程sleep等异步任务,不管Scala2还是3,这种写法本身就不可靠,异步任务要主动用Await等机制等待完成再退出进程。
    • 多线程场景下不要依赖语言的隐式初始化、隐式执行顺序语义,跨线程访问的变量要明确初始化时机、明确可见性。
    • 升级版本时优先跑全量并发测试用例,重点排查用了App入口、隐式延迟执行的逻辑块。

内容的提问来源于stack exchange,提问作者mbsf

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 13:39:38