Panduan untuk Skyframe StateMachines

Laporkan masalah Lihat sumber

Ringkasan

StateMachine Skyframe adalah objek fungsi dekonstruksi yang berada di heap. Fitur ini mendukung fleksibilitas dan evaluasi tanpa redundansi1 jika 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 mengekspos titik masuk ulang eksplisit sehingga komputasi sebelumnya dapat dilewati.

StateMachine dapat digunakan untuk mengekspresikan urutan, pencabangan, konkurensi logis terstruktur, dan disesuaikan khusus untuk interaksi Skyframe. StateMachine dapat disusun menjadi StateMachine yang lebih besar dan sub-StateMachine bersama. Konkurensi selalu hierarkis dengan konstruksi dan benar-benar logis. Setiap subtugas serentak berjalan di satu thread induk SkyFunction yang sama.

Pengantar

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

Pengantar singkat tentang mulai ulang Skyframe

Skyframe adalah framework yang melakukan evaluasi paralel grafik dependensi. Setiap node dalam grafik sesuai dengan evaluasi SkyFunction dengan SkyKey yang menentukan parameternya dan SkyValue yang menentukan hasilnya. Model komputasi tersebut sedemikian rupa sehingga SkyFunction dapat mencari SkyValues dengan SkyKey, yang memicu evaluasi paralel berulang dari SkyFunction tambahan. Bukannya pemblokiran, yang akan mengikat thread, ketika SkyValue yang diminta belum siap karena beberapa subgrafik komputasi tidak lengkap, SkyFunction yang meminta mengamati respons null getValue dan harus menampilkan null, bukan SkyValue, yang menandakan bahwa itu tidak lengkap karena input yang hilang. Skyframe memulai ulang SkyFunctions saat semua SkyValues yang diminta sebelumnya tersedia.

Sebelum SkyKeyComputeState diperkenalkan, cara tradisional untuk menangani mulai ulang adalah dengan menjalankan kembali komputasi sepenuhnya. Meskipun ini memiliki kerumitan kuadrat, fungsi yang ditulis dengan cara ini pada akhirnya selesai karena setiap pencarian yang dijalankan ulang lebih sedikit akan menampilkan null. Dengan SkyKeyComputeState, Anda dapat mengaitkan data check-point yang ditentukan secara manual dengan SkyFunction sehingga dapat menghemat komputasi yang signifikan.

StateMachine adalah objek yang berada di dalam SkyKeyComputeState dan menghilangkan semua komputasi secara virtual saat SkyFunction dimulai ulang (dengan asumsi SkyKeyComputeState tidak keluar dari cache) dengan mengekspos hook eksekusi yang ditangguhkan dan dilanjutkan.

Komputasi stateful di dalam SkyKeyComputeState

Dari sudut pandang desain yang berorientasi objek, sebaiknya simpan objek komputasi di dalam SkyKeyComputeState, bukan nilai data murni. Di Java, deskripsi minimum objek yang membawa objek adalah antarmuka fungsional dan ternyata sudah cukup. StateMachine memiliki definisi berikut yang aneh dan rekursif2.

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

Antarmuka Tasks sejalan dengan SkyFunction.Environment, tetapi didesain untuk asinkron dan menambahkan dukungan untuk subtugas serentak yang logis3.

Nilai yang ditampilkan step adalah StateMachine lain, yang memungkinkan spesifikasi 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;
  }
}

mendeskripsikan 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 langkah-langkah StateMachine, 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 resume eksplisit. Dengan demikian, komputasi dapat dihindari karena komputasi dapat dilanjutkan di bagian yang terakhir ditinggalkan.

Callback, kelanjutan, dan komputasi asinkron

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

Secara tradisional, callback dan kelanjutan digabungkan ke dalam satu konsep. Namun, StateMachine mempertahankan perbedaan di antara keduanya.

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

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

Kelanjutan adalah nilai yang ditampilkan StateMachine dari StateMachine dan mengenkapsulasi eksekusi kompleks yang mengikuti setelah semua komputasi asinkron diselesaikan. Pendekatan terstruktur ini membantu menjaga kompleksitas callback dapat dikelola.

Tugas

Antarmuka Tasks memberi StateMachine API untuk mencari SkyValues oleh SkyKey dan menjadwalkan subtugas 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 overload Tasks.lookUp untuk mencari SkyValues. Keduanya sesuai dengan SkyFunction.Environment.getValue dan SkyFunction.Environment.getValueOrThrow, serta memiliki semantik penanganan pengecualian yang serupa. Implementasi tidak langsung melakukan pencarian, tetapi akan mengelompokkan4 pencarian sebanyak mungkin sebelum melakukannya. Nilai tersebut 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 terhubung ke SkyFrame) menjamin bahwa nilai tersebut tersedia sebelum status berikutnya dimulai. Contohnya adalah:

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;
  }
}

Pada contoh di atas, langkah pertama melakukan pencarian untuk new Key(), meneruskan this sebagai konsumen. Hal ini memungkinkan karena DoesLookup mengimplementasikan Consumer<SkyValue>.

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

Subtugas

Tasks.enqueue meminta eksekusi subtugas serentak yang logis. Subtugas juga merupakan StateMachine dan dapat melakukan apa pun yang dapat dilakukan StateMachine reguler, termasuk membuat lebih banyak subtugas atau mencari SkyValues secara berulang. Sama seperti lookUp, driver mesin status memastikan bahwa semua subtugas selesai sebelum melanjutkan ke langkah berikutnya. Contohnya adalah:

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 serentak, semuanya berjalan dalam satu thread sehingga update i secara serentak tidak memerlukan sinkronisasi.

Serentak dan terstruktur

Karena setiap lookUp dan enqueue harus diselesaikan sebelum maju ke status berikutnya, ini berarti bahwa konkurensi secara alami terbatas pada struktur hierarki. Anda dapat membuat konkurensi hierarkis5 seperti yang ditunjukkan pada contoh berikut.

Serentak dan Terstruktur

Sulit untuk membedakan dari UML bahwa struktur serentak membentuk hierarki. Ada tampilan alternatif yang lebih menunjukkan struktur hierarki.

Serentak Tidak Terstruktur

Konkurensi terstruktur jauh lebih mudah untuk dipahami.

Pola alur dan komposisi

Bagian ini menampilkan contoh cara beberapa StateMachine dapat disusun dan solusi untuk masalah alur kontrol tertentu.

Status berurutan

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

Cabang

Status cabang di StateMachine dapat dicapai dengan menampilkan nilai yang berbeda menggunakan alur kontrol Java reguler, seperti yang ditunjukkan pada 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 subtugas terkadang dapat terasa canggung. Biarkan M1 dan M2 menjadi instance StateMachine yang berbagi StateMachine, S, dengan M1 dan M2 berturut-turut 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 cukup menyimpan stack panggilan. Bagian ini meninjau beberapa teknik untuk mencapai hal ini.

StateMachine sebagai elemen urutan terminal

Ini tidak menyelesaikan masalah awal yang dikemukakan. Ini hanya menunjukkan komposisi berurutan ketika StateMachine bersama menjadi 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();
  }
}

Ini berfungsi meskipun S adalah mesin status yang kompleks.

Subtugas untuk komposisi berurutan

Karena subtugas yang diantrekan dijamin akan selesai sebelum status berikutnya, terkadang Anda dapat sedikit menyalahgunakan6 mekanisme subtugas.

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

Kadang, menyalahgunakan Tasks.enqueue tidak mungkin dilakukan karena ada subtugas paralel atau panggilan Tasks.lookUp lainnya yang harus diselesaikan sebelum S dieksekusi. Dalam hal ini, memasukkan parameter runAfter ke S dapat digunakan untuk memberi tahu S tentang apa 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 ini secara bebas, misalnya dengan menyarangkan beberapa StateMachine dengan runAfter, adalah cara menuju Callback Hell. Sebaiknya pisahkan runAfter berurutan dengan status berurutan biasa.

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

dapat diganti dengan parameter 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 dibatalkan lebih awal karena error. Hal ini didasarkan pada fakta bahwa error sering kali akhirnya diperiksa dua kali, sekali oleh StateMachine yang memiliki referensi runAfter dan satu kali oleh mesin runAfter itu sendiri.

Setelah beberapa pertimbangan, kami memutuskan bahwa keseragaman kode lebih penting dibandingkan dengan menghapus duplikat pemeriksaan error. Akan menjadi 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 maju. Sesuai kontrak, memajukan status berarti semua pencarian dan subtugas SkyValue yang sebelumnya diantrekan diselesaikan sebelum status berikutnya dieksekusi. Terkadang logika StateMachine delegasi membuat progres fase tidak diperlukan atau kontraproduktif. Misalnya, jika step pertama yang didelegasikan melakukan pencarian SkyKey yang dapat diparalelkan dengan pencarian status delegasi, penundaan fase akan membuatnya berurutan. Akan lebih masuk akal untuk melakukan delegasi langsung, seperti yang ditunjukkan pada 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 tentang pengelolaan alur kontrol. Bagian ini menjelaskan penyebaran nilai data.

Mengimplementasikan callback Tasks.lookUp

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

Callback Tasks.lookUp

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

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

Pendekatan idiomatis adalah menggunakan lambda Java untuk menerapkan hal ini:

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

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

Ada juga error yang menangani overload Tasks.lookUp, yang serupa 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 penerapannya ditampilkan di bawah ini.

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 pencarian tanpa penanganan error, memiliki class StateMachine yang secara langsung mengimplementasikan callback akan menyimpan alokasi memori untuk lambda.

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

Memakai beberapa SkyValues

Beberapa pencarian SkyValue sering kali diperlukan. Pendekatan yang paling sering digunakan 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 dengan jelas karena jenis nilainya berbeda. Jika tidak, Anda dapat kembali ke implementasi berbasis lambda atau instance class dalam penuh yang menerapkan callback yang sesuai.

Memperluas nilai antara StateMachine

Sejauh ini, dokumen ini hanya menjelaskan cara mengatur pekerjaan dalam subtugas, tetapi subtugas juga perlu melaporkan nilai kembali ke pemanggil. Karena subtugas bersifat logika asinkron, hasilnya akan dikomunikasikan kembali kepada pemanggil menggunakan callback. Agar berfungsi, subtugas menentukan antarmuka sink yang dimasukkan 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 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 kembali hasilnya dan menentukan Caller.ResultSink-nya sendiri. Caller mengimplementasikan callback BarProducer.ResultSink. Setelah dimulai ulang, processResult akan memeriksa apakah value bernilai null untuk menentukan apakah terjadi error. Ini adalah pola perilaku yang umum setelah menerima output dari subtugas atau pencarian SkyValue.

Perlu diperhatikan bahwa implementasi acceptBarError akan meneruskan hasilnya ke Caller.ResultSink, seperti yang diwajibkan oleh Error bubling.

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

Penanganan error

Ada beberapa contoh penanganan error yang sudah ada di callback Tasks.lookUp dan Menerapkan nilai di antara StateMachines. Pengecualian, selain InterruptedException tidak ditampilkan, tetapi diteruskan melalui callback sebagai nilai. Callback tersebut sering kali memiliki semantik eksklusif atau semantik, dengan salah satu nilai atau error yang diteruskan.

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

Terjadi error dalam balon (--nokeep_getting)

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

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

Di StateMachine level teratas, yang berinteraksi dengan SkyFunction, ini dapat dilakukan dengan memanggil metode setException dari ValueOrExceptionProducer. ValueOrExceptionProducer.tryProduceValue kemudian akan menampilkan pengecualian, meskipun tidak ada SkyValues yang hilang.

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

Penanganan Peristiwa

Untuk SkyFunctions yang perlu memunculkan peristiwa, StoredEventHandler diinjeksikan ke SkyKeyComputeState dan selanjutnya dimasukkan ke dalam StateMachine yang memerlukannya. Secara historis, StoredEventHandler diperlukan karena Skyframe menghapus peristiwa tertentu kecuali jika diputar ulang, tetapi masalah ini kemudian diperbaiki. Injeksi StoredEventHandler dipertahankan karena menyederhanakan implementasi peristiwa yang dikeluarkan dari callback penanganan error.

Driver dan terhubung ke SkyFunctions

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

Ada sejumlah class yang dibuat 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 root StateMachine sebagai parameter. Memanggil Driver.drive akan mengeksekusi StateMachine sejauh yang dapat dilakukan tanpa memulai ulang Skyframe. Metode ini menampilkan nilai benar (true) saat StateMachine selesai dan jika tidak, menunjukkan bahwa tidak semua nilai tersedia.

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

Membuat instance langsung Driver

Implementasi StateMachine secara konvensional mengomunikasikan hasilnya melalui callback. Anda dapat langsung membuat instance Driver seperti yang ditunjukkan dalam contoh berikut.

Driver disematkan dalam implementasi SkyKeyComputeState beserta implementasi ResultSink yang sesuai untuk ditentukan sedikit lebih bawah. Di tingkat atas, objek State adalah penerima yang sesuai untuk hasil komputasi karena dijamin akan aktif 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 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;
  }
}

Kode untuk menghitung hasil secara lambat akan 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 memberikan pengecualian, menyematkan Driver adalah kemungkinan implementasi lainnya, 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 sebagai jenis SkyKeyComputeState dari fungsi tertentu).

@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;
}

Penyematan Driver dalam implementasi StateMachine lebih cocok untuk gaya coding sinkron Skyframe.

StateMachines yang dapat menghasilkan pengecualian

Jika tidak, ada class ValueOrExceptionProducer dan ValueOrException2Producer yang bisa disematkan SkyKeyComputeStateyang memiliki API sinkron agar cocok dengan kode SkyFunction sinkron.

Class abstrak ValueOrExceptionProducer menyertakan 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. }
}

Library ini mencakup instance Driver tersemat dan sangat mirip dengan class ResultProducer dalam Driver tersemat dan antarmuka dengan SkyFunction dengan cara yang serupa. Bukannya menentukan ResultSink, implementasi akan memanggil setValue atau setException jika salah satunya terjadi. Jika keduanya terjadi, pengecualian akan diprioritaskan. Metode tryProduceValue menghubungkan kode callback asinkron ke kode sinkron dan menampilkan pengecualian saat kode tersebut ditetapkan.

Seperti disebutkan sebelumnya, selama error bubling, mungkin saja error terjadi meskipun mesin belum selesai karena tidak semua input tersedia. Untuk mengakomodasi ini, tryProduceValue akan menampilkan pengecualian yang ditetapkan, bahkan sebelum mesin selesai.

Epilog: Pada akhirnya, menghapus callback

StateMachine adalah cara yang sangat efisien, tetapi intensif menggunakan boilerplate untuk melakukan komputasi asinkron. Kelanjutan (terutama dalam bentuk Runnable yang diteruskan ke ListenableFuture) tersebar luas di bagian kode Bazel tertentu, tetapi tidak umum dalam analisis SkyFunctions. Analisis sebagian besar terikat dengan CPU dan tidak ada API asinkron yang efisien untuk I/O disk. Pada akhirnya, sebaiknya optimalkan callback jauhnya karena callback tersebut memiliki kurva pembelajaran dan mengurangi keterbacaan.

Salah satu alternatif yang paling menjanjikan adalah thread virtual Java. Alih-alih harus menulis callback, semuanya diganti dengan panggilan sinkron yang memblokir. Hal ini memungkinkan karena mengikat resource thread virtual, tidak seperti thread platform, seharusnya murah. Namun, bahkan dengan thread virtual, mengganti operasi sinkron sederhana dengan pembuatan thread dan primitif sinkronisasi terlalu mahal. Kami melakukan migrasi dari StateMachine ke thread virtual Java dan urutannya jauh lebih lambat, sehingga menghasilkan hampir 3x peningkatan latensi analisis menyeluruh. Karena thread virtual masih menjadi fitur pratinjau, migrasi ini mungkin dapat dilakukan di masa mendatang saat performa meningkat.

Pendekatan lain yang perlu dipertimbangkan adalah menunggu coroutine Loom, jika ada. Keuntungannya di sini adalah Anda dapat mengurangi overhead sinkronisasi dengan menggunakan multitasking kooperatif.

Jika semuanya gagal, penulisan ulang bytecode level rendah juga dapat menjadi alternatif yang mungkin dilakukan. Dengan pengoptimalan yang memadai, Anda dapat mencapai performa yang mendekati kode callback yang ditulis tangan.

Lampiran

Penalaan Balik

Hell callback adalah masalah terkenal dalam kode asinkron yang menggunakan callback. Hal ini didasarkan pada fakta bahwa kelanjutan untuk langkah berikutnya berada dalam langkah sebelumnya. Jika ada banyak langkah, tingkatan ini bisa sangat mendalam. Jika digabungkan dengan alur kontrol, kode menjadi tidak dapat 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 implementasi bertingkat adalah bahwa frame stack langkah luar dapat dipertahankan. Di Java, variabel lambda yang diambil harus benar-benar final sehingga menggunakan variabel tersebut bisa jadi rumit. Penyarangan mendalam 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;
  }
}

Hell callback juga dapat terjadi jika pola injeksi runAfter digunakan terlalu padat, tetapi hal ini dapat dihindari dengan menyisipkan injeksi dengan langkah berurutan.

Contoh: Pencarian SkyValue berantai

Sering kali logika aplikasi memerlukan rantai pencarian SkyValue yang dependen, misalnya, jika SkyKey kedua bergantung pada SkyValue pertama. Mempertimbangkan hal ini secara naif, hal ini akan menghasilkan struktur callback yang kompleks dan bertingkat.

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 tersebut terlihat prosedur di seluruh transisi status: step2 mengikuti step1. Perhatikan bahwa di sini, lambda digunakan untuk menetapkan value2. Hal ini membuat pengurutan kode sesuai dengan urutan komputasi dari atas ke bawah.

Tips Lainnya

Keterbacaan: Urutan Eksekusi

Untuk meningkatkan keterbacaan, usahakan untuk mempertahankan implementasi StateMachine.step dalam urutan eksekusi dan implementasi callback tepat setelah mengikutinya dalam meneruskan kode. Hal ini tidak selalu mungkin dilakukan di mana cabang kontrol beralur. Komentar tambahan mungkin berguna dalam kasus seperti itu.

Dalam Contoh: Pencarian SkyValue yang Dirantai, referensi metode perantara dibuat untuk mencapai ini. Hal ini menukar sejumlah kecil performa untuk keterbacaan, yang mungkin cukup bermanfaat di sini.

Hipotesis Generasi

Objek Java berumur sedang merusak hipotesis generasi pembersih sampah memori Java, yang dirancang untuk menangani objek yang hidup dalam waktu sangat singkat atau objek yang hidup selamanya. Pada dasarnya, objek di SkyKeyComputeState melanggar hipotesis ini. Objek tersebut, yang berisi hierarki yang dikonstruksi dari semua StateMachine yang masih berjalan, yang di-root di Driver memiliki masa aktif menengah saat ditangguhkan, menunggu komputasi asinkron selesai.

Hal ini tampaknya tidak terlalu buruk di JDK19, tetapi saat menggunakan StateMachine, terkadang Anda dapat mengamati peningkatan waktu GC, bahkan dengan penurunan drastis pada sampah memori sebenarnya yang dihasilkan. Karena memiliki masa aktif sedang, StateMachine dapat dipromosikan ke generasi lama, yang membuatnya terisi lebih cepat, sehingga harus membersihkan GC utama atau yang lebih mahal.

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

Untuk variabel StateMachine, mengelompokkan hal-hal ke dalam subtugas dan mengikuti pola yang direkomendasikan untuk Menerapkan nilai antara StateMachine juga berguna. Perhatikan bahwa saat mengikuti pola, hanya StateMachine turunan yang memiliki referensi ke StateMachine induk dan bukan sebaliknya. Ini berarti bahwa saat turunan menyelesaikan dan memperbarui induk menggunakan callback hasil, turunan tersebut tentu saja akan berada di luar cakupan dan memenuhi syarat untuk GC.

Terakhir, dalam beberapa kasus, variabel StateMachine diperlukan di status sebelumnya, tetapi tidak di status berikutnya. Ada baiknya untuk membatalkan referensi objek besar setelah diketahui bahwa objek tersebut tidak lagi diperlukan.

Penamaan status

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

Diagram hierarki serentak

Berikut adalah tampilan alternatif dari diagram dalam Konkurensi terstruktur yang lebih menggambarkan struktur pohon. Blok membentuk pohon kecil.

3D Konkurensi Terstruktur


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

  2. Perhatikan bahwa step diizinkan untuk menampilkan InterruptedException, tetapi contoh menghilangkannya. Ada beberapa metode rendah dalam kode Bazel yang menampilkan pengecualian ini dan menyebarkannya ke Driver, untuk dijelaskan nanti, yang menjalankan StateMachine. Tidak masalah untuk tidak mendeklarasikannya saat tidak diperlukan.

  3. Subtugas serentak dimotivasi oleh ConfiguredTargetFunction yang melakukan pekerjaan independen untuk setiap dependensi. Daripada memanipulasi struktur data kompleks yang memproses semua dependensi sekaligus, sehingga menyebabkan ketidakefisienan, setiap dependensi memiliki StateMachine tersendiri.

  4. Beberapa panggilan tasks.lookUp dalam satu langkah dikelompokkan bersama. Pengelompokan tambahan dapat dibuat oleh pencarian yang terjadi dalam subtugas serentak. 

  5. Ini secara konsep mirip dengan konkurensi terstruktur Java jeps/428

  6. Caranya mirip dengan menghasilkan thread dan menggabungkannya untuk mencapai komposisi berurutan.