En la nueva versión de
unity en 2018, finalmente agregaron oficialmente el nuevo
sistema de componentes Entity, o
ECS para abreviar, que en lugar del trabajo habitual con los componentes del objeto solo puede funcionar con sus datos.
Un sistema de tareas adicional le ofrece el uso de potencia informática paralela para mejorar el rendimiento de su código.
Juntos, estos dos nuevos sistemas (
ECS y
Job System ) ofrecen un nuevo nivel de procesamiento de datos.
Específicamente, en este artículo no analizaré todo el sistema
ECS , que actualmente está disponible como un conjunto de herramientas descargadas por separado en la
unidad , sino que solo consideraré el sistema de tareas y cómo se puede usar fuera del paquete
ECS .
Nuevo sistema
Inicialmente, la
unidad podría haber utilizado la computación de subprocesos múltiples antes, pero todo esto tuvo que ser creado por el desarrollador por su cuenta, para resolver los problemas él mismo y sortear las dificultades. Y si antes era necesario trabajar directamente con cosas como crear subprocesos, cerrar subprocesos, grupos, sincronización, ahora todo este trabajo recaía en los hombros del motor, y el desarrollador solo necesitaba crear tareas y completarlas.
Las tareas
Para realizar cualquier cálculo en el nuevo sistema, es necesario utilizar tareas que son objetos que consisten en métodos y datos para el cálculo.
Al igual que cualquier otro dato en el sistema
ECS , las tareas en el
sistema de trabajo también se representan como estructuras que heredan una de las tres interfaces.
Trabajo
La interfaz de tareas más simple que contiene un método
Execute que no toma nada en forma de parámetros y no devuelve nada.
La tarea en sí se ve así:
Trabajopublic struct JobStruct : IJob { public void Execute() {} }
En el método
Ejecutar , puede realizar los cálculos necesarios.
IJobParallelFor
Otra interfaz con el mismo método de
ejecución , que a su vez ya acepta el
índice de parámetro numérico.
IJobParallelFor public struct JobStruct : IJobParallelFor { public void Execute(int index) {} }
Esta interfaz
IJobParallelFor , a diferencia de la interfaz
IJob , ofrece ejecutar una tarea varias veces y no solo ejecutarla, sino dividir esta ejecución en bloques que se distribuirán entre subprocesos.
No está claro No te preocupes por esto, te contaré más.IJobParallelForTransform
Y la última interfaz especial, que, como su nombre lo indica, está diseñada para funcionar con estas transformaciones del objeto. También contiene el método
Execute , con el
índice del parámetro numérico y el parámetro
TransformAccess donde se ubican la posición, el tamaño y la rotación de la transformación.
IJobParallelForTransform public struct JobStruct : IJobParallelForTransform { public void Execute(int index, TransformAccess transform) {} }
Debido al hecho de que no puede trabajar con objetos de la
unidad directamente en la tarea, esta interfaz solo puede procesar datos de transformación como una estructura
TransformAccess separada.
Hecho, ahora que sabe cómo se crean las estructuras de tareas, puede proceder a practicar.
Tarea completada
Vamos a crear una tarea simple heredada de la interfaz
IJob y completarla. Para esto necesitamos cualquier script
MonoBehaviour simple y la estructura de la tarea misma.
Testjob public class TestJob : MonoBehaviour { void Start() {} }
Ahora suelte este script en algún objeto en la escena. En el mismo script (
TestJob ) a continuación, escribiremos la estructura de la tarea y no olvidemos importar las bibliotecas necesarias.
Trabajo simple using Unity.Jobs; public struct SimpleJob : IJob { public void Execute() { Debug.Log("Hello parallel world!"); } }
En el método
Ejecutar , por ejemplo, imprima una línea simple en la consola.
Ahora pasemos al método
Start del script
TestJob , donde crearemos una instancia de la tarea y luego la ejecutaremos.
Testjob public class TestJob : MonoBehaviour { void Start() { SimpleJob job = new SimpleJob(); job.Schedule().Complete(); } }
Si hiciste todo como en el ejemplo, luego de comenzar el juego recibirás un mensaje simple en la consola como en la imagen.

Lo que sucede aquí: después de llamar al método
Schedule , el planificador coloca la tarea en el controlador y ahora puede completarse llamando al método
Complete .
Este fue un ejemplo de una tarea que simplemente imprimió texto en la consola. Para que una tarea realice cálculos paralelos, es necesario llenarla con datos.
Datos en la tarea
Al igual que en el sistema
ECS , en las tareas no hay acceso a los objetos de la
unidad , no puede obtener el
GameObject en la tarea y cambiar su nombre allí. Todo lo que puede hacer es transferir algunos parámetros de objeto separados a la tarea, cambiar estos parámetros y, después de completar la tarea, aplicar estos cambios nuevamente al objeto.
Existen varias limitaciones en los datos de la tarea en sí: en primer lugar, deben ser estructuras y, en segundo lugar, no deben ser tipos de datos
convertibles , es decir, no puede pasar el mismo
booleano o
cadena a la tarea.
Trabajo simple public struct SimpleJob : IJob { public float a, b; public void Execute() { float result = a + b; Debug.Log(result); } }
Y la condición principal: ¡los datos no incluidos en un contenedor solo se pueden acceder dentro de la tarea!
Contenedores
Cuando se trabaja con computación de subprocesos múltiples, es necesario intercambiar datos de alguna manera entre subprocesos. Para poder transferir datos a ellos y volver a leerlos en el sistema de tareas, para estos fines hay contenedores. Estos contenedores se presentan en forma de estructuras ordinarias y trabajo según el principio de un puente mediante el cual los datos elementales se sincronizan entre flujos.
Existen varios tipos de contenedores:
NativeArray . El tipo de contenedor más simple y más utilizado se presenta como una matriz simple con un tamaño fijo.
NativeSlice . Otro contenedor, una matriz, como se desprende de la traducción, está diseñada para cortar el NativeArray en pedazos.
Estos son los dos contenedores principales disponibles sin conectar un sistema
ECS . En una versión más avanzada, hay varios tipos de contenedores.
NativeList . Es una lista regular de datos.
NativeHashMap . Un análogo de un diccionario con una clave y un valor.
NativeMultiHashMap . El mismo
NativeHashMap con solo unos pocos valores bajo una clave.
NativeQueue Lista de colas de datos.
Como trabajamos sin conectar un sistema
ECS , solo
NativeArray y
NativeSlice están disponibles para
nosotros .
Antes de pasar a la parte práctica, es necesario analizar el punto más importante: la creación de instancias.
Crear contenedores
Como dije antes, estos contenedores representan un puente sobre el cual los datos se sincronizan entre hilos. El sistema de tareas abre este puente antes de comenzar a trabajar y lo cierra después de su finalización. El proceso de apertura se llama "
asignación " (
asignación ) o
"asignación de memoria" , el proceso de cierre se llama "
liberación de recursos " (
desechar ).
Es la asignación la que determina cuánto tiempo la tarea puede usar los datos en el contenedor; en otras palabras, cuánto tiempo estará abierto el puente.
Para comprender mejor estos dos procesos, echemos un vistazo a la imagen a continuación.

La parte inferior muestra el ciclo de vida del hilo principal (hilo
principal ), que se calcula en el número de cuadros; en el primer cuadro, creamos otro hilo paralelo (
hilo nuevo) que existe para un cierto número de cuadros y luego se cierra de forma segura.
En el mismo
hilo nuevo llega
la tarea con el contenedor.
Ahora mire la parte superior de la imagen.

La barra blanca
Asignación muestra la vida útil del contenedor. En el primer cuadro, se
asigna el contenedor: el puente se abre, hasta este momento el contenedor no existía, después de que se hayan completado todos los cálculos en la tarea, el contenedor se libera de la memoria y en el noveno cuadro se cierra el puente.
También en esta tira (
Asignación ) hay segmentos de tiempo (
Temp ,
TempJob y
Presistent ), cada uno de estos segmentos muestra la vida útil estimada del contenedor.
¿Por qué se necesitan estos segmentos? El hecho es que la ejecución de una tarea por duración puede ser diferente, podemos ejecutarla directamente en el mismo método donde la creamos, o podemos extender el tiempo de ejecución de la tarea si es bastante complicada, y estos segmentos muestran qué tan urgente y cuánto tiempo la tarea puede usar los datos en el contenedor
Si aún no está claro, analizaré cada tipo de asignación utilizando un ejemplo.Ahora podemos pasar a la parte práctica de crear contenedores, para esto volvemos al método
Start del script
TestJob y creamos una nueva instancia del contenedor
NativeArray y no nos olvidamos de conectar las bibliotecas necesarias.
Temp
Testjob using Unity.Jobs; using Unity.Collections; public class TestJob : MonoBehaviour { void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); } }
Para crear una nueva instancia de contenedor, debe especificar el tamaño y el tipo de asignación en su constructor. Este ejemplo usa el tipo
Temp , ya que la tarea se realizará solo en el método
Start .
Ahora inicialice exactamente la misma variable de matriz en la estructura de la tarea
SimpleJob .
Trabajo simple public struct SimpleJob : IJob { public NativeArray<int> array; public void Execute() {} }
Listo Ahora puede crear la tarea en sí misma y pasarle una instancia de matriz.
Inicio void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; }
Para ejecutar la tarea esta vez, utilizaremos su identificador
JobHandle para obtenerla llamando al mismo método
Schedule .
Inicio void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); }
Ahora puede llamar al método
Completo en su identificador y verificar si la tarea se ha completado para mostrar el texto en la consola.
Inicio 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(" "); }
Si ejecuta la tarea de esta forma, luego de comenzar el juego obtendrá un error rojo que dice que no liberó el contenedor de matriz de los recursos después de que se completó la tarea.
Algo asi.

Para evitar esto, llame al método
Dispose en el contenedor después de completar la tarea.
Inicio 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(); }
Entonces puede reiniciarlo de forma segura.
¡Pero la tarea no hace nada! - luego agregue un par de acciones.
Trabajo simple public struct SimpleJob : IJob { public NativeArray<int> array; public void Execute() { for(int i = 0; i < array.Length; i++) { array[i] = i * i; } } }
En el método
Ejecutar , multiplico el índice de cada elemento de la matriz por mí mismo y lo escribo de nuevo en la matriz de la
matriz para imprimir el resultado en la consola en el método de
Inicio .
Inicio 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(); }
¿Cuál será el resultado en la consola si imprimimos el último elemento de la matriz al cuadrado?
Así es como puede crear contenedores, ponerlos en tareas y realizar acciones en ellos.
Este fue un ejemplo utilizando el tipo de asignación
Temp , lo que implica completar una tarea dentro de un marco. Este tipo se utiliza mejor cuando necesita realizar cálculos rápidamente sin cargar el subproceso principal, pero debe tener cuidado si la tarea es demasiado complicada o si habrá muchos de ellos, puede producirse una caída, en este caso es mejor usar el tipo
TempJob, que analizaré más adelante.
Trabajo temporal
En este ejemplo,
modificaré ligeramente
la estructura de la tarea
SimpleJob y la heredaré de otra interfaz
IJobParallelFor .
Trabajo simple public struct SimpleJob : IJobParallelFor { public NativeArray<Vector2> array; public void Execute(int index) {} }
Además, dado que la tarea durará más de un fotograma, ejecutaremos y recopilaremos los resultados de la tarea en diferentes métodos
Despertar e
Iniciar presentados en forma de rutina. Para hacer esto, cambie un
poco la apariencia de la clase
TestJob .
Testjob public class TestJob : MonoBehaviour { private NativeArray<Vector2> array; private JobHandle handle; void Awake() {} IEnumerator Start() {} }
En el método
Despertar , crearemos una tarea y un contenedor de vectores, y en el método
Inicio , enviaremos los datos recibidos y liberaremos recursos.
Despierto void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; }
Una vez más, se crea un contenedor de
matriz con el tipo de asignación
TempJob , después de lo cual creamos una tarea y obtenemos su manejo llamando al método
Schedule con cambios menores.
Despierto void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; this.handle = job.Schedule(100, 5) }
El primer parámetro en el método
Schedule indica cuántas veces se ejecutará la tarea, aquí está el mismo número que el tamaño de la matriz de
matriz .
El segundo parámetro indica cuántos bloques compartir la tarea.
¿Qué otros bloques?Anteriormente, para completar una tarea, un subproceso simplemente llamaba al método
Execute una vez, ahora es necesario llamar a este método 100 veces, por lo que el planificador divide estas 100 veces de repeticiones en bloques que distribuye entre los subprocesos para no cargar ningún subproceso separado. En el ejemplo, cien repeticiones se dividirán en 5 bloques de 20 repeticiones cada una, es decir, el programador presumiblemente distribuirá estos 5 bloques en 5 hilos, donde cada hilo llamará al método
Ejecutar 20 veces. En la práctica, por supuesto, no es un hecho que el planificador haga exactamente eso, todo depende de la carga de trabajo del sistema, por lo que tal vez las 100 repeticiones sucedan en un hilo.
Ahora puede llamar al método
Completo en el controlador de tareas.
Despierto 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(); }
En la rutina de
Inicio , verificaremos la ejecución de la tarea y luego limpiaremos el contenedor.
Inicio IEnumerator Start() { while(this.handle.isCompleted == false){ yield return new WaitForEndOfFrame(); } this.array.Dispose(); }
Ahora pasemos a las acciones en la tarea misma.
Trabajo simple 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; } }
Después de completar la tarea en el método de
Inicio , muestre todos los elementos de la matriz en la consola.
Inicio IEnumerator Start() { while(this.handle.IsCompleted == false){ yield return new WaitForEndOfFrame(); } foreach(Vector2 vector in this.array) { print(vector); } this.array.Dispose(); }
Listo, puedes correr y mirar el resultado.
Para comprender la diferencia entre
IJob e
IJobParallelFor, eche un vistazo a las imágenes a continuación.
Por ejemplo,
IJob también puede usar un bucle simple
para realizar cálculos varias veces, pero en cualquier caso, un hilo solo puede llamar al método
Ejecutar una vez durante toda la tarea: así es como hacer que una persona realice cientos de las mismas acciones seguidas.
IJobParallelFor ofrece no solo ejecutar una tarea en un hilo varias veces, sino también distribuir estas repeticiones entre otros hilos.

En general, el tipo de asignación
TempJob es perfecto para la mayoría de las tareas que se realizan en varios marcos.
Pero, ¿qué sucede si necesita almacenar datos incluso después de completar una tarea? ¿Qué sucede si después de recibir el resultado no necesita destruirlo de inmediato? Para esto, es necesario usar el tipo de asignación
Persistente , lo que implica la liberación de recursos y luego "
cuando sea necesario". .
Persistente
Volvamos a la clase
TestJob y cámbiela. Ahora crearemos tareas en el método
OnEnable , verificaremos su ejecución en el método
Actualizar y
limpiaremos los recursos en el método
OnDisable .
En el ejemplo, moveremos el objeto en el método
Actualizar , para calcular la trayectoria usaremos dos contenedores de vectores:
inputArray en el que
colocaremos la posición actual y
outputArray desde donde recibiremos los resultados.
Testjob public class TestJob : MonoBehaviour { private NativeArray<Vector2> inputArray; private NativeArray<Vector2> outputArray; private JobHandle handle; void OnEnable() {} void Update() {} void OnDisable() {} }
También
modificaremos ligeramente
la estructura de la tarea
SimpleJob al heredarla de la interfaz
IJob para ejecutarla una vez.
Trabajo simple public struct SimpleJob : IJob { public void Execute() {} }
En la tarea misma, también traicionaremos dos contenedores de vectores, un vector de posición y un delta numérico, que moverán el objeto al objetivo.
Trabajo simple public struct SimpleJob : IJob { [ReadOnly] public NativeArray<Vector2> inputArray; [WriteOnly] public NativeArray<Vector2> outputArray; public Vector2 position; public float delta; public void Execute() {} }
Los atributos
ReadOnly y
WriteOnly muestran las restricciones de flujo en las acciones asociadas con los datos dentro de los contenedores.
ReadOnly ofrece la secuencia solo para leer datos del contenedor, el atributo
WriteOnly , por el contrario, permite que la secuencia solo escriba datos en el contenedor. Si necesita realizar estas dos acciones a la vez con un contenedor, no necesita marcarlo con un atributo.
Pasemos al método
OnEnable de la clase
TestJob donde se inicializarán los contenedores.
Onenable void OnEnable() { this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent); this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent); }
Las dimensiones de los contenedores serán únicas ya que es necesario transmitir y recibir parámetros solo una vez. El tipo de asignación será
persistente .
En el método
OnDisable ,
liberaremos los recursos de los contenedores.
Ondisable void OnDisable() { this.inputArray.Dispose(); this.outputArray.Dispose(); }
Creemos un método
CreateJob separado donde crearemos una tarea con su identificador y allí la
llenaremos con datos.
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(); }
En realidad, inputArray no es realmente necesario aquí, ya que es posible transferir un vector de dirección solo a la tarea, pero creo que será mejor entender por qué estos atributos ReadOnly y WriteOnly son necesarios.En el método de
actualización , comprobaremos si la tarea se ha completado, después de lo cual aplicaremos el resultado obtenido a la transformación del objeto y lo ejecutaremos nuevamente.
Actualización void Update() { if (this.handle.IsCompleted) { Vector2 newPosition = this.outputArray[0]; this.transform.position = newPosition; CreateJob(); } }
Antes de comenzar, modificaremos ligeramente el método
OnEnable para que la tarea se cree inmediatamente después de que se inicialicen los contenedores.
Onenable void OnEnable() { this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent); this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent); CreateJob(); }
Hecho, ahora puede ir a la tarea misma y realizar los cálculos necesarios en el método
Ejecutar .
Ejecutar public void Execute() { Vector2 newPosition = this.inputArray[0]; newPosition = Vector2.Lerp(this.position, newPosition, this.delta); this.outputArray[0] = newPosition; }
Para ver el resultado del trabajo, puede lanzar el script
TestJob en algún objeto y ejecutar el juego.
Por ejemplo, mi sprite se desplaza gradualmente hacia la derecha.
En general, el tipo de asignación
Persistente es ideal para contenedores reutilizables que no necesitan ser destruidos y recreados cada vez.
Entonces, ¿qué tipo usar?El tipo
Temp se usa mejor para realizar cálculos rápidamente, pero si la tarea es demasiado compleja y grande, puede producirse una holgura.
El tipo
TempJob es ideal para trabajar con objetos de
unidad , por lo que puede cambiar los parámetros de los objetos y aplicarlos, por ejemplo, en el siguiente cuadro.
El tipo
Persistente se puede usar cuando la velocidad no es importante para usted, pero solo necesita calcular constantemente algún tipo de datos adicionales, por ejemplo, procesar datos a través de una red o el trabajo de una IA.
Inválido y ningunoHay dos tipos más de asignación no válidos y ninguno , pero se necesitan más para la depuración y no participan en el trabajo.
Jobhandle
Por separado, vale la pena analizar las capacidades del manejador de tareas, porque además de verificar el proceso de ejecución de tareas, este pequeño manejador aún puede crear redes enteras de tareas a través de dependencias (aunque prefiero llamarlas más colas).
Por ejemplo, si necesita realizar dos tareas en una secuencia determinada, para esto solo necesita adjuntar el identificador de una tarea al identificador de otra.
Se ve algo como esto.

Cada identificador individual contiene inicialmente su propia tarea, pero cuando se combinan, obtenemos un nuevo identificador con dos tareas.
Inicio 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(); }
O eso.
Inicio void Start() { JobHandle handle; for(int i = 0; i < 10; i++) { Job job = new Job(); handle = job.Schedule(handle); } handle.Complete(); }
La secuencia de ejecución se guarda y el planificador no iniciará la siguiente tarea hasta que esté convencido de la anterior, pero es importante recordar que la propiedad del
controlador IsCompleted esperará a que se completen todas las tareas.
Conclusión
Contenedores
- Cuando trabaje con datos en contenedores, no olvide que se trata de estructuras, por lo que cualquier sobrescritura de datos en el contenedor no los cambia, sino que los crea de nuevo.
- ¿Qué sucede si establece el tipo de asignación Temp y no borra los recursos después de completar la tarea? El error
- ¿Puedo crear mis propios contenedores? Es posible que las unidades describan en detalle el proceso de creación de contenedores personalizados aquí, pero es mejor pensar algunas veces: ¿vale la pena, tal vez habrá suficientes contenedores normales?
Seguridad!
Datos estáticosNo intente utilizar datos estáticos en una tarea ( Aleatorio y otros), cualquier acceso a datos estáticos violará la seguridad del sistema. En realidad, en este momento puede acceder a datos estáticos, pero solo si está seguro de que no cambian durante el trabajo, es decir, son completamente estáticos y de solo lectura.¿Cuándo usar el sistema de tareas?Todos estos ejemplos que se dan aquí en el artículo son solo condicionales y muestran cómo trabajar con este sistema, y no cuándo usarlo. El sistema de tareas se puede usar sin ECS,debe comprender que el sistema también consume recursos en el trabajo y que, por cualquier motivo, escribir tareas de inmediato, crear montones de contenedores simplemente no tiene sentido: todo empeorará aún más. Por ejemplo, volver a calcular una matriz de 10 mil elementos de tamaño no será correcto: le llevará más tiempo trabajar como programador, pero recalcular todos los polígonos de un gran terreno o incluso generarlo es la solución correcta, puede dividir el terreno en tareas y procesar cada uno en una secuencia separada.En general, si participa constantemente en cálculos complejos en proyectos y busca constantemente nuevas oportunidades para hacer que este proceso sea menos intensivo en recursos, entonces Job SystemEsto es exactamente lo que necesitas. Si trabajas constantemente con cálculos complejos inseparables de los objetos y quieres que tu código funcione más rápido y sea compatible con la mayoría de las plataformas, entonces ECS definitivamente te ayudará con esto. Si crea proyectos solo para WebGL, entonces esto no es para usted, en este momento Job System no admite trabajar en navegadores, aunque esto no es un problema para unitecs, sino para los desarrolladores de navegadores.Fuente con todos los ejemplos