Sistema de trabalho. Visão geral do outro lado

Na nova versão unitária de 2018, eles finalmente adicionaram oficialmente o novo sistema de componentes de entidades, ou ECS , que permite trabalhar apenas com os dados, em vez do trabalho usual com componentes de objetos.

Um sistema de tarefas adicional oferece a você poder de computação paralela para melhorar o desempenho do seu código.

Juntos, esses dois novos sistemas ( ECS e Job System ) oferecem um novo nível de processamento de dados.

Especificamente, neste artigo, não analisarei todo o sistema ECS , atualmente disponível como um conjunto de ferramentas baixado separadamente na unidade , mas considerarei apenas o sistema de tarefas e como ele pode ser usado fora do pacote do ECS .

Novo sistema


Inicialmente, a unidade costumava ser capaz de usar a computação multithread, mas tudo isso tinha que ser criado pelo desenvolvedor por conta própria, para resolver os problemas e solucionar as armadilhas. E se antes era necessário trabalhar diretamente com coisas como criar threads, fechar threads, pools, sincronização, agora todo esse trabalho caía sobre os ombros do mecanismo, e o próprio desenvolvedor só precisava criar tarefas e concluí-las.

As tarefas


Para executar qualquer cálculo no novo sistema, é necessário usar tarefas que são objetos que consistem em métodos e dados para cálculo.

Como qualquer outro dado no sistema ECS , as tarefas no Job System também são representadas como estruturas que herdam uma das três interfaces.

Ijob


A interface de tarefa mais simples que contém um método Execute que não aceita nada na forma de parâmetros e não retorna nada.

A tarefa em si é assim:

Ijob
public struct JobStruct : IJob { public void Execute() {} } 


No método Execute , você pode executar os cálculos necessários.

IJobParallelFor


Outra interface com o mesmo método Execute , que por sua vez já aceita o índice numérico do parâmetro.

IJobParallelFor
 public struct JobStruct : IJobParallelFor { public void Execute(int index) {} } 


Essa interface IJobParallelFor , diferente da interface IJob , oferece a execução de uma tarefa várias vezes e não apenas a execução, mas divide essa execução em blocos que serão distribuídos entre os threads.

Não está claro Não se preocupe com isso, vou lhe contar mais.

IJobParallelForTransform


E a última interface especial, que, como o nome indica, é projetada para trabalhar com essas transformações do objeto. Ele também contém o método Execute , com o índice numérico do parâmetro e o parâmetro TransformAccess onde a posição, o tamanho e a rotação da transformação estão localizados.

IJobParallelForTransform
 public struct JobStruct : IJobParallelForTransform { public void Execute(int index, TransformAccess transform) {} } 


Como você não pode trabalhar com objetos de unidade diretamente na tarefa, essa interface só pode processar dados de transformação como uma estrutura TransformAccess separada.

Concluído, agora você sabe como as estruturas de tarefas são criadas, você pode continuar praticando.

Conclusão da tarefa


Vamos criar uma tarefa simples herdada da interface IJob e concluí-la. Para isso, precisamos de qualquer script simples do MonoBehaviour e da estrutura da tarefa em si.

Testjob
 public class TestJob : MonoBehaviour { void Start() {} } 


Agora solte esse script em algum objeto em cena. No mesmo script ( TestJob ) abaixo, escreveremos a estrutura da tarefa e não esqueceremos de importar as bibliotecas necessárias.

Simplejob
 using Unity.Jobs; public struct SimpleJob : IJob { public void Execute() { Debug.Log("Hello parallel world!"); } } 


No método Execute , por exemplo, imprima uma linha simples no console.

Agora vamos seguir para o método Start do script TestJob , onde criaremos uma instância da tarefa e depois a executaremos.

Testjob
 public class TestJob : MonoBehaviour { void Start() { SimpleJob job = new SimpleJob(); job.Schedule().Complete(); } } 


Se você fez tudo como no exemplo, depois de iniciar o jogo, você receberá uma mensagem simples no console, como na figura.

imagem

O que acontece aqui: depois de chamar o método Schedule , o planejador coloca a tarefa no identificador e agora pode ser concluída chamando o método Complete .

Este foi um exemplo de uma tarefa que simplesmente imprimiu texto no console. Para uma tarefa executar cálculos paralelos, é necessário preenchê-la com dados.

Dados na tarefa


Como no sistema ECS , nas tarefas não há acesso aos objetos de unidade , você não pode obter o GameObject na tarefa e alterar seu nome lá. Tudo o que você pode fazer é transferir alguns parâmetros separados do objeto para a tarefa, alterá-los e, após concluir a tarefa, aplicar essas alterações novamente ao objeto.

Existem várias limitações para os dados na própria tarefa: em primeiro lugar, devem ser estruturas e, em segundo lugar, não devem ser tipos de dados conversíveis , ou seja, você não pode passar o mesmo booleano ou string para a tarefa.

Simplejob
 public struct SimpleJob : IJob { public float a, b; public void Execute() { float result = a + b; Debug.Log(result); } } 


E a principal condição: os dados não incluídos em um contêiner só podem ser acessados ​​dentro da tarefa!

Contentores


Ao trabalhar com computação multithread, é necessário, de alguma forma, trocar dados entre threads. Para poder transferir dados para eles e lê-los novamente no sistema de tarefas, para esses fins, existem contêineres. Esses contêineres são apresentados na forma de estruturas comuns e eu trabalho com o princípio de uma ponte pela qual os dados elementares são sincronizados entre os fluxos.

Existem vários tipos de contêineres:
NativeArray . O tipo de contêiner mais simples e usado com mais frequência é apresentado como uma matriz simples com um tamanho fixo.
NativeSlice . Outro contêiner - uma matriz, como é evidente na tradução, é projetada para cortar o NativeArray em pedaços.

Esses são os dois principais contêineres disponíveis sem conectar um sistema ECS . Em uma versão mais avançada, existem vários outros tipos de contêineres.

NativeList . É uma lista regular de dados.
NativeHashMap . Um análogo de um dicionário com uma chave e um valor.
NativeMultiHashMap . O mesmo NativeHashMap com apenas alguns valores em uma chave.
NativeQueue Lista de filas de dados.

Como trabalhamos sem conectar um sistema ECS , apenas o NativeArray e o NativeSlice estão disponíveis para nós .

Antes de prosseguir para a parte prática, é necessário analisar o ponto mais importante - a criação de instâncias.

Criar contêineres


Como eu disse antes, esses contêineres representam uma ponte sobre a qual os dados são sincronizados entre os threads. O sistema de tarefas abre essa ponte antes de iniciar o trabalho e a fecha após sua conclusão. O processo de abertura é chamado de " alocação " ( Alocação ) ou "alocação de memória" , o processo de fechamento é chamado de " liberação de recursos " ( Dispose ).

É a alocação que determina quanto tempo a tarefa pode usar os dados no contêiner - em outras palavras, quanto tempo a ponte ficará aberta.

Para entender melhor esses dois processos, vamos dar uma olhada na figura abaixo.

imagem

A parte inferior mostra o ciclo de vida do encadeamento principal (encadeamento principal ), calculado no número de quadros; no primeiro quadro, criamos outro encadeamento paralelo ( novo encadeamento) que existe para um determinado número de quadros e, em seguida, é fechado com segurança.
No mesmo novo encadeamento, chega a tarefa com o contêiner.

Agora dê uma olhada no topo da imagem.

imagem

A barra branca Alocação mostra a vida útil do contêiner. No primeiro quadro, o contêiner é alocado - a ponte é aberta, até o momento em que o contêiner não existia, após todos os cálculos na tarefa terem sido concluídos, o contêiner é liberado da memória e no nono quadro, a ponte é fechada.

Também nesta faixa ( Alocação ) existem segmentos de tempo ( Temp , TempJob e Presistent ), cada um desses segmentos mostrando a vida útil estimada do contêiner.

Por que esses segmentos são necessários!? O fato é que a execução de uma tarefa por duração pode ser diferente, podemos executá-las diretamente no mesmo método em que a criamos, ou podemos estender o tempo de execução da tarefa, se for bastante complicado, e esses segmentos mostram quão urgente e por quanto tempo a tarefa pode usar os dados no recipiente.

Se ainda não estiver claro, analisarei cada tipo de alocação usando um exemplo.

Agora podemos passar para a parte prática da criação de contêineres, para isso, retornamos ao método Start do script TestJob e criamos uma nova instância do contêiner NativeArray e não esquecemos de conectar as bibliotecas necessárias.

Temp


Testjob
 using Unity.Jobs; using Unity.Collections; public class TestJob : MonoBehaviour { void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); } } 


Para criar uma nova instância de contêiner, você deve especificar o tamanho e o tipo de alocação em seu construtor. Este exemplo usa o tipo Temp , pois a tarefa será executada apenas no método Start .

Agora inicialize exatamente a mesma variável de matriz na estrutura da tarefa SimpleJob .

Simplejob
 public struct SimpleJob : IJob { public NativeArray<int> array; public void Execute() {} } 


Feito. Agora você pode criar a própria tarefa e passar uma instância de matriz para ela.

Iniciar
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; } 


Para executar a tarefa dessa vez, usaremos o identificador JobHandle para obtê-la chamando o mesmo método Schedule .

Iniciar
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); } 


Agora você pode chamar o método Complete em seu identificador e verificar se a tarefa foi concluída para exibir o texto no console.

Iniciar
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print(" "); } 


Se você executar a tarefa neste formulário, depois de iniciar o jogo, você receberá um erro vermelho gordo dizendo que não liberou o contêiner da matriz dos recursos após a conclusão da tarefa.

Algo assim.

imagem

Para evitar isso, chame o método Dispose no contêiner após concluir a tarefa.

Iniciar
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print("Complete"); array.Dispose(); } 


Então você pode reiniciá-lo com segurança.
Mas a tarefa não faz nada! - adicione algumas ações a ele.

Simplejob
 public struct SimpleJob : IJob { public NativeArray<int> array; public void Execute() { for(int i = 0; i < array.Length; i++) { array[i] = i * i; } } } 


No método Execute , multiplico o índice de cada elemento da matriz por mim e escrevo de volta na matriz da matriz para imprimir o resultado no console no método Start .

Iniciar
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print(job.array[job.array.Length - 1]); array.Dispose(); } 


Qual será o resultado no console se imprimirmos o último elemento da matriz ao quadrado?

É assim que você pode criar contêineres, colocá-los em tarefas e executar ações neles.

Este foi um exemplo usando o tipo de alocação Temp , o que implica concluir uma tarefa em um quadro. Esse tipo é melhor usado quando você precisa executar cálculos rapidamente sem carregar o encadeamento principal, mas precisa ter cuidado se a tarefa for muito complicada ou se houver muitos deles, podem ocorrer flacidez; nesse caso, é melhor usar o tipo TempJob, que analisarei mais adiante.

Tempjob


Neste exemplo, modificarei levemente a estrutura da tarefa SimpleJob e a herdarei de outra interface IJobParallelFor .

Simplejob
 public struct SimpleJob : IJobParallelFor { public NativeArray<Vector2> array; public void Execute(int index) {} } 


Além disso, como a tarefa será executada por mais de um quadro, executaremos e coletaremos os resultados da tarefa nos diferentes métodos Awake e Start apresentados na forma de uma rotina. Para fazer isso, altere um pouco a aparência da classe TestJob .

Testjob
 public class TestJob : MonoBehaviour { private NativeArray<Vector2> array; private JobHandle handle; void Awake() {} IEnumerator Start() {} } 


No método Awake , criaremos uma tarefa e um contêiner de vetor e, no método Start , produziremos os dados recebidos e liberaremos os recursos.

Desperta
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; } 


Aqui, novamente, um contêiner de matriz é criado com o tipo de alocação TempJob , após o qual criamos uma tarefa e obtemos seu identificador chamando o método Schedule com pequenas alterações.

Desperta
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; this.handle = job.Schedule(100, 5) } 


O primeiro parâmetro no método Schedule indica quantas vezes a tarefa será executada. Aqui está o mesmo número que o tamanho da matriz .
O segundo parâmetro indica quantos blocos para compartilhar a tarefa.

Que outros blocos?
Anteriormente, para concluir uma tarefa, um thread simplesmente chamado método Execute uma vez, agora é necessário chamar esse método 100 vezes, para que o planejador divida essas 100 vezes de repetições em blocos que ele distribui entre os threads para não carregar nenhum thread separado. No exemplo, cem repetições serão divididas em 5 blocos de 20 repetições cada, ou seja, o planejador presumivelmente distribuirá esses 5 blocos em 5 threads, onde cada thread chamará o método Execute 20 vezes. Na prática, é claro, não é fato que o agendador faça exatamente isso, tudo depende da carga de trabalho do sistema; portanto, talvez todas as 100 repetições ocorram em um único encadeamento.

Agora você pode chamar o método Complete no identificador de tarefas.

Desperta
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; this.handle = job.Schedule(100, 5); this.handle.Complete(); } 


Na rotina inicial , verificaremos a execução da tarefa e depois limparemos o contêiner.

Iniciar
 IEnumerator Start() { while(this.handle.isCompleted == false){ yield return new WaitForEndOfFrame(); } this.array.Dispose(); } 


Agora vamos seguir para as ações na própria tarefa.

Simplejob
 public struct SimpleJob : IJobParallelFor { public NativeArray<Vector2> array; public void Execute(int index) { float x = index; float y = index; Vector2 vector = new Vector2(x * x, y * y / (y * 2)); this.array[index] = vector; } } 


Após concluir a tarefa no método Iniciar , exiba todos os elementos da matriz no console.

Iniciar
 IEnumerator Start() { while(this.handle.IsCompleted == false){ yield return new WaitForEndOfFrame(); } foreach(Vector2 vector in this.array) { print(vector); } this.array.Dispose(); } 


Feito, você pode executar e analisar o resultado.

Para entender a diferença entre IJob e IJobParallelFor, dê uma olhada nas imagens abaixo.
Por exemplo, no IJob, você pode usar um loop for simples para executar cálculos várias vezes, mas, em qualquer caso, um encadeamento pode chamar o método Execute apenas uma vez durante toda a duração da tarefa - é assim que uma pessoa executa centenas das mesmas ações seguidas.

imagem

IJobParallelFor oferece não apenas executar uma tarefa em um thread várias vezes, mas também distribuir essas repetições entre outros threads.

imagem

Em geral, o tipo de alocação TempJob é perfeito para a maioria das tarefas executadas em vários quadros.

Mas e se você precisar armazenar dados mesmo depois de concluir uma tarefa, e se depois de receber o resultado não precisar destruí-lo imediatamente. Para isso, é necessário usar o tipo de alocação Persistente , o que implica a liberação de recursos e depois “ quando necessário!” .

Persistente


Vamos voltar à classe TestJob e alterá-la. Agora, criaremos tarefas no método OnEnable , verificaremos sua execução no método Update e limparemos os recursos no método OnDisable .
No exemplo, moveremos o objeto no método Update . Para calcular a trajetória, usaremos dois contêineres vetoriais - inputArray, nos quais colocaremos a posição atual e o outputArray de onde receberemos os resultados.

Testjob
 public class TestJob : MonoBehaviour { private NativeArray<Vector2> inputArray; private NativeArray<Vector2> outputArray; private JobHandle handle; void OnEnable() {} void Update() {} void OnDisable() {} } 


Também modificaremos ligeiramente a estrutura da tarefa SimpleJob , herdando-a da interface IJob para executá-la uma vez.

Simplejob
 public struct SimpleJob : IJob { public void Execute() {} } 


Na tarefa em si, trairemos também dois contêineres de vetores, um vetor de posição e um delta numérico, que moverão o objeto para o destino.

Simplejob
 public struct SimpleJob : IJob { [ReadOnly] public NativeArray<Vector2> inputArray; [WriteOnly] public NativeArray<Vector2> outputArray; public Vector2 position; public float delta; public void Execute() {} } 


Os atributos ReadOnly e WriteOnly mostram as restrições de fluxo nas ações associadas aos dados dentro dos contêineres. O ReadOnly oferece o fluxo apenas para ler dados do contêiner, o atributo WriteOnly , pelo contrário, permite que o fluxo grave apenas dados no contêiner. Se você precisar executar essas duas ações ao mesmo tempo com um contêiner, não será necessário marcá-lo com um atributo.

Vamos seguir para o método OnEnable da classe TestJob onde os contêineres serão inicializados.

Onenable
 void OnEnable() { this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent); this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent); } 


As dimensões dos contêineres serão únicas, pois é necessário transmitir e receber parâmetros apenas uma vez. O tipo de alocação será Persistente .
No método OnDisable , liberaremos os recursos dos contêineres.

Ondisable
 void OnDisable() { this.inputArray.Dispose(); this.outputArray.Dispose(); } 


Vamos criar um método CreateJob separado, em que criaremos uma tarefa com seu identificador e lá a preencheremos com dados.

CreateJob
 void CreateJob() { SimpleJob job = new SimpleJob(); job.delta = Time.deltaTime; Vector2 position = this.transform.position; job.position = position; Vector2 newPosition = position + Vector2.right; this.inputArray[0] = newPosition; job.inputArray = this.inputArray; job.outputArray = this.outputArray; this.handle = job.Schedule(); this.handle.Complete(); } 


Na verdade, o inputArray não é realmente necessário aqui, pois é possível transferir um vetor de direção apenas para a tarefa, mas acho que será melhor entender por que esses atributos ReadOnly e WriteOnly são necessários.

No método Update , verificaremos se a tarefa está concluída, após o que aplicamos o resultado obtido à transformação do objeto e a executamos novamente.

Update
 void Update() { if (this.handle.IsCompleted) { Vector2 newPosition = this.outputArray[0]; this.transform.position = newPosition; CreateJob(); } } 


Antes de iniciar, ajustaremos ligeiramente o método OnEnable para que a tarefa seja criada imediatamente após a inicialização dos contêineres.

Onenable
 void OnEnable() { this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent); this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent); CreateJob(); } 


Concluído, agora você pode ir para a própria tarefa e executar os cálculos necessários no método Execute .

Executar
 public void Execute() { Vector2 newPosition = this.inputArray[0]; newPosition = Vector2.Lerp(this.position, newPosition, this.delta); this.outputArray[0] = newPosition; } 


Para ver o resultado do trabalho, você pode lançar o script TestJob em algum objeto e executar o jogo.

Por exemplo, meu sprite muda gradualmente para a direita.

Animação
imagem

Em geral, o tipo de alocação Persistent é ótimo para contêineres reutilizáveis ​​que não precisam ser destruídos e recriados todas as vezes.

Então, que tipo de usar!?
O tipo Temp é melhor usado para executar cálculos rapidamente, mas se a tarefa for muito complexa e grande, pode ocorrer folga.
O tipo TempJob é ótimo para trabalhar com objetos de unidade , para que você possa alterar os parâmetros dos objetos e aplicá-los, por exemplo, no próximo quadro.
O tipo Persistente pode ser usado quando a velocidade não é importante para você, mas você só precisa calcular constantemente algum tipo de dados ao lado, por exemplo, processar dados em uma rede ou o trabalho de uma IA.

Inválido e Nenhum
Existem mais dois tipos de alocação Inválido e Nenhum , mas eles são mais necessários para depuração e não participam do trabalho.


Jobhandle


Separadamente, vale a pena analisar os recursos do identificador de tarefas, porque, além de verificar o processo de execução de tarefas, esse identificador pequeno ainda pode criar redes inteiras de tarefas por meio de dependências (embora eu prefira chamá-las de filas mais).

Por exemplo, se você precisar executar duas tarefas em uma determinada sequência, para isso, basta anexar o identificador de uma tarefa ao identificador de outra.

Parece algo assim.

imagem

Cada identificador individual contém inicialmente sua própria tarefa, mas quando combinados, obtemos um novo identificador com duas tarefas.

Iniciar
 void Start() { Job jobA = new Job(); JobHandle handleA = jobA.Schedule(); Job jobB = new Job(); JobHandle handleB = jobB.Schedule(); JobHandle result = JobHandle.CombineDependecies(handleA, handleB); result.Complete(); } 


Mais ou menos.

Iniciar
 void Start() { JobHandle handle; for(int i = 0; i < 10; i++) { Job job = new Job(); handle = job.Schedule(handle); } handle.Complete(); } 


A sequência de execução é salva e o planejador não iniciará a próxima tarefa até que esteja convencido da anterior, mas é importante lembrar que a propriedade do identificador IsCompleted aguardará a conclusão de todas as tarefas.

Conclusão


Contentores


  1. Ao trabalhar com dados em contêineres, não se esqueça de que são estruturas, portanto, qualquer substituição de dados no contêiner não os altera, mas os cria novamente.
  2. O que acontece se você definir o tipo de alocação Temp e não limpar os recursos após a conclusão da tarefa? O erro
  3. Posso criar meus próprios contêineres? É possível que as unidades descritas detalhadamente o processo de criação de contêineres personalizados aqui, mas é melhor pensar algumas vezes: vale a pena, talvez haja contêineres comuns suficientes!?

Segurança!


Dados estáticos.

Não tente usar dados estáticos em uma tarefa ( Aleatória e outros), qualquer acesso a dados estáticos violará a segurança do sistema. Na verdade, no momento, você pode acessar dados estáticos, mas apenas se tiver certeza de que eles não mudam durante o trabalho - ou seja, eles são completamente estáticos e somente leitura.

Quando usar o sistema de tarefas?

Todos esses exemplos dados aqui no artigo são apenas condicionais e mostram como trabalhar com esse sistema, e não quando usá-lo. O sistema de tarefas pode ser usado sem ECS,você precisa entender que o sistema também consome recursos no trabalho e que, por qualquer motivo, imediatamente escreve tarefas, criar montes de contêineres é simplesmente inútil - tudo se tornará ainda pior. Por exemplo, recalcular um array com um tamanho de 10 mil elementos não será correto - levará mais tempo para o agendador funcionar, mas recalcular todos os polígonos de um grande terrane ou mesmo gerá-lo é a solução certa, você pode dividir o terrane em tarefas e processar cada um em um fluxo separado.

Em geral, se você está constantemente envolvido em cálculos complexos em projetos e constantemente procura novas oportunidades para tornar esse processo menos intensivo em recursos, o Job Systemé exatamente isso que você precisa. Se você trabalha constantemente com cálculos complexos inseparavelmente de objetos e deseja que seu código funcione mais rapidamente e seja suportado na maioria das plataformas, o ECS definitivamente o ajudará com isso. Se você criar projetos apenas para o WebGL , isso não é para você, no momento, o Job System não suporta o trabalho em navegadores, embora isso não seja um problema para unitecs, mas para os próprios desenvolvedores.

Fonte com todos os exemplos

Source: https://habr.com/ru/post/pt420829/


All Articles