Lazy Vals in Scala: Ein Blick hinter die Kulissen

Keine Kommentare

Scala erlaubt die Nutzung des Keywords lazy in Verbindung mit val, um die Initialisierung bei Bedarf auszuführen. Bedarfsauswertung für val hört sich gut an, allerdings hat die konkrete Implementierung in scalac dem Scala Compiler ein paar sehr subtile Probleme, auf die man nicht immer gefasst ist. Dieser Artikel wirft einen Blick hinter die Kulissen und zeigt einige der Stolperfallen auf: neben einem Blick auf die Implementierung in scalac werden wir Szenarien vorstellen, in denen bei Bedarf ausgewertete val das komplette Programm abstürzen lassen, parallele Auswertung verhindern oder anderes Unerwartetes Verhalten zeigen.

Einführung

Dieser Beitrag wurde ursprünglich inspiriert von dem Talk Hands-on Dotty (slides) von Dmitry Petrashko, vorgetragen während der Scala World 2015. Dmitry hielt dort einen wunderbaren Talk über Dotty und erklärt einige der Fallstricke die durch Bedarfsauswertung entstehen, sowie die geänderte Implementierung in Dotty. Wir werden im Folgenden die Bedarfsauswertung von val diskutieren, uns einige der Beispiele von Dmitry genauer ansehen und zusätzliche Szenarien betrachten.

Wie lazy funktioniert

Das prägende Merkmal von lazy ist, dass das val nicht sofort initialisiert wird sondern einmalig1, bei Bedarf. Wenn der erste Zugriff erfolgt wird der Ausdruck ausgewertet und das Ergebnis dem Namen des val zugewiesen. Nachfolgende Zugriffe führen nicht zu wiederholter Auswertung: es wird stattdessen sofort das gespeicherte Ergebnis zurückgegeben.

Mit der oben stehenden Beschreibung, scheint es, als ob „Auswertung bei Bedarf“ harmlos ist und keine negativen Effekte hat, also warum fügt man lazy nicht immer hinzu, sozusagen als eine spekulative Optimierung? In Kürze werden wir sehen warum eben das meist keine gute Idee ist, aber bevor wir dieses Thema vertiefen, sollten wir erst einen genaueren Blick auf die Semantik von lazy val werfen.

Wenn wir wie folgt einen Ausdruck einem lazy val zuweisen:

lazy val two: Int = 1 + 1

erwarten wir, dass der Ausdruck 1 + 1 an den Namen two gebunden wird, jedoch noch keine Auswertung stattfindet. Beim ersten, und nur beim ersten Zugriff auf two wird der gespeicherte Ausdruck 1 + 1 ausgewertet und das Ergebnis zurückgegeben. Nachfolgende Zugriffe auf two führen nicht zu weiteren Auswertungen: stattdessen wurde das Ergebnis der Auswertung gespeichert und wird nun jedes Mal zurückgegeben.

Die Eigenschaft, das Auswertung nur einmal stattfindet ist dabei sehr wichtig. Insbesondere wird dies deutlich, wenn man an Szenarios denkt, bei denen nebenläufige Ausführung stattfindet. Wie, zum Beispiel, soll das Verhalten aussehen, wenn zwei oder mehr Threads gleichzeitig auf ein lazy val zugreifen wollen? Die oben erwähnte Eigenschaft, dass Auswertung nur einmal stattfindet, bedingt eine Form der Synchronisierung um mehrfache Auswertungen zu vermeiden. In der Praxis wird also ein Thread die Auswertung durchführen, während alle anderen Threads warten müssen. Erst wenn die Auswertung des zugewiesenen Ausdrucks abgeschlossen ist, können die wartenden Threads das Ergebnis lesen.

Und wie ist das in scalac, dem aktuellen Standard Scala Compiler implementiert? Dazu können wir ein Blick auf SIP-20 werfen. Im Beispiel wird dort eine Klasse LazyCell mit einem lazy val „value“ definiert:

final class LazyCell {
  lazy val value: Int = 42
}

Eine handgeschriebene Version des Codes, den der Compiler für LazyCell generiert sieht in etwa so aus:

final class LazyCell {
  @volatile var bitmap_0: Boolean = false                   // (1)
  var value_0: Int = _                                      // (2)
  private def value_lzycompute(): Int = {
    this.synchronized {                                     // (3)
      if (!bitmap_0) {                                      // (4)
        value_0 = 42                                        // (5)
        bitmap_0 = true
      }
    }
    value_0
  }
  def value = if (bitmap_0) value_0 else value_lzycompute() // (6)
}

In (3) sehen wir die Verwendung eines Monitors via this.synchronized {...} um zu garantieren, dass die Auswertung auch bei der Verwendung durch mehrere Threads nur einmal stattfindet. Der Compiler verwendetet eine Booleschen Variable ((1)) um den Initialisierungsstatus ((4) & (6)) der Variablen value_0 ((2)) zu verwalten. Diese speichert das finale Ergebnis der Auswertung und wird nach der initialen Auswertung gesetzt ((5)).

In der obigen Implementierung wird außerdem deutlich, dass ein lazy val – anders als ein normales val – bei jedem Zugriff zuerst den Status des Booleschen Flags überprüft ((6)). Das sollte man auf jeden Fall im Hinterkopf behalten, bevor man (versucht) lazy val als kostenlose Optimierung zu verwenden.

Nun da wir ein besseres Verständnis der zugrunde liegenden Mechanismen des lazy Keywords haben, können wir einen Blick auf eine Handvoll Szenarios werfen, bei denen es interessant wird.

Szenario 1: Nebenläufige Initialisierung von mehreren unabhängigen vals geschieht sequenziell

Um dieses Szenario zu verstehen ist es wichtig die oben erwähnte Verwendung von this.synchronized { } im Hinterkopf zu haben. Durch diese Zeile bedingen wir, dass wir ein exklusives Lock der momentane Instanz haben. Werden mehrere lazy val in der gleichen Instanz (z.B. in einem object) definiert, so führt der Zugriff auf eine der lazy val innerhalb der Instanz dazu, dass alle Threads warten müssen. Das ist auch der Fall, wenn diese auf unterschiedliche lazy val zugreifen.

Das folgende Codebeispiel demonstriert genau das: es werden zwei lazy val ((1) & (2)) innerhalb des ValStore object. Im object Scenario1 greifen wir nun auf beide val innerhalb eines Future (also separater Thread) zu. Zur Laufzeit wird der Zugriff auf die beiden lazy val aber sequenziell stattfinden. Im konkreten Fall, in dem die Berechnung der lazy val einige Zeit dauert, ist das besonders unerfreulich.

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent._
import scala.concurrent.duration._

def fib(n: Int): Int = n match {
  case x if x < 0 =>
    throw new IllegalArgumentException(
      "Only positive numbers allowed")
  case 0 | 1 => 1
  case _ => fib(n-2) + fib(n-1)
}

object ValStore {
  lazy val fortyFive = fib(45)                   // (1)
  lazy val fortySix  = fib(46)                   // (2)
}

object Scenario1 {
  def run = {
    val result = Future.sequence(Seq(            // (3)
      Future {
        ValStore.fortyFive
        println("done (45)")
      },
      Future {
        ValStore.fortySix
        println("done (46)")
      }
    ))
    Await.result(result, 1.minute)
  }
}

Das Szenario kann sehr einfach in der Scala REPL getestet werden. Dazu den obigen Code kopieren und per :paste in der REPL wieder einfügen und per Aufruf von Scenario1.run starten. Wenn alles funktioniert wird zuerst die Auswertung von ValStore.fortyFive stattfinden, dann der Text „done (45)“ und erst danach die Auswertung von ValStore.fortySix, natürlich ist auch die umgekehrte Reihenfolge möglich.

Szenario 2: Gefahr des Deadlocks bei Zugriff auf lazy vals

Im vorigen Szenario litten wir „nur“ unter unerwarteten Performance Problemen, falls mehrere lazy val innerhalb einer Instanz definiert sind und durch mehrere Threads Zugriff erfolgt. Im folgenden Szenario sind die Konsequenzen etwas problematischer:

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent._
import scala.concurrent.duration._

object A {
  lazy val base = 42
  lazy val start = B.step
}

object B {
  lazy val step = A.base
}

object Scenario2 {
  def run = {
    val result = Future.sequence(Seq(
      Future { A.start },                        // (1)
      Future { B.step }                          // (2)
    ))
    Await.result(result, 1.minute)
  }
}

Wir definieren drei lazy val in zwei verschieden object A und B. Die Abhängigkeiten unter den lazy val sind hier noch mal visualisiert:

dep

Der Wert von A.start ist abhängig von B.step, welcher von A.base abhängt. Wichtig: hier ist keine zyklische Beziehung vorhanden ist, trotzdem führt die Ausführung des obigen Szenarios via Scala REPL zu einem Deadlock (sollte es zufällig beim ersten Mal funktionieren, einfach noch einmal probieren):

scala> :paste
...
scala> Scenario2.run
java.util.concurrent.TimeoutException: Futures timed out after [1 minute]
  at scala.concurrent.impl.Promise$DefaultPromise.ready(Promise.scala:219)
  at scala.concurrent.impl.Promise$DefaultPromise.result(Promise.scala:223)
  at scala.concurrent.Await$$anonfun$result$1.apply(package.scala:190)
  at scala.concurrent.BlockContext$DefaultBlockContext$.blockOn(BlockContext.scala:53)
  at scala.concurrent.Await$.result(package.scala:190)
  ... 35 elided

Was passiert hier? Wir haben einen Deadlock, weil die beiden Future in (1) und (2) beim Zugriff auf A bzw. B wieder den exklusiven Zugriff haben durch Verwendung des Monitors. Damit die Ausführung fortgesetzt werden kann, braucht der Thread der exklusiven Zugriff auf A hat, auch Zugriff auf B wegen B.step. Der andere Thread dagegen hat exklusiven Zugriff auf B, braucht aber A.base. Wir haben also einen Deadlock. Im obigen Szenario ist die Situation recht leicht erkennbar, aber bei komplexeren Szenarien ist es ungleich schwerer auf die Ursache zu kommen. Die gleiche Situation kann auch bei Verwendung von class entstehen, obwohl es schwerer ist eine Situation wie die obige zu konstruieren. Im Allgemeinen sollte man aber auch erwähnen, dass die Deadlock Situation recht unwahrscheinlich ist, da es recht exaktes Timing beim gleichzeitigen Zugriff voraussetzt. Umso schwieriger ist es aber auch dieses Szenario zu reproduzieren, falls man es (gelegentlich) im Betrieb feststellt.

Szenario 3: Deadlock in Kombination mit Synchronisierung

Durch die Verwendung eines Monitors bei der Initialisierung von lazy val gibt es aber neben dem Problem aus Szenario 2 noch mehr Fälle, in denen es zu Problemen kommen kann. Zum Beispiel im folgenden Code:

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent._
import scala.concurrent.duration._

trait Compute {
  def compute: Future[Int] =
    Future(this.synchronized { 21 + 21 })        // (1)
}

object Scenario3 extends Compute {
  def run: Unit = {
    lazy val someVal: Int =
      Await.result(compute, 1.minute)            // (2)
    println(someVal)
  }
}

Ausführung in der Scala REPL führt zu folgendem Ergebnis:

scala> :paste
...
scala> Scenario3.run
java.util.concurrent.TimeoutException: Futures timed out after [1 minute]
  at scala.concurrent.impl.Promise$DefaultPromise.ready(Promise.scala:219)
  at scala.concurrent.impl.Promise$DefaultPromise.result(Promise.scala:223)
  at scala.concurrent.Await$$anonfun$result$1.apply(package.scala:190)
  at scala.concurrent.BlockContext$DefaultBlockContext$.blockOn(BlockContext.scala:53)
  at scala.concurrent.Await$.result(package.scala:190)
  at Scenario3$.someVal$lzycompute$1(<console>:62)
  at Scenario3$.someVal$1(<console>:62)
  at Scenario3$.run(<console>:63)
  ... 33 elided

Der Compute trait allein betrachtet ist zunächst harmlos, allerdings verwendet er einen Monitor via synchronized in (1). Kombiniert man Compute mit lazy val befindet man sich in einer Deadlock Situation. Der Zugriff auf someVal ((2)) im println(someVal) erzwingt die Auswertung des lazy val, was dazu führt, dass der momentane Thread exklusiven Zugriff auf das Scenario3 object bekommt. Beim Auswerten des Ausdrucks für someVal versucht die Methode compute ebenfalls exklusiven Zugriff zu bekommen – erneut befinden wir uns in einer Deadlock Situation.

Zusammenfassug

In den verschieden Szenarien wurden Future und synchronized als Beispiel benutzt. Wichtig ist aber, dass die Probleme unabhängig sind von den zwei konkret genutzten Arten Threads zu starten (Future) und Zugriffe zu synchronisieren (synchronized).

In diesem Artikel haben wir einen Blick hinter die Kulissen der scalac Implementierung von Scala’s lazy val geworfen und einige überraschende Szenarien genauer betrachtet:

  • sequenzielle Initialisierung aufgrund der internen Verwendung
    eines Monitors
  • Deadlock während des nebenläufigen Zugriffs auf lazy vals
  • Deadlock in Kombination mit anderen Synchronisierungsmitteln

Wie man sehen kann, ist lazy keine kostenlose Optimierung, welche man ohne weiteres Betrachten des konkreten Falles einfach anwenden kann. Im Gegenteil, vermutlich würde es bei vielen der lazy val mehr Sinn machen sie durch normale val oder def zu ersetzen. Die gute Nachricht ist, dass die Dotty Plattform bereits eine alternative Implementierung für die Initialisierung von lazy val verwendet (von Dmitry Petrashko), welche nicht die oben erwähnten Probleme hat. Für mehr Informationen über Dotty sind in den Referenzen Links zu Dmitry’s Talk und die GitHub von Dotty zu finden.

Alle Beispiele wurden mit Scala 2.11.7 getestet.

Referenzen

Fußnoten

1Das ist nicht ganz richtig. Falls bei der ersten Initialisierung eine Exception geworfen, werden darauf folgende Zugriffe erneut versuchen die Initialisierung vorzunehmen.

Tags

Markus Hauck

Markus Hauck arbeitet als IT Consultant und Scala Trainer bei codecentric. Seine Leidenschaft ist die Funktionale Programmierung und ausdrucksstarke Typsysteme.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.