Jaki jest (ukryty) koszt leniwego val Scali?

165

Jedną z przydatnych funkcji Scali jest to lazy val, że ocena a valjest opóźniona do momentu, gdy będzie to konieczne (przy pierwszym dostępie).

Oczywiście lazy valmusi mieć pewien narzut - gdzieś Scala musi śledzić, czy wartość została już oszacowana, a ocena musi zostać zsynchronizowana, ponieważ wiele wątków może próbować uzyskać dostęp do wartości po raz pierwszy w tym samym czasie.

Jaki dokładnie jest koszt lazy val- czy istnieje ukryta flaga logiczna związana z a, lazy valaby śledzić, czy została oszacowana, czy nie, co dokładnie jest zsynchronizowane i czy są jakieś dodatkowe koszty?

Ponadto załóżmy, że zrobię to:

class Something {
    lazy val (x, y) = { ... }
}

Czy to to samo, co posiadanie dwóch oddzielnych lazy valsiatek xi yczy otrzymuję narzut tylko raz, dla pary (x, y)?

Jesper
źródło

Odpowiedzi:

86

To jest wzięte z listy mailingowej scala i podaje szczegóły implementacji lazyw zakresie kodu Java (zamiast kodu bajtowego):

class LazyTest {
  lazy val msg = "Lazy"
}

jest skompilowany do czegoś równoważnego z następującym kodem Java:

class LazyTest {
  public int bitmap$0;
  private String msg;

  public String msg() {
    if ((bitmap$0 & 1) == 0) {
        synchronized (this) {
            if ((bitmap$0 & 1) == 0) {
                synchronized (this) {
                    msg = "Lazy";
                }
            }
            bitmap$0 = bitmap$0 | 1;
        }
    }
    return msg;
  }

}
oxbow_lakes
źródło
33
Myślę, że implementacja musiała się zmienić od czasu opublikowania tej wersji Javy w 2007 roku. Jest tylko jeden zsynchronizowany blok, a bitmap$0pole jest niestabilne w obecnej implementacji (2.8).
Mitch Blevins
1
Tak - powinienem był zwrócić większą uwagę na to, co publikowałem!
oxbow_lakes
8
@Mitch - mam nadzieję, że implementacja się zmieniła! Podwójnie sprawdzony anty-wzorzec inicjalizacji to klasyczny subtelny błąd. Zobacz en.wikipedia.org/wiki/Double-checked_locking
Malvolio,
20
Był antywzór do wersji Java 1.4. Ponieważ słowo kluczowe volatile w Javie 1.5 ma nieco bardziej rygorystyczne znaczenie, a teraz takie podwójne sprawdzanie jest OK.
iirekm
8
A więc od wersji 2.10, jaka jest obecna implementacja? Czy mógłbyś również podpowiedzieć, ile narzutów oznacza to w praktyce i jakąś praktyczną zasadę, kiedy używać, a kiedy unikać?
ib84
39

Wygląda na to, że kompilator ustawia pole int mapy bitowej na poziomie klasy, aby oznaczyć wiele leniwych pól jako zainicjowane (lub nie) i inicjuje pole docelowe w zsynchronizowanym bloku, jeśli odpowiedni xor mapy bitowej wskazuje, że jest to konieczne.

Za pomocą:

class Something {
  lazy val foo = getFoo
  def getFoo = "foo!"
}

tworzy przykładowy kod bajtowy:

 0  aload_0 [this]
 1  getfield blevins.example.Something.bitmap$0 : int [15]
 4  iconst_1
 5  iand
 6  iconst_0
 7  if_icmpne 48
10  aload_0 [this]
11  dup
12  astore_1
13  monitorenter
14  aload_0 [this]
15  getfield blevins.example.Something.bitmap$0 : int [15]
18  iconst_1
19  iand
20  iconst_0
21  if_icmpne 42
24  aload_0 [this]
25  aload_0 [this]
26  invokevirtual blevins.example.Something.getFoo() : java.lang.String [18]
29  putfield blevins.example.Something.foo : java.lang.String [20]
32  aload_0 [this]
33  aload_0 [this]
34  getfield blevins.example.Something.bitmap$0 : int [15]
37  iconst_1
38  ior
39  putfield blevins.example.Something.bitmap$0 : int [15]
42  getstatic scala.runtime.BoxedUnit.UNIT : scala.runtime.BoxedUnit [26]
45  pop
46  aload_1
47  monitorexit
48  aload_0 [this]
49  getfield blevins.example.Something.foo : java.lang.String [20]
52  areturn
53  aload_1
54  monitorexit
55  athrow

Wartości zainicjowane w krotkach, takie jak lazy val (x,y) = { ... }zagnieżdżone buforowanie za pomocą tego samego mechanizmu. Wynik krotki jest leniwie oceniany i zapisywany w pamięci podręcznej, a dostęp do wartości x lub y wyzwoli ocenę krotki. Wyodrębnianie indywidualnej wartości z krotki jest wykonywane niezależnie i leniwie (i buforowane). Więc powyższy kod dwukrotnie instancji generuje x, yi jest x$1polem typu Tuple2.

Mitch Blevins
źródło
26

W Scali 2.10 leniwa wartość, taka jak:

class Example {
  lazy val x = "Value";
}

jest kompilowany do kodu bajtowego, który przypomina następujący kod Java:

public class Example {

  private String x;
  private volatile boolean bitmap$0;

  public String x() {
    if(this.bitmap$0 == true) {
      return this.x;
    } else {
      return x$lzycompute();
    }
  }

  private String x$lzycompute() {
    synchronized(this) {
      if(this.bitmap$0 != true) {
        this.x = "Value";
        this.bitmap$0 = true;
      }
      return this.x;
    }
  }
}

Zauważ, że mapa bitowa jest reprezentowana przez boolean. Jeśli dodasz kolejne pole, kompilator zwiększy rozmiar pola, aby mogło reprezentować co najmniej 2 wartości, tj. Jako byte. To dotyczy tylko dużych klas.

Ale możesz się zastanawiać, dlaczego to działa? Lokalne pamięci podręczne wątku muszą zostać wyczyszczone podczas wprowadzania zsynchronizowanego bloku, tak aby nieulotna xwartość została opróżniona do pamięci. Ten artykuł na blogu zawiera wyjaśnienie .

Rafael Winterhalter
źródło
11

Scala SIP-20 proponuje nową implementację lazy val, która jest bardziej poprawna, ale ~ 25% wolniejsza niż "aktualna" wersja.

Do proponowanego wprowadzenia wygląda następująco:

class LazyCellBase { // in a Java file - we need a public bitmap_0
  public static AtomicIntegerFieldUpdater<LazyCellBase> arfu_0 =
    AtomicIntegerFieldUpdater.newUpdater(LazyCellBase.class, "bitmap_0");
  public volatile int bitmap_0 = 0;
}
final class LazyCell extends LazyCellBase {
  import LazyCellBase._
  var value_0: Int = _
  @tailrec final def value(): Int = (arfu_0.get(this): @switch) match {
    case 0 =>
      if (arfu_0.compareAndSet(this, 0, 1)) {
        val result = 0
        value_0 = result
        @tailrec def complete(): Unit = (arfu_0.get(this): @switch) match {
          case 1 =>
            if (!arfu_0.compareAndSet(this, 1, 3)) complete()
          case 2 =>
            if (arfu_0.compareAndSet(this, 2, 3)) {
              synchronized { notifyAll() }
            } else complete()
        }
        complete()
        result
      } else value()
    case 1 =>
      arfu_0.compareAndSet(this, 1, 2)
      synchronized {
        while (arfu_0.get(this) != 3) wait()
      }
      value_0
    case 2 =>
      synchronized {
        while (arfu_0.get(this) != 3) wait()
      }
      value_0
    case 3 => value_0
  }
}

Od czerwca 2013 r. Niniejszy SIP nie został zatwierdzony. Spodziewam się, że zostanie on zatwierdzony i włączony do przyszłej wersji Scali na podstawie dyskusji na liście mailingowej. W związku z tym myślę, że mądrze byłoby wziąć pod uwagę obserwację Daniela Śpiewaka :

Lazy val * nie * jest darmowy (a nawet tani). Używaj go tylko wtedy, gdy absolutnie potrzebujesz lenistwa dla poprawności, a nie dla optymalizacji.

Leif Wickland
źródło
10

Napisałem post w tej sprawie https://dzone.com/articles/cost-laziness

Krótko mówiąc, kara jest tak niewielka, że ​​w praktyce można ją zignorować.

rzymski
źródło
1
Dzięki za ten test. Czy możesz porównać również proponowane implementacje SIP-20?
Turadg
-6

biorąc pod uwagę kod bycode generowany przez scala dla leniwych, może wystąpić problem z bezpieczeństwem wątków, jak wspomniano w blokowaniu podwójnego sprawdzenia http://www.javaworld.com/javaworld/jw-05-2001/jw-0525-double.html?page=1

Huy Le
źródło
3
Twierdzenie to zostało również wysunięte przez komentarz do zaakceptowanej odpowiedzi przez mitcha i odrzucone przez @iirekm: Ten wzorzec jest w porządku od wersji java1.5.
Jens Schauder