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. Công cụ này hỗ trợ tính linh hoạt và việc đánh giá mà không cần dự phòng1 khi các giá trị bắt buộc không có sẵn ngay mà được tính toán không đồng bộ. StateMachine
không thể liên kết 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 SkyKeyComputeState
ra mắt, cách truyền thống để xử lý quá trình khởi động lại là chạy lại toàn bộ quá trình tính toán. Mặc dù 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ỏ hầu như mọi hoạt động tính toán lại khi SkyFunction khởi động lại (giả sử SkyKeyComputeState
không bị rơi khỏi bộ nhớ đệm) bằng cách hiển thị các hook tạm ngưng và tiếp tục thực thi.
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 mang tính đệ quy có tính tò mò như sau2.
@FunctionalInterface
public interface StateMachine {
StateMachine step(Tasks tasks) throws InterruptedException;
}
Giao diện Tasks
tương tự như SkyFunction.Environment
nhưng được thiết kế để không đồng bộ và hỗ trợ thêm các tác vụ phụ đồng thời về mặt logic3.
Giá trị trả về của step
là một StateMachine
khác, cho phép 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
.
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ưng và tiế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ại và thành phần tiếp tục được gộp chung 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.getValue
và SkyFunction.Environment.getValueOrThrow
và có ngữ nghĩa xử lý ngoại lệ tương tự. Việc triển khai không thực hiện tra cứu ngay lập tức, nhưng thay vào đó, sẽ theo lô4 nhiều lượt tra cứu nhất có thể trước khi thực hiện. Giá trị này có thể không có sẵn ngay, ví dụ: yêu cầu khởi động lại Skyframe, vì vậy, phương thức gọi chỉ định việc cần làm với giá trị thu được bằng cách sử dụng lệnh gọi lại.
Bộ xử lý StateMachine
(Driver
và cầu nối với SkyFrame) đảm bảo rằng giá trị sẽ có 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 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 tiếp theo DoesLookup.processValue
bắt đầu, tất cả các lần tra cứu về DoesLookup.step
đều hoàn tất. Do đó, value
có sẵn khi được truy cập trong processValue
.
Việc phụ cần làm
Tasks.enqueue
yêu cầu thực thi 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.
Giống như lookUp
, trình điều khiển máy trạng thái đảm bảo rằng tất cả các tác vụ phụ đều hoàn tất trước khi chuyển sang bước tiếp theo. Sau đây là một ví dụ.
class Subtasks implements StateMachine {
private int i = 0;
@Override
public StateMachine step(Tasks tasks) {
tasks.enqueue(new Subtask1());
tasks.enqueue(new Subtask2());
// The next step is Subtasks.processResults. It won't be called until both
// Subtask1 and Subtask 2 are complete.
return this::processResults;
}
private StateMachine processResults(Tasks tasks) {
System.out.println(i); // Prints "3".
return DONE; // Subtasks is done.
}
private class Subtask1 implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
i += 1;
return DONE; // Subtask1 is done.
}
}
private class Subtask2 implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
i += 2;
return DONE; // Subtask2 is done.
}
}
}
Mặc dù Subtask1
và Subtask2
đồng thời về mặt logic, mọi thứ đều chạy trong một luồng duy nhất, vì vậy, bản cập nhật "đồng thời" của i
không cần phải đồng bộ hoá.
Mô hình đồng thời có cấu trúc
Vì mọi lookUp
và enqueue
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.
Rất khó để nói qua 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.
Mô hình đồng thời có cấu trúc dễ hiểu hơn nhiều.
Cấu trúc và 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;
}
…
}
Một số nhánh nhất đị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 đô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ử M1 và M2 là các thực thể StateMachine
có chung một StateMachine
, S, trong đó M1 và M2 lần lượt là các trình tự <A, S, B> và <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.
Việc phụ cần làm cho cấu trúc 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ụ con song song khác hoặc lệnh gọi Tasks.lookUp
cần phải hoàn tất trước khi thực thi S. Trong trường hợp này, bạn có thể 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
, là con đường 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);
}
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 xuất phát từ thực tế là các lỗi thường được kiểm tra hai lần, một lần bởi StateMachine
có tham chiếu runAfter
và một lần bởi chính máy runAfter
.
Sau khi cân nhắc, chúng tôi quyết định rằng tính đồng nhất của mã quan trọng hơn việc loại bỏ trùng lặp 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 hoá 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);
Bạn nên sử dụng hàm lambda Java để triển khai việc này:
tasks.lookUp(key, value -> myValue = (MyValueClass)value);
trong đó myValue
là một biến thành phần của thực thể StateMachine
đang tiến hành tra cứu. Tuy nhiên, 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à một ví dụ về cách triển khai.
class PerformLookupWithError extends StateMachine, ValueOrExceptionSink<MyException> {
private MyValue value;
private MyException error;
@Override
public StateMachine step(Tasks tasks) {
tasks.lookUp(new MyKey(), MyException.class, ValueOrExceptionSink<MyException>) this);
return this::processResult;
}
@Override
public acceptValueOrException(@Nullable SkyValue value, @Nullable MyException exception) {
if (value != null) {
this.value = (MyValue)value;
return;
}
if (exception != null) {
this.error = exception;
return;
}
throw new IllegalArgumentException("Both parameters were unexpectedly null.");
}
private StateMachine processResult(Tasks tasks) {
if (exception != null) {
// Handles the error.
…
return DONE;
}
// Processes `value`, which is non-null.
…
}
}
Tương tự như với thao tác tra cứu mà không xử lý lỗi, việc để lớp StateMachine
trực tiếp triển khai lệnh gọi lại sẽ lưu quá trình phân bổ bộ nhớ cho lambda.
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 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. Để 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ột 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ẽ háo hức chuyển tiếp kết quả đến Caller.ResultSink
, theo yêu cầu trong phần Trình bày 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 vài ví dụ về cách xử lý lỗi trong lệnh gọi lại Tasks.lookUp
và Truyề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 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 phải kiểm tra các lỗi được lan truyền từ SkyFunction, ngay cả khi máy chưa xử lý xong.
Xử lý sự kiện
Đối với các 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ý việc thực thi các StateMachine
, bắt đầu bằng StateMachine
gốc đượ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 nhiệm vụ phụ này tạo ra cấu trúc cây, do Cơ chế đồng thời có cấu trúc. Driver
thực hiện 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ể tạo thực thể trực tiếp cho Driver
như trong ví dụ sau.
Driver
được nhúng trong quá trình triển khai SkyKeyComputeState
cùng với việc triển khai ResultSink
tương ứng sẽ được xác định 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 ValueOrExceptionProducer
và ValueOrException2Producer
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 là xoá các 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. Quá trình phân tích chủ yếu bị ràng buộc bởi CPU và không có API không đồng bộ hiệu quả cho ổ đĩa I/O. Cuối cùng, bạn nên tối ưu hoá các lệnh gọi lại vì 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ế hứa hẹn nhất là luồng ảo Java. Thay vì phải viết lệnh gọi lại, mọi thứ sẽ được thay thế bằng các lệnh gọi chặn, đồng bộ. Điều này có thể xảy ra vì việc liên kết tài nguyên luồng ảo, không giống như luồng nền tảng, được cho là 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 nguyên tắc đồng bộ hoá và tạo luồng vẫn 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 cấp, 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à tính năng xem trước, nên có thể sau này bạn vẫn có thể thực hiện quá trình di chuyển này khi hiệu suất cải thiện.
Một phương pháp khác cần 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ả, 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
Địa chỉ gọi lại
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ý đượ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 ư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. Về cơ bản, điều này sẽ dẫn đến một cấu trúc lệnh gọi lại phức tạp, được lồng sâu.
private ValueType1 value1;
private ValueType2 value2;
private StateMachine step1(...) {
tasks.lookUp(key1, (Consumer<SkyValue>) this); // key1 has type KeyType1.
return this::step2;
}
@Override
public void accept(SkyValue value) {
this.value1 = (ValueType1) value;
}
private StateMachine step2(...) {
KeyType2 key2 = computeKey(value1);
tasks.lookup(key2, this::acceptValueType2);
return this::step3;
}
private void acceptValueType2(SkyValue value) {
this.value2 = (ValueType2) value;
}
Tuy nhiên, vì các 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 trên 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. Nhận xét bổ sung có thể hữu ích trong những trường hợp như vậy.
Trong Ví dụ: Hoạt động tra cứu SkyValue theo chuỗi, một thông tin tham chiếu phương thức trung gian sẽ được tạo để đạt được điều này. Đ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 đã dựng của tất cả các StateMachine
vẫn đang chạy) bị can thiệp vào hệ thống tại Driver
có thời gian hoạt động trung gian khi chúng tạm ngưng, chờ các phép tính không đồng bộ hoàn tất.
Trong JDK19, có vẻ như ít tệ hơn, nhưng khi sử dụng StateMachine
, đôi khi bạn có thể thấy thời gian GC tăng lên, ngay cả khi lượng rác thực tế được tạo ra đã giảm đáng kể. Vì StateMachine
có thời gian hoạt động trung gian, nên chúng có thể được nâng cấp lên thế hệ cũ, khiến nó lấp đầy nhanh hơn, do đó cần phải dọn dẹp các GC lớn hoặc GC đầy đủ đắt tiền hơn.
Biện pháp phòng ngừa ban đầu là giảm thiểu việc sử dụng 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ể, biến step
của ngăn xếp cục bộ là các biến tạo trẻ và là biến GC một cách hiệu quả.
Đối với các biến StateMachine
, việc chia nhỏ 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 đó. Việc xoá tham chiếu các đối tượng lớn có thể giúp ích cho bạn khi biết rằng các đối tượng đó không còn cần thiết nữa.
Đặ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 đó. Cách thực hiện việc này trong StateMachine
không rõ ràng vì không có ngăn xếp. Ví dụ: giả sử phương thức foo
gọi một phương thức con bar
. Trong StateMachine
, trạng thái này có thể được chuyển đổi sang chuỗi trạng thái foo
, theo sau là bar
. foo
không còn bao gồm hành vi
bar
. Do đó, tên phương thức cho các trạng thái có xu hướng có phạm vi hẹp hơn, có thể phản ánh hành vi cục bộ.
Sơ đồ cây đồng thời
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ỏ.
-
Trái ngược với quy ước của Skyframe về việc khởi động lại từ đầu khi không có giá trị. ↩
-
Xin lưu ý rằng
step
được phép gửiInterruptedException
, 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 đếnDriver
(sẽ được mô tả sau) để chạyStateMachine
. Bạn có thể không khai báo để gửi ngoại lệ này khi không cần thiết. ↩ -
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. ↩ -
Nhiều lệnh gọi
tasks.lookUp
trong một bước sẽ được gộp theo nhóm. Bạn có thể tạo 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. ↩ -
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. ↩
-
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ự. ↩