Scala Stream 尾延迟和同步

Scala Stream tail laziness and synchronization

在他的一个视频中(关于 Scala 的惰性求值,即 lazy 关键字),Martin Odersky 展示了用于构造 Streamcons 操作的以下实现:

def cons[T](hd: T, tl: => Stream[T]) = new Stream[T] {
  def head = hd
  lazy val tail = tl
  ...
}

因此tail操作是使用语言的惰性求值特性简洁地编写的。

但实际上(在 Scala 2.11.7 中),tail 的实现有点不那么优雅:

@volatile private[this] var tlVal: Stream[A] = _
@volatile private[this] var tlGen = tl _
def tailDefined: Boolean = tlGen eq null
override def tail: Stream[A] = {
  if (!tailDefined)
    synchronized {
      if (!tailDefined) {
        tlVal = tlGen()
        tlGen = null
      }
    }

  tlVal
}

双重检查锁定和两个可变字段:这就是您在 Java.

中实现线程安全惰性计算的大致方式

所以问题是:

  1. Scala 的 lazy 关键字在多线程情况下不提供任何 'evaluated maximum once' 保证吗?
  2. 实际 tail 实现中使用的模式是在 Scala 中执行线程安全惰性求值的惯用方式吗?
  1. 你是对的,lazy vals 使用锁定精确地防止两个线程同时访问时的双重计算。此外,未来的发展将在没有锁定的情况下提供相同的保证。
  2. 在我看来,当涉及到一种语言时,什么是惯用语是一个非常有争议的话题,这种语言在设计上允许采用各种不同的惯用语。然而,一般来说,当更多地转向纯函数式编程的方向时,应用程序代码往往被认为是惯用的,因为它在易于测试和推理方面提供了一系列有趣的优势,只有在以下情况下才有意义放弃严重的担忧。这种担忧可能是一种性能问题,这就是为什么 Scala Collection API 的当前实现在大多数情况下公开功能接口时大量使用(内部和受限范围内)vars ,while 循环并从命令式编程中建立模式(如您在问题中突出显示的模式)。

Scala lazy 值在多线程情况下仅计算一次。这是因为 lazy 成员的计算实际上包含在生成的代码中的同步块中。

让我们看一下简单的claas,

class LazyTest {

  lazy val x = 5

}

现在,让我们用 scalac 编译它,

scalac -Xprint:all LazyTest.scala

这将导致,

package <empty> {
  class LazyTest extends Object {
    final <synthetic> lazy private[this] var x: Int = _;
    @volatile private[this] var bitmap[=12=]: Boolean = _;
    private def x$lzycompute(): Int = {
      LazyTest.this.synchronized(if (LazyTest.this.bitmap[=12=].unary_!())
        {
          LazyTest.this.x = (5: Int);
          LazyTest.this.bitmap[=12=] = true
        });
      LazyTest.this.x
    };
    <stable> <accessor> lazy def x(): Int = if (LazyTest.this.bitmap[=12=].unary_!())
      LazyTest.this.x$lzycompute()
    else
      LazyTest.this.x;
    def <init>(): LazyTest = {
      LazyTest.super.<init>();
      ()
    }
  }
}

您应该能够看到...惰性求值是线程安全的。您还会看到与 Scala 2.11.7

中 "less elegant" 实现的一些相似之处

您也可以尝试类似以下的测试,

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

case class A(i: Int) {

  lazy val j = {
    println("calculating j")
    i + 1
  }

}

def checkLazyInMultiThread(): Unit = {

  val a = A(6)

  val futuresList = Range(1, 20).toList.map(i => Future{
    println(s"Future $i :: ${a.j}")
  })

  Future.sequence(futuresList).onComplete(_ => println("completed"))

}

checkLazyInMultiThread()

现在,标准库中的实现避免使用 lazy,因为它们能够提供比这种通用 lazy 翻译更有效的解决方案。

Doesn't lazy keyword of Scala provide any 'evaluated maximum once' guarantee in a multi-threaded case?

是的,就像其他人所说的那样。

Is the pattern used in real tail implementation an idiomatic way to do a thread-safe lazy evaluation in Scala?

编辑:

我想我有 实际的 答案,为什么不 lazy valStream 有 public 面向 API 方法,例如 hasDefinitionSize 继承自 TraversableOnce。为了知道 Stream 是否具有有限大小,我们需要一种方法来检查 而无需具体化底层 Stream 尾部。由于 lazy val 实际上并未公开底层位,因此我们不能这样做。

这得到了 SI-1220

的支持

为了加强这一点,@Jasper-M 指出新的 LazyList api in strawman (Scala 2.13 collection makeover) 不再有这个问题,因为整个集合层次结构已经重做,不再有这样的顾虑。


与性能相关的问题

我会说 "it depends" 你是从哪个角度看这个问题的。从 LOB 的角度来看,为了实现的简洁明了,我肯定会选择 lazy val。但是,如果您从 Scala 集合库作者的角度来看它,事情就会开始有所不同。以这种方式思考,您正在创建一个库,它可能会被许多人使用,并且 运行 在世界各地的许多机器上使用。这意味着您应该考虑每个结构的内存开销,尤其是当您自己创建这样一个基本数据结构时。

我这样说是因为当你使用 lazy val 时,你会按设计生成一个额外的 Boolean 字段,该字段会标记该值是否已初始化,我假设这就是库作者的意思旨在避免。 JVM 上 Boolean 的大小当然取决于 VM,即使是一个字节也是需要考虑的事情,尤其是当人们生成大量 Stream 数据时。同样,这绝对 不是 我通常会考虑的事情,并且绝对是对内存使用的微优化。

我认为性能是这里的关键点之一的原因是 SI-7266 它修复了 Stream 中的内存泄漏。请注意跟踪字节码以确保在生成的 class.

中没有保留额外值的重要性。

实现上的区别在于tail初始化与否的定义是检查生成器的方法实现:

def tailDefined: Boolean = tlGen eq null

而不是 class 上的字段。