Um guia para StateMachines do Skyframe

Informar um problema Mostrar fonte Por noite · 7,3 · 7,2 · 7,1 · 7,0 · 6,5

Visão geral

Um StateMachine de Skyframe é um objeto de função desconstruído que reside no da pilha. Ele oferece suporte flexível e de avaliação sem redundância1 quando valores obrigatórios não estão imediatamente disponíveis, mas são calculados de forma assíncrona. O StateMachine não pode vincular um recurso de linha de execução enquanto espera, mas precisa suspensa e retomada. Assim, a desconstrução expõe a reentrada explícita para que os cálculos anteriores possam ser pulados.

StateMachines podem ser usadas para expressar sequências, ramificações, lógica estruturada a simultaneidade e são adaptados especificamente para a interação com Skyframe. StateMachines podem ser compostas em StateMachines maiores e compartilhar sub-StateMachines. A simultaneidade é sempre hierárquica por construção e totalmente lógico. Cada subtarefa simultânea é executada no único pai compartilhado linha de execução SkyFunction.

Introdução

Esta seção motiva e apresenta brevemente as StateMachines, encontradas nas java.com.google.devtools.build.skyframe.state .

Uma breve introdução às reinicializações do Skyframe

O Skyframe é um framework que faz avaliações paralelas de gráficos de dependência. Cada nó do gráfico corresponde à avaliação de uma SkyFunction com um SkyKey especificando seus parâmetros e SkyValue especificando seu resultado. O é de modo que a SkyFunction possa procurar SkyValues pela SkyKey, acionando avaliação paralela e recursiva de SkyFunctions adicionais. Em vez de bloqueio, que delimitaria uma linha de execução, quando um SkyValue solicitado ainda não está pronto porque algum subgráfico de computação está incompleto, o pedido A SkyFunction observa uma resposta null getValue e retorna null em vez de um SkyValue, indicando que ele está incompleto devido à ausência de entradas. O Skyframe reinicia as SkyFunctions quando todos os SkyValues solicitados anteriormente fiquem disponíveis.

Antes do surgimento do SkyKeyComputeState, a forma tradicional de gerenciar um reinício foi executar novamente o cálculo. Embora tenha uma função quadrática as funções escritas dessa forma acabaram sendo concluídas porque cada nova execução, menos pesquisas retornam null. Com SkyKeyComputeState, é possível associar dados de check-point especificados à mão a um SkyFunction, economizando recomputação.

StateMachines são objetos que residem dentro de SkyKeyComputeState e eliminam praticamente todos os cálculos quando o SkyFunction é reiniciado (supondo que SkyKeyComputeState não sai do cache) ao expor a suspensão e a retomada hooks de execução.

Cálculos com estado dentro de SkyKeyComputeState

Do ponto de vista do design orientado a objetos, faz sentido armazenar objetos computacionais dentro de SkyKeyComputeState em vez de valores de dados puros. Em Java, a descrição mínima de um comportamento de transporte de objetos é uma interface funcional (em inglês) e acaba sendo suficiente. Um StateMachine tem a seguir, uma definição curiosamente recursiva2.

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

A interface Tasks é análoga a SkyFunction.Environment, mas é projetada para assíncrona e adiciona suporte a subtarefas logicamente simultâneas3.

O valor de retorno de step é outro StateMachine, permitindo que a especificação de uma sequência de etapas, de forma indutiva. step retorna DONE quando a StateMachine foi concluído. Exemplo:

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

descreve um StateMachine com a seguinte saída.

hello
world

A referência do método this::step2 também é um StateMachine devido a step2 que satisfaz a definição de interface funcional do StateMachine. Método referências são a forma mais comum de especificar o próximo estado em um StateMachine:

Como suspender e retomar

Intuitivamente, dividir um cálculo em etapas StateMachine, em vez de função monolítica, fornece os hooks necessários para suspender e retomar um de computação. Quando StateMachine.step retorna, há uma suspensão explícita. ponto A continuação especificada pelo valor StateMachine retornado é uma um ponto de currículo explícito. Assim, a recomputação pode ser evitada porque a computação em nuvem pode ser retomada exatamente de onde parou.

Callbacks, continuações e computação assíncrona

Em termos técnicos, um StateMachine serve como uma continuação, determinando a o cálculo posterior a ser executado. Em vez de bloquear, uma StateMachine pode suspender voluntariamente retornando da função step, que transfere o controle de volta para uma instância Driver. O Driver pode depois alterne para um StateMachine pronto ou renuncia ao controle de volta para o Skyframe.

Tradicionalmente, callbacks e continuação são combinados em um conceito. No entanto, StateMachines mantêm uma distinção entre os dois.

  • Callback: descreve onde armazenar o resultado de uma chamada de computação.
  • Continuação: especifica o próximo estado de execução.

Os callbacks são necessários ao invocar uma operação assíncrona, ou seja, a operação real não ocorre imediatamente após a chamada do método, como em o caso de uma pesquisa do SkyValue. Os retornos de chamada devem ser o mais simples possível.

Continuações são os valores de retorno de StateMachine de StateMachines e encapsular a execução complexa que ocorre quando todas as instâncias computacionais resolvidos. Essa abordagem estruturada ajuda a manter a complexidade e retornos de chamadas gerenciáveis.

Tarefas

A interface Tasks fornece StateMachines com uma API para procurar SkyValues pela SkyKey e agendar subtarefas simultâneas.

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

Pesquisas do SkyValue

As StateMachines usam sobrecargas de Tasks.lookUp para procurar SkyValues. São análoga a SkyFunction.Environment.getValue e SkyFunction.Environment.getValueOrThrow e têm processamento de exceções semelhante semântica. A implementação não realiza a pesquisa imediatamente, mas Em vez disso, agrupe4 o maior número possível de pesquisas antes de fazer isso. O valor podem não estar disponíveis imediatamente, por exemplo, ao exigir uma reinicialização do Skyframe, portanto, o autor da chamada especifica o que fazer com o valor resultante usando um callback.

O processador StateMachine (Drivers e a ponte para SkyFrame) garante que o valor esteja disponível antes de o próximo estado começa. Confira um exemplo abaixo.

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

No exemplo acima, a primeira etapa procura por new Key(), transmitindo this como consumidor. Isso é possível porque DoesLookup implementa Consumer<SkyValue>.

Por contrato, antes do início do próximo estado DoesLookup.processValue, todos os pesquisas de DoesLookup.step foram concluídas. Portanto, value está disponível quando ele é acessado em processValue.

Subtarefas

Tasks.enqueue solicita a execução de subtarefas logicamente simultâneas. As subtarefas também são StateMachines e podem fazer qualquer coisa de StateMachines normais pode fazer, incluindo a criação recursiva de mais subtarefas ou a pesquisa do SkyValues. Assim como lookUp, o driver da máquina de estado garante que todas as subtarefas sejam concluir antes de prosseguir para a próxima etapa. Confira um exemplo abaixo.

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

Embora Subtask1 e Subtask2 sejam logicamente simultâneos, tudo é executado em uma linha de execução única, para que a tag "concurrent" de i não precisa de nenhuma e sincronização.

Simultaneidade estruturada

Como cada lookUp e enqueue precisam ser resolvidos antes de avançar para a próxima isso significa que a simultaneidade é naturalmente limitada a estruturas em árvore. Está é possível criar uma simultaneidade5 hierárquica, como mostrado no exemplo.

Simultaneidade estruturada

É difícil dizer na UML que a estrutura de simultaneidade forma uma árvore. Há uma visualização alternativa que mostra melhor estrutura de árvore.

Simultaneidade não estruturada

É muito mais fácil entender a simultaneidade estruturada.

Composição e controle de padrões de fluxo

Esta seção apresenta exemplos de como vários StateMachines podem ser compostos e soluções para certos problemas de fluxo de controle.

Estados sequenciais

Esse é o padrão de fluxo de controle mais comum e simples. Um exemplo de Isso é mostrado em Cálculos com estado dentro SkyKeyComputeState

Ramificação

Os estados de ramificação em StateMachines podem ser alcançados com o retorno de diferentes usando o fluxo de controle Java normal, conforme mostrado no exemplo a seguir.

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;
  }
  …
}

É muito comum que algumas ramificações retornem DONE para a conclusão antecipada.

Composição sequencial avançada

Como a estrutura de controle StateMachine não tem memória, o compartilhamento de StateMachine e definições de tarefas como subtarefas podem ser estranhas. Permita que M1 e M2 são instâncias StateMachine que compartilham um StateMachine, S, com M1 e M2 sendo as sequências <A, S, B> e <X, S, Y>, respectivamente. O problema é que S não sabe se deve continuam para B ou Y após a conclusão e StateMachines não mantêm uma pilha de chamadas. Esta seção analisa algumas técnicas para fazer isso.

StateMachine como elemento de sequência terminal

Isso não resolve o problema inicial apresentado. Ele só demonstra composição quando o StateMachine compartilhado é terminal na sequência.

// 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();
  }
}

Isso funciona mesmo que S seja uma máquina de estado complexa.

Subtarefa para composição sequencial

Como as subtarefas enfileiradas certamente serão concluídas antes do próximo estado, é às vezes é possível abusar um pouco6 do mecanismo de subtarefas.

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

Injeção de runAfter

Às vezes, é impossível violar Tasks.enqueue porque há outras subtarefas paralelas ou chamadas Tasks.lookUp que precisam ser concluídas antes de S é executado. Nesse caso, injetar um parâmetro runAfter em S pode ser usado para informar S o que fazer em seguida.

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

Essa abordagem é mais limpa do que abusar de subtarefas. No entanto, aplicar isso também liberalmente, por exemplo, ao aninhar vários StateMachines com runAfter, é no caminho para Callback Hell. É melhor dividir as sequências runAfters com estados sequenciais comuns.

  return new S(/* runAfter= */ new T(/* runAfter= */ this::nextStep))

pode ser substituído pelo seguinte.

  private StateMachine step1(Tasks tasks) {
     doStep1();
     return new S(/* runAfter= */ this::intermediateStep);
  }

  private StateMachine intermediateStep(Tasks tasks) {
    return new T(/* runAfter= */ this::nextStep);
  }

Alternativa proibida: runAfterUnlessError

Em um rascunho anterior, consideramos um runAfterUnlessError que seria cancelado no início dos erros. Isso foi motivado pelo fato de que os erros muitas vezes acabam se verificado duas vezes, uma pelo StateMachine que tem uma referência a runAfter e uma vez pela própria máquina runAfter.

Após algumas deliberações, decidimos que a uniformidade do código é mais mais importante do que eliminar a duplicação da verificação de erros. Seria confuso se o O mecanismo runAfter não funcionou de maneira consistente com o tasks.enqueue, que sempre requer verificação de erros.

Delegação direta

Cada vez que há uma transição formal de estado, a repetição Driver principal avança. De acordo com o contrato, "avançar estados" significa que todos os SkyValue enfileirados anteriormente pesquisas e subtarefas são resolvidas antes da execução do próximo estado. Às vezes, a lógica de um StateMachine delegado faz um avanço de fase desnecessário ou contraprodutivas. Por exemplo, se o primeiro step do delegado realizar Pesquisas SkyKey que podem ser carregadas em paralelo com pesquisas do estado delegado um avanço de fase os tornaria sequenciais. Poderia fazer mais sentido e realizará a delegação direta, conforme mostrado no exemplo abaixo.

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

Fluxo de dados

O foco da discussão anterior tem sido o gerenciamento do fluxo de controle. Isso descreve a propagação de valores de dados.

Como implementar callbacks Tasks.lookUp

Há um exemplo de implementação de um callback Tasks.lookUp no SkyValue pesquisas. Esta seção fornece justificativas e sugestões para lidar com vários SkyValues.

Tasks.lookUp callbacks

O método Tasks.lookUp recebe um callback, sink, como parâmetro.

  void lookUp(SkyKey key, Consumer<SkyValue> sink);

A abordagem idiomática seria usar uma lambda Java para implementar isso:

  tasks.lookUp(key, value -> myValue = (MyValueClass)value);

com myValue sendo uma variável de membro da instância StateMachine fazendo o pesquisa. No entanto, a lambda exige uma alocação de memória extra em comparação implementar a interface Consumer<SkyValue> no StateMachine implementação. O lambda ainda é útil quando há várias pesquisas que seria ambíguo.

Há também sobrecargas de tratamento de erros de Tasks.lookUp, semelhantes a 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);
  }

Confira abaixo um exemplo de implementação.

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

Assim como nas pesquisas sem processamento de erros, usar a classe StateMachine diretamente implementar o callback salva uma alocação de memória para a lamba.

O tratamento de erros fornece um pouco mais de detalhes, mas, essencialmente, não há muita diferença entre a propagação de erros e os valores normais.

Como consumir vários SkyValues

Muitas vezes, são necessárias várias pesquisas do SkyValue. Uma abordagem que funciona é mudar o tipo de SkyValue. A seguir, há um exemplo que tem foi simplificado com o código de produção do protótipo.

  @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);
  }

A implementação do callback Consumer<SkyValue> pode ser compartilhada de forma inequívoca porque os tipos de valor são diferentes. Quando esse não for o caso, voltar a baseadas em lambda ou instâncias completas de classe interna que implementam retornos de chamada apropriados.

Propagação de valores entre StateMachines

Até agora, este documento só explicou como organizar o trabalho em uma subtarefa, mas também precisam informar valores ao autor da chamada. Como as subtarefas são logicamente assíncrono, seus resultados são comunicados de volta ao autor da chamada usando um retorno de chamada. Para que isso funcione, a subtarefa define uma interface de coletor injetada por meio do seu construtor.

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

Então, o autor da chamada StateMachine teria esta aparência:

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

O exemplo anterior demonstra algumas coisas. Caller precisa propagar retorna os resultados e define seu próprio Caller.ResultSink. Caller implementa o Callbacks BarProducer.ResultSink. Na retomada, processResult verifica se value é nulo para determinar se ocorreu um erro. Esse é um comportamento comum depois de aceitar a saída de uma subtarefa ou pesquisa do SkyValue.

Observe que a implementação de acceptBarError encaminha ansiosamente o resultado para o Caller.ResultSink, conforme exigido pelo Bolha de erros (link em inglês).

Alternativas para StateMachines de nível superior são descritas em Drivers e fazendo uma ponte para o SkyFunctions.

Tratamento de erros

Já existem alguns exemplos de tratamento de erros em Tasks.lookUp callbacks e Propagar valores entre StateMachines. Exceções, exceto InterruptedException não são geradas, mas são transmitidas callbacks como valores. Esses callbacks costumam ter semântica exclusiva ou semântica, exatamente um de um valor ou erro que está sendo passado.

A próxima seção descreve uma interação sutil, mas importante, com o Skyframe tratamento de erros.

Balanço de erros (--nokeep_Vamos)

Durante o balão de erros, um SkyFunction pode ser reiniciado mesmo se nem todos os pedidos Os SkyValues estão disponíveis. Nesses casos, o estado subsequente nunca será alcançado devido ao contrato da API Tasks. No entanto, o StateMachine precisa ainda assim propagar a exceção.

Como a propagação precisa ocorrer independentemente de o próximo estado ser alcançado, o callback de tratamento de erros precisa realizar essa tarefa. Para um StateMachine interno, isso é conseguido invocando o retorno de chamada pai.

No nível superior StateMachine, que faz interface com a SkyFunction, ela pode basta chamar o método setException do ValueOrExceptionProducer. O ValueOrExceptionProducer.tryProduceValue vai gerar a exceção, mesmo se faltarem SkyValues.

Se um Driver estiver sendo usado diretamente, é essencial verificar se há propagadas pela SkyFunction, mesmo que a máquina não tenha concluído processamento.

Tratamento de eventos

No caso do SkyFunctions que precisam emitir eventos, um StoredEventHandler é injetado no SkyKeyComputeState e injetadas em StateMachines que exigem para resolvê-los com rapidez. Historicamente, o StoredEventHandler era necessário devido ao descarte do Skyframe determinados eventos, a menos que sejam repetidos, mas isso foi corrigido posteriormente. A injeção de StoredEventHandler é preservada porque simplifica a implementação de eventos emitidos de callbacks de tratamento de erros.

Drivers e a ponte para o SkyFunctions

Um Driver é responsável por gerenciar a execução de StateMachines, começando com uma raiz StateMachine especificada. Como StateMachines podem enfileiram StateMachines de subtarefas de maneira recursiva, uma única Driver poderá gerenciar várias subtarefas. Essas subtarefas criam uma estrutura de árvore, um resultado Simultaneidade estruturada. O Driver agrupa o SkyValue em lotes pesquisas em subtarefas para melhorar a eficiência.

Há várias classes criadas em torno do Driver, com a API a seguir.

public final class Driver {
  public Driver(StateMachine root);
  public boolean drive(SkyFunction.Environment env) throws InterruptedException;
}

Driver usa uma única raiz StateMachine como parâmetro. Ligando O Driver.drive executa a StateMachine o máximo possível sem uma Reinicialização do Skyframe. Retorna "true" quando o StateMachine é concluído e "false" caso contrário, indicando que nem todos os valores estavam disponíveis.

Driver mantém o estado simultâneo do StateMachine e está bem adequado para incorporação em SkyKeyComputeState.

Como instanciar Driver diretamente

As implementações de StateMachine comunicam os resultados de maneira convencional usando . É possível instanciar diretamente um Driver, como mostrado nas exemplo a seguir.

O Driver está incorporado na implementação do SkyKeyComputeState com o uma implementação do ResultSink correspondente a ser definida um pouco mais para baixo. No nível superior, o objeto State é um receptor apropriado para os resultado do cálculo, já que ele persiste a 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;
  }
}

O código abaixo esboça 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;
  }
}

O código para calcular lentamente o resultado vai ficar assim:

@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;
}

Incorporando Driver

Se o StateMachine produzir um valor e não gerar exceções, a incorporação Driver é outra implementação possível, conforme mostrado no exemplo abaixo.

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

A SkyFunction pode ter um código parecido com este (em que State é o tipo específico da função de 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;
}

A incorporação de Driver na implementação de StateMachine é uma opção melhor para o estilo de codificação síncrona do Skyframe.

StateMachines que podem produzir exceções

Caso contrário, há ValueOrExceptionProducer incorporáveis com SkyKeyComputeState. e ValueOrException2Producer que têm APIs síncronas para corresponder código síncrono SkyFunction.

A classe abstrata ValueOrExceptionProducer inclui os métodos a seguir.

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

Ele inclui uma instância Driver incorporada e se parece muito com o Classe ResultProducer no driver de incorporação e interfaces com a SkyFunction de maneira semelhante. Em vez de definir um ResultSink, as implementações chamam setValue ou setException quando uma delas ocorre. Quando ambos ocorrem, a exceção tem prioridade. Método tryProduceValue vincula o código de retorno de chamada assíncrono ao código síncrono e gera uma quando um for definido.

Conforme observado anteriormente, durante o balão de erros, é possível que um erro ocorra mesmo que a máquina ainda não tenha terminado, porque nem todas as entradas estão disponíveis. Para acomodar isso, tryProduceValue gera qualquer exceção definida, mesmo antes da máquina está pronto.

Epílogo: remover callbacks

StateMachines são uma forma altamente eficiente, mas que exigem muito código boilerplate de realizar computação assíncrona. Continuações (principalmente na forma de Runnables) transmitidos para ListenableFuture) são disseminados em certas partes do código do Bazel, mas não são prevalentes no SkyFunctions de análise. A análise é principalmente vinculada à CPU e não há APIs assíncronas eficientes para E/S de disco. Em algum momento, seria é bom otimizar as comunicações de retorno, já que elas têm uma curva de aprendizado e impedem facilitar a leitura.

Uma das alternativas mais promissoras são as linhas de execução virtuais Java. Em vez de ter que escrever callbacks, tudo é substituído por chamadas síncronas, chamadas. Isso é possível porque vincular um recurso de linha de execução virtual, ao contrário de um de plataforma, é para ser barato. No entanto, mesmo com linhas de execução virtuais, substituindo operações síncronas simples pela criação e sincronização de linhas de execução. primitivos é muito caro. Realizamos uma migração de StateMachine s para linhas de execução virtuais Java que eram ordens de magnitude mais lentas, levando a a latência de análise de ponta a ponta aumentou em quase três vezes. Como as linhas de execução virtuais são ainda é um recurso em fase de pré-lançamento, é possível que essa migração possa ser realizada em um em uma data posterior quando o desempenho melhorar.

Outra abordagem a ser considerada é aguardar as corrotinas do Loom, se elas alguma vez fiquem disponíveis. A vantagem aqui é que pode ser possível reduzir a sobrecarga de sincronização usando o modo de multitarefa cooperativo.

Se tudo o mais falhar, a regravação de bytecode de baixo nível também pode ser alternativa. Com otimização suficiente, é possível atingir que se aproxima do código de callback escrito à mão.

Apêndice

Inferno do callback

O "inferno do callback" é um problema infame no código assíncrono que usa callbacks. Isso decorre do fato de que a continuação para uma etapa subsequente está aninhada na etapa anterior. Se houver muitas etapas, esse aninhamento pode ser extremamente de profundidade. Se acoplado ao fluxo de controle, o código se torna incontrolável.

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

Uma das vantagens das implementações aninhadas é que o frame de pilha do a etapa externa pode ser preservada. Em Java, as variáveis lambda capturadas precisam ser e final, então usar essas variáveis pode ser complicado. Aninhamento profundo é evitados retornando referências de métodos como continuações em vez de lambdas mostrada a seguir.

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

A injeção de runAfter também pode causar uma "injeção de callback" é usado muito densamente, mas isso pode ser evitado intercalando as injeções com etapas sequenciais.

Exemplo: pesquisas do SkyValue encadeadas

Muitas vezes, a lógica do aplicativo exige cadeias dependentes de O SkyValue procura, por exemplo, se uma segunda SkyKey depende do primeiro SkyValue. Pensando nisso ingenuamente, isso resultaria em um processo complexo e profundamente aninhado a estrutura de callback.

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

No entanto, como as continuações são especificadas como referências de método, o código fica processual entre transições de estado: step2 segue step1. Observe que, aqui, O lambda é usado para atribuir value2. Isso faz com que a ordem do código corresponda ao ordem do cálculo de cima para baixo.

Dicas diversas

Legibilidade: ordem de execução

Para melhorar a legibilidade, mantenha as implementações de StateMachine.step na ordem de execução e implementações de callback imediatamente após são transmitidos no código. Isso nem sempre é possível quando o fluxo de controle galhos. Comentários adicionais podem ser úteis nesses casos.

Em Example: Chained SkyValue lookups, uma referência de método intermediário é criada para isso. Isso diminui um pouco de desempenho para facilitar a leitura, o que provavelmente valerá a pena aqui.

Hipótese geracional

Objetos Java de média duração quebram a hipótese geracional do conceito Java coletor de lixo, projetado para lidar com objetos que vivem por um ou objetos que vivem eternamente. Por definição, os objetos em SkyKeyComputeState violam essa hipótese. Esses objetos, que contêm os árvore construída de todas as StateMachines ainda em execução, com raízes em Driver, têm uma vida útil intermediária à medida que são suspensos, aguardando cálculos assíncronos a serem concluídas.

Parece menos ruim no JDK19, mas ao usar StateMachines, às vezes pode ser é possível observar um aumento no tempo de GC, mesmo com reduções drásticas de lixo gerado. Como StateMachines têm uma vida útil intermediária eles podem ser promovidos para a geração antiga, fazendo com que preencham mais rapidamente, assim precisar de GCs maiores ou completas para fazer a limpeza.

A precaução inicial é minimizar o uso de variáveis StateMachine, mas isso nem sempre é viável, por exemplo, se um valor for necessário em vários estados. Sempre que possível, as variáveis step da pilha local são de geração mais nova. de dados e GC com eficiência.

Para variáveis StateMachine, dividir as coisas em subtarefas e seguir o padrão recomendado para Propagar valores entre StateMachines também é útil. Observe que, quando Seguindo o padrão, somente StateMachines filhos têm referências ao pai StateMachines e não o contrário. Isso significa que, conforme os filhos completam atualizar os pais usando callbacks de resultado, as crianças vão sair naturalmente e se qualificar para o GC.

Por fim, em alguns casos, uma variável StateMachine é necessária em estados anteriores. mas não em estados posteriores. Pode ser benéfico anular referências de grandes objetos quando se sabe que eles não são mais necessários.

Como nomear estados

Ao nomear um método, normalmente é possível nomear um método para o comportamento que acontece dentro desse método. É menos claro como fazer isso no StateMachines porque não há pilha. Por exemplo, suponha que o método foo chama um submétodo bar. Em StateMachine, isso pode ser traduzido para o sequência de estados foo, seguida por bar. foo não inclui mais o comportamento bar. Como resultado, os nomes de métodos para estados tendem a ter um escopo mais restrito, possivelmente refletindo o comportamento local.

Diagrama de árvore de simultaneidade

Veja a seguir uma visão alternativa do diagrama em Estrutura simultaneidade que retrate melhor a estrutura de árvore. Os blocos formam uma pequena árvore.

Simultaneidade estruturada 3D


  1. Diferente da convenção do Skyframe de reiniciar desde o início quando não estão disponíveis. 

  2. Observe que step tem permissão para gerar InterruptedException, mas a os exemplos omitem isso. Há alguns métodos baixos no código do Bazel que geram essa exceção e ela se propaga até o Driver, que será descrito posteriormente, que executa o StateMachine. É bom não declarar que ele é gerado quando desnecessários.

  3. Subtarefas simultâneas foram motivadas pela ConfiguredTargetFunction, realiza um trabalho independente para cada dependência. Em vez de manipular estruturas de dados complexas que processam todas as dependências de uma só vez, ineficiências, cada dependência tem a própria infraestrutura StateMachine:

  4. Várias chamadas tasks.lookUp em uma única etapa são agrupadas. Lotes adicionais podem ser criados por pesquisas que ocorrem dentro de subtarefas. 

  5. Conceitualmente, isso é semelhante à simultaneidade estruturada do Java jeps/428 (link em inglês). 

  6. Fazer isso é semelhante a gerar e unir uma linha de execução para conseguir composição sequencial.