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 bị tạm ngưng và tiếp tục. Do đó, quá trình phân tích cú pháp sẽ hiển thị các điểm truy cập lại rõ ràng để có thể bỏ qua các phép tính trước đó.
Bạn có thể dùng StateMachine
để thể hiện trình tự, phân nhánh, đồng thời có cấu trúc logic và được điều chỉnh riêng cho hoạt động tương tác với Skyframe. Bạn có thể kết hợp StateMachine
thành StateMachine
lớn hơn và chia sẻ StateMachine
con. Tính đồng thời luôn có phân cấp theo cấu trúc và hoàn toàn hợp lý. Mọi tác vụ phụ đồng thời đều chạy trong một luồng SkyFunction mẹ dùng chung.
Giới thiệu
Phần này trình bày ngắn gọn về lý do và giới thiệu StateMachine
có trong gói java.com.google.devtools.build.skyframe.state
.
Giới thiệu ngắn gọn về việc khởi động lại Skyframe
Skyframe là một khung thực hiện việc đánh giá song song các biểu đồ phần phụ thuộc.
Mỗi nút trong biểu đồ tương ứng với việc đánh giá một SkyFunction có SkyKey chỉ định các tham số và SkyValue chỉ định kết quả. Mô hình tính toán là một SkyFunction có thể tra cứu SkyValues theo SkyKey, kích hoạt quá trình đánh giá song song, đệ quy của các SkyFunction bổ sung. Thay vì chặn, thao tác này sẽ liên kết một luồng, khi một SkyValue được yêu cầu chưa sẵn sàng vì một số đồ thị con của phép tính chưa hoàn tất, SkyFunction yêu cầu sẽ quan sát phản hồi null
getValue
và sẽ trả về null
thay vì SkyValue, cho biết rằng nó chưa hoàn tất do thiếu dữ liệu đầu vào.
Skyframe khởi động lại SkyFunctions khi tất cả SkyValues đã yêu cầu trước đó đều có sẵn.
Trước khi giới thiệu SkyKeyComputeState
, cách xử lý truyền thống để khởi động lại là chạy lại toàn bộ quá trình tính toán. Mặc dù 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ỏ gần như mọi hoạt động tính toán lại khi SkyFunction khởi động lại (giả sử rằng SkyKeyComputeState
không bị loại khỏi bộ nhớ đệm) bằng cách hiển thị các móc thực thi tạm ngưng và tiếp tục.
Tính toán trạng thái bên trong SkyKeyComputeState
Từ quan điểm thiết kế hướng đối tượng, bạn nên cân nhắc việc lưu trữ các đối tượng điện 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 quy cách trình tự các bước theo quy tắc. 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 nhỏ quá trình tính toán thành các bước StateMachine
, thay vì hàm nguyên khối, sẽ cung cấp các nội dung hook cần thiết để tạm ngưng và tiếp tục tính toán. Khi StateMachine.step
trả về, sẽ có một điểm tạm ngưng rõ ràng. Việc tiếp tục được chỉ định bởi giá trị StateMachine
được trả về là một điểm tiếp tục rõ ràng. Do đó, bạn có thể tránh đượ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, các đoạn tiếp tục và việc 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 quay lại từ hàm step
. Hàm này sẽ chuyển quyền kiểm soát về lại một thực thể Driver
. Sau đó, Driver
có thể chuyển sang StateMachine
đã sẵn sàng hoặc trả lại quyền kiểm soát cho Skyframe.
Theo truyền thống, lệnh gọi lại và tiếp tục được gộp thành một khái niệm.
Tuy nhiên, StateMachine
vẫn duy trì sự khác biệt giữa hai loại này.
- Lệnh gọi lại – mô tả vị trí lưu trữ kết quả của một phép tính không đồng bộ.
- Tiếp tục – chỉ định trạng thái thực thi tiếp theo.
Bạn cần có lệnh gọi lại khi gọi một thao tác không đồng bộ, tức là thao tác thực tế không xảy ra ngay lập tức khi gọi phương thức, như trong trường hợp tra cứu SkyValue. Bạn nên giữ cho lệnh gọi lại càng đơn giản càng tốt.
Tiếp tục là các giá trị trả về StateMachine
của StateMachine
và đóng gói quá trình thực thi phức tạp sau khi tất cả các phép tính không đồng bộ được phân giải. Phương pháp có cấu trúc này giúp giảm độ phức tạp của lệnh gọi lại.
Tasks
Giao diện Tasks
cung cấp cho StateMachine
một API để tra cứu SkyValues theo SkyKey và lên lịch các tác vụ phụ đồng thời.
interface Tasks {
void enqueue(StateMachine subtask);
void lookUp(SkyKey key, Consumer<SkyValue> sink);
<E extends Exception>
void lookUp(SkyKey key, Class<E> exceptionClass, ValueOrExceptionSink<E> sink);
// lookUp overloads for 2 and 3 exception types exist, but are elided here.
}
Truy vấn SkyValue
StateMachine
sử dụng các phương thức nạp chồng Tasks.lookUp
để tra cứu SkyValues. Các lớp này tương tự như SkyFunction.Environment.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 đế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
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 nhiều tác vụ phụ đồng thời về mặt logic.
Tác vụ phụ cũng là StateMachine
và có thể làm mọi việc mà StateMachine
thông thường có thể làm, bao gồm cả việc tạo đệ quy các tác vụ phụ khác hoặc tra cứu SkyValues.
Tương tự như lookUp
, trình điều khiển máy trạng thái đảm bảo rằng tất cả các tác vụ phụ đều hoàn tất trước khi chuyển sang bước tiếp theo. Sau đây là ví dụ.
class Subtasks implements StateMachine {
private int i = 0;
@Override
public StateMachine step(Tasks tasks) {
tasks.enqueue(new Subtask1());
tasks.enqueue(new Subtask2());
// The next step is Subtasks.processResults. It won't be called until both
// Subtask1 and Subtask 2 are complete.
return this::processResults;
}
private StateMachine processResults(Tasks tasks) {
System.out.println(i); // Prints "3".
return DONE; // Subtasks is done.
}
private class Subtask1 implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
i += 1;
return DONE; // Subtask1 is done.
}
}
private class Subtask2 implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
i += 2;
return DONE; // Subtask2 is done.
}
}
}
Mặc dù Subtask1
và Subtask2
chạy đồng thời về mặt logic, nhưng mọi thứ đều chạy trong một luồng duy nhất, vì vậy, việc cập nhật "đồng thời" của i
không cần bất kỳ hoạt động đồng bộ hoá nào.
Mô hình đồng thời có cấu trúc
Vì mọi 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 mô hình đồng thời 5 phân cấp 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 thông qua luồng điều khiển Java thông thường, như trong ví dụ sau.
class Branch implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
// Returns different state machines, depending on condition.
if (shouldUseA()) {
return this::performA;
}
return this::performB;
}
…
}
Thông thường, một số nhánh sẽ trả về DONE
trong trường hợp hoàn thành sớm.
Cấu trúc tuần tự nâng cao
Vì cấu trúc điều khiển StateMachine
không có bộ nhớ, nên đôi khi việc chia sẻ định nghĩa StateMachine
dưới dạng tác vụ phụ có thể gây khó khăn. Giả sử 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
Điều này không giải quyết được vấn đề ban đầu đã đặt ra. Mã này chỉ minh hoạ thành phần kết hợp tuần tự khi StateMachine
dùng chung là thiết bị đầu cuối trong trình tự.
// S is the shared state machine.
class S implements StateMachine { … }
class M1 implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
performA();
return new S();
}
}
class M2 implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
performX();
return new S();
}
}
Điều này vẫn hoạt động ngay cả khi S chính là một máy trạng thái phức tạp.
Tác vụ phụ cho thành phần kết hợp tuần tự
Vì các tác vụ phụ trong hàng đợi được đảm bảo sẽ hoàn thành 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ế phụ nhiệm.
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);
}
Phương án thay thế bị cấm: runAfterUnlessError
Trong một bản nháp trước đó, chúng tôi từng xem xét một runAfterUnlessError
sẽ sớm huỷ bỏ 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ỏ 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 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, các trạng thái tiến bộ có nghĩa là tất cả các hoạt động tra cứu và tác vụ phụ SkyValue được 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ả cách 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 cơ sở và đề xuất cách tiếp cận để xử lý nhiều SkyValue.
Lệnh gọi lại Tasks.lookUp
Phương thức Tasks.lookUp
lấy một lệnh gọi lại, sink
, làm tham số.
void lookUp(SkyKey key, Consumer<SkyValue> sink);
Phương pháp thông thường là sử dụng hàm lambda Java để triển khai việc này:
tasks.lookUp(key, value -> myValue = (MyValueClass)value);
trong đó myValue
là biến thành viên của thực thể StateMachine
thực hiện truy vấn. Tuy nhiên, hàm lambda yêu cầu phân bổ thêm bộ nhớ so với việc triển khai giao diện Consumer<SkyValue>
trong quá trình triển khai StateMachine
. Lambda vẫn hữu ích khi có nhiều lượt tra cứu sẽ không rõ ràng.
Ngoài ra, còn có các phương thức nạp chồng xử lý lỗi của Tasks.lookUp
, tương tự như SkyFunction.Environment.getValueOrThrow
.
<E extends Exception> void lookUp(
SkyKey key, Class<E> exceptionClass, ValueOrExceptionSink<E> sink);
interface ValueOrExceptionSink<E extends Exception> {
void acceptValueOrException(@Nullable SkyValue value, @Nullable E exception);
}
Dưới đây là ví dụ về cách triển khai.
class PerformLookupWithError extends StateMachine, ValueOrExceptionSink<MyException> {
private MyValue value;
private MyException error;
@Override
public StateMachine step(Tasks tasks) {
tasks.lookUp(new MyKey(), MyException.class, ValueOrExceptionSink<MyException>) this);
return this::processResult;
}
@Override
public acceptValueOrException(@Nullable SkyValue value, @Nullable MyException exception) {
if (value != null) {
this.value = (MyValue)value;
return;
}
if (exception != null) {
this.error = exception;
return;
}
throw new IllegalArgumentException("Both parameters were unexpectedly null.");
}
private StateMachine processResult(Tasks tasks) {
if (exception != null) {
// Handles the error.
…
return DONE;
}
// Processes `value`, which is non-null.
…
}
}
Cũng như với các lượt tra cứu không có tính năng xử lý lỗi, việc lớp StateMachine
trực tiếp triển khai lệnh gọi lại sẽ tiết kiệm được một lượt phân bổ bộ nhớ cho 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 SkyValue
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 phải như vậy, bạn có thể quay lại 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 đủ có triển khai các phương thức gọi lại phù 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ộ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ẽ chuyển tiếp nhanh kết quả đến Caller.ResultSink
, như yêu cầu của tính năng Bong bóng lỗi.
Các phương án thay thế cho StateMachine
cấp cao nhất được mô tả trong Driver
và
cầu nối đến SkyFunctions.
Xử lý lỗi
Có một vài ví dụ về cách xử lý lỗi trong lệnh gọi lại Tasks.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ị. Những lệnh gọi lại như vậy thường có ngữ nghĩa độc quyền hoặc ngữ nghĩa, trong đó truyền chính xác một giá trị hoặc lỗi.
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ì việc truyền phải xảy ra bất kể trạng thái tiếp theo có đạt được hay không, nên lệnh gọi lại xử lý lỗi phải thực hiện tác vụ này. Đối với StateMachine
bên trong, bạn có thể thực hiện việc này bằng cách gọi lệnh gọi lại mẹ.
Ở StateMachine
cấp cao nhất, giao diện với SkyFunction, bạn có thể thực hiện việc này bằng cách gọi phương thức setException
của ValueOrExceptionProducer
.
Sau đó, ValueOrExceptionProducer.tryProduceValue
sẽ gửi ngoại lệ, ngay cả khi có SkyValues bị thiếu.
Nếu đang sử dụng trực tiếp Driver
, bạn cần 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ý quá trình thực thi StateMachine
, bắt đầu bằng một StateMachine
gốc đã chỉ định. Vì StateMachine
có thể xếp các tác vụ phụ StateMachine
vào hàng đợi theo cách đệ 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 việc tra cứu SkyValue theo hàng loạt các tác vụ phụ để cải thiện hiệu quả.
Có một số lớp được xây dựng xung quanh Driver
, với API sau đây.
public final class Driver {
public Driver(StateMachine root);
public boolean drive(SkyFunction.Environment env) throws InterruptedException;
}
Driver
lấy một 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. Phương thức này sẽ trả về giá trị true khi StateMachine
hoàn tất và trả về giá trị false, nếu không thì trả về giá trị đúng, cho biết rằng không phải giá trị nào cũng 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
.
Trực tiếp tạo thực thể Driver
Các phương thức triển khai StateMachine
thường thông báo kết quả thông qua các lệnh gọi lại. Bạn có thể trực tiếp tạo bản sao Driver
như trong ví dụ sau.
Driver
được nhúng trong quá trình triển khai SkyKeyComputeState
cùng với việc triển khai ResultSink
tương ứng sẽ được xác định thêm một chút. Ở cấp cao nhất, đối tượng State
là một trình thu thích hợp cho kết quả của phép tính vì đối tượng này được đảm bảo tồn tại lâu hơn Driver
.
class State implements SkyKeyComputeState, ResultProducer.ResultSink {
// The `Driver` instance, containing the full tree of all `StateMachine`
// states. Responsible for calling `StateMachine.step` implementations when
// asynchronous values are available and performing batched SkyFrame lookups.
//
// Non-null while `result` is being computed.
private Driver resultProducer;
// Variable for storing the result of the `StateMachine`
//
// Will be non-null after the computation completes.
//
private ResultType result;
// Implements `ResultProducer.ResultSink`.
//
// `ResultProducer` propagates its final value through a callback that is
// implemented here.
@Override
public void acceptResult(ResultType result) {
this.result = result;
}
}
Mã bên dưới phác thảo ResultProducer
.
class ResultProducer implements StateMachine {
interface ResultSink {
void acceptResult(ResultType value);
}
private final Parameters parameters;
private final ResultSink sink;
… // Other internal state.
ResultProducer(Parameters parameters, ResultSink sink) {
this.parameters = parameters;
this.sink = sink;
}
@Override
public StateMachine step(Tasks tasks) {
… // Implementation.
return this::complete;
}
private StateMachine complete(Tasks tasks) {
sink.acceptResult(getResult());
return DONE;
}
}
Sau đó, mã để tính toán từng phần kết quả 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
cầu nối mã gọi lại không đồng bộ với mã đồng bộ và gửi một trường hợp ngoại lệ khi mã đượ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. Để đáp ứng điều này, tryProduceValue
sẽ gửi mọi trường hợp 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 thành phần tiếp tục (đặc biệt là ở dạng Runnable
được truyền đến ListenableFuture
) phổ biến trong một số phần của mã Bazel, nhưng không phổ biến trong các tính năng phân tích SkyFunctions. 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 các thao tác tạo luồng và đồng bộ hoá nguyên gốc cũng quá tốn kém. Chúng tôi đã di chuyển từ StateMachine
sang các luồng ảo Java và các luồng này chậm hơn nhiều 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à 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 xem xét là chờ coroutine Loom (nếu có). Ưu điểm ở đây là có thể giảm chi phí đồng bộ hoá bằng cách sử dụng đa nhiệm phối hợp.
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 đủ mức tối ưu hoá, bạn có thể đạt được hiệu suất tiếp cận mã gọi lại được viết thủ công.
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 nhau này có thể cực kỳ sâu. Nếu đi kèm với quy trình điều khiển, thì mã sẽ trở thành không thể quản lý.
class CallbackHell implements StateMachine {
@Override
public StateMachine step(Tasks task) {
doA();
return (t, l) -> {
doB();
return (t1, l2) -> {
doC();
return DONE;
};
};
}
}
Một trong những ưu điểm của việc triển khai lồng nhau là khung ngăn xếp của bước bên ngoài có thể được giữ nguyên. Trong Java, các biến lambda được ghi lại phải là biến cuối cùng một cách hiệu quả. Vì vậy, việc sử dụng các biến như vậy có thể rườm rà. 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ụ: Tra cứu SkyValue theo chuỗi
Thông thường, logic ứng dụng yêu cầu các chuỗi phụ thuộc của lượt tra cứu SkyValue, ví dụ: nếu SkyKey thứ hai phụ thuộc vào SkyValue đầu tiên. Nếu suy nghĩ đơn giản, điều này sẽ dẫn đến một cấu trúc gọi lại phức tạp, lồng nhau sâu.
private ValueType1 value1;
private ValueType2 value2;
private StateMachine step1(...) {
tasks.lookUp(key1, (Consumer<SkyValue>) this); // key1 has type KeyType1.
return this::step2;
}
@Override
public void accept(SkyValue value) {
this.value1 = (ValueType1) value;
}
private StateMachine step2(...) {
KeyType2 key2 = computeKey(value1);
tasks.lookup(key2, this::acceptValueType2);
return this::step3;
}
private void acceptValueType2(SkyValue value) {
this.value2 = (ValueType2) value;
}
Tuy nhiên, vì các 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
. 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ụ: Truy vấn SkyValue theo chuỗi, một tham chiếu phương thức trung gian sẽ được tạo để thực hiện việc này. Điều này đánh đổi một chút hiệu suất để tăng khả năng đọc, điều này có thể đáng giá ở đây.
Giả thuyết về thế hệ
Các đối tượng Java tồn tại trung bình phá vỡ giả thuyết tạo sinh của trình thu gom rác Java. Trình thu thập này được thiết kế để xử lý các đối tượng chỉ tồn tại trong một 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 tồn tại trung gian nên chúng có thể được quảng bá lên thế hệ cũ, khiến nó bị lấp đầy nhanh hơn, do đó cần phải có GC lớn hoặc đầy đủ tốn kém hơn để dọn dẹp.
Biện pháp phòng ngừa ban đầu là giảm thiểu việc sử dụng các biến StateMachine
, nhưng điều này không phải lúc nào cũng khả thi, ví dụ: nếu cần một giá trị trên nhiều trạng thái. Nếu có thể, các biến step
trong ngăn xếp cục bộ là các biến thế hệ mới và được GC một cách hiệu quả.
Đối với các biến StateMachine
, việc chia nhỏ thành các nhiệm vụ phụ và làm theo mẫu được đề xuất để Truyền giá trị giữa các StateMachine
cũng rất hữu ích. Lưu ý rằng khi làm theo mẫu, chỉ StateMachine
con mới có tham chiếu đến StateMachine
mẹ chứ không phải ngược lại. Điều này có nghĩa là khi các phần tử con hoàn tất và cập nhật phần tử mẹ bằng lệnh gọi lại kết quả, các phần tử con sẽ tự nhiên nằm ngoài phạm vi và đủ điều kiện để GC.
Cuối cùng, trong một số trường hợp, 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, bạn thường có thể đặt tên cho một phương thức cho hành vi xảy ra trong phương thức đó. Bạn sẽ khó hiểu hơn về cách thực hiện việc này trong StateMachine
vì không có ngăn xếp. Ví dụ: giả sử phương thức foo
gọi một phương thức con bar
. Trong StateMachine
, 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
nữa. 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 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 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 lô bổ sung bằng các hoạt động tra cứu diễn ra trong các tác vụ phụ đồng thời. ↩ -
Về mặt lý thuyết, tính năng này tương tự như jeps/428 của tính năng đồng thời có cấu trúc của Java. ↩
-
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ự. ↩