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:
Ijobpublic 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.

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.

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.

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.

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.
IJobParallelFor oferece não apenas executar uma tarefa em um thread várias vezes, mas também distribuir essas repetições entre outros threads.

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

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