Hướng dẫn về Skyframe StateMachines

Báo cáo vấn đề Xem nguồn

Tổng quan

Skyframe StateMachine là một đối tượng hàm được huỷ cấu trúc nằm trên vùng nhớ khối xếp. Công cụ này hỗ trợ tính linh hoạt và đá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 nhưng được tính không đồng bộ. StateMachine không thể liên kết tài nguyên chuỗi trong khi chờ, mà phải tạm ngưng và tiếp tục. Do đó, cấu trúc giải phóng sẽ hiển thị các điểm truy cập lại rõ ràng để có thể bỏ qua các phép tính trước.

Bạn có thể sử dụng StateMachine để thể hiện 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 hoạt động tương tác với 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ụ. Tính năng đồng thời luôn có tính phân cấp theo cấu trúc và hoàn toàn hợp lý. Mọi việc phụ cần làm đồng thời sẽ chạy trong một chuỗi chính được chia sẻ của Skyky.

Giới thiệu

Phần này vận động và giới thiệu ngắn gọn về StateMachine, 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à khung thực hiện đá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 kết quả đánh giá một SkyFunction bằng một SkyKey chỉ định các tham số và SkyValue chỉ định kết quả của nó. Mô hình tính toán sao cho SkyFunction có thể tra cứu SkyValues bằng SkyKey, kích hoạt đánh giá đệ quy, song song các SkyFunctions bổ sung. Thay vì chặn, điều này sẽ liên kết một luồng khi một SkyValue được yêu cầu chưa sẵn sàng vì một số đồ thị con của phép tính chưa hoàn tất, thì SkyFunction yêu cầu sẽ quan sát phản hồi null getValue và sẽ trả về null thay vì SkyValue, báo hiệu rằng nó chưa hoàn thà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 đó có sẵn.

Trước khi giới thiệu SkyKeyComputeState, cách truyền thống để xử lý khởi động lại là chạy lại toàn bộ quá trình tính toán. Mặc dù điều 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 sẽ hoàn tất vì mỗi lần chạy lại, một số lần tra cứu sẽ trả về null. Với SkyKeyComputeState, bạn có thể liên kết dữ liệu điểm kiểm tra chỉ định bằng tay với SkyFunction, giúp lưu lại quá trình tính toán lại đáng kể.

StateMachine là các đối tượng tồn tại bên trong SkyKeyComputeState và loại bỏ gần như toàn bộ quá trình tính toán lại khi SkyFunction khởi động lại (giả sử rằng SkyKeyComputeState không thoát 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 xem xét việc lưu trữ đối tượng tính toán bên trong SkyKeyComputeState thay vì giá trị dữ liệu thuần túy. Trong Java, mô tả tối thiểu về một hành vi mang đối tượng là giao diện chức năng và hóa ra là đủ. StateMachine có định nghĩa đệ quy, một cách tò mò2 sau đây.

@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ế cho chế độ không đồng bộ và hỗ trợ thêm cho 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 xác định trình tự các bước, theo quy nạp. 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

Lưu ý tham chiếu phương thức this::step2 cũng là 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 và tiếp tục

Theo trực giác, việc chia nhỏ phép tính thành các bước StateMachine thay vì một hàm nguyên khối sẽ cung cấp các hook cần thiết để tạm ngưngtiếp tục tính toán. Khi StateMachine.step trả về, sẽ có một điểm tạm ngưng rõ ràng. Giá trị 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ì có thể tiếp tục tính toán chính xác nơi đã dừng lại.

Lệnh gọi lại, hàm tiếp tục và phép tính không đồng bộ

Về mặt kỹ thuật, StateMachine đóng vai trò là giá trị tiếp tục, giúp xác định việc tính toán tiếp theo sẽ được thực thi. Thay vì chặn, StateMachine có thể tự nguyện tạm ngưng 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 StateMachine sẵn sàng hoặc từ bỏ quyền kiểm soát để quay lại Skyframe.

Theo truyền thống, lệnh gọi lạiphần tiếp tục được kết hợp thành một khái niệm. Tuy nhiên, StateMachine vẫn duy trì sự khác biệt giữa hai loại mã này.

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

Bạn phải gọi lại khi gọi một thao tác không đồng bộ, có nghĩa là thao tác thực tế sẽ không diễn ra ngay khi gọi phương thức, như trong trường hợp tra cứu SkyValue. Cuộc gọi lại nên được giữ đơn giản nhất có thể.

Tiếp tục là các giá trị trả về StateMachine của StateMachine và đóng gói cách thực thi phức tạp sau khi tất cả các phép tính không đồng bộ được phân giải. Phương pháp tiếp cận có cấu trúc này giúp duy trì tính phức tạp của lệnh gọi lại có thể quản lý.

Tasks

Giao diện Tasks cung cấp cho StateMachine một API để tra cứu SkyValues bằng SkyKey và lên lịch các nhiệm 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 để tìm SkyValues. Các lớp này tương tự như SkyFunction.Environment.getValueSkyFunction.Environment.getValueOrThrow, đồng thời có các ngữ nghĩa xử lý ngoại lệ tương tự. Cách triển khai không thực hiện ngay hoạt động tra cứu mà thay vào đó, sẽ xử lý hàng loạt4 lượt tìm kiếm nhiều nhất có thể. Giá trị này có thể không có sẵn ngay lập tức, ví dụ: cần khởi động lại Skyframe, vì vậy, phương thức gọi sẽ chỉ định việc cần làm với giá trị thu được.

Bộ xử lý StateMachine (Driver và cầu nối sang SkyFrame) đảm bảo rằng giá trị đó có sẵn 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 là tra cứu new Key(), chuyển this làm thực thể tiêu thụ. Điều này có thể xảy ra vì DoesLookup triển khai Consumer<SkyValue>.

Theo hợp đồng, trước khi trạng thái DoesLookup.processValue tiếp theo bắt đầu, tất cả các lượt tra cứu của DoesLookup.step đã hoàn tất. Do đó, bạn có thể sử dụng value khi truy cập processValue.

Việc phụ cần làm

Tasks.enqueue yêu cầu thực thi các tác vụ phụ đồng thời về mặt logic. Việc phụ cần làm cũng là StateMachine và có thể làm bất cứ việc gì StateMachine thông thường có thể làm, bao gồm cả việc tạo thêm việc phụ cần làm 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ụ đã 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, nhưng mọi thứ chạy trong một luồng duy nhất nên 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, điều này có nghĩa là cơ chế xử lý đồng thời vốn chỉ giới hạn ở cấu trúc cây. Bạn có thể tạo mô hình đồng thời5 phân cấp như trong ví dụ sau.

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

Từ UML, cấu trúc đồng thời tạo thành một cây rất khó để nói rõ. Có một chế độ xem thay thế hiển thị rõ hơn cấu trúc cây.

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

Tính năng đồng thời có cấu trúc dễ hiểu hơn nhiều.

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

Phần này trình bày các ví dụ về cách soạn nhiều StateMachine và các 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 luồng điều khiển phổ biến và đơn giản nhất. Ví dụ về vấn đề này được trình bày trong bài viết Các phép tính trạng thái bên trong SkyKeyComputeState.

Phân nhánh

Bạn có thể đạt được cá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;
  }
  …
}

Một số nhánh nhất định thường trả về DONE để hoàn tất sớm.

Bố cục tuần tự nâng cao

Vì cấu trúc điều khiển StateMachine không cần bộ nhớ, nên việc chia sẻ định nghĩa StateMachine làm nhiệm vụ con đôi khi có thể gây khó khăn. Gọi M1M2 là các thực thể StateMachine dùng chung StateMachine, S, với M1M2 lần lượt là các trình tự <A, S, B><X, S, Y>. Vấn đề là S không biết nên tiếp tục với B hay Y sau khi hoàn tất và StateMachine không hoàn toàn giữ lại ngăn xếp lệnh gọi. Phần này sẽ 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

Cách này không giải quyết được sự cố ban đầu gây ra. Nó chỉ thể hiện cấu trúc 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();
  }
}

Tính năng này hoạt động ngay cả khi S là một máy trạng thái phức tạp.

Tác vụ phụ cho cấu trúc tuần tự

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

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

runAfter lần tiêm

Đôi khi, việc lạm dụng Tasks.enqueue là không thể vì có các tác vụ phụ khác hoặc lệnh gọi Tasks.lookUp phải được hoàn thành trước khi thực thi S. Trong trường hợp này, việc chèn tham số runAfter vào S có thể được dùng để thông báo cho S 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 là lợi dụng việc phụ cần làm. Tuy nhiên, việc áp dụng tuỳ chọn này cũng rất tự do, ví dụ: bằng cách lồng nhiều StateMachine với runAfter, là con đường dẫn đến Callback Hell. Thay vào đó, bạn nên chia nhỏ các runAfter tuần tự với 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);
  }

Lựa chọn thay thế cho Cấm: runAfterUnlessError

Trong một bản nháp trước đó, chúng tôi đã xem xét một runAfterUnlessError có thể huỷ bỏ lỗi sớm. Nguyên nhân là do các lỗi thường bị 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 một số lần 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 sao chép 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. Cơ chế này luôn yêu cầu kiểm tra lỗi.

Ủy quyền trực tiếp

Mỗi khi có một quá trình chuyển đổi trạng thái chính thức, vòng lặp Driver chính sẽ diễn ra. Theo hợp đồng, trạng thái tiến có nghĩa là tất cả hoạt động tra cứu và công việc phụ trên SkyValue đã được đưa vào hàng đợi trước đó sẽ phân giải trước khi trạng thái tiếp theo thực thi. Đôi khi, logic của một thực thể đại diện StateMachine khiến cho một giai đoạn tiến trình không cần thiết hoặc phản tác dụng. Ví dụ: nếu step đầu tiên của đại biểu thực hiện tra cứu SkyKey có thể song song với việc tra cứu trạng thái ủy quyền, thì việc chuyển tiếp giai đoạn sẽ làm cho các lượt tìm kiếm đó tuần tự. Có thể hợp lý hơn khi thực hiện ủy quyền trực tiếp, như được hiển thị 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 cuộc thảo luận trước đó là quản lý luồng kiểm soát. Phần này mô tả sự phổ biến của các giá trị dữ liệu.

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

Dưới đây là ví dụ về cách triển khai lệnh gọi lại Tasks.lookUp trong lệnh kiểm tra SkyValue. Phần này đưa ra lý do và đề xuất cách tiếp cận để xử lý nhiều SkyValues.

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

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

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

Bạn có thể sử dụng hàm lambda Java để triển khai phương pháp này:

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

với myValue là biến thành viên của phiên bản StateMachine đang thực hiện tra cứu. Tuy nhiên, lambda yêu cầu phân bổ bộ nhớ bổ sung 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 thông tin tra cứu không rõ ràng.

Cũng có lỗi xử lý quá tải 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.
    …
  }
}

Giống như việ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 mức phân bổ bộ nhớ cho lamba.

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à giá trị bình thường.

Sử dụng nhiều SkyValue

Bạn thường phải tra cứu nhiều giá trị SkyValue. Một cách tiếp cận hiệu quả trong phần lớn thời gian là bật loại SkyValue. Sau đây là 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ẻ quá trình 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ị là khác nhau. Nếu không phải như vậy, việc quay lại sử dụng phương thức triển khai dựa trên lambda hoặc đầy đủ các thực thể lớp bên trong sẽ triển khai các lệnh gọi lại phù hợp là khả thi.

Tuyên truyền các giá trị giữa StateMachine

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

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

Sau đó, 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ên minh họa một vài điều. Caller phải truyền lại kết quả và xác định Caller.ResultSink của riêng mình. Caller triển khai các lệnh gọi lại BarProducer.ResultSink. Khi tiếp tục, processResult sẽ kiểm tra xem value có rỗng hay không để xác định xem đã xảy ra lỗi hay chưa. Đây là dạng hành vi phổ biến sau khi chấp nhận kết quả đầu ra của việc phụ cần làm hoặc tra cứu SkyValue.

Xin lưu ý rằng việc triển khai acceptBarError một cách háo hức chuyển tiếp kết quả đến Caller.ResultSink, theo yêu cầu bằng cách Lỗi sủi bọt.

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

Xử lý lỗi

Đã có một vài ví dụ về cách xử lý lỗi trong lệnh gọi lại Tasks.lookUpTruyền giá trị giữa StateMachines. Ngoại lệ, ngoại trừ InterruptedException, sẽ không được gửi mà thay vào đó sẽ được chuyển qua các 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 đó chỉ có đúng một giá trị hoặc lỗi được truyền.

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

Lỗi sủi bọt (--nokeep_going)

Trong quá trình tạo lỗi, một SkyFunction có thể được khởi động lại ngay cả khi không phải tất cả SkyValues 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 đến do hợp đồng API Tasks. Tuy nhiên, StateMachine vẫn sẽ áp dụng ngoại lệ.

Vì việc truyền giống phải diễn 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 nội bộ, bạn có thể thực hiện việc này bằng cách gọi lệnh gọi lại của thành phần mẹ.

StateMachine cấp cao nhất, giao diện 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 kiểm tra các lỗi được truyền từ SkyFunction, ngay cả khi máy chưa xử lý xong.

Xử lý sự kiện

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

Driver và bắc cầu lên SkyFunction

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 nhiệm vụ con vào StateMachine một cách đệ quy, nên Driver duy nhất có thể quản lý nhiều nhiệm vụ con. Những việc phụ cần làm này sẽ tạo ra một cấu trúc cây, do có tính năng đồng thời có cấu trúc. Driver phân loại các lượt tìm kiếm SkyValue qua 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 StateMachine gốc làm tham số. Việc gọi Driver.drive sẽ thực thi StateMachine trong phạm vi có thể mà không cần khởi động lại Skyframe. Phương thức này trả về giá trị true khi StateMachine hoàn tất và false nếu không cho thấy rằng không phải tất cả giá trị đều 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.

Tạo trực tiếp Driver

Việc triển khai StateMachine thường truyền đạt kết quả của chúng thông qua lệnh gọi lại. Bạn có thể khởi tạo trực tiếp Driver như trong ví dụ sau.

Driver được nhúng trong quá trình triển khai SkyKeyComputeState cùng với hoạt động triển khai của ResultSink tương ứng để được xác định thêm một chút. Ở cấp cao nhất, đối tượng State là một trình 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ã bên dưới 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 giúp cho kết quả 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;
}

Đang 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ả thi khác, 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ã giống như sau (trong đó State là loại cụ thể của hàm 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 trong quá trình triển khai StateMachine phù hợp hơn với kiểu mã hoá đồng bộ của Skyframe.

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

Nếu không, sẽ 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 Trình điều khiển nhúng và giao diện với SkyFunction theo cách tương tự. Thay vì xác định ResultSink, các phương thức triển khai sẽ gọi setValue hoặc setException khi một trong hai hoạt động đó xảy ra. Khi cả hai điều này xảy ra, ngoại lệ sẽ được ưu tiên. Phương thức tryProduceValue sẽ kết nối mã gọi lại không đồng bộ với mã đồng bộ và gửi một ngoại lệ khi đã đặ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 được thực hiện vì không phải tất cả dữ liệu đầu vào đều có sẵn. Để đáp ứng yêu cầu này, tryProduceValue sẽ gửi bất kỳ trường hợp ngoại lệ nào đã đặt, ngay cả trước khi máy chạy xong.

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

StateMachine là một cách có hiệu quả cao nhưng là nguyên mẫu để thực hiện phép tính không đồng bộ. Các thành phần tiếp nối (đặc biệt là dưới dạng Runnable được truyền đến ListenableFuture) rất phổ biến trong một số phần của mã Bazel, nhưng không phổ biến trong các hàm phân tích SkyFunction. Công cụ 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ả cho I/O trên ổ đĩa. Cuối cùng, bạn nên tối ưu hoá các hàm gọi lại vì chúng có đường cong học tập và trở ngại khả năng đọc được.

Một trong những phương án 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 đồng bộ, chặn. Điều này có thể xảy ra vì việc liên kết tài nguyên chuỗi ảo, không giống như luồng nền tảng, được cho là rẻ. Tuy nhiên, ngay cả với các luồng ảo, việc thay thế các hoạt động đồng bộ đơn giản bằng các nguyên tắc tạo và đồng bộ hoá luồng cũng tốn kém. Chúng tôi đã di chuyển các luồng ảo từ StateMachine sang Java. Các lệnh này chậm hơn 3 lần, dẫn đến độ trễ phân tích hai đầu tăng gần gấp 3 lần. Vì các luồng ảo vẫn là một tính năng xem trước, nên có thể hoạt động di chuyển này sẽ được thực hiện vào một ngày sau đó khi hiệu suất được cải thiện.

Một phương pháp khác cần cân nhắc là chờ coroutine Loom nếu chúng có sẵn. Ưu điểm ở đây là bạn có thể giảm mức hao tổn đồng bộ hoá bằng cách sử dụng tính năng đa nhiệm phối hợp.

Nếu vẫn không thành công, việc viết lại mã byte cấp thấp cũng có thể là một phương án thay thế khả thi. Nếu có đủ tính năng tối ưu hoá, bạn có thể đạt được hiệu suất bằng cách sử dụng mã lệnh gọi lại được viết thủ công.

Phụ lục

Địa chỉ gọi lại

Địa chỉ gọi lại là một vấn đề khét tiếng trong mã không đồng bộ sử dụng lệnh gọi lại. Nó xuất phát từ thực tế là phần tiếp theo của bước tiếp theo được lồng trong bước trước đó. Nếu có nhiều bước, việc lồng ghép này có thể cực kỳ sâu sắc. Nếu cùng với luồng điều khiển, mã sẽ không thể quản lý được.

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 lợi thế 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 thu thập phải là dữ liệu có hiệu quả cuối cùng để việc sử dụng các biến đó có thể cồng kềnh. Bạn cần tránh lồng ghép sâu bằng cách tham chiếu đến phương thức trả về dưới dạng các giá trị tiếp theo thay vì lambda như dưới đâ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;
  }
}

Địa chỉ gọi lại cũng có thể xảy ra nếu bạn sử dụng mẫu runAfter quá nhiều, nhưng có thể tránh được điều này bằng cách chèn các thành phần xen kẽ 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 tìm kiếm SkyValue phụ thuộc, chẳng hạn như nếu SkyKey thứ hai phụ thuộc vào SkyValue đầu tiên. Suy nghĩ về vấn đề này một cách thô sơ, điều này sẽ dẫn đến một cấu trúc gọi lại phức tạp, được lồng ghép 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 giá trị tiếp tục được chỉ định làm tham chiếu phương thức, nên mã sẽ tuân thủ quy trình chuyển đổi trạng thái: step2 tuân theo step1. Xin lưu ý rằng ở đây, một lambda được dùng để gán value2. Điều này giúp thứ tự của mã khớp với thứ tự tính toán 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ì việc triển khai StateMachine.step theo thứ tự thực thi và triển khai lệnh gọi lại ngay sau khi vị trí được truyền vào mã. Điều này không phải lúc nào 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 các trường hợp như vậy.

Trong Ví dụ: Tra cứu SkyValue theo chuỗi, hệ thống sẽ tạo một tệp tham chiếu phương thức trung gian để đạt được điều này. Việc này đánh đổi một số hiệu suất nhỏ để dễ đọc, có thể đáng giá ở đây.

Giả thuyết thế hệ

Đối tượng Java tồn tại ở mức trung bình phá vỡ giả thuyết thế hệ của trình thu gom rác Java, được thiết kế để xử lý các đối tượng tồn tại trong thời gian rất ngắn hoặc các đối tượng tồn tại mãi mãi. 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 xâ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ó vòng đời trung gian khi chúng tạm ngưng, chờ các quá trình tính toán không đồng bộ hoàn tất.

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

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

Đối với các biến StateMachine, việc chia nhỏ nội dung 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 rất hữu ích. Lưu ý khi tuân thủ mẫu, chỉ các 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ả, thành phần con cái sẽ tự nhiên nằm ngoài phạm vi và đủ điều kiện sử dụng GC.

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

Trạng thái đặt tên

Khi đặt tên phương thức, thường có thể đặt tên cho một phương thức cho hành vi xảy ra trong phương thức đó. Sẽ không rõ ràng hơn về cách thực hiện việc này trong StateMachine 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, lệnh này có thể được chuyển thành trình tự trạng thái foo, theo sau là bar. foo không còn chứa hành vi bar. Do đó, tên phương thức cho các trạng thái có xu hướng hẹp hơn về phạm vi, có thể phản ánh hành vi cục bộ.

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

Sau đây là khung hiển thị thay thế của sơ đồ trong Mô hình đồng thời có cấu trúc, mô tả rõ hơn cấu trúc cây. Các khối này tạo thành một cây nhỏ.

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


  1. Ngược lại với quy ước khởi động lại ngay từ đầu khi Skyframe không có giá trị. 

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

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

  4. Nhiều lệnh gọi tasks.lookUp trong một bước được nhóm lại với nhau. Bạn có thể tạo các lô bổ sung bằng cách tra cứu xảy ra trong các tác vụ phụ đồng thời.

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

  6. Cách làm này cũng tương tự như việc tạo một luồng và tham gia luồng đó để đạt được cấu trúc tuần tự.