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:
1lazy 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:
1final class LazyCell {
2 lazy val value: Int = 42
3}
Eine handgeschriebene Version des Codes, den der Compiler für LazyCell
generiert sieht in etwa so aus:
1final class LazyCell { 2 @volatile var bitmap_0: Boolean = false // (1) 3 var value_0: Int = _ // (2) 4 private def value_lzycompute(): Int = { 5 this.synchronized { // (3) 6 if (!bitmap_0) { // (4) 7 value_0 = 42 // (5) 8 bitmap_0 = true 9 } 10 } 11 value_0 12 } 13 def value = if (bitmap_0) value_0 else value_lzycompute() // (6) 14}
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.
1import scala.concurrent.ExecutionContext.Implicits.global
2import scala.concurrent._
3import scala.concurrent.duration._
4
5def fib(n: Int): Int = n match {
6 case x if x < 0 =>
7 throw new IllegalArgumentException(
8 "Only positive numbers allowed")
9 case 0 | 1 => 1
10 case _ => fib(n-2) + fib(n-1)
11}
12
13object ValStore {
14 lazy val fortyFive = fib(45) // (1)
15 lazy val fortySix = fib(46) // (2)
16}
17
18object Scenario1 {
19 def run = {
20 val result = Future.sequence(Seq( // (3)
21 Future {
22 ValStore.fortyFive
23 println("done (45)")
24 },
25 Future {
26 ValStore.fortySix
27 println("done (46)")
28 }
29 ))
30 Await.result(result, 1.minute)
31 }
32}
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:
1import scala.concurrent.ExecutionContext.Implicits.global
2import scala.concurrent._
3import scala.concurrent.duration._
4
5object A {
6 lazy val base = 42
7 lazy val start = B.step
8}
9
10object B {
11 lazy val step = A.base
12}
13
14object Scenario2 {
15 def run = {
16 val result = Future.sequence(Seq(
17 Future { A.start }, // (1)
18 Future { B.step } // (2)
19 ))
20 Await.result(result, 1.minute)
21 }
22}
Wir definieren drei lazy val
in zwei verschieden object
A
und B
. Die Abhängigkeiten unter den lazy val
sind hier noch mal visualisiert:
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):
1scala> :paste
2...
3scala> Scenario2.run
4java.util.concurrent.TimeoutException: Futures timed out after [1 minute]
5 at scala.concurrent.impl.Promise$DefaultPromise.ready(Promise.scala:219)
6 at scala.concurrent.impl.Promise$DefaultPromise.result(Promise.scala:223)
7 at scala.concurrent.Await$$anonfun$result$1.apply(package.scala:190)
8 at scala.concurrent.BlockContext$DefaultBlockContext$.blockOn(BlockContext.scala:53)
9 at scala.concurrent.Await$.result(package.scala:190)
10 ... 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:
1import scala.concurrent.ExecutionContext.Implicits.global
2import scala.concurrent._
3import scala.concurrent.duration._
4
5trait Compute {
6 def compute: Future[Int] =
7 Future(this.synchronized { 21 + 21 }) // (1)
8}
9
10object Scenario3 extends Compute {
11 def run: Unit = {
12 lazy val someVal: Int =
13 Await.result(compute, 1.minute) // (2)
14 println(someVal)
15 }
16}
Ausführung in der Scala REPL führt zu folgendem Ergebnis:
1scala> :paste
2...
3scala> Scenario3.run
4java.util.concurrent.TimeoutException: Futures timed out after [1 minute]
5 at scala.concurrent.impl.Promise$DefaultPromise.ready(Promise.scala:219)
6 at scala.concurrent.impl.Promise$DefaultPromise.result(Promise.scala:223)
7 at scala.concurrent.Await$$anonfun$result$1.apply(package.scala:190)
8 at scala.concurrent.BlockContext$DefaultBlockContext$.blockOn(BlockContext.scala:53)
9 at scala.concurrent.Await$.result(package.scala:190)
10 at Scenario3$.someVal$lzycompute$1(<console>:62)
11 at Scenario3$.someVal$1(<console>:62)
12 at Scenario3$.run(<console>:63)
13 ... 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
- Hands-on Dotty (slides ) by Dmitry Petrashko
- SIP-20 – Improved Lazy Vals Initialization
- Dotty – The Dotty research platform
Fußnoten
Weitere Beiträge
von Markus Hauck
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Markus Hauck
IT Consultant & Developer
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.