Hướng dẫn về Skyframe StateMachines

Báo cáo vấn đề Xem nguồn Nightly · 7.4 . 7.3 · 7.2 · 7.1 · 7.0 · 6.5

Tổng quan

StateMachine Skyframe là một đối tượng-hàm đã được phân tích cú pháp nằm trên vùng nhớ khối xếp. Phương thứ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 lập tức nhưng được tính toán không đồng bộ. StateMachine không thể liên kế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 phân tích cú pháp 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ể dùng StateMachine để thể hiện trình tự, phân nhánh, đồng thời có cấu trúc logic và được điều chỉnh riêng cho hoạt động tương tác với Skyframe. Bạn có thể kết hợp StateMachine thành StateMachine lớn hơn và chia sẻ StateMachine con. Tính đồng thời luôn có phân cấp theo cấu trúc và hoàn toàn hợp lý. Mọi tác vụ phụ đồng thời đều chạy trong một luồng SkyFunction mẹ dùng chung.

Giới thiệu

Phần này trình bày ngắn gọn về lý do và giới thiệu 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à 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 việc đánh giá một SkyFunction có SkyKey chỉ định các tham số và SkyValue chỉ định kết quả. Mô hình tính toán là một SkyFunction có thể tra cứu SkyValues theo SkyKey, kích hoạt quá trình đánh giá song song, đệ quy của các SkyFunction bổ sung. Thay vì chặn, thao tác 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, SkyFunction yêu cầu sẽ quan sát phản hồi null getValue và sẽ trả về null thay vì SkyValue, cho biết rằng nó chưa hoàn tất do thiếu dữ liệu đầu vào. Skyframe khởi động lại SkyFunctions khi tất cả SkyValues đã yêu cầu trước đó đều có sẵn.

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

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

Tính toán 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 tính toán bên trong SkyKeyComputeState thay vì các giá trị dữ liệu thuần tuý. Trong Java, nội dung mô tả tối thiểu về một đối tượng mang hành vi là một giao diện chức năng và điều này là đủ. StateMachine có định nghĩa lồng nhau sau đây2.

@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 chỉ định một trình tự các bước một cách 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

Xin lưu ý rằng 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 một 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 trình bổ trợ cần thiết để tạm ngưngtiếp tục một phép tính. Khi StateMachine.step trả về, sẽ có một điểm tạm ngưng rõ ràng. Phần tiếp tục do giá trị StateMachine trả về chỉ định là một điểm tiếp tục rõ ràng. Do đó, bạn có thể tránh được việc tính toán lại vì có thể tiếp tục tính toán từ chính xác vị trí đã dừng.

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

Về mặt kỹ thuật, StateMachine đóng vai trò là tiếp tục, 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 tạm ngưng bằng cách trả về từ hàm step, hàm này sẽ chuyển quyền kiểm soát trở lại một thực thể Driver. Sau đó, Driver có thể chuyển sang StateMachine đã sẵn sàng hoặc trả lại quyền kiểm soát cho Skyframe.

Theo truyền thống, lệnh gọi lạitiếp tục được gộ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 này.

  • Lệnh gọi lại – mô tả vị trí 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.

Bạn 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 lập tức khi gọi phương thức, như trong trường hợp tra cứu SkyValue. Bạn nên giữ cho lệnh gọi lại càng đơn giản càng tốt.

Tiếp tục là các giá trị trả về StateMachine của StateMachine và đóng gói quá trình 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 có cấu trúc này giúp giảm độ phức tạp của lệnh gọi lại.

Tasks

Giao diện Tasks cung cấp cho StateMachine một API để tra cứu SkyValues theo 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.
}

Truy vấn SkyValue

StateMachine sử dụng các phương thức nạp chồng Tasks.lookUp để tra cứu SkyValues. Các lớp này tương tự như SkyFunction.Environment.getValueSkyFunction.Environment.getValueOrThrow và có ngữ nghĩa xử lý ngoại lệ tương tự. Quá trình triển khai không thực hiện ngay việc tra cứu, mà thay vào đó, các lô4 nhiều lượt tra cứu nhất có thể trước khi thực hiện. Giá trị có thể không có sẵn ngay lập tức, ví dụ: yêu cầu 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ằng cách sử dụng lệnh gọi lại.

Bộ xử lý StateMachine (Driver và cầu nối đến SkyFrame) đảm bảo rằng giá trị này có sẵn trước khi trạng thái tiếp theo bắt đầu. Sau đây là 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ẽ tra cứu new Key(), truyền this làm đối tượng sử dụng. Đ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 tiếp theo DoesLookup.processValue bắt đầu, tất cả các lượt tra cứu của DoesLookup.step đều hoàn tất. Do đó, value sẽ có sẵn khi truy cập trong 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. Tác vụ phụ cũng là StateMachine và có thể làm mọi việc mà StateMachine thông thường có thể làm, bao gồm cả việc tạo đệ quy các tác vụ phụ khác hoặc tra cứu SkyValues. Tương tự 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à 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 chạy đồng thời về mặt logic, nhưng mọi thứ đều chạy trong một luồng duy nhất, vì vậy, việc cập nhật "đồng thời" của i không cần bất kỳ hoạt động đồng bộ hoá nào.

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

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

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

Khó có thể nhận biết được từ UML rằng cấu trúc đồng thời tạo thành một cây. 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

Cơ chế xử lý đồng thời có cấu trúc dễ hiểu hơn nhiều.

Cấu trúc và các mẫu kiểm soát luồng

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 kiểm soát nhất định.

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ề điều này được hiển thị trong Tính toán trạng thái bên trong SkyKeyComputeState.

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 bằng 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;
  }
  …
}

Rất thường xảy ra trường hợp một số nhánh nhất định trả về DONE để hoàn thành sớm.

Cấu trúc tuần tự nâng cao

Vì cấu trúc điều khiển StateMachine không có bộ nhớ, nên đôi khi việc chia sẻ định nghĩa StateMachine dưới dạng tác vụ phụ có thể gây khó khăn. Giả sử M1M2 là các thực thể StateMachine có chung một StateMachine, S, trong đó 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 liệu có tiếp tục đến B hay Y sau khi hoàn tất hay không và StateMachine không giữ nguyên 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

Cách này không giải quyết được vấn đề ban đầu. Mã 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();
  }
}

Điều này vẫn hoạt động ngay cả khi S chính là một máy trạng thái phức tạp.

Tác vụ phụ cho thành phần kết hợp tuần tự

Vì các tác vụ phụ đã thêm vào hàng đợi được đảm bảo hoàn tất 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ế 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;
  }
}

Chèn runAfter

Đôi khi, bạn không thể lạm dụng Tasks.enqueue vì có các tác vụ phụ song song khác hoặc lệnh gọi Tasks.lookUp phải được hoàn tất trước khi S thực thi. Trong trường hợp này, bạn có thể 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 việc cần làm phụ. Tuy nhiên, việc áp dụng quá nhiều tính năng này, chẳng hạn như bằng cách lồng nhiều StateMachine với runAfter, sẽ dẫn đến Callback Hell (Cuộc gọi lại địa ngục). Tốt hơn là bạn nên chia 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);
  }

Phương án thay thế bị cấm: runAfterUnlessError

Trong bản nháp trước, chúng ta đã xem xét một runAfterUnlessError sẽ huỷ sớm khi gặp lỗi. Điều này là do 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 trong quá 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, cơ chế này luôn yêu cầu kiểm tra lỗi.

Uỷ 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ẽ tiến triển. Theo hợp đồng, trạng thái tiến lên có nghĩa là tất cả các lượt tra cứu SkyValue và tác vụ phụ đã 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 một StateMachine uỷ quyền khiến việc chuyển pha trở nên 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 tượng uỷ quyền thực hiện các lượt tra cứu SkyKey có thể được song song với các lượt tra cứu của trạng thái uỷ quyền, thì việc chuyển pha sẽ làm cho các lượt tra cứu này tuần tự. Bạn nên thực hiện việc uỷ quyền trực tiếp, như trong ví dụ dưới đây.

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 điều khiển. Phần này mô tả việc truyền giá trị dữ liệu.

Triển khai 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 tìm kiếm SkyValue. Phần này đưa ra lý do và đề xuất các phương pháp xử lý nhiều SkyValues.

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

Phương pháp thông thường là sử dụng hàm lambda Java để triển khai việc này:

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

trong đó myValue là biến thành viên của thực thể StateMachine thực hiện truy vấn. Tuy nhiên, 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. Lambda vẫn hữu ích khi có nhiều lượt tra cứu sẽ không rõ ràng.

Ngoài ra, còn có các phương thức nạp chồng xử lý lỗ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à 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.
    …
  }
}

Cũng như với các lượt tra cứu không có tính năng xử lý lỗi, việc lớp StateMachine trực tiếp triển khai lệnh gọi lại sẽ tiết kiệm được một lượt phân bổ bộ nhớ cho lambda.

Xử lý lỗi cung cấp thêm một chút thông tin chi tiết, nhưng về cơ bản, không có nhiều sự 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 SkyValues

Thông thường, bạn cần thực hiện nhiều lượt tra cứu SkyValue. Một phương pháp hoạt động hiệu quả trong nhiều trường hợp là bật loại SkyValue. Sau đây là ví dụ được đơn giản hoá từ mã phát hành 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ẻ cách 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, bạn có thể quay lại các 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 đủ triển khai các lệnh gọi lại thích hợp.

Truyền giá trị giữa các 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 trong một tác vụ phụ, nhưng các tác vụ phụ cũng cần báo cáo giá trị trở lại cho phương thức gọi. Vì các tác vụ phụ về mặt logic là không đồng bộ, nên kết quả của các tác vụ phụ này đượ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. Để làm cho việc này hoạt động, tác vụ phụ xác định một giao diện bồn lưu trữ được chèn thông qua hàm khởi tạo của giao diệ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;
  }
}

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ước minh hoạ 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 có lỗi xảy ra hay không. Đây là mẫu hành vi phổ biến sau khi chấp nhận đầu ra từ một tác vụ phụ hoặc tra cứu SkyValue.

Xin lưu ý rằng việc triển khai acceptBarError sẽ chuyển tiếp nhanh kết quả đến Caller.ResultSink, như yêu cầu của tính năng Bong bóng lỗi.

Các phương á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

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. Các ngoại lệ, ngoài InterruptedException, không được gửi mà được truyền qua các lệnh gọi lại dưới dạng giá trị. Các lệnh gọi lại như vậy thường có ngữ nghĩa độc quyền hoặc, với chỉ một trong các giá trị hoặc lỗi được truyền.

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

Truyền lỗi lên trên (--nokeep_going)

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

Vì quá trình truyền phải xảy ra bất kể trạng thái tiếp theo có được đạt đến hay không, nên lệnh gọi lại xử lý lỗi phải thực hiện nhiệm vụ này. Đối với StateMachine bên trong, bạn có thể 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 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 có SkyValues bị thiếu.

Nếu đang sử dụng trực tiếp Driver, bạn cần kiểm tra 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 các SkyFunction cần phát sự kiện, StoredEventHandler sẽ được chèn vào SkyKeyComputeState và được chèn thêm vào các StateMachine yêu cầu sự kiện. Trước đây, StoredEventHandler là cần thiết do Skyframe bỏ qua một số sự kiện nhất định trừ khi chúng được phát lại, nhưng vấn đề này đã được khắc phục sau đó. Tính năng chèn StoredEventHandler được giữ nguyên vì tính năng này đơn giản hoá việc triển khai các sự kiện phát ra từ 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ý quá trình thực thi StateMachine, bắt đầu bằng một StateMachine gốc đã chỉ định. Vì StateMachine có thể đưa StateMachine tác vụ phụ vào hàng đợi đệ quy, nên một Driver có thể quản lý nhiều tác vụ phụ. Các tác vụ phụ này tạo ra một cấu trúc cây, là kết quả của Concurrency có cấu trúc. Driver thực hiện các lượt tra cứu SkyValue theo lô trên 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.

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

Driver lấy một StateMachine gốc duy nhất làm tham số. Việc gọi Driver.drive sẽ thực thi StateMachine ở mức tối đa mà không cần khởi động lại Skyframe. Hàm này trả về true khi StateMachine hoàn tất và false nếu không, cho biết 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 vào SkyKeyComputeState.

Tạo bản sao trực tiếp Driver

Các phương thức triển khai StateMachine thường thông báo kết quả thông qua các lệnh gọi lại. Bạn có thể trực tiếp tạo bản sao 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 thêm một chút. Ở cấp cao nhất, đối tượng State là một trình thu thích hợp cho kết quả của phép tính vì đối tượng này được đảm bảo 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 kết quả một cách lười biếng 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 có ngoại lệ nào, thì việc nhúng Driver là một cách triển khai 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ã 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 trong quá trình triển khai StateMachine phù hợp hơn với kiểu lập trình đồng bộ của Skyframe.

StateMachines có thể tạo ra các 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à rất 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 hoạt động triển khai sẽ gọi setValue hoặc setException khi một trong hai sự kiện đó xảy ra. Khi cả hai điều kiện xảy ra, ngoại lệ sẽ được ưu tiên. Phương thức tryProduceValue 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 được đặt.

Như đã lưu ý trước đó, trong quá trình tạo bọt 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. Để phù hợp với điều này, tryProduceValue sẽ gửi mọi ngoại lệ đã đặt, ngay cả trước khi máy hoàn tất.

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

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

Một trong những giải pháp thay thế đầy 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 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 luồng ảo, không giống như luồng nền tảng, được cho là rẻ. 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 các thao tác tạo luồng và đồng bộ hoá nguyên gốc cũng quá tốn kém. Chúng tôi đã di chuyển từ StateMachine sang các luồng ảo Java và các luồng này chậm hơn nhiều, dẫn đế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à một tính năng xem trước, nên bạn có thể thực hiện quá trình di chuyển này vào một ngày khác 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 có. Ưu điểm ở đây là bạn có thể giảm chi phí đồng bộ hoá bằng cách sử dụng tính năng đa nhiệm hợp tác.

Nếu tất cả các giải pháp khác không hiệu quả, 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 đủ tính năng tối ưu hoá, bạn có thể đạt được hiệu suất gần bằng mã gọi lại viết tay.

Phụ lục

Lệnh gọi lại địa ngục

Lệnh gọi lại 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 bắt nguồn từ việc việc tiếp tục cho một 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. Nếu kết hợp với luồng điều khiển, mã sẽ trở nên 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à khung ngăn xếp của bước bên ngoài có thể được giữ nguyên. Trong Java, các biến lambda đã thu thập phải là biến cuối cùng hiệu quả, vì vậy, việc sử dụng các biến như vậy có thể gây phiền toái. Bạn có thể tránh việc lồng 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ư sau.

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ệnh gọi lại địa ngục cũng có thể xảy ra nếu bạn sử dụng quá nhiều mẫu chèn runAfter, nhưng bạn có thể tránh điều này bằng cách xen kẽ các lệnh chèn với các bước tuần tự.

Ví dụ: Truy vấn 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 lượt tra cứu SkyValue, ví dụ: nếu SkyKey thứ hai phụ thuộc vào SkyValue đầu tiên. Nếu suy nghĩ đơn giản, điều này sẽ dẫn đến một cấu trúc gọi lại phức tạp, lồng nhau 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 phần tiếp tục được chỉ định là tham chiếu phương thức, nên mã sẽ có dạng quy trình trong các quá trình chuyển đổi trạng thái: step2 theo sau step1. Lưu ý rằng ở đây, một lambda được dùng để chỉ định value2. Điều này giúp 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ì 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 các lệnh này được truyền trong mã. Điều này không phải lúc nào cũng có thể thực hiện được khi luồng điều khiển phân nhánh. Trong những trường hợp như vậy, bạn có thể thêm nhận xét để giúp ích.

Trong Ví dụ: Truy vấn SkyValue theo chuỗi, một tham chiếu phương thức trung gian sẽ được tạo để thực hiện việc này. Điều này đánh đổi một chút hiệu suất để tăng khả năng đọc, điều này có thể đáng giá ở đây.

Giả thuyết về thế hệ

Các đối tượng Java có thời gian tồn tại trung bình sẽ phá vỡ giả thuyết về thế hệ của trình thu gom rác Java. Trình thu gom rác này được thiết kế để xử lý các đối tượng tồn tại trong một khoảng 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 được tạo của tất cả StateMachine vẫn đang chạy, bắt nguồn từ Driver có thời gian tồn tại trung gian khi tạm ngưng, chờ các phép tính không đồng bộ hoàn tất.

Tình trạng này có vẻ ít nghiêm trọng hơn trong JDK19, nhưng khi sử dụng StateMachine, đôi khi bạn có thể nhận 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 tồn tại trung gian nên chúng có thể được quảng bá lên thế hệ cũ, khiến nó bị lấp đầy nhanh hơn, do đó cần phải có GC lớn hoặc đầy đủ tốn kém 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 đ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ị trên nhiều trạng thái. Nếu có thể, các biến step trong ngăn xếp cục bộ là các biến thế hệ mới và được GC một cách hiệu quả.

Đối với các biến StateMachine, việc chia nhỏ thành các nhiệm vụ phụ và làm theo mẫu được đề xuất để Truyền giá trị giữa các StateMachine cũng rất 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 các phần tử con hoàn tất và cập nhật phần tử mẹ bằng lệnh gọi lại kết quả, các phần tử con sẽ tự nhiên nằm ngoài phạm vi và đủ điều kiện để GC.

Cuối cùng, trong một số trường hợp, bạn 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 đó. Bạn có thể đặt giá trị rỗng cho các tệp tham chiếu của các đối tượng lớn khi biết rằng các đối tượng đó không còn cần thiết nữa.

Đặt tên trạng thái

Khi đặt tên cho một phương thức, bạn 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 đó. Bạn sẽ khó hiểu 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 con bar. Trong StateMachine, bạn có thể dịch mã này thành trình tự trạng thái foo, theo sau là bar. foo không còn bao gồm hành vi bar. Do đó, tên phương thức cho các trạng thái thườ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

Dưới đây là một chế độ xem thay thế của sơ đồ trong Concurrency có cấu trúc mô tả rõ hơn cấu trúc cây. Các khối tạo thành một cây nhỏ.

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


  1. Trái ngược với quy ước của Skyframe là khởi động lại từ đầu khi 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 điều này. Có một vài phương thức cấp thấp trong mã Bazel gửi ngoại lệ này và ngoại lệ này sẽ truyền đến Driver (sẽ được mô tả sau) để chạy StateMachine. Bạn có thể không khai báo để gửi ngoại lệ này khi không cần thiết. 

  3. Các tác 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ý tất cả các phần phụ thuộc cùng một lúc, gây ra tình trạng kém 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 được nhóm lại với nhau. Bạn có thể tạo thêm lô bằng các lượt tra cứu xảy ra trong các tác vụ phụ đồng thời. 

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

  6. Việc này tương tự như việc tạo một luồng và tham gia luồng đó để đạt được thành phần tuần tự.