Panduan untuk Skyframe StateMachines

Laporkan masalah Lihat sumber Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

Ringkasan

StateMachine Skyframe adalah objek fungsi yang didekonstruksi yang berada di heap. Hal ini mendukung evaluasi yang fleksibel dan tanpa redundansi1 saat nilai yang diperlukan tidak langsung tersedia, tetapi dihitung secara asinkron. StateMachine tidak dapat mengikat resource thread saat menunggu, tetapi harus ditangguhkan dan dilanjutkan. Dengan demikian, dekonstruksi akan mengekspos titik masuk kembali yang eksplisit sehingga komputasi sebelumnya dapat dilewati.

StateMachine dapat digunakan untuk menyatakan urutan, percabangan, konkurensi logis terstruktur, dan disesuaikan secara khusus untuk interaksi Skyframe. StateMachine dapat disusun menjadi StateMachine yang lebih besar dan berbagi sub-StateMachine. Konkurensi selalu bersifat hierarkis berdasarkan konstruksi dan murni logis. Setiap sub-tugas serentak berjalan di thread SkyFunction induk bersama tunggal.

Pengantar

Bagian ini secara singkat memotivasi dan memperkenalkan StateMachine, yang ditemukan dalam paket java.com.google.devtools.build.skyframe.state.

Pengenalan singkat tentang mulai ulang Skyframe

Skyframe adalah framework yang melakukan evaluasi paralel pada grafik dependensi. Setiap node dalam grafik sesuai dengan evaluasi SkyFunction dengan SkyKey yang menentukan parameternya dan SkyValue yang menentukan hasilnya. Model komputasionalnya sedemikian rupa sehingga SkyFunction dapat mencari SkyValue berdasarkan SkyKey, yang memicu evaluasi rekursif dan paralel dari SkyFunction tambahan. Daripada memblokir, yang akan mengikat thread, saat SkyValue yang diminta belum siap karena beberapa subgrafik komputasi belum selesai, SkyFunction yang meminta mengamati respons null getValue dan harus menampilkan null bukan SkyValue, yang menandakan bahwa SkyValue belum selesai karena input tidak ada. Skyframe memulai ulang SkyFunctions saat semua SkyValue yang diminta sebelumnya tersedia.

Sebelum diperkenalkannya SkyKeyComputeState, cara tradisional untuk menangani restart adalah dengan menjalankan ulang komputasi sepenuhnya. Meskipun memiliki kompleksitas kuadrat, fungsi yang ditulis dengan cara ini pada akhirnya akan selesai karena setiap dijalankan ulang, lebih sedikit pencarian yang menampilkan null. Dengan SkyKeyComputeState, Anda dapat mengaitkan data titik pemeriksaan yang ditentukan secara manual dengan SkyFunction, sehingga menghemat komputasi ulang yang signifikan.

StateMachine adalah objek yang berada di dalam SkyKeyComputeState dan menghilangkan hampir semua penghitungan ulang saat SkyFunction dimulai ulang (dengan asumsi bahwa SkyKeyComputeState tidak keluar dari cache) dengan mengekspos hook eksekusi penangguhan dan pelanjutan.

Komputasi stateful di dalam SkyKeyComputeState

Dari sudut pandang desain berorientasi objek, sebaiknya pertimbangkan untuk menyimpan objek komputasi di dalam SkyKeyComputeState, bukan nilai data murni. Di Java, deskripsi minimum objek yang membawa perilaku adalah antarmuka fungsional dan ternyata sudah cukup. StateMachine memiliki definisi rekursif yang aneh berikut2.

@FunctionalInterface
public interface StateMachine {
  StateMachine step(Tasks tasks) throws InterruptedException;
}

Antarmuka Tasks serupa dengan SkyFunction.Environment, tetapi didesain untuk asinkronisitas dan menambahkan dukungan untuk subtask yang secara logis bersamaan3.

Nilai yang ditampilkan step adalah StateMachine lain, yang memungkinkan penentuan urutan langkah secara induktif. step menampilkan DONE saat StateMachine selesai. Contoh:

class HelloWorld implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks) {
    System.out.println("hello");
    return this::step2;  // The next step is HelloWorld.step2.
  }

  private StateMachine step2(Tasks tasks) {
     System.out.println("world");
     // DONE is special value defined in the `StateMachine` interface signaling
     // that the computation is done.
     return DONE;
  }
}

menjelaskan StateMachine dengan output berikut.

hello
world

Perhatikan bahwa referensi metode this::step2 juga merupakan StateMachine karena step2 memenuhi definisi antarmuka fungsional StateMachine. Referensi metode adalah cara paling umum untuk menentukan status berikutnya dalam StateMachine.

Menangguhkan dan melanjutkan

Secara intuitif, memecah komputasi menjadi StateMachine langkah, bukan fungsi monolitik, menyediakan hook yang diperlukan untuk menangguhkan dan melanjutkan komputasi. Saat StateMachine.step ditampilkan, ada titik penangguhan eksplisit. Kelanjutan yang ditentukan oleh nilai StateMachine yang ditampilkan adalah titik lanjutan eksplisit. Dengan demikian, penghitungan ulang dapat dihindari karena penghitungan dapat dilanjutkan tepat di tempat terakhir kali dihentikan.

Callback, kelanjutan, dan komputasi asinkron

Secara teknis, StateMachine berfungsi sebagai kelanjutan, yang menentukan komputasi berikutnya yang akan dieksekusi. Daripada memblokir, StateMachine dapat secara sukarela menangguhkan dengan kembali dari fungsi step, yang mentransfer kembali kontrol ke instance Driver. Driver kemudian dapat beralih ke StateMachine siap atau melepaskan kontrol kembali ke Skyframe.

Biasanya, callback dan kelanjutan digabungkan menjadi satu konsep. Namun, StateMachine mempertahankan perbedaan antara keduanya.

  • Callback - menjelaskan tempat menyimpan hasil komputasi asinkron.
  • Lanjutan - menentukan status eksekusi berikutnya.

Callback diperlukan saat memanggil operasi asinkron, yang berarti bahwa operasi sebenarnya tidak terjadi segera setelah memanggil metode, seperti dalam kasus pencarian SkyValue. Callback harus dibuat sesederhana mungkin.

Lanjutan adalah nilai yang ditampilkan StateMachine dari StateMachine dan merangkum eksekusi kompleks yang terjadi setelah semua komputasi asinkron diselesaikan. Pendekatan terstruktur ini membantu menjaga kompleksitas panggilan balik tetap dapat dikelola.

Tugas

Antarmuka Tasks menyediakan API untuk StateMachine guna mencari SkyValue berdasarkan SkyKey dan menjadwalkan sub-tugas serentak.

interface Tasks {
  void enqueue(StateMachine subtask);

  void lookUp(SkyKey key, Consumer<SkyValue> sink);

  <E extends Exception>
  void lookUp(SkyKey key, Class<E> exceptionClass, ValueOrExceptionSink<E> sink);

  // lookUp overloads for 2 and 3 exception types exist, but are elided here.
}

Pencarian SkyValue

StateMachine menggunakan penggantian Tasks.lookUp untuk mencari SkyValues. Keduanya analog dengan SkyFunction.Environment.getValue dan SkyFunction.Environment.getValueOrThrow serta memiliki semantik penanganan pengecualian yang serupa. Implementasi tidak langsung melakukan pencarian, tetapi mengelompokkan4 sebanyak mungkin pencarian sebelum melakukannya. Nilai mungkin tidak langsung tersedia, misalnya, memerlukan mulai ulang Skyframe, sehingga pemanggil menentukan apa yang harus dilakukan dengan nilai yang dihasilkan menggunakan callback.

Prosesor StateMachine (Driver dan penghubung ke SkyFrame) menjamin bahwa nilai tersedia sebelum status berikutnya dimulai. Berikut contohnya.

class DoesLookup implements StateMachine, Consumer<SkyValue> {
  private Value value;

  @Override
  public StateMachine step(Tasks tasks) {
    tasks.lookUp(new Key(), (Consumer<SkyValue>) this);
    return this::processValue;
  }

  // The `lookUp` call in `step` causes this to be called before `processValue`.
  @Override  // Implementation of Consumer<SkyValue>.
  public void accept(SkyValue value) {
    this.value = (Value)value;
  }

  private StateMachine processValue(Tasks tasks) {
    System.out.println(value);  // Prints the string representation of `value`.
    return DONE;
  }
}

Dalam contoh di atas, langkah pertama melakukan pencarian untuk new Key(), meneruskan this sebagai konsumen. Hal ini dapat terjadi karena DoesLookup menerapkan Consumer<SkyValue>.

Berdasarkan kontrak, sebelum status DoesLookup.processValue berikutnya dimulai, semua pencarian DoesLookup.step selesai. Oleh karena itu, value tersedia saat diakses di processValue.

Subtugas

Tasks.enqueue meminta eksekusi sub-tugas yang secara logis serentak. Subtugas juga merupakan StateMachine dan dapat melakukan apa pun yang dapat dilakukan StateMachine reguler, termasuk membuat lebih banyak subtugas secara rekursif atau mencari SkyValue. Seperti lookUp, driver mesin status memastikan bahwa semua sub-tugas selesai sebelum melanjutkan ke langkah berikutnya. Berikut contohnya.

class Subtasks implements StateMachine {
  private int i = 0;

  @Override
  public StateMachine step(Tasks tasks) {
    tasks.enqueue(new Subtask1());
    tasks.enqueue(new Subtask2());
    // The next step is Subtasks.processResults. It won't be called until both
    // Subtask1 and Subtask 2 are complete.
    return this::processResults;
  }

  private StateMachine processResults(Tasks tasks) {
    System.out.println(i);  // Prints "3".
    return DONE;  // Subtasks is done.
  }

  private class Subtask1 implements StateMachine {
    @Override
    public StateMachine step(Tasks tasks) {
      i += 1;
      return DONE;  // Subtask1 is done.
    }
  }

  private class Subtask2 implements StateMachine {
    @Override
    public StateMachine step(Tasks tasks) {
      i += 2;
      return DONE;  // Subtask2 is done.
    }
  }
}

Meskipun Subtask1 dan Subtask2 secara logis bersifat serentak, semuanya berjalan dalam satu thread sehingga update "serentak" i tidak memerlukan sinkronisasi.

Serentak dan terstruktur

Karena setiap lookUp dan enqueue harus diselesaikan sebelum melanjutkan ke status berikutnya, berarti konkurensi secara alami terbatas pada struktur hierarki. Anda dapat membuat konkurensi5 hierarkis seperti yang ditunjukkan dalam contoh berikut.

Serentak dan Terstruktur

Sulit untuk mengetahui dari UML bahwa struktur konkurensi membentuk hierarki. Ada tampilan alternatif yang lebih baik dalam menampilkan struktur hierarki.

Konkurensi Tidak Terstruktur

Konkurensi terstruktur jauh lebih mudah dipahami.

Pola alur komposisi dan kontrol

Bagian ini menyajikan contoh cara menyusun beberapa StateMachine dan solusi untuk masalah alur kontrol tertentu.

Status berurutan

Ini adalah pola alur kontrol yang paling umum dan mudah. Contohnya ditampilkan dalam Komputasi stateful di dalam SkyKeyComputeState.

Percabangan

Percabangan status di StateMachine dapat dicapai dengan menampilkan nilai yang berbeda menggunakan alur kontrol Java reguler, seperti yang ditunjukkan dalam contoh berikut.

class Branch implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks) {
    // Returns different state machines, depending on condition.
    if (shouldUseA()) {
      return this::performA;
    }
    return this::performB;
  }
  
}

Sangat umum bagi cabang tertentu untuk menampilkan DONE, untuk penyelesaian awal.

Komposisi berurutan lanjutan

Karena struktur kontrol StateMachine tidak memiliki memori, berbagi definisi StateMachine sebagai subtask terkadang bisa terasa canggung. Misalkan M1 dan M2 adalah instance StateMachine yang berbagi StateMachine, S, dengan M1 dan M2 masing-masing adalah urutan <A, S, B> dan <X, S, Y>. Masalahnya adalah S tidak tahu apakah akan melanjutkan ke B atau Y setelah selesai dan StateMachine tidak benar-benar menyimpan call stack. Bagian ini mengulas beberapa teknik untuk mencapainya.

StateMachine sebagai elemen urutan terminal

Hal ini tidak menyelesaikan masalah awal yang diajukan. Hanya menunjukkan komposisi berurutan saat StateMachine bersama adalah terminal dalam urutan.

// S is the shared state machine.
class S implements StateMachine {  }

class M1 implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks) {
    performA();
    return new S();
  }
}

class M2 implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks) {
    performX();
    return new S();
  }
}

Hal ini berfungsi meskipun S itu sendiri adalah mesin status yang kompleks.

Subtugas untuk komposisi berurutan

Karena subtask dalam antrean dijamin selesai sebelum status berikutnya, terkadang mekanisme subtask dapat sedikit disalahgunakan6.

class M1 implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks) {
    performA();
    // S starts after `step` returns and by contract must complete before `doB`
    // begins. It is effectively sequential, inducing the sequence < A, S, B >.
    tasks.enqueue(new S());
    return this::doB;
  }

  private StateMachine doB(Tasks tasks) {
    performB();
    return DONE;
  }
}

class M2 implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks) {
    performX();
    // Similarly, this induces the sequence < X, S, Y>.
    tasks.enqueue(new S());
    return this::doY;
  }

  private StateMachine doY(Tasks tasks) {
    performY();
    return DONE;
  }
}

Injeksi runAfter

Terkadang, penyalahgunaan Tasks.enqueue tidak mungkin terjadi karena ada subtask paralel atau panggilan Tasks.lookUp lain yang harus diselesaikan sebelum S dieksekusi. Dalam hal ini, penyisipan parameter runAfter ke dalam S dapat digunakan untuk memberi tahu S tentang tindakan yang harus dilakukan selanjutnya.

class S implements StateMachine {
  // Specifies what to run after S completes.
  private final StateMachine runAfter;

  @Override
  public StateMachine step(Tasks tasks) {
     // Performs some computations.
    return this::processResults;
  }

  @Nullable
  private StateMachine processResults(Tasks tasks) {
     // Does some additional processing.

    // Executes the state machine defined by `runAfter` after S completes.
    return runAfter;
  }
}

class M1 implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks) {
    performA();
    // Passes `this::doB` as the `runAfter` parameter of S, resulting in the
    // sequence < A, S, B >.
    return new S(/* runAfter= */ this::doB);
  }

  private StateMachine doB(Tasks tasks) {
    performB();
    return DONE;
  }
}

class M2 implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks) {
    performX();
    // Passes `this::doY` as the `runAfter` parameter of S, resulting in the
    // sequence < X, S, Y >.
    return new S(/* runAfter= */ this::doY);
  }

  private StateMachine doY(Tasks tasks) {
    performY();
    return DONE;
  }
}

Pendekatan ini lebih bersih daripada menyalahgunakan subtugas. Namun, menerapkan hal ini secara terlalu bebas, misalnya, dengan menyarangkan beberapa StateMachine dengan runAfter, akan mengarah ke Callback Hell. Sebaiknya pisahkan runAfter berurutan dengan status berurutan biasa.

  return new S(/* runAfter= */ new T(/* runAfter= */ this::nextStep))

dapat diganti dengan yang berikut.

  private StateMachine step1(Tasks tasks) {
     doStep1();
     return new S(/* runAfter= */ this::intermediateStep);
  }

  private StateMachine intermediateStep(Tasks tasks) {
    return new T(/* runAfter= */ this::nextStep);
  }

Alternatif terlarang: runAfterUnlessError

Dalam draf sebelumnya, kami telah mempertimbangkan runAfterUnlessError yang akan membatalkan lebih awal jika terjadi error. Hal ini didasari oleh fakta bahwa error sering kali diperiksa dua kali, sekali oleh StateMachine yang memiliki referensi runAfter dan sekali oleh mesin runAfter itu sendiri.

Setelah beberapa kali berdiskusi, kami memutuskan bahwa keseragaman kode lebih penting daripada menghilangkan duplikasi pemeriksaan error. Akan membingungkan jika mekanisme runAfter tidak berfungsi secara konsisten dengan mekanisme tasks.enqueue, yang selalu memerlukan pemeriksaan error.

Delegasi langsung

Setiap kali ada transisi status formal, loop Driver utama akan berlanjut. Sesuai kontrak, memajukan status berarti semua pencarian dan sub-tugas SkyValue yang sebelumnya dimasukkan dalam antrean diselesaikan sebelum status berikutnya dieksekusi. Terkadang logika delegasi StateMachine membuat kemajuan fase tidak diperlukan atau kontraproduktif. Misalnya, jika step pertama delegasi melakukan pencarian SkyKey yang dapat diparalelkan dengan pencarian status delegasi, maka peningkatan fase akan membuatnya berurutan. Mungkin lebih masuk akal untuk melakukan delegasi langsung, seperti yang ditunjukkan dalam contoh di bawah.

class Parent implements StateMachine {
  @Override
  public StateMachine step(Tasks tasks ) {
    tasks.lookUp(new Key1(), this);
    // Directly delegates to `Delegate`.
    //
    // The (valid) alternative:
    //   return new Delegate(this::afterDelegation);
    // would cause `Delegate.step` to execute after `step` completes which would
    // cause lookups of `Key1` and `Key2` to be sequential instead of parallel.
    return new Delegate(this::afterDelegation).step(tasks);
  }

  private StateMachine afterDelegation(Tasks tasks) {
    
  }
}

class Delegate implements StateMachine {
  private final StateMachine runAfter;

  Delegate(StateMachine runAfter) {
    this.runAfter = runAfter;
  }

  @Override
  public StateMachine step(Tasks tasks) {
    tasks.lookUp(new Key2(), this);
    return ;
  }

  // Rest of implementation.
  

  private StateMachine complete(Tasks tasks) {
    
    return runAfter;
  }
}

Aliran data

Fokus diskusi sebelumnya adalah mengelola alur kontrol. Bagian ini menjelaskan propagasi nilai data.

Menerapkan callback Tasks.lookUp

Ada contoh penerapan callback Tasks.lookUp dalam pencarian SkyValue. Bagian ini memberikan alasan dan menyarankan pendekatan untuk menangani beberapa SkyValue.

Panggilan balik Tasks.lookUp

Metode Tasks.lookUp menggunakan callback, sink, sebagai parameter.

  void lookUp(SkyKey key, Consumer<SkyValue> sink);

Pendekatan idiomatis adalah menggunakan lambda Java untuk mengimplementasikan hal ini:

  tasks.lookUp(key, value -> myValue = (MyValueClass)value);

dengan myValue menjadi variabel anggota instance StateMachine yang melakukan pencarian. Namun, lambda memerlukan alokasi memori tambahan dibandingkan dengan penerapan antarmuka Consumer<SkyValue> dalam penerapan StateMachine. Lambda masih berguna jika ada beberapa pencarian yang akan ambigu.

Ada juga overload penanganan error Tasks.lookUp, yang analog dengan SkyFunction.Environment.getValueOrThrow.

  <E extends Exception> void lookUp(
      SkyKey key, Class<E> exceptionClass, ValueOrExceptionSink<E> sink);

  interface ValueOrExceptionSink<E extends Exception> {
    void acceptValueOrException(@Nullable SkyValue value, @Nullable E exception);
  }

Contoh penerapan ditampilkan di bawah.

class PerformLookupWithError extends StateMachine, ValueOrExceptionSink<MyException> {
  private MyValue value;
  private MyException error;

  @Override
  public StateMachine step(Tasks tasks) {
    tasks.lookUp(new MyKey(), MyException.class, ValueOrExceptionSink<MyException>) this);
    return this::processResult;
  }

  @Override
  public acceptValueOrException(@Nullable SkyValue value, @Nullable MyException exception) {
    if (value != null) {
      this.value = (MyValue)value;
      return;
    }
    if (exception != null) {
      this.error = exception;
      return;
    }
    throw new IllegalArgumentException("Both parameters were unexpectedly null.");
  }

  private StateMachine processResult(Tasks tasks) {
    if (exception != null) {
      // Handles the error.
      
      return DONE;
    }
    // Processes `value`, which is non-null.
    
  }
}

Seperti halnya pencarian tanpa penanganan error, jika class StateMachine mengimplementasikan callback secara langsung, alokasi memori untuk lambda akan dihemat.

Penanganan error memberikan sedikit lebih banyak detail, tetapi pada dasarnya, tidak ada banyak perbedaan antara propagasi error dan nilai normal.

Menggunakan beberapa SkyValue

Sering kali diperlukan beberapa pencarian SkyValue. Pendekatan yang sering kali berhasil adalah mengaktifkan jenis SkyValue. Berikut adalah contoh yang telah disederhanakan dari kode produksi prototipe.

  @Nullable
  private StateMachine fetchConfigurationAndPackage(Tasks tasks) {
    var configurationKey = configuredTarget.getConfigurationKey();
    if (configurationKey != null) {
      tasks.lookUp(configurationKey, (Consumer<SkyValue>) this);
    }

    var packageId = configuredTarget.getLabel().getPackageIdentifier();
    tasks.lookUp(PackageValue.key(packageId), (Consumer<SkyValue>) this);

    return this::constructResult;
  }

  @Override  // Implementation of `Consumer<SkyValue>`.
  public void accept(SkyValue value) {
    if (value instanceof BuildConfigurationValue) {
      this.configurationValue = (BuildConfigurationValue) value;
      return;
    }
    if (value instanceof PackageValue) {
      this.pkg = ((PackageValue) value).getPackage();
      return;
    }
    throw new IllegalArgumentException("unexpected value: " + value);
  }

Implementasi callback Consumer<SkyValue> dapat dibagikan secara jelas karena jenis nilainya berbeda. Jika tidak demikian, kembali ke implementasi berbasis lambda atau instance class dalam penuh yang menerapkan callback yang sesuai dapat dilakukan.

Menyebarkan nilai antar-StateMachine

Sejauh ini, dokumen ini hanya menjelaskan cara mengatur pekerjaan dalam sub-tugas, tetapi sub-tugas juga perlu melaporkan nilai kembali ke pemanggil. Karena sub-tugas bersifat asinkron secara logis, hasilnya dikomunikasikan kembali ke pemanggil menggunakan callback. Agar ini berfungsi, sub-tugas menentukan antarmuka sink yang disuntikkan melalui konstruktornya.

class BarProducer implements StateMachine {
  // Callers of BarProducer implement the following interface to accept its
  // results. Exactly one of the two methods will be called by the time
  // BarProducer completes.
  interface ResultSink {
    void acceptBarValue(Bar value);
    void acceptBarError(BarException exception);
  }

  private final ResultSink sink;

  BarProducer(ResultSink sink) {
     this.sink = sink;
  }

   // StateMachine steps that end with this::complete.

  private StateMachine complete(Tasks tasks) {
    if (hasError()) {
      sink.acceptBarError(getError());
      return DONE;
    }
    sink.acceptBarValue(getValue());
    return DONE;
  }
}

Pemanggil StateMachine kemudian akan terlihat seperti berikut.

class Caller implements StateMachine, BarProducer.ResultSink {
  interface ResultSink {
    void acceptCallerValue(Bar value);
    void acceptCallerError(BarException error);
  }

  private final ResultSink sink;

  private Bar value;

  Caller(ResultSink sink) {
    this.sink = sink;
  }

  @Override
  @Nullable
  public StateMachine step(Tasks tasks) {
    tasks.enqueue(new BarProducer((BarProducer.ResultSink) this));
    return this::processResult;
  }

  @Override
  public void acceptBarValue(Bar value) {
    this.value = value;
  }

  @Override
  public void acceptBarError(BarException error) {
    sink.acceptCallerError(error);
  }

  private StateMachine processResult(Tasks tasks) {
    // Since all enqueued subtasks resolve before `processResult` starts, one of
    // the `BarResultSink` callbacks must have been called by this point.
    if (value == null) {
      return DONE;  // There was a previously reported error.
    }
    var finalResult = computeResult(value);
    sink.acceptCallerValue(finalResult);
    return DONE;
  }
}

Contoh sebelumnya menunjukkan beberapa hal. Caller harus menyebarkan hasilnya kembali dan menentukan Caller.ResultSink-nya sendiri. Caller mengimplementasikan callback BarProducer.ResultSink. Setelah dilanjutkan, processResult memeriksa apakah value bernilai null untuk menentukan apakah terjadi error. Ini adalah pola perilaku umum setelah menerima output dari sub-tugas atau pencarian SkyValue.

Perhatikan bahwa penerapan acceptBarError meneruskan hasil ke Caller.ResultSink dengan segera, seperti yang diperlukan oleh Error bubbling.

Alternatif untuk StateMachine level teratas dijelaskan dalam Driver dan menjembatani ke SkyFunctions.

Penanganan error

Sudah ada beberapa contoh penanganan error di callback Tasks.lookUp dan Menyebarkan nilai antar-StateMachines. Pengecualian, selain InterruptedException tidak akan ditampilkan, tetapi diteruskan melalui callback sebagai nilai. Callback tersebut sering kali memiliki semantik eksklusif-atau, dengan tepat satu nilai atau error yang diteruskan.

Bagian berikutnya menjelaskan interaksi yang halus, tetapi penting dengan penanganan error Skyframe.

Error yang muncul (--nokeep_going)

Selama error bubbling, SkyFunction dapat dimulai ulang meskipun tidak semua SkyValue yang diminta tersedia. Dalam kasus seperti itu, status berikutnya tidak akan pernah dicapai karena kontrak API Tasks. Namun, StateMachine harus tetap menyebarkan pengecualian.

Karena propagasi harus terjadi terlepas dari apakah status berikutnya tercapai, callback penanganan error harus melakukan tugas ini. Untuk StateMachine dalam, hal ini dicapai dengan memanggil callback induk.

Di StateMachine tingkat teratas, yang berinteraksi dengan SkyFunction, hal ini dapat dilakukan dengan memanggil metode setException dari ValueOrExceptionProducer. ValueOrExceptionProducer.tryProduceValue kemudian akan memunculkan pengecualian, meskipun ada SkyValue yang tidak ada.

Jika Driver digunakan secara langsung, penting untuk memeriksa error yang diteruskan dari SkyFunction, meskipun mesin belum selesai memproses.

Penanganan Peristiwa

Untuk SkyFunction yang perlu memancarkan peristiwa, StoredEventHandler disuntikkan ke SkyKeyComputeState dan selanjutnya disuntikkan ke StateMachine yang memerlukannya. Sebelumnya, StoredEventHandler diperlukan karena Skyframe menghilangkan peristiwa tertentu kecuali jika diputar ulang, tetapi masalah ini telah diperbaiki. Injeksi StoredEventHandler dipertahankan karena menyederhanakan penerapan peristiwa yang dipancarkan dari callback penanganan error.

Driver dan menjembatani ke SkyFunctions

Driver bertanggung jawab untuk mengelola eksekusi StateMachine, dimulai dengan StateMachine root yang ditentukan. Karena StateMachines dapat mengantrekan subtugas StateMachines secara rekursif, satu Driver dapat mengelola banyak subtugas. Subtugas ini membuat struktur hierarki, hasil dari Konkurensi terstruktur. Batch Driver melakukan pencarian SkyValue di seluruh sub-tugas untuk meningkatkan efisiensi.

Ada sejumlah class yang dibangun di sekitar Driver, dengan API berikut.

public final class Driver {
  public Driver(StateMachine root);
  public boolean drive(SkyFunction.Environment env) throws InterruptedException;
}

Driver menggunakan satu StateMachine root sebagai parameter. Memanggil Driver.drive akan menjalankan StateMachine sejauh yang dapat dilakukan tanpa restart Skyframe. Menampilkan nilai benar jika StateMachine selesai dan salah jika tidak, yang menunjukkan bahwa tidak semua nilai tersedia.

Driver mempertahankan status serentak StateMachine dan sangat cocok untuk disematkan di SkyKeyComputeState.

Membuat instance Driver secara langsung

Implementasi StateMachine biasanya menyampaikan hasilnya melalui callback. Driver dapat langsung dibuat instance-nya seperti yang ditunjukkan dalam contoh berikut.

Driver disematkan dalam implementasi SkyKeyComputeState bersama dengan implementasi ResultSink yang sesuai untuk ditentukan lebih lanjut di bawah. Di tingkat teratas, objek State adalah penerima yang sesuai untuk hasil komputasi karena dijamin akan bertahan lebih lama dari Driver.

class State implements SkyKeyComputeState, ResultProducer.ResultSink {
  // The `Driver` instance, containing the full tree of all `StateMachine`
  // states. Responsible for calling `StateMachine.step` implementations when
  // asynchronous values are available and performing batched SkyFrame lookups.
  //
  // Non-null while `result` is being computed.
  private Driver resultProducer;

  // Variable for storing the result of the `StateMachine`
  //
  // Will be non-null after the computation completes.
  //
  private ResultType result;

  // Implements `ResultProducer.ResultSink`.
  //
  // `ResultProducer` propagates its final value through a callback that is
  // implemented here.
  @Override
  public void acceptResult(ResultType result) {
    this.result = result;
  }
}

Kode di bawah ini membuat sketsa ResultProducer.

class ResultProducer implements StateMachine {
  interface ResultSink {
    void acceptResult(ResultType value);
  }

  private final Parameters parameters;
  private final ResultSink sink;

   // Other internal state.

  ResultProducer(Parameters parameters, ResultSink sink) {
    this.parameters = parameters;
    this.sink = sink;
  }

  @Override
  public StateMachine step(Tasks tasks) {
      // Implementation.
    return this::complete;
  }

  private StateMachine complete(Tasks tasks) {
    sink.acceptResult(getResult());
    return DONE;
  }
}

Kemudian, kode untuk menghitung hasil secara lambat dapat terlihat seperti berikut.

@Nullable
private Result computeResult(State state, Skyfunction.Environment env)
    throws InterruptedException {
  if (state.result != null) {
    return state.result;
  }
  if (state.resultProducer == null) {
    state.resultProducer = new Driver(new ResultProducer(
      new Parameters(), (ResultProducer.ResultSink)state));
  }
  if (state.resultProducer.drive(env)) {
    // Clears the `Driver` instance as it is no longer needed.
    state.resultProducer = null;
  }
  return state.result;
}

Menyematkan Driver

Jika StateMachine menghasilkan nilai dan tidak memunculkan pengecualian, penyematan Driver adalah kemungkinan penerapan lain, seperti yang ditunjukkan dalam contoh berikut.

class ResultProducer implements StateMachine {
  private final Parameters parameters;
  private final Driver driver;

  private ResultType result;

  ResultProducer(Parameters parameters) {
    this.parameters = parameters;
    this.driver = new Driver(this);
  }

  @Nullable  // Null when a Skyframe restart is needed.
  public ResultType tryProduceValue( SkyFunction.Environment env)
      throws InterruptedException {
    if (!driver.drive(env)) {
      return null;
    }
    return result;
  }

  @Override
  public StateMachine step(Tasks tasks) {
      // Implementation.
}

SkyFunction mungkin memiliki kode yang terlihat seperti berikut (dengan State adalah jenis spesifik fungsi SkyKeyComputeState).

@Nullable  // Null when a Skyframe restart is needed.
Result computeResult(SkyFunction.Environment env, State state)
    throws InterruptedException {
  if (state.result != null) {
    return state.result;
  }
  if (state.resultProducer == null) {
    state.resultProducer = new ResultProducer(new Parameters());
  }
  var result = state.resultProducer.tryProduceValue(env);
  if (result == null) {
    return null;
  }
  state.resultProducer = null;
  return state.result = result;
}

Menyematkan Driver dalam implementasi StateMachine lebih cocok dengan gaya coding sinkron Skyframe.

StateMachine yang dapat menghasilkan pengecualian

Jika tidak, ada class SkyKeyComputeState yang dapat disematkan ValueOrExceptionProducer dan ValueOrException2Producer yang memiliki API sinkron untuk mencocokkan kode SkyFunction sinkron.

Class abstrak ValueOrExceptionProducer mencakup metode berikut.

public abstract class ValueOrExceptionProducer<V, E extends Exception>
    implements StateMachine {
  @Nullable
  public final V tryProduceValue(Environment env)
      throws InterruptedException, E {
      // Implementation.
  }

  protected final void setValue(V value)  {   // Implementation. }
  protected final void setException(E exception) {   // Implementation. }
}

Class ini mencakup instance Driver yang disematkan dan sangat mirip dengan class ResultProducer di Driver penyematan dan berinteraksi dengan SkyFunction dengan cara yang serupa. Daripada menentukan ResultSink, penerapan memanggil setValue atau setException saat salah satu dari peristiwa tersebut terjadi. Jika keduanya terjadi, pengecualian akan diprioritaskan. Metode tryProduceValue menjembatani kode callback asinkron ke kode sinkron dan memunculkan pengecualian saat salah satunya ditetapkan.

Seperti yang disebutkan sebelumnya, selama error bubbling, error dapat terjadi meskipun komputer belum selesai karena tidak semua input tersedia. Untuk mengakomodasi hal ini, tryProduceValue akan memunculkan pengecualian yang ditetapkan, bahkan sebelum mesin selesai.

Epilog: Menghapus callback pada akhirnya

StateMachine adalah cara yang sangat efisien, tetapi intensif boilerplate untuk melakukan komputasi asinkron. Kelanjutan (terutama dalam bentuk Runnables yang diteruskan ke ListenableFuture) tersebar luas di bagian tertentu kode Bazel, tetapi tidak umum di SkyFunction analisis. Analisis sebagian besar terikat CPU dan tidak ada API asinkron yang efisien untuk I/O disk. Pada akhirnya, sebaiknya hapus panggilan balik karena memiliki kurva pembelajaran dan menghambat keterbacaan.

Salah satu alternatif yang paling menjanjikan adalah thread virtual Java. Daripada harus menulis callback, semuanya diganti dengan panggilan sinkron yang memblokir. Hal ini dimungkinkan karena mengikat resource thread virtual, tidak seperti thread platform, seharusnya tidak mahal. Namun, bahkan dengan thread virtual, mengganti operasi sinkron sederhana dengan pembuatan thread dan primitif sinkronisasi terlalu mahal. Kami melakukan migrasi dari StateMachines ke thread virtual Java dan thread tersebut jauh lebih lambat, sehingga menyebabkan peningkatan latensi analisis end-to-end hampir 3x lipat. Karena thread virtual masih merupakan fitur pratinjau, migrasi ini dapat dilakukan di lain waktu saat performa meningkat.

Pendekatan lain yang dapat dipertimbangkan adalah menunggu coroutine Loom, jika coroutine tersebut tersedia. Keuntungannya adalah kemungkinan dapat mengurangi biaya sinkronisasi dengan menggunakan multitasking kooperatif.

Jika semua upaya di atas tidak berhasil, penulisan ulang bytecode tingkat rendah juga bisa menjadi alternatif yang layak. Dengan pengoptimalan yang memadai, performa yang mendekati kode callback yang ditulis tangan mungkin dapat dicapai.

Lampiran

Callback Hell

Callback hell adalah masalah terkenal dalam kode asinkron yang menggunakan callback. Hal ini berasal dari fakta bahwa kelanjutan untuk langkah berikutnya disarangkan dalam langkah sebelumnya. Jika ada banyak langkah, nesting ini bisa sangat dalam. Jika digabungkan dengan alur kontrol, kode menjadi sulit dikelola.

class CallbackHell implements StateMachine {
  @Override
  public StateMachine step(Tasks task) {
    doA();
    return (t, l) -> {
      doB();
      return (t1, l2) -> {
        doC();
        return DONE;
      };
    };
  }
}

Salah satu keuntungan dari penerapan bertingkat adalah frame stack dari langkah luar dapat dipertahankan. Di Java, variabel lambda yang diambil harus efektif final sehingga penggunaan variabel tersebut bisa merepotkan. Penyusunan bertingkat yang dalam dihindari dengan menampilkan referensi metode sebagai kelanjutan, bukan lambda seperti yang ditunjukkan sebagai berikut.

class CallbackHellAvoided implements StateMachine {
  @Override
  public StateMachine step(Tasks task) {
    doA();
    return this::step2;
  }

  private StateMachine step2(Tasks tasks) {
    doB();
    return this::step3;
  }

  private StateMachine step3(Tasks tasks) {
    doC();
    return DONE;
  }
}

Callback hell juga dapat terjadi jika pola injeksi runAfter digunakan terlalu padat, tetapi hal ini dapat dihindari dengan menyelingi injeksi dengan langkah-langkah berurutan.

Contoh: Pencarian SkyValue yang dirantai

Sering kali logika aplikasi memerlukan rantai pencarian SkyValue yang saling bergantung, misalnya, jika SkyKey kedua bergantung pada SkyValue pertama. Jika dipikirkan secara sederhana, hal ini akan menghasilkan struktur callback yang kompleks dan bertingkat dalam.

private ValueType1 value1;
private ValueType2 value2;

private StateMachine step1(...) {
  tasks.lookUp(key1, (Consumer<SkyValue>) this);  // key1 has type KeyType1.
  return this::step2;
}

@Override
public void accept(SkyValue value) {
  this.value1 = (ValueType1) value;
}

private StateMachine step2(...) {
  KeyType2 key2 = computeKey(value1);
  tasks.lookup(key2, this::acceptValueType2);
  return this::step3;
}

private void acceptValueType2(SkyValue value) {
  this.value2 = (ValueType2) value;
}

Namun, karena kelanjutan ditentukan sebagai referensi metode, kode terlihat prosedural di seluruh transisi status: step2 mengikuti step1. Perhatikan bahwa di sini, lambda digunakan untuk menetapkan value2. Hal ini membuat urutan kode cocok dengan urutan komputasi dari atas ke bawah.

Tips Lain-Lain

Keterbacaan: Pengurutan Eksekusi

Untuk meningkatkan keterbacaan, usahakan agar implementasi StateMachine.step dalam urutan eksekusi dan implementasi callback segera setelah implementasi tersebut diteruskan dalam kode. Hal ini tidak selalu dapat dilakukan jika alur kontrol bercabang. Komentar tambahan mungkin berguna dalam kasus tersebut.

Dalam Contoh: Pencarian SkyValue yang dirantai, referensi metode perantara dibuat untuk mencapai hal ini. Hal ini menukar sedikit performa dengan keterbacaan, yang mungkin bermanfaat di sini.

Hipotesis Generasi

Objek Java yang berumur sedang melanggar hipotesis generasional pengumpul sampah Java, yang dirancang untuk menangani objek yang berumur sangat singkat atau objek yang berumur selamanya. Menurut definisi, objek dalam SkyKeyComputeState melanggar hipotesis ini. Objek tersebut, yang berisi pohon yang dibuat dari semua StateMachine yang masih berjalan, yang berakar di Driver memiliki masa aktif menengah saat ditangguhkan, menunggu penyelesaian komputasi asinkron.

Tampaknya tidak terlalu buruk di JDK19, tetapi saat menggunakan StateMachines, terkadang dapat diamati peningkatan waktu GC, bahkan dengan penurunan dramatis dalam sampah yang dihasilkan. Karena StateMachine memiliki masa pakai menengah, StateMachine dapat dipromosikan ke generasi lama, sehingga pengisiannya lebih cepat, dan memerlukan GC besar atau penuh yang lebih mahal untuk membersihkannya.

Tindakan pencegahan awal adalah meminimalkan penggunaan variabel StateMachine, tetapi tidak selalu memungkinkan, misalnya, jika nilai diperlukan di beberapa status. Jika memungkinkan, variabel stack lokal step adalah variabel generasi muda dan di-GC secara efisien.

Untuk variabel StateMachine, memecah tugas menjadi sub-tugas dan mengikuti pola yang direkomendasikan untuk Menyebarkan nilai antar StateMachine juga akan membantu. Perhatikan bahwa saat mengikuti pola, hanya StateMachine turunan yang memiliki referensi ke StateMachine induk, dan bukan sebaliknya. Artinya, saat anak-anak menyelesaikan dan memperbarui induk menggunakan callback hasil, anak-anak secara alami keluar dari cakupan dan memenuhi syarat untuk GC.

Terakhir, dalam beberapa kasus, variabel StateMachine diperlukan di status sebelumnya, tetapi tidak di status selanjutnya. Sebaiknya hapus referensi objek besar setelah diketahui bahwa objek tersebut tidak diperlukan lagi.

Penamaan status

Saat memberi nama metode, biasanya Anda dapat memberi nama metode untuk perilaku yang terjadi dalam metode tersebut. Kurang jelas cara melakukannya di StateMachines karena tidak ada stack. Misalnya, anggap metode foo memanggil sub-metode bar. Dalam StateMachine, hal ini dapat diterjemahkan ke dalam urutan status foo, diikuti dengan bar. foo tidak lagi menyertakan perilaku bar. Akibatnya, nama metode untuk status cenderung lebih sempit cakupannya, yang berpotensi mencerminkan perilaku lokal.

Diagram struktur serentak

Berikut adalah tampilan alternatif diagram dalam Structured concurrency yang lebih menggambarkan struktur hierarki. Blok-blok tersebut membentuk pohon kecil.

Konkurensi Terstruktur 3D


  1. Berbeda dengan konvensi Skyframe yang memulai ulang dari awal saat nilai tidak tersedia. 

  2. Perhatikan bahwa step diizinkan untuk memunculkan InterruptedException, tetapi contohnya tidak menyertakan hal ini. Ada beberapa metode tingkat rendah dalam kode Bazel yang memunculkan pengecualian ini dan menyebar ke Driver, yang akan dijelaskan nanti, yang menjalankan StateMachine. Tidak masalah jika tidak dideklarasikan untuk dilempar saat tidak diperlukan. 

  3. Subtugas serentak didorong oleh ConfiguredTargetFunction yang melakukan pekerjaan independen untuk setiap dependensi. Daripada memanipulasi struktur data kompleks yang memproses semua dependensi sekaligus, yang menyebabkan inefisiensi, setiap dependensi memiliki StateMachine independennya sendiri. 

  4. Beberapa panggilan tasks.lookUp dalam satu langkah dikelompokkan bersama. Batching tambahan dapat dibuat dengan pencarian yang terjadi dalam sub-tugas serentak. 

  5. Secara konseptual, hal ini mirip dengan konkurensi terstruktur Java jeps/428

  6. Tindakan ini mirip dengan membuat thread dan menggabungkannya untuk mencapai komposisi berurutan.