Hướng dẫn về Skyframe StateMachines

Báo cáo vấn đề Xem nguồn Nightly/3}

Tổng quan

Skyframe StateMachine là một đối tượng hàm đã giải mã nằm trên vùng nhớ khối xếp. Công cụ này hỗ trợ tính linh hoạt và việc đánh giá mà không cần dự phòng1 khi các giá trị bắt buộc không có sẵn ngay mà được tính toán không đồng bộ. StateMachine không thể liên kết một tài nguyên luồng trong khi chờ, mà phải bị tạm ngưng và tiếp tục. Do đó, quá trình giải cấu trúc sẽ cho thấy các điểm nhập lại rõ ràng để có thể bỏ qua các phép tính trước đó.

StateMachine có thể được dùng để biểu thị trình tự, phân nhánh, đồng thời logic có cấu trúc và được điều chỉnh riêng cho tương tác Skyframe. StateMachine có thể được kết hợp thành các StateMachine lớn hơn và dùng chung các StateMachine phụ. Mô hình đồng thời luôn được phân cấp theo cấu trúc và theo logic hoàn toàn. Mọi tác vụ phụ đồng thời đều chạy trong một luồng SkyFunction mẹ được chia sẻ.

Giới thiệu

Phần này thúc đẩy và giới thiệu các StateMachine một cách ngắn gọn, có trong gói java.com.google.devtools.build.skyframe.state.

Giới thiệu ngắn gọn về việc khởi động lại Skyframe

Skyframe là một khung thực hiện việc đánh giá song song các biểu đồ phần phụ thuộc. Mỗi nút trong biểu đồ tương ứng với đánh giá của một SkyFunction có một SkyKey chỉ định các tham số của nó và SkyValue chỉ định kết quả của nó. Mô hình tính toán này sao cho SkyFunction có thể tra cứu SkyValues bằng SkyKey, kích hoạt việc đánh giá đệ quy, song song của SkyFunctions bổ sung. Thay vì chặn (sẽ liên kết một luồng) khi một SkyValue được yêu cầu chưa sẵn sàng do một số đồ thị con tính toán chưa hoàn tất, SkyFunction yêu cầu quan sát phản hồi null getValue và nên trả về null thay vì một SkyValue, cho biết rằng nó chưa hoàn chỉnh do thiếu dữ liệu đầu vào. Skyframe khởi động lại SkyFunctions khi tất cả SkyValues được yêu cầu trước đó đều có sẵn.

Trước khi SkyKeyComputeState ra mắt, cách truyền thống để xử lý quá trình khởi động lại là chạy lại toàn bộ quá trình tính toán. Mặc dù phương thức này có độ phức tạp bậc hai, nhưng các hàm được viết theo cách này cuối cùng cũng sẽ hoàn thành vì mỗi lần chạy lại, số lượt tra cứu sẽ trả về null ít hơn. Với SkyKeyComputeState, bạn có thể liên kết dữ liệu điểm kiểm tra được chỉ định thủ công với SkyFunction, tiết kiệm tính toán đáng kể.

StateMachine là các đối tượng nằm bên trong SkyKeyComputeState và loại bỏ hầu như mọi hoạt động tính toán lại khi SkyFunction khởi động lại (giả sử SkyKeyComputeState không bị rơi khỏi bộ nhớ đệm) bằng cách hiển thị các hook tạm ngưng và tiếp tục thực thi.

Các phép tính có trạng thái bên trong SkyKeyComputeState

Từ quan điểm thiết kế hướng đối tượng, bạn nên cân nhắc việc lưu trữ các đối tượng điện toán bên trong SkyKeyComputeState thay vì các giá trị dữ liệu thuần tuý. Trong Java, thông tin mô tả tối thiểu về hành vi mang đối tượng là một giao diện chức năng và hoá ra như vậy là đủ. StateMachine có định nghĩa đệ quy có tính tò mò như sau2.

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

Giao diện Tasks tương tự như SkyFunction.Environment nhưng được thiết kế để không đồng bộ và hỗ trợ thêm các tác vụ phụ đồng thời về mặt logic3.

Giá trị trả về của step là một StateMachine khác, cho phép quy cách trình tự các bước theo quy tắc. step trả về DONE khi StateMachine hoàn tất. Ví dụ:

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

mô tả StateMachine với kết quả sau.

hello
world

Xin lưu ý rằng tham chiếu phương thức this::step2 cũng là một StateMachine do step2 đáp ứng định nghĩa giao diện chức năng của StateMachine. Tham chiếu phương thức là cách phổ biến nhất để chỉ định trạng thái tiếp theo trong StateMachine.

Tạm ngưng rồi tiếp tục

Theo trực quan, việc chia nhỏ quá trình tính toán thành các bước StateMachine, thay vì hàm nguyên khối, sẽ cung cấp các hook cần thiết để suspendsuspend tính toán. Khi StateMachine.step trả về, sẽ có một điểm tạm ngưng rõ ràng. Việc tiếp tục được chỉ định bởi giá trị StateMachine được trả về là một điểm tiếp tục rõ ràng. Do đó, bạn có thể tránh việc tính toán lại vì quá trình tính toán có thể được chọn chính xác tại nơi đã dừng lại.

Lệnh gọi lại, các thành phần tiếp tục và việc tính toán không đồng bộ

Về mặt kỹ thuật, StateMachine đóng vai trò continuation, xác định phép tính tiếp theo sẽ được thực thi. Thay vì chặn, StateMachine có thể tự nguyện suspend bằng cách quay lại từ hàm step. Hàm này sẽ chuyển quyền kiểm soát về lại một thực thể Driver. Sau đó, Driver có thể chuyển sang một StateMachine sẵn sàng hoặc từ bỏ quyền kiểm soát về Skyframe.

Theo truyền thống, callbackscallbacks được gộp chung thành một khái niệm. Tuy nhiên, StateMachine duy trì sự khác biệt giữa 2 loại này.

  • Callback (Gọi lại) – mô tả nơi lưu trữ kết quả của một phép tính không đồng bộ.
  • Tiếp tục – chỉ định trạng thái thực thi tiếp theo.

Cần có lệnh gọi lại khi gọi một thao tác không đồng bộ, tức là thao tác thực tế không xảy ra ngay khi gọi phương thức này, như trong trường hợp tra cứu SkyValue. Các lệnh gọi lại nên được duy trì ở mức đơn giản nhất có thể.

Nội dung tiếp tục (continuation) là các giá trị trả về StateMachine của các giá trị StateMachine và đóng gói quy trình thực thi phức tạp tiếp theo sau khi tất cả các phép tính không đồng bộ được giải quyết. Phương pháp có cấu trúc này giúp duy trì độ phức tạp của lệnh gọi lại.

Tasks

Giao diện Tasks cung cấp cho các StateMachine một API để tra cứu SkyValues bằng SkyKey và để lên lịch các tác vụ phụ đồng thời.

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

Tra cứu SkyValue

StateMachine sử dụng các phương thức nạp chồng Tasks.lookUp để tra cứu các SkyValue. Các thuật ngữ này tương tự như SkyFunction.Environment.getValueSkyFunction.Environment.getValueOrThrow, đồng thời có ngữ nghĩa xử lý ngoại lệ tương tự. Việc triển khai không thực hiện tra cứu ngay lập tức, nhưng thay vào đó, sẽ theo lô4 nhiều lượt tra cứu nhất có thể trước khi thực hiện. Giá trị này có thể không có sẵn ngay, ví dụ: yêu cầu khởi động lại Skyframe, vì vậy, phương thức gọi chỉ định việc cần làm với giá trị thu được bằng cách sử dụng lệnh gọi lại.

Bộ xử lý StateMachine (Driver và cầu nối với SkyFrame) đảm bảo rằng giá trị sẽ có trước khi trạng thái tiếp theo bắt đầu. Sau đây là một ví dụ.

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

Trong ví dụ trên, bước đầu tiên sẽ thực hiện thao tác tra cứu new Key(), truyền this dưới dạng thực thể tiêu thụ. Điều đó có thể xảy ra vì DoesLookup sẽ triển khai Consumer<SkyValue>.

Theo hợp đồng, trước khi trạng thái tiếp theo DoesLookup.processValue bắt đầu, tất cả các lần tra cứu về DoesLookup.step đều hoàn tất. Do đó, value có sẵn khi được truy cập trong processValue.

Việc phụ cần làm

Tasks.enqueue yêu cầu thực thi nhiều tác vụ phụ đồng thời về mặt logic. Tác vụ phụ cũng là StateMachine và có thể thực hiện mọi việc mà StateMachine thông thường có thể làm, bao gồm cả việc tạo thêm tác vụ phụ theo quy trình đệ quy hoặc tra cứu SkyValues. Giống như lookUp, trình điều khiển máy trạng thái đảm bảo rằng tất cả các tác vụ phụ đều hoàn tất trước khi chuyển sang bước tiếp theo. Sau đây là một ví dụ.

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

Mặc dù Subtask1Subtask2 đồng thời về mặt logic, mọi thứ đều chạy trong một luồng duy nhất, vì vậy, bản cập nhật "đồng thời" của i không cần đồng bộ hoá.

Mô hình đồng thời có cấu trúc

Vì mọi lookUpenqueue đều phải phân giải trước khi chuyển sang trạng thái tiếp theo, nên điều đó có nghĩa là tính năng đồng thời bị giới hạn tất nhiên ở cấu trúc dạng cây. Bạn có thể tạo mô hình đồng thời 5 phân cấp như trong ví dụ sau.

Tính năng đồng thời có cấu trúc

Rất khó để nói qua UML rằng cấu trúc đồng thời tạo thành một cây. Bạn có thể dùng một khung hiển thị thay thế cho thấy rõ hơn cấu trúc cây.

Mô hình đồng thời không có cấu trúc

Mô hình đồng thời có cấu trúc dễ hiểu hơn nhiều.

Thành phần và mẫu luồng điều khiển

Phần này trình bày các ví dụ về cách kết hợp nhiều StateMachine và giải pháp cho một số vấn đề về luồng điều khiển.

Trạng thái tuần tự

Đây là mẫu quy trình điều khiển phổ biến và đơn giản nhất. Ví dụ về điều này được trình bày trong phần Các phép tính có tính trạng thái bên trong SkyKeyComputeState.

Phân nhánh

Bạn có thể đạt được trạng thái phân nhánh trong StateMachine bằng cách trả về các giá trị khác nhau thông qua luồng điều khiển Java thông thường, như trong ví dụ sau.

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

Thông thường, một số nhánh sẽ trả về DONE trong trường hợp hoàn thành sớm.

Thành phần tuần tự nâng cao

Vì cấu trúc điều khiển StateMachine không có bộ nhớ, nên việc chia sẻ định nghĩa StateMachine dưới dạng tác vụ phụ đôi khi có thể gây khó khăn. Cho M1M2 là các thực thể StateMachine dùng chung StateMachine, S, với M1M2 là các trình tự <A, S, B><X, S, Y> tương ứng. Vấn đề là S không biết nên tiếp tục B hay Y sau khi hoàn tất, và StateMachine chưa thực sự giữ ngăn xếp lệnh gọi. Phần này xem xét một số kỹ thuật để đạt được điều này.

StateMachine làm phần tử trình tự đầu cuối

Điều này không giải quyết được vấn đề ban đầu đã đặt ra. Hướng dẫn này chỉ minh hoạ thành phần kết hợp tuần tự khi StateMachine dùng chung là thiết bị đầu cuối trong trình tự.

// 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();
  }
}

API này hoạt động ngay cả khi bản thân S là một máy trạng thái phức tạp.

Việc phụ cần làm cho cấu trúc tuần tự

Vì các tác vụ phụ trong hàng đợi được đảm bảo sẽ hoàn thành trước trạng thái tiếp theo, nên đôi khi, bạn có thể lạm dụng một chút6 cơ chế phụ của nhiệm vụ.

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

Chèn runAfter

Đôi khi, bạn không thể lạm dụng Tasks.enqueue vì có các tác vụ con song song khác hoặc lệnh gọi Tasks.lookUp cần phải hoàn tất trước khi thực thi S. Trong trường hợp này, bạn có thể sử dụng tính năng chèn tham số runAfter vào S để thông báo cho S về việc cần làm tiếp theo.

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

Phương pháp này rõ ràng hơn so với việc lạm dụng các tác vụ phụ. Tuy nhiên, việc áp dụng tuỳ chọn này quá tự do, chẳng hạn như bằng cách lồng nhiều StateMachine với runAfter, sẽ chính là đường dẫn đến Callback Hell. Bạn nên chia nhỏ các runAfter tuần tự bằng các trạng thái tuần tự thông thường.

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

có thể được thay thế bằng nội dung sau.

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

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

Thay thế bị cấm: runAfterUnlessError

Trong một bản nháp trước đó, chúng tôi từng xem xét một runAfterUnlessError sẽ sớm huỷ bỏ lỗi. Điều này xuất phát từ thực tế là các lỗi thường được kiểm tra hai lần, một lần bởi StateMachine có tham chiếu runAfter và một lần bởi chính máy runAfter.

Sau khi cân nhắc, chúng tôi quyết định rằng tính đồng nhất của mã quan trọng hơn việc loại bỏ trùng lặp quy trình kiểm tra lỗi. Sẽ rất khó hiểu nếu cơ chế runAfter không hoạt động nhất quán với cơ chế tasks.enqueue (luôn yêu cầu kiểm tra lỗi).

Uỷ quyền trực tiếp

Mỗi khi có một sự chuyển đổi trạng thái chính thức, vòng lặp Driver chính sẽ tiến triển. Theo hợp đồng, các trạng thái tiến bộ có nghĩa là tất cả các hoạt động tra cứu và tác vụ phụ SkyValue được thêm vào hàng đợi trước đó sẽ được phân giải trước khi trạng thái tiếp theo thực thi. Đôi khi, logic của StateMachine uỷ quyền khiến việc chuyển giai đoạn tiến lên là không cần thiết hoặc phản tác dụng. Ví dụ: nếu step đầu tiên của thực thể uỷ quyền thực hiện hoạt động tra cứu SkyKey có thể được tải song song với hoạt động tra cứu trạng thái uỷ quyền, thì việc nâng cấp giai đoạn sẽ khiến các hoạt động này theo tuần tự. Việc uỷ quyền trực tiếp có thể sẽ hợp lý hơn, như trong ví dụ bên dưới.

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

Luồng dữ liệu

Trọng tâm của nội dung thảo luận trước là quản lý quy trình kiểm soát. Phần này mô tả việc truyền các giá trị dữ liệu.

Triển khai lệnh gọi lại Tasks.lookUp

Có một ví dụ về cách triển khai lệnh gọi lại Tasks.lookUp trong hoạt động tra cứu SkyValue. Phần này đưa ra cơ sở và đề xuất cách tiếp cận để xử lý nhiều SkyValue.

Lệnh gọi lại Tasks.lookUp

Phương thức Tasks.lookUp lấy một lệnh gọi lại sink làm tham số.

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

Bạn nên sử dụng hàm lambda Java để triển khai việc này:

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

trong đó myValue là một biến thành phần của thực thể StateMachine đang tiến hành tra cứu. Tuy nhiên, hàm lambda yêu cầu phân bổ thêm bộ nhớ so với việc triển khai giao diện Consumer<SkyValue> trong quá trình triển khai StateMachine. Hàm lambda vẫn hữu ích khi có nhiều lượt tra cứu không rõ ràng.

Ngoài ra, lỗi khi xử lý quá tải của Tasks.lookUp, tương tự như 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);
  }

Dưới đây là một ví dụ về cách triển khai.

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.
    …
  }
}

Tương tự như với thao tác tra cứu mà không xử lý lỗi, việc để lớp StateMachine trực tiếp triển khai lệnh gọi lại sẽ lưu quá trình phân bổ bộ nhớ cho lambda.

Phần Xử lý lỗi cung cấp thông tin chi tiết hơn một chút, nhưng về cơ bản, không có nhiều khác biệt giữa việc truyền lỗi và các giá trị thông thường.

Sử dụng nhiều SkyValue

Nhiều lần tra cứu SkyValue thường bắt buộc. Một phương pháp hiệu quả thường xuyên là chuyển sang loại SkyValue. Sau đây là một ví dụ đã được đơn giản hoá từ mã sản xuất nguyên mẫu.

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

Bạn có thể chia sẻ việc triển khai lệnh gọi lại Consumer<SkyValue> một cách rõ ràng vì các loại giá trị khác nhau. Nếu không phải như vậy, bạn có thể quay lại phương thức triển khai dựa trên lambda hoặc các thực thể lớp bên trong đầy đủ có triển khai các phương thức gọi lại phù hợp.

Truyền các giá trị giữa StateMachine giây

Cho đến nay, tài liệu này chỉ giải thích cách sắp xếp công việc trong một tác vụ phụ, nhưng các tác vụ phụ cũng cần báo cáo lại giá trị cho phương thức gọi. Vì các tác vụ phụ không đồng bộ theo logic, nên kết quả sẽ được thông báo lại cho phương thức gọi thông qua lệnh gọi lại. Để thực hiện việc này, tác vụ phụ sẽ xác định giao diện bồn lưu trữ dữ liệu được chèn thông qua hàm khởi tạo của nó.

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

Khi đó, phương thức gọi StateMachine sẽ có dạng như sau.

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

Ví dụ trước minh hoạ một vài điều. Caller phải truyền kết quả trở lại và xác định Caller.ResultSink của riêng nó. Caller triển khai các phương thức gọi lại BarProducer.ResultSink. Sau khi tiếp tục, processResult sẽ kiểm tra xem value có rỗng hay không để xác định xem có xảy ra lỗi hay không. Đây là mẫu hành vi phổ biến sau khi chấp nhận kết quả từ một tác vụ phụ hoặc thao tác tra cứu SkyValue.

Lưu ý rằng việc triển khai acceptBarError sẽ nhanh chóng chuyển tiếp kết quả đến Caller.ResultSink, theo yêu cầu trong phần Lỗi bong bóng.

Các lựa chọn thay thế cho StateMachine cấp cao nhất được mô tả trong Driver và cầu nối đến SkyFunctions.

Xử lý lỗi

Bạn có thể xem một số ví dụ về cách xử lý lỗi trong lệnh gọi lại Tasks.lookUpCách truyền các giá trị giữa StateMachines. Các ngoại lệ (ngoại trừ InterruptedException) sẽ không được gửi, mà được truyền đi thông qua lệnh gọi lại dưới dạng giá trị. Những lệnh gọi lại như vậy thường có ngữ nghĩa độc quyền hoặc ngữ nghĩa, trong đó truyền chính xác một giá trị hoặc lỗi.

Phần tiếp theo mô tả một tương tác tinh tế nhưng quan trọng với quá trình xử lý lỗi Skyframe.

Lỗi khi bật chuông thông báo (--nokeep_ hết)

Trong khi thông báo lỗi, bạn có thể khởi động lại SkyFunction ngay cả khi không phải tất cả các SkyValues được yêu cầu đều có sẵn. Trong những trường hợp như vậy, trạng thái tiếp theo sẽ không bao giờ đạt được do hợp đồng API Tasks. Tuy nhiên, StateMachine sẽ vẫn truyền ngoại lệ.

Vì việc truyền phải xảy ra bất kể trạng thái tiếp theo có đạt được hay không, nên lệnh gọi lại xử lý lỗi phải thực hiện tác vụ này. Đối với StateMachine bên trong, bạn thực hiện việc này bằng cách gọi lệnh gọi lại mẹ.

StateMachine cấp cao nhất giao tiếp với SkyFunction, bạn có thể thực hiện việc này bằng cách gọi phương thức setException của ValueOrExceptionProducer. Sau đó, ValueOrExceptionProducer.tryProduceValue sẽ gửi ngoại lệ, ngay cả khi thiếu SkyValues.

Nếu đang sử dụng Driver trực tiếp, bạn cần phải kiểm tra các lỗi được lan truyền từ SkyFunction, ngay cả khi máy chưa xử lý xong.

Xử lý sự kiện

Đối với các SkyFunctions cần phát các sự kiện, một StoredEventHandler được chèn vào SkyKeyComputeState và được chèn thêm vào các StateMachine yêu cầu các sự kiện đó. Trước đây, StoredEventHandler là cần thiết do Skyframe làm gián đoạn một số sự kiện nhất định trừ phi chúng được phát lại, nhưng sau đó điều này đã được khắc phục. Quá trình chèn StoredEventHandler được giữ nguyên vì tính năng này giúp đơn giản hoá việc triển khai các sự kiện được phát ra từ các lệnh gọi lại xử lý lỗi.

Driver và cầu nối đến SkyFunctions

Driver chịu trách nhiệm quản lý việc thực thi các StateMachine, bắt đầu bằng StateMachine gốc được chỉ định. Vì StateMachine có thể thêm các tác vụ phụ StateMachine vào hàng đợi theo quy tắc đệ quy, nên một Driver có thể quản lý nhiều nhiệm vụ phụ. Các nhiệm vụ phụ này tạo ra cấu trúc cây, do Cơ chế đồng thời có cấu trúc. Driver thực hiện việc tra cứu SkyValue theo hàng loạt các tác vụ phụ để cải thiện hiệu quả.

Có một số lớp được xây dựng xung quanh Driver, với API sau đây.

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

Driver lấy một căn bậc StateMachine làm tham số. Việc gọi Driver.drive sẽ thực thi StateMachine trong phạm vi có thể hoạt động mà không cần khởi động lại Skyframe. Phương thức này sẽ trả về giá trị true khi StateMachine hoàn tất và trả về giá trị false, nếu không, cho biết rằng không phải giá trị nào cũng có sẵn.

Driver duy trì trạng thái đồng thời của StateMachine và rất phù hợp để nhúng trong SkyKeyComputeState.

Trực tiếp tạo thực thể Driver

Thông thường, quá trình triển khai StateMachine sẽ thông báo kết quả qua lệnh gọi lại. Bạn có thể tạo thực thể trực tiếp cho Driver như trong ví dụ sau.

Driver được nhúng trong quá trình triển khai SkyKeyComputeState cùng với việc triển khai ResultSink tương ứng sẽ được xác định sâu hơn một chút. Ở cấp cao nhất, đối tượng State là đối tượng nhận thích hợp cho kết quả tính toán vì đối tượng này được đảm bảo sẽ tồn tại lâu hơn 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;
  }
}

Mã dưới đây phác thảo 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;
  }
}

Sau đó, mã để tính toán từng phần kết quả có thể có dạng như sau.

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

Nhúng Driver

Nếu StateMachine tạo ra một giá trị và không đưa ra ngoại lệ, thì việc nhúng Driver là một cách triển khai khác có thể thực hiện, như trong ví dụ sau.

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 có thể có mã như sau (trong đó State là loại hàm cụ thể của 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;
}

Việc nhúng Driver vào phương thức triển khai StateMachine phù hợp hơn với phong cách lập trình đồng bộ của Skyframe.

StateMachines có thể tạo ra ngoại lệ

Nếu không, có các lớp ValueOrExceptionProducerValueOrException2Producer có thể nhúng SkyKeyComputeState có API đồng bộ để khớp với mã SkyFunction đồng bộ.

Lớp trừu tượng ValueOrExceptionProducer bao gồm các phương thức sau.

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

Lớp này bao gồm một thực thể Driver được nhúng và gần giống với lớp ResultProducer trong phần Trình điều khiển nhúng cũng như các giao diện với SkyFunction theo cách tương tự. Thay vì xác định ResultSink, các quá trình triển khai sẽ gọi setValue hoặc setException khi một trong hai điều đó xảy ra. Khi cả hai trường hợp xảy ra, ngoại lệ sẽ được ưu tiên. Phương thức tryProduceValue cầu nối mã gọi lại không đồng bộ với mã đồng bộ và gửi một trường hợp ngoại lệ khi mã được đặt.

Như đã lưu ý trước đó, trong quá trình tạo bong bóng lỗi, có thể xảy ra lỗi ngay cả khi máy chưa hoàn tất vì không phải tất cả dữ liệu đầu vào đều có sẵn. Để đáp ứng điều này, tryProduceValue sẽ gửi mọi trường hợp ngoại lệ đã đặt, ngay cả trước khi máy hoàn tất.

Phần kết: Cuối cùng là xoá các lệnh gọi lại

StateMachine là một cách hiệu quả cao nhưng tập trung nhiều vào các mã nguyên mẫu để thực hiện việc tính toán không đồng bộ. Các thành phần tiếp tục (đặc biệt là ở dạng Runnable được truyền đến ListenableFuture) phổ biến trong một số phần của mã Bazel, nhưng không phổ biến trong các tính năng phân tích SkyFunctions. Quá trình phân tích chủ yếu bị ràng buộc bởi CPU và không có API không đồng bộ hiệu quả nào cho ổ đĩa I/O. Cuối cùng, bạn nên tối ưu hoá các lệnh gọi lại vì các lệnh gọi lại này có đường cong tự học và cản trở khả năng đọc.

Một trong những giải pháp thay thế hứa hẹn nhất là luồng ảo Java. Thay vì phải viết lệnh gọi lại, mọi thứ sẽ được thay thế bằng các lệnh gọi chặn, đồng bộ. Điều này có thể xảy ra vì việc liên kết tài nguyên luồng ảo, không giống như luồng nền tảng, được cho là có chi phí thấp. Tuy nhiên, ngay cả với luồng ảo, việc thay thế các thao tác đồng bộ đơn giản bằng nguyên tắc đồng bộ hoá và tạo luồng vẫn quá tốn kém. Chúng tôi đã tiến hành quá trình di chuyển từ các luồng ảo StateMachine sang luồng ảo Java. Các đơn đặt hàng này có độ lớn chậm hơn, khiến độ trễ phân tích toàn diện tăng gần gấp 3 lần. Vì luồng ảo vẫn là tính năng xem trước, nên có thể sau này bạn vẫn có thể thực hiện quá trình di chuyển này khi hiệu suất cải thiện.

Một phương pháp khác cần xem xét là chờ coroutine Loom (nếu có). Ưu điểm ở đây là có thể giảm chi phí đồng bộ hoá bằng cách sử dụng đa nhiệm phối hợp.

Nếu vẫn không thành công, thì việc viết lại mã byte cấp thấp cũng có thể là một giải pháp thay thế khả thi. Với đủ mức tối ưu hoá, bạn có thể đạt được hiệu suất tiếp cận mã gọi lại được viết thủ công.

Phụ lục

Địa chỉ gọi lại

Callback Hell là một vấn đề nổi tiếng trong mã không đồng bộ sử dụng lệnh gọi lại. Điều này xuất phát từ thực tế là phần tiếp tục cho bước tiếp theo được lồng vào bước trước đó. Nếu có nhiều bước, việc lồng nhau này có thể cực kỳ sâu. Nếu đi kèm với quy trình điều khiển, thì mã sẽ trở thành không thể quản lý.

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

Một trong những ưu điểm của việc triển khai lồng nhau là có thể giữ nguyên khung ngăn xếp của bước bên ngoài. Trong Java, các biến lambda được ghi lại phải là biến cuối cùng một cách hiệu quả. Vì vậy, việc sử dụng các biến như vậy có thể rườm rà. Bạn cần tránh lồng ghép sâu bằng cách trả về các tệp tham chiếu phương thức dưới dạng tiếp tục thay vì lambda, như minh hoạ sau đây.

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

Lỗi lệnh gọi lại cũng có thể xảy ra nếu mẫu chèn runAfter được sử dụng quá mật độ, nhưng bạn có thể tránh điều này bằng cách xen kẽ các thao tác chèn với các bước tuần tự.

Ví dụ: Tra cứu SkyValue theo chuỗi

Thông thường, logic ứng dụng yêu cầu các chuỗi phụ thuộc của hoạt động tra cứu SkyValue, ví dụ: nếu SkyKey thứ hai phụ thuộc vào SkyValue đầu tiên. Về cơ bản, điều này sẽ dẫn đến một cấu trúc lệnh gọi lại phức tạp, được lồng sâu.

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

Tuy nhiên, vì các thành phần tiếp tục được chỉ định làm tham chiếu phương thức, nên mã sẽ trông theo quy trình qua các chuyển đổi trạng thái: step2 theo sau step1. Ở đây, xin lưu ý rằng lambda sẽ được dùng để gán cho value2. Điều này làm cho thứ tự của mã khớp với thứ tự của phép tính từ trên xuống dưới.

Mẹo khác

Mức độ dễ đọc: Thứ tự thực thi

Để cải thiện khả năng đọc, hãy cố gắng duy trì hoạt động triển khai StateMachine.step theo thứ tự thực thi và triển khai lệnh gọi lại ngay sau khi chúng được truyền vào mã. Không phải lúc nào điều này cũng có thể xảy ra khi luồng điều khiển phân nhánh. Nhận xét bổ sung có thể hữu ích trong những trường hợp như vậy.

Trong Ví dụ: Hoạt động tra cứu SkyValue theo chuỗi, một thông tin tham chiếu phương thức trung gian sẽ được tạo để đạt được điều này. Thao tác này sẽ đổi một phần nhỏ hiệu suất để dễ đọc, đây có thể là điều đáng để thực hiện ở đây.

Giả thuyết thế hệ

Các đối tượng Java tồn tại trung bình phá vỡ giả thuyết tạo sinh của trình thu gom rác Java. Trình thu thập này được thiết kế để xử lý các đối tượng chỉ tồn tại trong một thời gian rất ngắn hoặc các đối tượng tồn tại vĩnh viễn. Theo định nghĩa, các đối tượng trong SkyKeyComputeState vi phạm giả thuyết này. Các đối tượng như vậy (chứa cây đã dựng của tất cả các StateMachine vẫn đang chạy) bị can thiệp vào hệ thống tại Driver có thời gian tồn tại trung gian khi chúng tạm ngưng, chờ các phép tính không đồng bộ hoàn tất.

Trong JDK19, có vẻ như ít tệ hơn, nhưng khi sử dụng StateMachine, đôi khi bạn có thể thấy thời gian GC tăng lên, ngay cả khi lượng rác thực tế được tạo ra đã giảm đáng kể. Vì StateMachine có thời gian hoạt động trung gian, nên chúng có thể được nâng cấp lên thế hệ cũ, khiến nó lấp đầy nhanh hơn, do đó cần phải dọn dẹp các GC lớn hoặc GC đầy đủ đắt tiền hơn.

Biện pháp phòng ngừa ban đầu là giảm thiểu việc sử dụng biến StateMachine. Tuy nhiên, điều này không phải lúc nào cũng khả thi, ví dụ: nếu cần một giá trị ở nhiều trạng thái. Nếu có thể, biến step của ngăn xếp cục bộ là các biến tạo trẻ và là biến GC một cách hiệu quả.

Đối với các biến StateMachine, việc chia nhỏ mọi thứ thành các tác vụ phụ và tuân theo mẫu được đề xuất để Truyền giá trị giữa các StateMachine cũng hữu ích. Lưu ý rằng khi làm theo mẫu, chỉ StateMachine con mới có tham chiếu đến StateMachine mẹ chứ không phải ngược lại. Điều này có nghĩa là khi thành phần con hoàn tất và cập nhật thành phần mẹ bằng lệnh gọi lại kết quả, các thành phần con sẽ nằm ngoài phạm vi một cách tự nhiên và đủ điều kiện sử dụng GC.

Cuối cùng, trong một số trường hợp, cần có biến StateMachine ở các trạng thái trước đó nhưng không cần ở các trạng thái sau. Việc xoá tham chiếu các đối tượng lớn có thể giúp ích cho bạn khi biết rằng các đối tượng đó không còn cần thiết nữa.

Các tiểu bang đặt tên

Khi đặt tên cho một phương thức, thông thường, bạn có thể đặt tên cho phương thức cho hành vi xảy ra trong phương thức đó. Cách thực hiện việc này trong StateMachine không rõ ràng vì không có ngăn xếp. Ví dụ: giả sử phương thức foo gọi một phương thức phụ bar. Trong StateMachine, trạng thái này có thể được chuyển đổi sang chuỗi trạng thái foo, theo sau là bar. foo không còn bao gồm hành vi bar nữa. Do đó, tên phương thức cho các trạng thái có xu hướng có phạm vi hẹp hơn, có thể phản ánh hành vi cục bộ.

Sơ đồ cây đồng thời

Sau đây là chế độ xem thay thế cho biểu đồ trong Mô hình đồng thời có cấu trúc mô tả chính xác hơn cấu trúc cây. Các khối tạo thành một cây nhỏ.

Mô hình đồng thời có cấu trúc 3D


  1. Trái ngược với quy ước của Skyframe về việc khởi động lại từ đầu khi không có giá trị. 

  2. Lưu ý rằng step được phép gửi InterruptedException, nhưng các ví dụ bỏ qua thuộc tính này. Có một số phương thức thấp trong mã Bazel gửi ngoại lệ này và sẽ truyền đến Driver, được mô tả sau này sẽ chạy StateMachine. Bạn có thể không khai báo về việc gửi tệp khi không cần thiết.

  3. Các nhiệm vụ phụ đồng thời được thúc đẩy bởi ConfiguredTargetFunction, thực hiện công việc độc lập cho từng phần phụ thuộc. Thay vì thao tác với các cấu trúc dữ liệu phức tạp xử lý mọi phần phụ thuộc cùng lúc, gây ra tình trạng không hiệu quả, mỗi phần phụ thuộc sẽ có StateMachine độc lập riêng.

  4. Nhiều lệnh gọi tasks.lookUp trong một bước sẽ được gộp theo nhóm. Bạn có thể tạo lô bổ sung bằng các hoạt động tra cứu diễn ra trong các tác vụ phụ đồng thời.

  5. Về mặt lý thuyết, tính năng này tương tự như jeps/428 của tính năng đồng thời có cấu trúc của Java.

  6. Cách thực hiện này tương tự như việc tạo một luồng và kết hợp luồng đó để đạt được thành phần kết hợp tuần tự.