Isolated_agents: Isolados fáceis para Flutter

Tempo de leitura: 5 minutes

Este artigo apresenta um novo pacote para Flutter e Dart que torna os isolados mais fáceis de trabalhar. Ajuda qualquer pessoa que queira descarregar o trabalho de um isolado para outro. Se você quiser pular adiante, verifique o novo pacote Isolated_agents.

 

Fundo

À medida que os projetos Flutter amadurecem e se tornam mais complexos, não é incomum começar a ver gargalos no thread de IU do mecanismo Flutter. O recurso async/await do Dart é ótimo para trabalhar com APIs assíncronas, mas não ajuda a escrever um novo código que é executado fora do thread da interface do usuário. No início da história do Dart, Isolados foram adicionados para resolver esse problema. A Wikipedia classifica o Dart como uma linguagem Actor Model e, embora o Dart tenha regiões isoladas de memória associadas a threads de execução, ele não possui um protocolo ou API predefinido para comunicação entre eles.

Para usar um Isolate de qualquer maneira mais complicada do que apenas “disparar uma tarefa e receber uma notificação quando concluída”, é preciso:

  1. Desenvolva um protocolo para como o Isolate comunicará entrada e saída, por exemplo, como os dados são codificados na API SendPort?
  2. Execute um handshake SendPort para estabelecer um canal de comunicação.
  3. Gerencie uma fila de manipuladores a serem executados em série quando os resultados forem calculados.
  4. Elabore um protocolo de tratamento de erros.
  5. Elabore um protocolo de desligamento.

A equipe do Flutter reconheceu que o uso de Isolates é complicado e adicionou a função de computação. Isso torna o caso de uso “dispare uma tarefa e receba uma notificação quando ela for concluída” mais fácil de escrever, mas abstrai completamente o Isolate, que não dá ao desenvolvedor controle total sobre o desempenho de seu código. Você não pode controlar a sobrecarga de gerar um estado isolado ou armazenado no isolado. Ambos os fatos contribuem para um desempenho abaixo do ideal, quando o objetivo principal da função era melhorar o desempenho.

Se ao menos houvesse uma API de nível superior em cima de um Isolate que mantivesse o controle do Isolate e a facilidade de uso da computação… Isso poderia simplificar as tarefas de multiprocessamento e tornar as tarefas de multiprocessamento complexas realizáveis.

 

Agentes Clojure

Ao abordar uma solução para o problema do trabalho extra que o Dart Isolates exige para ser útil, voltei a minha experiência com a linguagem de programação Clojure. Clojure é uma linguagem de modelo de ator funcional cujos dados são imutáveis por padrão. Ele foi projetado com o multiprocessamento pesado em mente e não tem o mesmo problema com multiprocessamento detalhado e difícil que o Dart tem. O designer adotou uma abordagem ligeiramente diferente para os atores do que as linguagens como Erlang, em vez de se inspirar em linguagens funcionais. Em Clojure, o conceito de agente é um dado associado a um thread. Nesse sentido, é semelhante ao Dart Isolates, mas ao contrário do Dart Isolates, os agentes têm um protocolo, canal e mecanismo de resposta estabelecidos. O protocolo envia encerramentos para o thread e os executa lá — os resultados substituem o estado mantido.

Um uso simples de um Agente:

; Create the thread with state = 0
(let [foo (agent 0)]
  ; Execute x + 1 on the background thread and assign it
  ; to the value associated with the thread.
  (send foo (fn [x] (+ x 1)))
  (send foo (fn [x] (+ x 1)))
  ; Request and wait for the value from `foo` and print it.
  (print (deref foo)))

 

Pacote isolado_agentes

O novo pacote, Isolated_agents, implementa um padrão semelhante ao do agente Clojure mencionado anteriormente. Ele cria um protocolo padronizado para comunicação entre isolados e elimina os apertos de mão e o gerenciamento de estado que você deve executar para usá-los com eficácia.

Aqui está o mesmo exemplo acima, escrito em Dart com o pacote Isolated_agents:

void main() async {
  // Create the thread with state = 0
  Agent<int> foo = await Agent.create(() => 0);
  // Execute 1 + 2 on the background thread and assign it
  // to the value associated with the thread.
  foo.update((x) => x + 1);
  foo.update((x) => x + 1);
  // Request and wait for the value from `foo` and print it.
  print(await foo.exit());
}

 

Padrões de agente

O exemplo anterior com o uso de Isolated_agents foi muito simples. Aqui estão alguns padrões mais interessantes que podem ser realizados usando Agentes como blocos de construção.

Manipulador de plano de fundo de longa duração

O método de computação gera e mata um novo Dart Isolate: isso incorre em sobrecarga ao girar o Isolate. Você pode eliminar essa sobrecarga usando um Agente de longa duração:

// An agent that lives for the duration of the main isolate.
final Future<Agent<String>> _agent = Agent.create(‘’);

Future<String> _receiveEncryptedMessage(String encrypted) async {
  Agent<String> agent = await _agent;
  agent.update((_) => _decrypt(encrypted));
  // Notice the use of `read` (not `kill`) to keep the agent alive.
  return agent.read();
}

 

Gerenciamento de estado de isolamento cruzado

Gerenciar o estado mutável em vários isolados é complicado, mas atribuir um Agente para gerenciar o estado facilita:

// Executed on the root Isolate.
void _addUser(Agent<DataModel> model, String name) {
  model.update((database) {
  database.addUser(name);
  });
}

// Autosave writing to disk by periodically executing on
// the background isolate.
void _autosave(Agent<DataModel> model) {
  DataModel db = await agent.read();
  db.saveToDisk();
}

 

Pipeline

Imagine que você deseja configurar um pipeline em que um trabalho de trabalho está sendo preparado enquanto outro está sendo atendido. Neste exemplo, um Isolate gera uma árvore que é simultaneamente renderizada como uma imagem por outro Isolate:

class Pipeline {
  Pipeline._(this.builder, this.renderer);

  final Agent<Tree?> builder;
  final Agent<Object?> renderer;
  
  static Future<Pipeline> create() async {...}

  Future<Image> process(int index) {
    ReceivePort receiver = ReceivePort();
    SendPort sender = receiver.sender;
    builder.update((_) => _buildTree(index));
    builder.update((tree) {
      renderer.update((_) {
        Image image = _buildImage(tree);
        sender.send(image);
      });
      return null; 
    });
    Object? object = await receiver.first;
    return object! as Image;
  }
}

Future<List<Image>> _startJobs(Pipeline pipeline) {
  List<Future<Image>> images = [];
  for (int i = 0; i < 10; ++i) {
    images.add(pipeline.process(i));
  }

  return Future.wait(images);
}

 

Cache de operação em segundo plano

Este caso de uso tem um cálculo pesado que precisa ser executado em um fundo isolado, mas o resultado deve ser armazenado em cache. Desta forma, o mesmo cálculo nunca é feito duas vezes, e novos cálculos podem ser baseados em cálculos anteriores:

final Future<Agent<WorldMap>> _agent = Agent.create(() => WorldMap());

Future<LocalMap> getLocalMap(int latitude, int longitude) async {
  Agent<WorldMap> agent = await _agent;
  agent.update((worldMap) => {
    if (!worldMap.hasLocalMap(latitude, longitude)) {
      // Perform heavy loading of the map on background isolate and memoize
      // the result.
      worldMap.loadLocalMap(latitude, longitude);
    }
    return worldMap;
  });
  return agent.read(query: (worldMap) =>
      worldMap.getLocalMap(latitude, longitude));
}

 

Exemplo em Flutter

Você pode verificar um exemplo que usa isolado_agentes no projeto romeo_juliet no GitHub.

O aplicativo executa as seguintes etapas:

  1. Carregue o recurso de texto com todas as mensagens descriptografadas, divida-as, criptografe-as e grave-as no diretório de documentos.
  2. Consultar o diretório de documentos da plataforma e armazená-lo no agente.
  3. Inicie um cronômetro que (a cada segundo) acione um trabalho para ler a mensagem criptografada do diretório de documentos e descriptografá-la.
  4. Quando o trabalho terminar, se tivermos uma nova mensagem descriptografada, adicione-a ao cache na raiz isolada e recarregue a exibição de rolagem.

 

Como esse código difere do uso direto do Isolate?

  • Essa abordagem requer menos código e fornece uma interface consistente para trabalhar com Isolates.

Como esse código difere do uso da função de computação do Flutter?

  1. O Isolate é reutilizado sempre que o aplicativo recebe a solicitação para descriptografar uma nova mensagem.
  2. O Isolate pode armazenar o estado, neste caso no caminho do diretório de documentos, para que não precise recalculá-lo no Isolate ou enviá-lo todas as vezes.

Ambas as coisas contribuem para um melhor desempenho.

 

Uma nota sobre o desempenho

Embora o Isolated_agents desbloqueie casos de uso de multiprocessamento mais complicados, isso é um pouco prejudicado pelo desempenho da API SendPort do Dart. Deve-se considerar o overhead dos payloads que são enviados entre os Agentes. Por esta razão, os seguintes métodos foram adicionados:

  • O método Agent.read permite ler uma porção menor do estado mantido por um Agente com o parâmetro de consulta.
  • O método Agent.exit permite ler o valor de um Agente morrendo em tempo constante.

O Dart tenta, na maioria dos casos, otimizar a comunicação de dados entre Isolados, mas infelizmente não é explícito sobre quando essas otimizações acontecem. Espero que, em algum momento, o Dart possa ter uma semântica de movimento que nos permita afirmar a transmissão de dados em tempo constante entre os isolados, mas parece improvável que isso aconteça tão cedo.