Tổng quan
Skyframe StateMachine
là một đối tượng hàm được giải mã 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 tạm ngưng và tiếp tục. Do đó, quá trình giải cấu trúc sẽ cho thấy các điểm nhập lại rõ ràng để có thể bỏ qua các phép tính trước đó.
StateMachine
có thể được dùng để biểu thị trình tự, phân nhánh, đồng thời logic có cấu trúc và được điều chỉnh riêng cho tương tác Skyframe. StateMachine
có thể được kết hợp thành các StateMachine
lớn hơn và dùng chung các StateMachine
phụ. Mô hình đồng thời luôn được phân cấp theo cấu trúc và theo logic hoàn toàn. Mọi tác vụ phụ đồng thời đều chạy trong một luồng SkyFunction mẹ 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 đánh giá của một SkyFunction có một SkyKey chỉ định các tham số của nó và SkyValue chỉ định kết quả của nó. Mô hình tính toán 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 được 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ù phương thức này có độ phức tạp bậc hai, nhưng các hàm được viết theo cách này cuối cùng sẽ hoàn thành vì mỗi lần chạy lại, số lượt tra cứu sẽ trả về null
ít hơn. Với SkyKeyComputeState
, bạn có thể liên kết dữ liệu điểm kiểm tra 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.
Các phép tính có trạng thái bên trong SkyKeyComputeState
Từ quan điểm thiết kế hướng đối tượng, bạn nên cân nhắc việc lưu trữ các đối tượng tính toán bên trong SkyKeyComputeState
thay vì các giá trị dữ liệu thuần tuý.
Trong Java, thông tin mô tả tối thiểu về hành vi mang đối tượng là một giao diện chức năng và hoá ra như vậy là đủ. StateMachine
có định nghĩa 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
.
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 một StateMachine
sẵn sàng hoặc từ bỏ quyền kiểm soát về 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 đó sau khi tất cả các phép tính không đồng bộ được giải quyết. Phương pháp có cấu trúc này giúp duy trì độ phức tạp của lệnh gọi lại.
Tasks
Giao diện Tasks
cung cấp cho 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 thuật ngữ này tương tự như SkyFunction.Environment.getValue
và SkyFunction.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 đó, 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 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ượ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à 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
đều phải phân giải trước khi chuyển sang trạng thái tiếp theo, nên điều đó có nghĩa là tính năng đồng thời bị giới hạn tất nhiên ở cấu trúc dạng cây. Bạn có thể tạo tính năng đồng thời phân cấp 5 như trong ví dụ sau.
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.
Cơ chế xử lý đồ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 điều khiển.
Trạng thái tuần tự
Đây là mẫu quy trình điều khiển phổ biến và đơn giản nhất. Ví dụ về điều này được hiển thị trong Tính toán trạng thái bên trong SkyKeyComputeState
.
Phân nhánh
Bạn có thể đạt được trạng thái phân nhánh trong StateMachine
bằng cách trả về các giá trị khác nhau 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.
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 việc chia sẻ định nghĩa StateMachine
dưới dạng tác vụ phụ đôi khi có thể gây khó khăn. 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();
}
}
API này hoạt động ngay cả khi bản thân S là một máy có 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ụ 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
, 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 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ỏ hoạt động kiểm tra lỗi trùng lặp. 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 sự chuyển đổi trạng thái chính thức, vòng lặp Driver
chính sẽ tiến triển.
Theo hợp đồng, 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
Có một ví dụ về cách triển khai lệnh gọi lại Tasks.lookUp
trong hoạt động tra cứu SkyValue. Phần này đưa ra 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ó lỗi khi xử lý lỗi quá tải của Tasks.lookUp
, tương tự như SkyFunction.Environment.getValueOrThrow
.
<E extends Exception> void lookUp(
SkyKey key, Class<E> exceptionClass, ValueOrExceptionSink<E> sink);
interface ValueOrExceptionSink<E extends Exception> {
void acceptValueOrException(@Nullable SkyValue value, @Nullable E exception);
}
Dưới đây là 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 lamba.
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 các 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;
}
}
Khi đó, phương thức gọi StateMachine
sẽ có dạng như sau.
class Caller implements StateMachine, BarProducer.ResultSink {
interface ResultSink {
void acceptCallerValue(Bar value);
void acceptCallerError(BarException error);
}
private final ResultSink sink;
private Bar value;
Caller(ResultSink sink) {
this.sink = sink;
}
@Override
@Nullable
public StateMachine step(Tasks tasks) {
tasks.enqueue(new BarProducer((BarProducer.ResultSink) this));
return this::processResult;
}
@Override
public void acceptBarValue(Bar value) {
this.value = value;
}
@Override
public void acceptBarError(BarException error) {
sink.acceptCallerError(error);
}
private StateMachine processResult(Tasks tasks) {
// Since all enqueued subtasks resolve before `processResult` starts, one of
// the `BarResultSink` callbacks must have been called by this point.
if (value == null) {
return DONE; // There was a previously reported error.
}
var finalResult = computeResult(value);
sink.acceptCallerValue(finalResult);
return DONE;
}
}
Ví dụ trước minh hoạ một vài điều. Caller
phải truyền 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 kết quả từ một tác vụ phụ hoặc thao tá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 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 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 tiếp với SkyFunction, bạn có thể thực hiện việc này bằng cách gọi phương thức setException
của ValueOrExceptionProducer
.
Sau đó, ValueOrExceptionProducer.tryProduceValue
sẽ gửi ngoại lệ, ngay cả khi thiếu SkyValues.
Nếu đang sử dụng 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 căn bậc StateMachine
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 sâu hơn 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 đưa ra ngoại lệ, thì việc nhúng Driver
là một cách triển khai khác có thể thực hiện, như trong ví dụ sau.
class ResultProducer implements StateMachine {
private final Parameters parameters;
private final Driver driver;
private ResultType result;
ResultProducer(Parameters parameters) {
this.parameters = parameters;
this.driver = new Driver(this);
}
@Nullable // Null when a Skyframe restart is needed.
public ResultType tryProduceValue( SkyFunction.Environment env)
throws InterruptedException {
if (!driver.drive(env)) {
return null;
}
return result;
}
@Override
public StateMachine step(Tasks tasks) {
… // Implementation.
}
SkyFunction có thể có mã như sau (trong đó State
là loại hàm cụ thể của SkyKeyComputeState
).
@Nullable // Null when a Skyframe restart is needed.
Result computeResult(SkyFunction.Environment env, State state)
throws InterruptedException {
if (state.result != null) {
return state.result;
}
if (state.resultProducer == null) {
state.resultProducer = new ResultProducer(new Parameters());
}
var result = state.resultProducer.tryProduceValue(env);
if (result == null) {
return null;
}
state.resultProducer = null;
return state.result = result;
}
Việc nhúng Driver
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à gần giống với lớp ResultProducer
trong phần Nhúng trình điều khiển cũng như 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 nguyên tắc đồng bộ hoá và tạo luồng vẫn quá tốn kém. Chúng tôi đã tiến hành quá trình di chuyển từ các luồng ảo StateMachine
sang luồng ảo Java. Các đơn đặt hàng này có độ lớn chậm hơn, khiến độ trễ phân tích toàn diện tăng gần gấp 3 lần. Vì luồng ảo vẫn là 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ả, 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ý đượ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à 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 đã 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ỗi lệnh gọi lại cũng có thể xảy ra nếu mẫu chèn runAfter
được sử dụng quá mật độ, nhưng bạn có thể tránh điều này bằng cách xen kẽ các thao tác chèn theo 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 hoạt động 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 thành phần tiếp tục được chỉ định làm tham chiếu phương thức, mã sẽ trông theo quy trình qua các chuyển đổi trạng thái: step2
theo sau step1
. Ở đây, xin lưu ý rằng lambda sẽ được dùng để gán cho value2
. Điều này 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ụ: 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 đượ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.
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 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ỏ mọi thứ thành các tác vụ phụ và tuân theo mẫu được đề xuất để Truyền giá trị giữa các StateMachine
cũng hữu ích. Lưu ý rằng khi làm theo mẫu, chỉ StateMachine
con mới có tham chiếu đến StateMachine
mẹ chứ không phải ngược lại. Điều này có nghĩa là khi 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, 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.
Các tiểu bang đặt tên
Khi đặt tên cho một phương thức, thông thường, bạn có thể đặt tên cho phương thức cho hành vi
xảy ra trong phương thức đó. Cách thực hiện việc này trong StateMachine
không rõ ràng vì không có ngăn xếp. Ví dụ: giả sử phương thức foo
gọi một phương thức 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 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ỏ.
-
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ị. ↩
-
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 rằng sẽ gửi dữ liệu này khi không cần thiết. ↩ -
Các nhiệm vụ phụ đồng thời được thúc đẩy bởi
ConfiguredTargetFunction
, thực hiện công việc độc lập cho từng phần phụ thuộc. Thay vì thao tác với các cấu trúc dữ liệu phức tạp xử lý 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 đượ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. ↩ -
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ự. ↩