Scala流的尾部惰性和同步

5
在他的视频中(关于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中,双重检查锁和两个volatile字段是实现线程安全的延迟计算的常见方式。

那么问题是:

  1. 在多线程情况下,Scala的lazy关键字是否提供了“最多评估一次”的保证?
  2. 在Scala中,实际的tail实现模式是否是执行线程安全的延迟计算的惯用方式?

就新开发的集合而言,实现方式要简单得多,具体可以参考这里 - Jasper-M
@Jasper-M,这个新的实现是否在多线程情况下提供了“最多评估一次”的保证?如果是,它是如何实现的?非常抱歉问这些愚蠢的问题,但我对Scala还不熟悉,我并没有看到那段代码与Martin最初在幻灯片上展示的有任何根本区别。 - Roman Puchkovskiy
如果我没记错的话,唯一会被重新评估的是空指针检查(因为该字段是易失性变量),这非常快。 - Roman Puchkovskiy
你是在谈论新的实现吗?我没有看到任何volatile或null检查。 - L.Lampart
@L.Lampart 新的实现是一个lazy val。它们保证在第一次访问时只被评估一次。 - Jasper-M
显示剩余3条评论
3个回答

4
Scala中的lazy关键字在多线程情况下是否提供“仅计算一次”保证?
是的,正如其他人所说的那样。
在Scala中,实现线程安全的延迟计算的模式是否是惯用方式?
编辑:
我认为我有了真正的答案,为什么不使用lazy val。Stream具有公共API方法,例如从TraversableOnce继承的hasDefinitionSize。为了知道Stream是否具有有限大小,我们需要一种方法来检查而不会使底层Stream tail具体化。由于lazy val实际上不公开底层位,因此我们无法做到这一点。
这得到SI-1220的支持。
为了加强这一点,@Jasper-M 指出,在strawman(Scala 2.13集合改造)中的新的LazyList api不再有此问题,因为整个集合层次结构已经被重新设计,不再存在这样的问题。

性能相关问题

我会说,这个问题要看你从哪个角度来看。从LOB的角度来看,为了简洁和实现的清晰度,我会选择使用lazy val。但是,如果你从Scala集合库作者的角度来看,情况就不同了。可以这样想,在创建一个可能被全球许多人使用并在许多机器上运行的库时,你应该考虑每个结构的内存开销,特别是如果你要自己创建一个基本数据结构。

我之所以这么说,是因为当你使用lazy val时,你会按设计生成一个额外的Boolean字段,用于标记值是否已经初始化,我认为这是库作者想要避免的。在JVM上,一个Boolean的大小当然取决于VM,但即使是一个字节也值得考虑,特别是当人们正在生成大量的Stream数据时。再次强调,这绝对不是我通常考虑的事情,而且这显然是一种针对内存使用的微小优化。

我认为性能是关键点之一的原因是SI-7266,它修复了Stream中的内存泄漏问题。请注意跟踪字节码以确保不会在生成的类中保留任何额外的值非常重要。
实现的区别在于tail的定义是否被初始化是一个检查生成器的方法实现:
def tailDefined: Boolean = tlGen eq null

而不是类上的字段。


2
Scala的lazy值在多线程情况下只被评估一次。这是因为lazy成员的评估实际上在生成的代码中被包装在同步块中。
让我们看一个简单的类,
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$0: Boolean = _;
    private def x$lzycompute(): Int = {
      LazyTest.this.synchronized(if (LazyTest.this.bitmap$0.unary_!())
        {
          LazyTest.this.x = (5: Int);
          LazyTest.this.bitmap$0 = true
        });
      LazyTest.this.x
    };
    <stable> <accessor> lazy def x(): Int = if (LazyTest.this.bitmap$0.unary_!())
      LazyTest.this.x$lzycompute()
    else
      LazyTest.this.x;
    def <init>(): LazyTest = {
      LazyTest.super.<init>();
      ()
    }
  }
}

您应该能够看到,惰性求值是线程安全的。而且你也会发现与Scala 2.11.7中“不太优雅”的实现有一些相似之处。

您还可以尝试类似以下测试的实验:

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 翻译更高效的解决方案。

1
  1. 你说得对,lazy val使用锁来防止在两个线程同时访问时进行重复计算。未来的发展将会在没有锁的情况下提供相同的保证。
  2. 在我看来,关于惯用语的问题是一个非常有争议的问题,因为这门语言本身就允许采用各种不同的惯用语。然而,一般来说,当应用程序代码更倾向于纯函数式编程时,它往往被认为是惯用的,因为这样做会带来一系列有趣的优势,例如易于测试和推理,只有在严重担忧的情况下才有意义放弃这些优势。这种担忧可能是性能方面的,这就是为什么Scala Collection API的当前实现在大多数情况下暴露了一个函数接口,但在内部和受限范围内广泛使用varwhile循环和从命令式编程中建立的模式(如你在问题中所强调的那个)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接