개요
Skyframe StateMachine
는 힙에 있는 해체된 함수 객체입니다. 필수 값을 즉시 사용할 수 없지만 비동기식으로 계산되는 경우 중복 없이 유연한 평가를 지원합니다1. StateMachine
는 대기하는 동안 스레드 리소스를 연결할 수 없으며 대신 일시중지했다가 재개해야 합니다. 따라서 역해체는 이전 계산을 건너뛸 수 있도록 명시적 재진입 지점을 노출합니다.
StateMachine
는 시퀀스, 브랜치, 구조화된 논리 동시 실행을 표현하는 데 사용할 수 있으며 Skyframe 상호작용에 맞게 특별히 조정됩니다. StateMachine
는 더 큰 StateMachine
로 구성하고 하위 StateMachine
를 공유할 수 있습니다. 동시 실행은 항상 구성상 계층적이며 순전히 논리적입니다. 모든 동시 하위 태스크는 단일 공유 상위 SkyFunction 스레드에서 실행됩니다.
소개
이 섹션에서는 java.com.google.devtools.build.skyframe.state
패키지에 있는 StateMachine
의 동기를 간단히 설명하고 소개합니다.
Skyframe 재시작에 대한 간단한 소개
Skyframe은 종속 항목 그래프의 병렬 평가를 실행하는 프레임워크입니다.
그래프의 각 노드는 매개변수를 지정하는 SkyKey와 결과를 지정하는 SkyValue가 있는 SkyFunction의 평가에 해당합니다. 계산 모델은 SkyFunction이 SkyKey로 SkyValues를 조회하여 추가 SkyFunction의 재귀식 병렬 평가를 트리거할 수 있습니다. 계산의 일부 하위 그래프가 불완전하여 요청된 SkyValue가 아직 준비되지 않은 경우 스레드를 사용하는 차단 대신 요청하는 SkyFunction은 null
getValue
응답을 관찰하고 SkyValue 대신 null
를 반환하여 누락된 입력으로 인해 불완전하다는 신호를 보내야 합니다.
Skyframe은 이전에 요청된 모든 SkyValues를 사용할 수 있게 되면 SkyFunctions를 다시 시작합니다.
SkyKeyComputeState
가 도입되기 전에는 재시작을 처리하는 기존 방식은 계산을 완전히 다시 실행하는 것이었습니다. 이 방법은 이차 방정식 복잡도가 있지만, 이렇게 작성된 함수는 결국 완료됩니다. 재실행할 때마다 더 적은 조회가 null
를 반환하기 때문입니다. SkyKeyComputeState
를 사용하면 수동으로 지정된 체크포인트 데이터를 SkyFunction과 연결하여 상당한 재계산을 절약할 수 있습니다.
StateMachine
는 SkyKeyComputeState
내에 있는 객체이며, 일시중지 및 재개 실행 후크를 노출하여 SkyFunction이 다시 시작될 때 거의 모든 재계산을 제거합니다 (SkyKeyComputeState
가 캐시에서 제외되지 않는다고 가정).
SkyKeyComputeState
내의 상태ful 계산
객체 지향 설계 관점에서 보면 순수한 데이터 값 대신 계산 객체를 SkyKeyComputeState
내에 저장하는 것이 좋습니다.
Java에서 동작을 전달하는 객체의 최소한의 설명은 기능 인터페이스이며 충분합니다. StateMachine
에는 다음과 같이 흥미롭게도 재귀적인 정의가 있습니다2.
@FunctionalInterface
public interface StateMachine {
StateMachine step(Tasks tasks) throws InterruptedException;
}
Tasks
인터페이스는 SkyFunction.Environment
와 유사하지만 비동기식으로 설계되었으며 논리적으로 동시 실행되는 하위 태스크에 대한 지원을 추가합니다3.
step
의 반환 값은 다른 StateMachine
이므로 일련의 단계를 귀납적으로 지정할 수 있습니다. StateMachine
이 완료되면 step
은 DONE
을 반환합니다. 예를 들면 다음과 같습니다.
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;
}
}
다음과 같은 출력으로 StateMachine
를 설명합니다.
hello
world
step2
가 StateMachine
의 기능 인터페이스 정의를 충족하므로 메서드 참조 this::step2
도 StateMachine
입니다. 메서드 참조는 StateMachine
에서 다음 상태를 지정하는 가장 일반적인 방법입니다.
직관적으로, 계산을 모놀리식 함수 대신 StateMachine
단계로 분할하면 계산을 일시중지하고 재개하는 데 필요한 후크가 제공됩니다. StateMachine.step
가 반환되면 명시적인 일시중지 지점이 있습니다. 반환된 StateMachine
값으로 지정된 연속은 명시적 재개 지점입니다. 따라서 계산을 중단한 지점부터 다시 계산할 수 있으므로 재계산을 피할 수 있습니다.
콜백, 연속, 비동기 계산
기술적으로 StateMachine
는 실행할 후속 계산을 결정하는 연속 역할을 합니다. StateMachine
는 차단하는 대신 step
함수에서 반환하여 Driver
인스턴스로 제어를 다시 전달함으로써 자발적으로 정지할 수 있습니다. 그러면 Driver
는 준비된 StateMachine
로 전환하거나 Skyframe에 다시 제어를 넘길 수 있습니다.
기존에는 콜백과 연속이 하나의 개념으로 혼동되었습니다.
그러나 StateMachine
는 두 가지를 구분합니다.
- 콜백 - 비동기 컴퓨팅의 결과를 저장할 위치를 설명합니다.
- 계속: 다음 실행 상태를 지정합니다.
비동기 작업을 호출할 때는 콜백이 필요합니다. 즉, SkyValue 조회와 같이 메서드를 호출하는 즉시 실제 작업이 실행되지는 않습니다. 콜백은 최대한 간단하게 유지해야 합니다.
연속은 StateMachine
의 StateMachine
반환 값이며 모든 비동기 계산이 해결된 후에 이어지는 복잡한 실행을 캡슐화합니다. 이 구조화된 접근 방식은 콜백의 복잡성을 관리 가능한 수준으로 유지하는 데 도움이 됩니다.
작업
Tasks
인터페이스는 StateMachine
에 SkyKey로 SkyValues를 조회하고 동시 하위 태스크를 예약하는 API를 제공합니다.
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.
}
SkyValue 조회
StateMachine
는 Tasks.lookUp
오버로드를 사용하여 SkyValues를 조회합니다. SkyFunction.Environment.getValue
및 SkyFunction.Environment.getValueOrThrow
와 유사하며 유사한 예외 처리 시맨틱스를 갖습니다. 구현은 즉시 조회를 실행하지 않고 대신 조회를 최대한 많이 일괄 처리4한 후 실행합니다. 값을 즉시 사용할 수 없는 경우(예: Skyframe 재시작 필요) 호출자가 콜백을 사용하여 결과 값으로 할 작업을 지정합니다.
StateMachine
프로세서 (Driver
및 SkyFrame에 대한 브리징)는 다음 상태가 시작되기 전에 값을 사용할 수 있도록 보장합니다. 예시는 다음과 같습니다.
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;
}
}
위 예에서 첫 번째 단계는 this
를 소비자로 전달하여 new Key()
을 조회합니다. 이는 DoesLookup
가 Consumer<SkyValue>
를 구현하기 때문에 가능합니다.
계약에 따라 다음 상태 DoesLookup.processValue
가 시작되기 전에 DoesLookup.step
의 모든 조회가 완료됩니다. 따라서 value
는 processValue
에서 액세스할 때 사용할 수 있습니다.
하위 할 일
Tasks.enqueue
는 논리적으로 동시 실행되는 하위 태스크의 실행을 요청합니다.
하위 태스크도 StateMachine
이며 더 많은 하위 태스크를 재귀적으로 만들거나 SkyValues를 조회하는 등 일반 StateMachine
에서 할 수 있는 모든 작업을 할 수 있습니다.
lookUp
와 마찬가지로 상태 머신 드라이버는 다음 단계로 진행하기 전에 모든 하위 태스크가 완료되었는지 확인합니다. 예시는 다음과 같습니다.
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.
}
}
}
Subtask1
와 Subtask2
는 논리적으로 동시 실행되지만 모든 것이 단일 스레드에서 실행되므로 i
의 '동시' 업데이트에는 동기화가 필요하지 않습니다.
구조화된 동시 실행
모든 lookUp
및 enqueue
는 다음 상태로 진행하기 전에 확인되어야 하므로 동시 실행은 자연스럽게 트리 구조로 제한됩니다. 다음 예와 같이 계층적5 동시 실행을 만들 수 있습니다.
UML에서는 동시 실행 구조가 트리를 형성한다는 것을 알 수 없습니다. 트리 구조를 더 잘 보여주는 대체 뷰가 있습니다.
구조화된 동시 실행은 추론하기가 훨씬 쉽습니다.
컴포지션 및 제어 흐름 패턴
이 섹션에서는 여러 StateMachine
를 구성하는 방법의 예와 특정 제어 흐름 문제의 해결 방법을 보여줍니다.
순차 상태
이는 가장 일반적이고 간단한 제어 흐름 패턴입니다. 그 예는 SkyKeyComputeState
내부의 상태ful 계산에 나와 있습니다.
브랜치
다음 예와 같이 일반 Java 제어 흐름을 사용하여 서로 다른 값을 반환하면 StateMachine
의 브랜치 상태를 실행할 수 있습니다.
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;
}
…
}
조기 완료를 위해 특정 브랜치가 DONE
를 반환하는 것은 매우 일반적입니다.
고급 순차 구성
StateMachine
제어 구조는 메모리리스이므로 StateMachine
정의가 하위 태스크로 공유되는 경우 불편할 수 있습니다. M1과 M2는 StateMachine
S를 공유하는 StateMachine
인스턴스이고, M1과 M2는 각각 <A, S, B> 및 <X, S, Y> 시퀀스입니다. 문제는 S가 완료된 후 B로 계속 진행해야 할지 아니면 Y로 진행해야 할지 모르고 StateMachine
가 호출 스택을 제대로 유지하지 않는다는 점입니다. 이 섹션에서는 이를 달성하기 위한 몇 가지 기술을 검토합니다.
StateMachine
가 터미널 시퀀스 요소로 사용됨
이렇게 해도 제기된 초기 문제가 해결되지는 않습니다. 공유 StateMachine
가 시퀀스에서 터미널인 경우에만 순차 컴포지션을 보여줍니다.
// 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();
}
}
이는 S 자체가 복잡한 상태 머신인 경우에도 작동합니다.
순차 컴포지션을 위한 하위 태스크
대기열에 추가된 하위 작업은 다음 상태 전에 완료되므로 하위 작업 메커니즘을 약간 악용할 수 있습니다.6
class M1 implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
performA();
// S starts after `step` returns and by contract must complete before `doB`
// begins. It is effectively sequential, inducing the sequence < A, S, B >.
tasks.enqueue(new S());
return this::doB;
}
private StateMachine doB(Tasks tasks) {
performB();
return DONE;
}
}
class M2 implements StateMachine {
@Override
public StateMachine step(Tasks tasks) {
performX();
// Similarly, this induces the sequence < X, S, Y>.
tasks.enqueue(new S());
return this::doY;
}
private StateMachine doY(Tasks tasks) {
performY();
return DONE;
}
}
runAfter
삽입
S가 실행되기 전에 완료해야 하는 다른 동시 하위 태스크나 Tasks.lookUp
호출이 있으면 Tasks.enqueue
를 악용할 수 없는 경우가 있습니다. 이 경우 runAfter
매개변수를 S에 삽입하여 S에 다음에 실행할 작업을 알릴 수 있습니다.
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;
}
}
이 접근 방식은 하위 할 일을 악용하는 것보다 깔끔합니다. 하지만 runAfter
로 여러 StateMachine
를 중첩하는 등 너무 자유롭게 적용하면 콜백 지옥으로 이어집니다. 대신 일반 순차 상태로 순차 runAfter
를 분할하는 것이 좋습니다.
return new S(/* runAfter= */ new T(/* runAfter= */ this::nextStep))
다음으로 대체할 수 있습니다.
private StateMachine step1(Tasks tasks) {
doStep1();
return new S(/* runAfter= */ this::intermediateStep);
}
private StateMachine intermediateStep(Tasks tasks) {
return new T(/* runAfter= */ this::nextStep);
}
Forbidden 대안: runAfterUnlessError
이전 초안에서는 오류가 발생하면 조기에 중단되는 runAfterUnlessError
를 고려했습니다. 이는 오류가 종종 runAfter
참조가 있는 StateMachine
에서 한 번, runAfter
머신 자체에서 한 번씩 두 번 확인되는 경우가 많다는 사실에 기인합니다.
잠시 숙고한 후 오류 검사의 중복 삭제보다 코드의 일관성이 더 중요하다고 판단했습니다. runAfter
메커니즘이 항상 오류 검사가 필요한 tasks.enqueue
메커니즘과 일관된 방식으로 작동하지 않으면 혼란스러울 수 있습니다.
직접 위임
공식 상태 전환이 있을 때마다 기본 Driver
루프가 진행됩니다.
계약에 따라 상태를 진행하면 다음 상태가 실행되기 전에 이전에 큐에 추가된 모든 SkyValue 조회 및 하위 태스크가 해결됩니다. 위임자 StateMachine
의 로직으로 인해 위상 전진이 불필요하거나 비효율적일 때가 있습니다. 예를 들어 대리자의 첫 번째 step
가 위임 상태의 조회와 병렬화할 수 있는 SkyKey 조회를 실행하는 경우, 위상 전진을 통해 순차적으로 실행됩니다. 아래 예와 같이 직접 위임을 실행하는 것이 더 적절할 수 있습니다.
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;
}
}
데이터 흐름
이전 토론에서는 제어 흐름 관리에 중점을 두었습니다. 이 섹션에서는 데이터 값의 전파를 설명합니다.
Tasks.lookUp
콜백 구현
SkyValue 조회에서 Tasks.lookUp
콜백을 구현하는 예가 있습니다. 이 섹션에서는 여러 SkyValue를 처리하는 근거를 제공하고 접근 방식을 제안합니다.
Tasks.lookUp
콜백
Tasks.lookUp
메서드는 콜백 sink
을 매개변수로 사용합니다.
void lookUp(SkyKey key, Consumer<SkyValue> sink);
관용적인 접근 방식은 Java 람다를 사용하여 이를 구현하는 것입니다.
tasks.lookUp(key, value -> myValue = (MyValueClass)value);
여기서 myValue
는 조회를 실행하는 StateMachine
인스턴스의 멤버 변수입니다. 그러나 람다에는 StateMachine
구현에서 Consumer<SkyValue>
인터페이스를 구현하는 것보다 메모리 할당이 추가로 필요합니다. 모호할 수 있는 조회가 여러 개 있는 경우에도 람다는 유용합니다.
SkyFunction.Environment.getValueOrThrow
와 유사한 Tasks.lookUp
의 오류 처리 오버로드도 있습니다.
<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);
}
구현 예는 아래와 같습니다.
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.
…
}
}
오류 처리가 없는 조회와 마찬가지로 StateMachine
클래스가 콜백을 직접 구현하면 람바의 메모리 할당이 절약됩니다.
오류 처리에서 좀 더 자세히 설명하지만 기본적으로 오류 전파와 일반 값 전파에는 큰 차이가 없습니다.
여러 SkyValue 사용
SkyValue 조회가 여러 번 필요한 경우가 많습니다. 대부분의 경우 SkyValue 유형을 사용 설정하는 접근 방식이 효과적입니다. 다음은 프로토타입 프로덕션 코드에서 간소화된 예입니다.
@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);
}
값 유형이 다르므로 Consumer<SkyValue>
콜백 구현은 명확하게 공유할 수 있습니다. 그렇지 않은 경우 람다 기반 구현이나 적절한 콜백을 구현하는 전체 내부 클래스 인스턴스로 대체하는 것이 좋습니다.
StateMachine
간에 값 전파
지금까지 이 문서에서는 하위 태스크에서 작업을 정렬하는 방법만 설명했지만 하위 태스크도 호출자에게 값을 다시 보고해야 합니다. 하위 태스크는 논리적으로 비동기식이므로 결과는 콜백을 사용하여 호출자에게 다시 전달됩니다. 이를 위해 하위 태스크는 생성자를 통해 삽입되는 싱크 인터페이스를 정의합니다.
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;
}
}
그러면 호출자 StateMachine
는 다음과 같이 표시됩니다.
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;
}
}
위의 예는 몇 가지 사항을 보여줍니다. Caller
는 결과를 다시 전파해야 하며 자체 Caller.ResultSink
를 정의합니다. Caller
는 BarProducer.ResultSink
콜백을 구현합니다. 재개 시 processResult
는 value
가 null인지 확인하여 오류가 발생했는지 확인합니다. 이는 하위 태스크 또는 SkyValue 조회 출력을 수락한 후의 일반적인 동작 패턴입니다.
acceptBarError
의 구현은 오류 버블링에 따라 결과를 Caller.ResultSink
에 즉시 전달합니다.
최상위 StateMachine
의 대안은 Driver
및 SkyFunctions에 대한 브리징에 설명되어 있습니다.
오류 처리
Tasks.lookUp
콜백 및 StateMachines
간에 값 전파에 이미 오류 처리의 예가 몇 가지 있습니다. InterruptedException
이외의 예외는 발생하지 않고 대신 콜백을 통해 값으로 전달됩니다. 이러한 콜백은 값 또는 오류 중 정확히 하나만 전달되는 배타적 OR 시맨틱을 갖는 경우가 많습니다.
다음 섹션에서는 Skyframe 오류 처리와의 미묘하지만 중요한 상호작용을 설명합니다.
오류 버블링 (--nokeep_going)
오류 버블링 중에 요청된 모든 SkyValue를 사용할 수 없는 경우에도 SkyFunction이 다시 시작될 수 있습니다. 이 경우 Tasks
API 계약으로 인해 후속 상태에 도달하지 못합니다. 하지만 StateMachine
는 여전히 예외를 전파해야 합니다.
다음 상태에 도달했는지와 관계없이 전파가 발생해야 하므로 오류 처리 콜백이 이 작업을 실행해야 합니다. 내부 StateMachine
의 경우 상위 콜백을 호출하여 이를 실행합니다.
SkyFunction과 상호작용하는 최상위 StateMachine
에서는 ValueOrExceptionProducer
의 setException
메서드를 호출하여 이 작업을 실행할 수 있습니다.
그러면 누락된 SkyValues가 있더라도 ValueOrExceptionProducer.tryProduceValue
에서 예외가 발생합니다.
Driver
가 직접 활용되는 경우 머신이 처리를 완료하지 않았더라도 SkyFunction에서 전파된 오류를 확인하는 것이 중요합니다.
이벤트 처리
이벤트를 내보내야 하는 SkyFunctions의 경우 StoredEventHandler
가 SkyKeyComputeState에 삽입되고 필요한 StateMachine
에 추가로 삽입됩니다. 이전에는 Skyframe에서 재생되지 않는 한 특정 이벤트를 삭제하므로 StoredEventHandler
가 필요했지만 이후에 이 문제가 수정되었습니다.
StoredEventHandler
삽입은 오류 처리 콜백에서 내보낸 이벤트의 구현을 단순화하므로 보존됩니다.
Driver
및 SkyFunctions로의 브리징
Driver
는 지정된 루트 StateMachine
에서 시작하여 StateMachine
의 실행을 관리합니다. StateMachine
는 하위 태스크 StateMachine
를 재귀적으로 큐에 추가할 수 있으므로 단일 Driver
는 여러 하위 태스크를 관리할 수 있습니다. 이러한 하위 태스크는 구조화된 동시 실행의 결과로 트리 구조를 만듭니다. Driver
는 효율성을 높이기 위해 하위 태스크에서 SkyValue 조회를 일괄 처리합니다.
다음 API를 사용하여 Driver
를 중심으로 빌드된 여러 클래스가 있습니다.
public final class Driver {
public Driver(StateMachine root);
public boolean drive(SkyFunction.Environment env) throws InterruptedException;
}
Driver
는 단일 루트 StateMachine
를 매개변수로 사용합니다. Driver.drive
를 호출하면 Skyframe를 다시 시작하지 않고도 StateMachine
가 실행될 수 있는 만큼 실행됩니다. StateMachine
가 완료되면 true를 반환하고 그렇지 않으면 false를 반환하여 일부 값을 사용할 수 없음을 나타냅니다.
Driver
는 StateMachine
의 동시 실행 상태를 유지하며 SkyKeyComputeState
에 삽입하는 데 적합합니다.
Driver
직접 인스턴스화
StateMachine
구현은 일반적으로 콜백을 통해 결과를 전달합니다. 다음 예와 같이 Driver
를 직접 인스턴스화할 수 있습니다.
Driver
는 SkyKeyComputeState
구현에 삽입되며, 이에 상응하는 ResultSink
의 구현은 조금 더 아래에서 정의됩니다. 최상위 수준에서 State
객체는 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;
}
}
아래 코드는 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;
}
}
그러면 결과를 지연 컴퓨팅하는 코드는 다음과 같습니다.
@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;
}
Driver
임베딩
StateMachine
가 값을 생성하고 예외를 발생시키지 않는 경우 다음 예와 같이 Driver
를 삽입하는 것도 또 다른 구현 방법입니다.
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에는 다음과 같은 코드가 있을 수 있습니다 (여기서 State
는 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;
}
StateMachine
구현에 Driver
를 삽입하는 것이 Skyframe의 동기식 코딩 스타일에 더 적합합니다.
예외를 발생시킬 수 있는 StateMachine
그렇지 않은 경우 동기식 SkyFunction 코드와 일치하는 동기식 API가 있는 SkyKeyComputeState
삽입 가능 ValueOrExceptionProducer
및 ValueOrException2Producer
클래스가 있습니다.
ValueOrExceptionProducer
추상 클래스에는 다음 메서드가 포함되어 있습니다.
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. }
}
삽입된 Driver
인스턴스를 포함하며 드라이버 삽입의 ResultProducer
클래스와 매우 유사하며 유사한 방식으로 SkyFunction과 상호작용합니다. 구현은 ResultSink
를 정의하는 대신 둘 중 하나가 발생할 때 setValue
또는 setException
를 호출합니다.
둘 다 발생하면 예외가 우선 적용됩니다. tryProduceValue
메서드는 비동기 콜백 코드를 동기식 코드로 연결하고 동기식 코드가 설정되면 예외를 발생시킵니다.
앞서 언급한 대로 오류 버블링 중에 일부 입력이 사용 가능하지 않으므로 머신이 아직 완료되지 않았더라도 오류가 발생할 수 있습니다. 이를 수용하기 위해 tryProduceValue
는 머신이 완료되기 전에 설정된 예외를 발생시킵니다.
마무리 문구: 콜백 삭제
StateMachine
는 비동기 컴퓨팅을 실행하는 데 매우 효율적이지만 템플릿이 많이 사용되는 방법입니다. 연속 (특히 ListenableFuture
에 전달된 Runnable
형식)은 Bazel 코드의 특정 부분에 널리 사용되지만 분석 SkyFunctions에서는 널리 사용되지 않습니다. 분석은 대부분 CPU에 종속되며 디스크 I/O를 위한 효율적인 비동기 API는 없습니다. 결국 콜백은 학습 곡선이 있고 가독성을 저해하므로 최적화하는 것이 좋습니다.
가장 유망한 대안 중 하나는 Java 가상 스레드입니다. 콜백을 작성하는 대신 모든 것이 동기식 차단 호출로 대체됩니다. 이는 플랫폼 스레드와 달리 가상 스레드 리소스를 연결하는 것이 저렴하기 때문에 가능합니다. 그러나 가상 스레드가 있더라도 간단한 동기 작업을 스레드 생성 및 동기화 원시 요소로 대체하는 것은 비용이 너무 많이 듭니다. StateMachine
에서 Java 가상 스레드로 이전했는데 속도가 훨씬 느려 엔드 투 엔드 분석 지연 시간이 거의 3배 증가했습니다. 가상 스레드는 아직 미리보기 기능이므로 성능이 개선될 때 나중에 이 이전을 실행할 수도 있습니다.
고려할 수 있는 또 다른 접근 방식은 Loom 코루틴을 사용할 수 있게 되면 기다리는 것입니다. 여기서 이 방법의 장점은 협력적 멀티태스킹을 사용하여 동기화 오버헤드를 줄일 수 있다는 것입니다.
다른 방법이 모두 실패하면 하위 수준 바이트 코드 재작성도 실행 가능한 대안이 될 수 있습니다. 충분히 최적화하면 수동으로 작성된 콜백 코드에 가까운 성능을 얻을 수 있습니다.
부록
콜백 지옥
콜백 지옥은 콜백을 사용하는 비동기 코드에서 악명 높은 문제입니다. 이는 후속 단계의 연속이 이전 단계 내에 중첩되어 있기 때문입니다. 단계가 많으면 중첩이 매우 깊어질 수 있습니다. 제어 흐름과 결합하면 코드를 관리할 수 없게 됩니다.
class CallbackHell implements StateMachine {
@Override
public StateMachine step(Tasks task) {
doA();
return (t, l) -> {
doB();
return (t1, l2) -> {
doC();
return DONE;
};
};
}
}
중첩된 구현의 한 가지 이점은 외부 단계의 스택 프레임을 보존할 수 있다는 것입니다. Java에서는 캡처된 람다 변수가 사실상 최종 변수여야 하므로 이러한 변수를 사용하는 것이 번거로울 수 있습니다. 다음과 같이 메서드 참조를 람다 대신 연속으로 반환하여 깊은 중첩을 방지합니다.
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;
}
}
runAfter
삽입 패턴이 너무 빽빽하게 사용되는 경우에도 콜백 지옥이 발생할 수 있지만, 삽입을 순차 단계와 섞어 사용하면 이를 방지할 수 있습니다.
예: 체이닝된 SkyValue 조회
애플리케이션 로직에 SkyValue 조회의 종속 체인이 필요한 경우가 많습니다(예: 두 번째 SkyKey가 첫 번째 SkyValue에 종속되는 경우). 단순히 생각해 보면 깊이 중첩된 복잡한 콜백 구조가 만들어집니다.
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;
}
그러나 연속은 메서드 참조로 지정되므로 코드는 상태 전환 전반에서 절차적으로 보입니다. step2
가 step1
뒤에 오는 것을 볼 수 있습니다. 여기서는 람다를 사용하여 value2
를 할당합니다. 이렇게 하면 코드의 순서가 위에서 아래로 계산의 순서와 일치합니다.
기타 팁
가독성: 실행 순서
가독성을 높이려면 StateMachine.step
구현을 실행 순서대로 유지하고 코드에서 전달된 위치 바로 뒤에 콜백 구현을 유지하세요. 제어 흐름이 분기되는 경우에는 항상 가능하지는 않습니다. 이 경우 추가 주석이 도움이 될 수 있습니다.
예: 체이닝된 SkyValue 조회에서는 이를 위해 중간 메서드 참조가 생성됩니다. 이렇게 하면 가독성을 위해 약간의 성능이 저하되지만 여기서는 그만한 가치가 있습니다.
세대 가설
중간 수명 Java 객체는 매우 짧은 시간 동안 지속되는 객체 또는 영구적으로 지속되는 객체를 처리하도록 설계된 Java 가비지 컬렉터의 세대 가설을 위반합니다. 정의상 SkyKeyComputeState
의 객체는 이 가설을 위반합니다. 이러한 객체는 여전히 실행 중인 모든 StateMachine
의 생성된 트리를 포함하며 Driver
에 루팅됩니다. 비동기 계산이 완료될 때까지 대기하면서 일시중지되므로 중간 수명 주기를 갖습니다.
JDK19에서는 그다지 나쁘지 않은 것 같지만 StateMachine
를 사용할 때는 생성된 실제 가비지가 크게 감소하더라도 GC 시간이 증가하는 경우가 있습니다. StateMachine
는 중간 수명주기를 가지므로 이전 세대로 승격되어 더 빨리 채워지므로 정리하는 데 더 비용이 많이 드는 전체 GC 또는 전체 GC가 필요합니다.
초기 예방 조치는 StateMachine
변수 사용을 최소화하는 것이지만, 여러 상태에서 값이 필요한 경우와 같이 항상 실행 가능한 것은 아닙니다. 가능한 경우 로컬 스택 step
변수는 초기 세대 변수이며 효율적으로 GC됩니다.
StateMachine
변수의 경우 작업을 하위 태스크로 분할하고 StateMachine
간에 값 전파에 권장되는 패턴을 따르는 것도 유용합니다. 패턴을 따를 때는 하위 StateMachine
에만 상위 StateMachine
에 대한 참조가 있고 그 반대는 성립하지 않습니다. 즉, 하위 요소가 결과 콜백을 사용하여 상위 요소를 완료하고 업데이트하면 하위 요소가 자연스럽게 범위에서 벗어나 GC 대상이 됩니다.
마지막으로, StateMachine
변수가 이전 상태에서는 필요하지만 이후 상태에서는 필요하지 않은 경우도 있습니다. 더 이상 필요하지 않은 것으로 확인되면 대규모 객체의 참조를 null로 설정하는 것이 좋습니다.
상태 이름 지정
메서드 이름을 지정할 때는 일반적으로 해당 메서드 내에서 발생하는 동작의 이름을 지정할 수 있습니다. 스택이 없으므로 StateMachine
에서 이를 실행하는 방법은 덜 명확합니다. 예를 들어 foo
메서드가 하위 메서드 bar
를 호출한다고 가정해 보겠습니다. StateMachine
에서는 이를 상태 시퀀스 foo
(bar
뒤에 오는)로 변환할 수 있습니다. foo
에는 더 이상 bar
동작이 포함되지 않습니다. 따라서 상태의 메서드 이름은 범위가 더 좁아서 로컬 동작을 반영할 수 있습니다.
동시 실행 트리 다이어그램
다음은 트리 구조를 더 잘 보여주는 구조적 동시 실행의 다이어그램을 다른 방식으로 본 것입니다. 블록이 작은 나무를 형성합니다.
-
값을 사용할 수 없을 때 처음부터 다시 시작하는 Skyframe의 관례와는 대조적입니다. ↩
-
step
는InterruptedException
을 발생시킬 수 있지만 예에서는 이를 생략합니다. Bazel 코드에는 이 예외를 발생시키는 몇 가지 낮은 수준의 메서드가 있으며, 나중에 설명할StateMachine
를 실행하는Driver
까지 전파됩니다. 필요하지 않은 경우 발생하도록 선언하지 않아도 됩니다. ↩ -
동시 하위 태스크는 각 종속 항목에 대해 독립적인 작업을 실행하는
ConfiguredTargetFunction
에서 비롯되었습니다. 모든 종속 항목을 한 번에 처리하는 복잡한 데이터 구조를 조작하여 비효율성을 도입하는 대신 각 종속 항목에는 자체 독립적인StateMachine
가 있습니다. ↩ -
단일 단계 내의 여러
tasks.lookUp
호출이 함께 일괄 처리됩니다. 동시 하위 태스크 내에서 발생하는 조회를 통해 추가 일괄 처리를 만들 수 있습니다. ↩ -
이렇게 하면 스레드를 생성하고 조인하여 순차 컴포지션을 실행하는 것과 유사합니다. ↩