Hướng dẫn về Skyframe StateMachines

Báo cáo vấn đề Xem nguồn Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

Tổng quan

StateMachine Skyframe là một đối tượng hàm được phân tách nằm trên vùng nhớ heap. Thao tác này hỗ trợ việc đánh giá và linh hoạt mà không bị dư thừa1 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ể ràng buộc tài nguyên luồng trong khi chờ đợi, mà thay vào đó phải bị tạm ngưng và tiếp tục. Do đó, quá trình phân tích cấu trúc sẽ cho thấy các điểm vào lại rõ ràng để có thể bỏ qua các phép tính trước đó.

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

Giới thiệu

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

Trước khi SkyKeyComputeState ra đời, cách xử lý truyền thống khi 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 sẽ hoàn tất vì mỗi lần chạy lại, ít lượt tra cứu hơn sẽ trả về null. 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 một SkyFunction, giúp tiết kiệm đáng kể thời gian tính toán lại.

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

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

Theo quan điểm thiết kế hướng đối tượng, việc xem xét 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ý là hợp lý. 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à hoá ra nội dung này là đủ. Một StateMachine có định nghĩa sau đây, một cách đệ quy kỳ lạ2.

@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 tính không đồng bộ và hỗ trợ thêm các tác vụ phụ đồng thời một cách hợp lý 3.

Giá trị trả về của step là một StateMachine khác, cho phép chỉ định một chuỗi các bước, theo 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ả một StateMachine với đầu ra 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 và tiếp tục

Theo trực giác, việc chia nhỏ 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 hook 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 được 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ì quá trình tính toán có thể được tiếp tục chính xác tại nơi đã dừng.

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

Về mặt kỹ thuật, StateMachine đóng vai trò là một phần tiếp theo, 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. Thao tác 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 nhường quyền kiểm soát lại cho Skyframe.

Theo truyền thống, lệnh gọi lạiphần tiếp theo đượ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 này.

  • Lệnh 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ộ.
  • Continuation (Tiếp tục) – chỉ định trạng thái thực thi tiếp theo.

Bạn phải dùng lệnh gọi lại khi gọi một thao tác không đồng bộ. Điều này có nghĩa 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 các lệnh gọi lại càng đơn giản càng tốt.

Continuations 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 diễn ra 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 bạn quản lý được độ phức tạp của các lệnh gọi lại.

Tasks

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

Tra cứu SkyValue

StateMachine sử dụng Tasks.lookUp quá tải để tra cứu SkyValue. Chúng tương tự như SkyFunction.Environment.getValueSkyFunction.Environment.getValueOrThrow, đồng thời 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 đó, sẽ xử lý hàng loạt4 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 chỉ định việc cần làm với giá trị kết quả bằng cách sử dụng một lệnh gọi lại.

Trình xử lý StateMachine (Driver và cầu nối đến 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 sẽ tra cứu new Key(), truyền this làm người dùng. Điều đó 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ệnh tra cứu DoesLookup.step đều hoàn tất. Do đó, value sẽ 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 các tác vụ phụ đồng thời một cách hợp lý. Các 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 thêm các tác vụ phụ một cách đệ quy hoặc tra cứu SkyValue. 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à 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 là đồng thời về mặt logic, nhưng mọi thứ đều chạy trong một luồng duy nhất nên việc cập nhật "đồng thời" 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 đều phải phân giải trước khi chuyển sang trạng thái tiếp theo, nên điều này có nghĩa là tính đồng thời tự nhiên bị giới hạn ở các cấu trúc cây. Bạn có thể tạo mức độ đồng thời theo hệ phân cấp 5 như trong ví dụ sau.

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

Thật khó để nhận biết 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ế giúp hiển thị cấu trúc cây rõ ràng hơn.

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.

Mẫu thành phần và 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 luồng kiểm soát phổ biến và đơn giản nhất. Ví dụ về điều này được minh hoạ trong Các phép tính có 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 bằng cách sử dụ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;
  }
  
}

Một số nhánh thường trả về DONE để 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ẻ các định nghĩa StateMachine dưới dạng các tác vụ phụ đôi khi có thể gây khó khăn. Giả sử M1M2 là các thực thể StateMachine dùng chung một StateMachine, S, với M1M2 lần lượt là các chuỗi <A, S, B><X, S, Y>. Vấn đề là S không biết nên tiếp tục đến B hay Y sau khi hoàn thành và StateMachine không hoàn toàn giữ lại ngăn xếp lệnh gọi. Phần này xem xét một số kỹ thuật để đạt được mục tiêu này.

StateMachine làm phần tử chuỗi kết thúc

Cách này không giải quyết được vấn đề ban đầu. Thao tác này chỉ minh hoạ thành phần tuần tự khi StateMachine được chia sẻ là thành phần cuối cùng trong chuỗi.

// 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 hiệu quả ngay cả khi S là một máy trạng thái phức tạp.

Việc phụ cần làm cho thành phần tuần tự

Vì các tác vụ phụ trong hàng đợi được đảm bảo hoàn thành trước trạng thái tiếp theo, nên đôi khi bạn có thể hơi lạm dụng6 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 injection

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

Không được phép: runAfterUnlessError

Trong bản nháp trước đó, chúng tôi đã xem xét một runAfterUnlessError sẽ huỷ bỏ sớm khi có 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ó một 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ỏ mã 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 lên. Theo hợp đồng, các trạng thái nâng cao có nghĩa là tất cả các lệnh tìm kiếm SkyValue và các tác vụ phụ đã được xếp hàng trước đó sẽ được giải quyết trước khi trạng thái tiếp theo thực thi. Đôi khi, logic của một đại biểu StateMachine khiến việc tiến pha 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 uỷ quyền thực hiện các lệnh tra cứu SkyKey có thể được song song hoá với các lệnh tra cứu trạng thái uỷ quyền, thì việc chuyển giai đoạn sẽ khiến các lệnh tra cứu này diễn ra tuần tự. Việc thực hiện uỷ quyền trực tiếp có thể hợp lý hơn, như minh hoạ 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 đây là về việc quản lý luồng kiểm soát. Phần này mô tả quá trình 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 tra cứu SkyValue. Phần này cung cấp lý do và đề xuất các phương pháp xử lý nhiều SkyValue.

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

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

Cách tiếp cận theo đặc tính ngôn ngữ là sử dụng một lambda Java để triển khai việc này:

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

với myValue là một biến thành viên của thực thể StateMachine đang thực hiện tra cứu. 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. Hàm lambda vẫn hữu ích khi có nhiều lượt tìm kiếm 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.
    
  }
}

Tương tự như các thao tác tra cứu không có tính năng xử lý lỗi, việc có lớp StateMachine trực tiếp triển khai lệnh gọi lại sẽ giúp tiết kiệm một lượng bộ nhớ được phân bổ 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 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

Bạn thường cần tra cứu SkyValue nhiều lần. Một phương pháp thường hiệu quả là bật 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. Khi không phải như vậy, 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 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 trong một tác vụ phụ, nhưng các tác vụ phụ cũng cần báo cáo các giá trị cho phương thức gọi. Vì các tác vụ phụ là không đồng bộ về mặt logic, nên kết quả của chúng sẽ được truyền lại cho phương thức gọi bằng một lệnh gọi lại. Để hoạt động này diễn ra, việc cần làm phụ sẽ xác định một giao diện nguồn dữ liệu được truyền 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;
  }
}

Sau đó, một hàm 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 hoạ một số điều. Caller phải truyền kết quả của mình trở lại và xác định Caller.ResultSink riêng. 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ó phải là giá trị rỗng hay không để xác định xem có xảy ra lỗi hay không. Đây là một mẫu hành vi phổ biến sau khi chấp nhận đầu ra từ một nhiệm vụ phụ hoặc lệnh tra cứu SkyValue.

Xin lưu ý rằng việc triển khai acceptBarError sẽ chuyển tiếp kết quả đến Caller.ResultSink ngay lập tức, theo yêu cầu của Error bubbling (Truyền lỗi).

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

Đã có một số ví dụ về việc xử lý lỗi trong lệnh gọi lại Tasks.lookUpTruyền các giá trị giữa StateMachines. Các trường hợp ngoại lệ, ngoài InterruptedException, không được đưa ra mà thay vào đó đượ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 loại trừ lẫn nhau, với chính xác 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 truyền tải (--nokeep_going)

Trong quá trình truyền lỗi, SkyFunction có thể được khởi động lại ngay cả khi không phải tất cả SkyValue đượ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 vẫn sẽ truyền trường hợp ngoại lệ.

Vì quá trình truyền 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 nhiệm vụ này. Đối với một 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 của thành phần mẹ.

StateMachine cấp cao nhất (tương tác 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ẽ khai báo trường hợp ngoại lệ, ngay cả khi có SkyValue bị thiếu.

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

Xử lý sự kiện

Đối với những SkyFunction cần phát ra sự kiện, StoredEventHandler sẽ được chèn vào SkyKeyComputeState và được chèn thêm vào StateMachine cần đến. Trước đây, StoredEventHandler là cần thiết do Skyframe loại bỏ một số sự kiện nhất định trừ phi chúng được phát lại, nhưng sau đó vấn đề này đã được khắc phục. Hoạt động chèn StoredEventHandler được giữ lại vì hoạt động này giúp đơ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 của StateMachine, bắt đầu bằng một StateMachine gốc được chỉ định. Vì StateMachine có thể đệ quy hàng đợi StateMachine của tác vụ phụ, 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 Mô hình đồng thời có cấu trúc. Driver sẽ xử lý hàng loạt các lượt tra cứu SkyValue trên các tác vụ phụ để cải thiện hiệu suất.

Có một số lớp được xây dựng dựa trên 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 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ề true khi StateMachine hoàn tất và trả về false nếu không, cho biết không phải tất cả cá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.

Khởi tạo trực tiếp Driver

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

Driver được nhúng trong quá trình triển khai SkyKeyComputeState cùng với quá trình 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 đích nhận phù hợp cho kết quả của phép tính vì đối tượng này chắc chắn 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 kết quả một cách trì hoãn 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 phát sinh 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ư minh hoạ 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ã trông 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 quá trình triển khai StateMachine phù hợp hơn với kiểu mã hoá đồng bộ của Skyframe.

StateMachine có thể tạo ra các trường hợp ngoại lệ

Nếu không, sẽ có các lớp SkyKeyComputeState có thể nhúng ValueOrExceptionProducerValueOrException2Producer có cá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à giống hệt lớp ResultProducer trong Trình điều khiển nhúng và tương tác 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 hoạt động đó xảy ra. Khi cả hai đều 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 một mã được đặt.

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

Lời kết: Cuối cùng sẽ xoá các lệnh gọi lại

StateMachine là một cách có hiệu suất cao nhưng tốn nhiều mã lặp lại để thực hiện tính toán không đồng bộ. Các lệnh tiếp tục (đặc biệt ở dạng Runnable được truyền đến ListenableFuture) xuất hiện rộng rãi trong một số phần của mã Bazel, nhưng không phổ biến trong SkyFunction phân tích. Phân tích chủ yếu bị giới hạn về 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á để loại bỏ các lệnh gọi lại vì chúng có đường cong học tập và cản trở khả năng đọc.

Một trong những lựa chọn thay thế đầy hứa hẹn nhất là các luồng ảo Java. Thay vì phải viết các 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 một tài nguyên luồng ảo, không giống như một luồng nền tảng, được cho là không tốn kém. Tuy nhiên, ngay cả với các luồng ảo, việc thay thế các thao tác đồng bộ đơn giản bằng các nguyên tắc cơ bản về việc tạo và đồng bộ hoá luồng 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 lần, dẫn đến độ trễ phân tích đầu cuối tăng gần gấp 3 lần. Vì luồng ảo vẫn là một tính năng dùng thử, nên có thể bạn sẽ thực hiện quy 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 cách khác cần cân nhắc là chờ các coroutine Loom, nếu chúng có sẵn. Lợi thế ở đây là bạn có thể giảm mức hao tổn khi đồng bộ hoá bằng cách sử dụng cơ chế hợp tác đa nhiệm.

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

Phụ lục

Callback Hell

Callback hell (địa ngục lệnh 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. Điều này xuất phát từ việc phần tiếp tục cho 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ể rất sâu. Nếu kết hợp với luồng điều khiển, mã sẽ trở nên khó 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 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 ghi lại phải có hiệu quả cuối cùng, 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 tham chiếu phương thức dưới dạng các phần tiếp diễn thay vì các lambda như minh hoạ 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;
  }
}

Callback hell cũng có thể xảy ra nếu mẫu tiêm runAfter được sử dụng quá dày đặc, nhưng bạn có thể tránh điều này bằng cách xen kẽ các lần tiêm với các bước tuần tự.

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

Thường thì logic ứng dụng yêu cầu các chuỗi tra cứu SkyValue phụ thuộc, ví dụ: nếu SkyKey thứ hai phụ thuộc vào SkyValue đầu tiên. Nếu nghĩ một cách đơn giả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, lồng nhau.

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 lệnh tiếp tục được chỉ định dưới dạng tham chiếu phương thức, nên mã có vẻ mang tính thủ tục trong các quá trình chuyển đổi trạng thái: step2 theo sau step1. Xin 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ự tính toán từ trên xuống dưới.

Các mẹo khác

Tính dễ đọc: Thứ tự thực thi

Để cải thiện khả năng đọc, hãy cố gắng giữ các phương thức triển khai StateMachine.step theo thứ tự thực thi và các phương thức triển khai lệnh gọi lại ngay sau khi chúng đượ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. Trong những trường hợp như vậy, bạn có thể cung cấp thêm nhận xét.

Trong Ví dụ: Tra cứu SkyValue theo chuỗi, một phương thức tham chiếu trung gian được tạo để đạt được điều này. Điều này đánh đổi một chút hiệu suất để có 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 phá vỡ giả thuyết theo 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 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 được tạo của tất cả StateMachine đang chạy, bắt nguồn từ Driver có tuổi thọ trung gian khi chúng tạm ngưng, chờ các phép tính không đồng bộ hoàn tất.

Có vẻ như vấn đề này í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ó tuổi thọ trung bình nên chúng có thể được thăng cấp lên thế hệ cũ, khiến thế hệ này đầy nhanh hơn, do đó cần đến các GC chính hoặc GC đầ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 không phải lúc nào cũng khả thi, chẳng hạn như 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 của ngăn xếp cục bộ là các biến thế hệ trẻ và được 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 nhiệm vụ phụ và làm theo mẫu được đề xuất để Truyền các giá trị giữa các StateMachine cũng rất hữu ích. Lưu ý rằng khi tuân theo mẫu, chỉ các StateMachine con mới có cá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 hoàn tất và cập nhật cho cha mẹ bằng các lệnh gọi lại kết quả, trẻ em sẽ tự động thoát khỏi phạm vi và đủ điều kiện để được thu gom rác.

Cuối cùng, trong một số trường hợp, bạn cần biến StateMachine ở các trạng thái trước đó nhưng không cần ở các trạng thái sau này. Bạn nên đặt giá trị rỗng cho các tham chiếu của đối tượng lớn khi biết rằng chú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 phương thức đó theo hành vi xảy ra trong phương thức đó. Không rõ 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, điều này có thể được chuyển thành chuỗi trạng thái foo, 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 hẹp hơn về phạm vi, có khả năng phản ánh hành vi tại địa phương.

Sơ đồ cây về tính đồng thời

Sau đây là một khung hiển thị thay thế của sơ đồ trong Đồng thời có cấu trúc, mô tả rõ hơn về 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 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 truyền InterruptedException, nhưng các ví dụ bỏ qua điều này. Có một số phương thức thấp trong mã Bazel sẽ gửi ngoại lệ này và ngoại lệ này sẽ lan truyền lên Driver (sẽ được mô tả sau) để chạy StateMachine. Bạn không cần khai báo rằng phương thức này sẽ được truyền 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, việc giới thiệu sự không hiệu quả, mỗi phần phụ thuộc đều có StateMachine độc lập riêng. 

  4. Nhiều lệnh gọi tasks.lookUp trong một bước duy nhất được nhóm lại với nhau. Bạn có thể tạo thêm quy trình xử lý hàng loạt bằng cách tra cứu trong các tác vụ phụ đồng thời. 

  5. Về mặt khái niệm, điều này tương tự như tính đồ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à kết hợp luồng đó để đạt được thành phần tuần tự.