Skyframe StateMachines 가이드

문제 신고 소스 보기

개요

Skyframe StateMachine는 힙에 있는 해체 함수 객체입니다. 필수 값이 즉시 제공되지 않고 비동기식으로 계산되는 경우 중복성 없는 유연한 평가1를 지원합니다. StateMachine는 기다리는 동안 스레드 리소스를 묶을 수 없지만 대신 정지하고 다시 시작해야 합니다. 따라서 해체는 명시적인 재진입 지점을 노출하므로 이전 계산을 건너뛸 수 있습니다.

StateMachine은 시퀀스, 분기, 구조화된 논리적 동시 실행을 표현하는 데 사용할 수 있으며 Skyframe 상호작용에 맞게 맞춤설정될 수 있습니다. StateMachine은 더 큰 StateMachine으로 구성되고 하위 StateMachine를 공유할 수 있습니다. 동시 실행은 항상 생성에 따라 계층화되고 순수하게 논리적입니다. 모든 동시 하위 태스크는 단일 공유 상위 SkyFunction 스레드에서 실행됩니다.

소개

이 섹션에서는 java.com.google.devtools.build.skyframe.state 패키지에 있는 StateMachine를 간략하게 소개하고 소개합니다.

Skyframe 다시 시작에 관한 간략한 소개

Skyframe은 종속 항목 그래프를 병렬로 평가하는 프레임워크입니다. 그래프의 각 노드는 매개변수를 지정하는 SkyKey, 결과를 지정하는 SkyValue로 SkyFunction 평가에 대응합니다. 계산 모델은 SkyFunction에서 SkyValue로 SkyValues를 조회하여 추가 SkyFunction에 대한 재귀적, 병렬 평가를 트리거하는 것과 같습니다. 일부 하위 계산이 불완전하여 요청한 SkyValue가 아직 준비되지 않은 경우 스레드를 묶는 대신 요청하는 스카이 기능이 null getValue 응답을 관측하고 SkyValue 대신 null를 반환하여 입력 누락으로 인해 불완전하다는 것을 나타내야 합니다. 이전에 요청된 모든 SkyValue를 사용할 수 있게 되면 Skyframe이 SkyFunctions를 다시 시작합니다.

SkyKeyComputeState를 도입하기 전에 다시 시작을 처리하는 전통적인 방법은 계산을 완전히 다시 실행하는 것이었습니다. 이는 2차 복잡도를 하지만, 이러한 방식으로 작성된 함수는 각 재실행의 최소 조회 수가 null를 반환하므로 결국 완료됩니다. SkyKeyComputeState를 사용하면 직접 지정한 체크포인트 데이터를 SkyFunction과 연결하여 상당한 계산을 절약할 수 있습니다.

StateMachineSkyKeyComputeState 내부에 상주하며 정지 및 재개 실행 후크를 노출하여 SkyFunction이 다시 시작될 때 (SkyKeyComputeState가 캐시에서 사라지지 않는다고 가정) 모든 재계산을 제거하는 객체입니다.

SkyKeyComputeState 내부의 스테이트풀(Stateful) 계산

객체 지향 설계 관점에서 순수한 데이터 값 대신 SkyKeyComputeState 내에 계산 객체를 저장하는 것이 좋습니다. 자바에서는 객체를 전달하는 동작에 대한 최소한의 설명이 기능 인터페이스만 있으면 충분합니다. StateMachine에는 다음과 같은 재귀적 정의가 있습니다2.

@FunctionalInterface
public interface StateMachine {
  StateMachine step(Tasks tasks) throws InterruptedException;
}

Tasks 인터페이스는 SkyFunction.Environment과 유사하지만 비동기용으로 설계되었으며 논리적인 동시 태스크 하위 지원을 추가합니다.3.

step의 반환 값은 또 다른 StateMachine이며 단계 시퀀스 지정이 유도됩니다. StateMachine가 완료되면 stepDONE를 반환합니다. 예를 들면 다음과 같습니다.

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

메서드 참조 this::step2StateMachine입니다. step2StateMachine의 기능 인터페이스 정의를 충족하기 때문입니다. 메서드 참조는 StateMachine에서 다음 상태를 지정하는 가장 일반적인 방법입니다.

정지 및 재개

직관적으로 계산을 모놀리식 함수 대신 StateMachine 단계로 분할하여 계산을 정지하고 재개하는 데 필요한 후크를 제공합니다. StateMachine.step가 반환되면 명시적인 정지 지점이 있는 것입니다. 반환된 StateMachine 값으로 지정된 연속값은 명시적인 재개 지점입니다. 따라서 컴퓨팅은 정확히 중단한 지점부터 다시 시작할 수 있기 때문에 피할 수 있습니다.

콜백, 연속, 비동기 계산

기술적으로 StateMachine연속 역할을 하여 실행할 후속 계산을 결정합니다. StateMachine는 차단하는 대신 step 함수에서 반환하여 자발적으로 정지할 수 있습니다. 그러면 함수가 다시 Driver 인스턴스로 전송됩니다. 그러면 Driver가 준비된 StateMachine로 전환하거나 컨트롤을 다시 Skyframe으로 이전할 수 있습니다.

일반적으로 콜백연속은 하나의 개념으로 구성됩니다. 그러나 StateMachine는 둘을 구별합니다.

  • Callback - 비동기 계산 결과를 저장할 위치를 설명합니다.
  • 연속 - 다음 실행 상태를 지정합니다.

콜백은 비동기 작업을 호출할 때 필요합니다. 즉, SkyValue 조회의 경우와 같이 메서드를 호출하는 즉시 실제 작업이 발생하지 않습니다. 콜백은 최대한 단순하게 유지해야 합니다.

연속StateMachineStateMachine 반환 값이며 모든 비동기 계산이 완료된 후 이어지는 복잡한 실행을 캡슐화합니다. 이 구조화된 접근 방식은 콜백의 복잡성을 관리하는 데 도움이 됩니다.

태스크

Tasks 인터페이스는 StateMachine에 SkyKeys를 사용하여 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 조회

StateMachineTasks.lookUp 오버로드를 사용하여 SkyValues를 조회합니다. SkyFunction.Environment.getValueSkyFunction.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;
  }
}

위의 예에서 첫 번째 단계는 new Key()를 조회하여 this를 소비자로 전달합니다. DoesLookupConsumer<SkyValue>를 구현하므로 가능합니다.

계약에 따라 다음 상태 DoesLookup.processValue가 시작되기 전에 DoesLookup.step의 모든 조회가 완료됩니다. 따라서 valueprocessValue에서 액세스할 때 사용할 수 있습니다.

하위 할 일

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.
    }
  }
}

Subtask1Subtask2는 논리적으로 동시 실행되지만 모든 작업이 단일 스레드에서 실행되므로 i의 '동시' 업데이트에는 동기화가 필요하지 않습니다.

구조화된 동시 실행

모든 lookUpenqueue는 다음 상태로 넘어가기 전에 확인되어야 하므로 동시 실행이 자연스럽게 트리 구조로 제한됩니다. 다음 예와 같이 계층적 5 동시 실행을 만들 수 있습니다.

구조화된 동시 실행

UML에서 동시 실행 구조가 트리를 형성한다는 것을 알기가 어렵습니다. 트리 구조를 더 잘 보여주는 대체 뷰가 있습니다.

구조화되지 않은 동시 실행

구조화된 동시 실행은 추론하기 훨씬 쉽습니다.

구성 및 제어 흐름 패턴

이 섹션에서는 여러 StateMachine를 구성하는 방법에 관한 예와 특정 제어 흐름 문제의 해결 방법을 보여줍니다.

순차적 상태

이는 가장 일반적이고 간단한 제어 흐름 패턴입니다. 이에 관한 예는 SkyKeyComputeState 내의 스테이트풀(Stateful) 계산에 나와 있습니다.

브랜치

다음 예와 같이 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 정의를 하위 태스크로 공유하는 것이 어색할 수 있습니다. M1M2StateMachine, S를 공유하는 StateMachine 인스턴스이고, M1M2를 각각 <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;
  }
}

이 접근 방식은 하위 할 일을 악용하는 것보다 더 명확합니다. 그러나 예를 들어 여러 StateMachinerunAfter로 중첩하여 이 방식을 너무 자유롭게 적용하면 Callback Hell로 연결됩니다. 대신 순차적 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);
  }

금지됨 대체 방법: 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);

관용적인 접근 방식은 자바 람다를 사용하여 이를 구현하는 것입니다.

  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을 정의해야 합니다. CallerBarProducer.ResultSink 콜백을 구현합니다. 재개 시 processResultvalue가 null인지 확인하여 오류가 발생했는지 확인합니다. 이는 하위 태스크 또는 SkyValue 조회의 출력을 허용한 후 일반적인 동작 패턴입니다.

acceptBarError 구현은 오류 버블링에서 요구하는 대로 결과를 Caller.ResultSink에 빠르게 전달합니다.

최상위 StateMachine의 대안은 Driver 및 SkyFunctions로 연결에 설명되어 있습니다.

오류 처리

이미 Tasks.lookUp 콜백StateMachines 간 값 전파에 있는 오류 처리의 몇 가지 예시가 있습니다. InterruptedException 이외의 예외는 발생하지 않으며 대신 콜백을 통해 값으로 전달됩니다. 이러한 콜백에는 독점적 또는 의미 체계가 있는 경우가 많으며 정확히 하나의 값 또는 오류만 전달됩니다.

다음 섹션에서는 미묘하지만 중요한 Skyframe 오류 처리 상호작용을 설명합니다.

버블링 (--nokeep_going)

오류 버블링 중에 요청된 모든 SkyValue를 사용할 수 없더라도 SkyFunction이 다시 시작될 수 있습니다. 이러한 경우 Tasks API 계약으로 인해 후속 상태에 도달할 수 없습니다. 그러나 StateMachine는 여전히 예외를 전파해야 합니다.

전파는 다음 상태에 도달했는지에 관계없이 이루어져야 하므로 오류 처리 콜백은 이 태스크를 수행해야 합니다. 내부 StateMachine의 경우 상위 콜백을 호출하면 됩니다.

SkyFunction과 상호작용하는 최상위 StateMachine에서 ValueOrExceptionProducersetException 메서드를 호출하면 됩니다. 그러면 SkyValues가 누락되더라도 ValueOrExceptionProducer.tryProduceValue에서 예외가 발생합니다.

Driver를 직접 활용하는 경우 머신의 처리가 완료되지 않았더라도 SkyFunction에서 전파된 오류가 있는지 확인해야 합니다.

이벤트 처리

이벤트를 내보내야 하는 SkyFunction의 경우 StoredEventHandler가 SkyKeyComputeState에 삽입되고 이를 요구하는 StateMachine에 추가로 삽입됩니다. 이전에는 StoredEventHandler이(가) 다시 재생되지 않는 한 특정 이벤트를 드롭하는 Skyframe으로 인해 필요했지만 이 문제는 수정되었습니다. 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를 호출하면 스카이프레임 다시 시작 없이 진행할 수 있는 한 StateMachine가 실행됩니다. StateMachine가 완료되면 true를 반환하고 그렇지 않으면 false를 반환하여 일부 값을 사용할 수 없음을 나타냅니다.

DriverStateMachine의 동시 상태를 유지하며 SkyKeyComputeState에 삽입하기에 적합합니다.

Driver 직접 인스턴스화

StateMachine 구현은 일반적으로 콜백을 통해 결과를 전달합니다. 다음 예와 같이 Driver를 직접 인스턴스화할 수 있습니다.

Driver는 상응하는 ResultSink의 구현과 함께 좀 더 아래쪽에 정의되도록 SkyKeyComputeState 구현에 삽입됩니다. 최상위 수준에서 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-삽입 가능한 ValueOrExceptionProducerValueOrException2Producer 클래스가 있습니다.

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는 없습니다. 콜백은 학습 곡선이 있고 가독성을 저해하므로 결국에는 콜백을 최적화하는 것이 좋습니다.

가장 좋은 대안은 자바 가상 스레드입니다. 콜백을 작성하는 대신 모든 것이 동기식 차단 호출로 대체됩니다. 이는 플랫폼 스레드와 달리 가상 스레드 리소스를 사용하는 것이 비용이 적게 들기 때문에 가능합니다. 그러나 가상 스레드의 경우에도 단순한 동기 작업을 스레드 생성 및 동기화 프리미티브로 교체하려면 너무 많은 비용이 듭니다. StateMachine에서 자바 가상 스레드로의 마이그레이션을 실행했더니 훨씬 느리게 실행되어 엔드 투 엔드 분석 지연 시간이 거의 3배 증가했습니다. 가상 스레드는 여전히 미리보기 기능이므로 성능이 개선되면 나중에 이 이전을 실행할 수 있습니다.

고려해야 할 또 다른 접근 방식은 Loom 코루틴을 사용할 수 있게 될 때 기다리는 것입니다. 여기에서 이점은 공동작업 멀티태스킹을 사용하여 동기화 오버헤드를 줄일 수 있다는 것입니다.

다른 모든 방법이 실패하면 하위 수준의 바이트 코드 재작성도 실행 가능한 대안이 될 수 있습니다. 최적화가 충분하면 필기 입력 콜백 코드에 접근하는 성능을 얻을 수 있습니다.

부록

콜백 지옥

콜백 지옥은 콜백을 사용하는 비동기 코드에서 악명 높은 문제입니다. 이는 후속 단계의 연속이 이전 단계 내에 중첩되어 있기 때문입니다. 단계가 많으면 이 중첩은 매우 깊을 수 있습니다. 제어 흐름과 결합되면 코드를 관리할 수 없게 됩니다.

class CallbackHell implements StateMachine {
  @Override
  public StateMachine step(Tasks task) {
    doA();
    return (t, l) -> {
      doB();
      return (t1, l2) -> {
        doC();
        return DONE;
      };
    };
  }
}

중첩 구현의 장점 중 하나는 외부 단계의 스택 프레임을 보존할 수 있다는 것입니다. 자바에서 캡처된 람다 변수는 사실상 최종 버전이어야 하므로 이러한 변수를 사용하는 것이 번거로울 수 있습니다. 딥 중첩은 다음과 같이 람다 대신 연속으로 메서드 참조를 반환하여 피합니다.

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 조회

예를 들어 두 번째 SkyKey가 첫 번째 SkyValue에 종속되는 경우와 같이 애플리케이션 로직에 종속된 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;
}

그러나 연속은 메서드 참조로 지정되므로 코드는 상태 전환 전반에 걸쳐 절차처럼 보입니다. step2step1를 따릅니다. 여기서 람다는 value2를 할당하는 데 사용됩니다. 이렇게 하면 코드의 순서가 위에서 아래로 계산되는 순서와 일치하게 됩니다.

기타 팁

가독성: 실행 순서 지정

가독성을 높이려면 코드에서 StateMachine.step가 구현되는 바로 다음 실행 순서와 콜백 구현을 유지하도록 하세요. 이는 제어 흐름이 분기되는 경우에는 항상 가능하지 않습니다. 이러한 경우에는 추가 의견이 도움이 될 수 있습니다.

예: 체인된 SkyValue 조회에서 이를 위해 중간 메서드 참조가 생성됩니다. 이렇게 하면 가독성을 위해 소량의 성능이 저하됩니다. 이 정도의 가치가 있습니다.

세대 기반 가설

수명이 중간인 자바 객체는 매우 짧은 기간 동안만 유지되는 객체나 영구적으로 유지되는 객체를 처리하도록 설계된 자바 가비지 컬렉터의 세대가 깨집니다. 정의에 따라 SkyKeyComputeState의 객체는 이 가설을 위반합니다. 아직 Driver를 기반으로 하는 여전히 실행 중인 모든 StateMachine의 구성된 트리를 포함하는 이러한 객체는 정지 시 중간 수명이 있어 비동기 계산이 완료되기를 기다립니다.

JDK19에서는 별로 좋지 않은 것 같지만 StateMachine를 사용하면 생성된 실제 가비지가 급격히 감소하더라도 GC 시간이 증가할 수 있습니다. StateMachine는 수명이 중간 정도이므로 이전 세대로 승격되어 더 빨리 채워지기 때문에 더 비싼 메이저 또는 전체 GC가 정리되어야 합니다.

초기 예방 조치는 StateMachine 변수의 사용을 최소화하는 것입니다. 하지만 여러 주에 걸쳐 값이 필요한 경우와 같이 항상 실행 가능한 것은 아닙니다. 가능한 경우 로컬 스택 step 변수는 새로운 생성 변수이며 효율적으로 GC됩니다.

StateMachine 변수의 경우 하위 태스크로 분류하고 StateMachine 간 값 전파에 권장되는 패턴을 따르는 것도 도움이 됩니다. 패턴을 따를 때는 하위 StateMachine만 상위 StateMachine를 참조하며 그 반대도 마찬가지입니다. 즉, 하위 요소가 결과 콜백을 사용하여 상위 요소를 완료하고 업데이트하면 하위 요소가 자연스럽게 범위를 벗어나 GC가 될 수 있습니다.

마지막으로, 이전 상태에서는 StateMachine 변수가 필요하지만 이후 상태에서는 필요하지 않습니다. 더 이상 필요하지 않은 큰 객체를 알고 있으면 참조를 null로 제외하는 것이 좋습니다.

상태 이름 지정

메서드 이름을 지정할 때는 일반적으로 메서드 내에서 발생하는 동작의 이름을 지정할 수 있습니다. 스택이 없기 때문에 StateMachine에서 이 작업을 실행하는 방법이 명확하지 않습니다. 예를 들어 foo 메서드가 bar 하위 메서드를 호출한다고 가정해 보겠습니다. StateMachine에서 이는 상태 시퀀스 foo로 변환되고 뒤이어 bar가 될 수 있습니다. foo에는 더 이상 bar 동작이 포함되지 않습니다. 따라서 상태의 메서드 이름 범위가 좁아지는 경향이 있어 잠재적으로 로컬 동작이 반영될 수 있습니다.

동시 실행 트리 다이어그램

다음은 트리 구조를 더 잘 묘사하는 구조화된 동시 실행의 대체 뷰입니다. 블록은 작은 나무를 형성합니다.

구조화된 동시 실행 3D


  1. 값을 사용할 수 없을 때 처음부터 다시 시작하는 Skyframe의 규칙과 달리, 

  2. step에서 InterruptedException을 발생시킬 수 있지만, 여기에서는 이 단계를 생략합니다. Bazel 코드에는 이 예외를 발생시키는 메서드가 몇 가지 있고, 메서드가 StateMachine를 실행하는 Driver로 전파됩니다. 불필요한 것을 발생시킨다고 선언하지 않아도 괜찮습니다.🇦

  3. 동시 하위 태스크는 각 종속 항목에 독립적인 작업을 실행하는 ConfiguredTargetFunction에 의해 동기가 부여되었습니다. 모든 종속 항목을 한 번에 처리하는 복잡한 데이터 구조를 조작하여 비효율성을 초래하는 대신 각 종속 항목은 고유한 StateMachine를 갖습니다.🇦

  4. 한 단계 내에 여러 tasks.lookUp 호출이 일괄 처리됩니다. 동시 하위 태스크 내에서 조회를 통해 일괄 처리를 만들 수 있습니다. 

  5. 이는 개념적으로 자바의 구조화된 동시 실행 jeps/428과 유사합니다. 

  6. 이는 스레드를 생성하고 순차적 구성을 달성하기 위해 조인하는 것과 비슷합니다.