Guía práctica del sistema Unity Job y Burst: ¿Cómo usarlos correctamente?
Si el objetivo es simplemente la programación multihilo, usar Async para cambiar de hilo o System.Threading es el método más conveniente y claro. Burst también se puede llamar directamente, no necesariamente se requiere Job. Consulta los artículos sobre Async y Llamada directa. Pero si se trata de una gran cantidad de operaciones pequeñas, entonces es cuando debes considerar el sistema Job.
La documentación de Unity Job es extremadamente incompleta. Este artículo es un registro de experiencia de uso a largo plazo y debería ser bastante completo.
Propósito y limitaciones de Job
A diferencia del enfoque general de multihilo, el sistema Unity Job es un sistema que simula el alto rendimiento de la GPU mediante múltiples hilos, es decir, el costo de asignar tareas es extremadamente bajo.
Job solo puede manejar la cantidad de cálculo dentro de 1 fotograma, y al igual que las funciones de cuda/shader, son pequeños núcleos. Las operaciones pesadas aún afectarán los FPS, porque a veces Job se asigna al hilo principal para ejecutarse. Si deseas ejecutar cálculos de larga duración, o los divides en muchos pequeños Jobs, o los hilos de .Net siguen siendo la mejor opción.
Entonces surge la pregunta: si es similar a la GPU, ¿por qué no usar ComputeShader directamente? Correcto, ComputeShader puede hacer todo el trabajo de Job, y es más conveniente. Excepto:
- Si necesitas intercambiar datos frecuentemente con la CPU, la GPU no es buena en esto, y Job basado en CPU tiene ventajas.
- El sistema ECS (DOTS) está basado en Job, por la misma razón anterior.
Nota: WebGL no es compatible con Job ni ComputeShader, ni con la aceleración Burst, pero:
- WebGL está siendo reemplazado por WebGPU, que sí es compatible con ComputeShader, y los navegadores nuevos ya lo admiten (en IOS17 se debe habilitar en la configuración, en la versión 19 de vista previa está habilitado por defecto).
- En la configuración, WebAssembly 2023 puede habilitar el soporte multihilo para Job, y el soporte del navegador es más completo. Pero oficialmente dicen que ni siquiera es una característica experimental, no se puede usar. En Unity6 LTS, habilitarlo causará un Crash. El multihilo en wasm les ha traído dilemas durante muchos años, y dado que DOTS es actualmente más importante, junto con el soporte de multihilo wasm en .Net8, tal vez estén esperando a que Unity7 soporte .Net8.
Rendimiento
Proporcionar métricas de rendimiento facilita la comprensión de la intención de diseño y la aplicabilidad. También puedes leer el artículo primero y luego volver a ver las pruebas de rendimiento.
Se probaron 8 proyectos, primero se probó el rendimiento de 3 tipos de asignadores:
- BenchAllocatorTemp: Ejecutar 100,000 asignaciones Allocator.Temp.
- BenchAllocatorTempJob: Igual que arriba, asignador Allocator.TempJob.
- BenchAllocatorPersistent: Igual que arriba, asignador Allocator.Persistent. El resultado fue que TempJob es el más rápido, seguido de Persistent.
Luego se probó el rendimiento de 4 modos de Job:
- BenchBaseLine: Usar For para ejecutar 100,000 cálculos simples como línea de referencia.
- BenchIJob: Tiempo para programar 100,000 Jobs.
- BenchIJobParallelFor: Tiempo para programar por lotes 100,000 Jobs en modo paralelo.
- BenchIJobParallelForBurst: Igual que arriba, pero con Burst habilitado.
- BenchIJobParallelForBurstLoopVectorization: Programar 10 Jobs, cada Job calcula 10,000 veces con For, y con vectorización Burst habilitada.
El siguiente es el efecto de la prueba en mi PC:

Median es la mediana del tiempo de ejecución del proyecto de prueba, en milisegundos.
Se puede ver que el costo de programación de Job es pequeño, está diseñado para ejecutar una gran cantidad de tareas. Por supuesto, aquí solo realicé cálculos simples de multiplicación, por lo que la mejora de Job es limitada.
Tipos de datos
Primero, Burst no admite tipos administrados de C#, solo se pueden usar tipos con la misma longitud que C, que se pueden copiar directamente con memcpy (sin necesidad de serialización/marshal), llamados blittable. Incluyen tipos básicos como int, etc. (char, string y bool a veces son administrados, no los uses), y arrays unidimensionales al estilo C de tipos blittable (new int[5]). Y Job inevitablemente se usa en combinación con Burst, por lo que sigue esta restricción.
Unity encapsula un tipo seguro para hilos llamado NativeArray específicamente para su uso en Jobs. Estos tipos pueden compartir datos con el hilo principal sin necesidad de copiar, porque al copiar solo se pasa el puntero de datos, y múltiples copias hacen referencia a la misma región de memoria. Los derivados son NativeList, NativeQueue, NativeHashMap, NativeHashSet, NativeText, etc., pero estos solo se pueden usar en un solo hilo.
Nota: No se puede usar código como nativeArray[0].x = 1.0f, o nativeArray[0]++;, el valor no cambiará porque lo que devuelve no es una referencia.
Seguridad de hilos
La seguridad de hilos se implementa mediante restricciones de programación. Una misma instancia de NativeArray solo puede tener 1 Job escribiendo en ella, de lo contrario se lanzará una excepción. Si los datos se pueden paralelizar mediante segmentación, se puede usar IJobParallelFor para ejecutar por lotes en el NativeArray. Si los datos son de solo lectura, se pueden identificar definiendo la variable miembro con, por ejemplo, [ReadOnly] public NativeArray<int> input;.
Cuando Job está escribiendo, el hilo principal no puede leer el NativeArray, dará error, hay que esperar a que termine.
Asignación de memoria (allocate)
Primero, los tipos Native requieren que los destruyas manualmente con Dispose() después de usarlos, no se destruyen automáticamente. Para ello, Unity añadió seguimiento de fugas de memoria.
Al crear nuevos tipos Native, debes elegir entre 3 tipos de asignadores: Temp, TempJob, Persistent. La velocidad de asignación va de rápida a lenta. Temp tiene un ciclo de vida de 1 fotograma, TempJob de 4 fotogramas. ¿Qué significan?
Tempsignifica que lo uses dentro de la función actual y loDispose()antes de que termine la función. Por lo tanto, si olvidas el Dispose, Unity informará un error inmediatamente en el siguiente renderizado, pero esta asignación en realidad es bastante lenta.TempJobtiene condiciones de error más flexibles, en realidad aún debes usarlo dentro de 1 fotograma, pero puedes hacer Dispose en el siguiente fotograma.Persistentno da error, debes tener cuidado tú mismo.
El proyecto BenchAllocator en las pruebas de rendimiento anteriores probaba el rendimiento de estos 3. Se puede ver que Allocator.Temp tomó 4 veces más tiempo que TempJob. La documentación dice que Temp es el más rápido, esto es un BUG o un problema del modo Editor.
Ejecutar Job de un solo hilo
El proceso completo es escribir tu propia clase IJob, programarla (Schedule) desde el hilo principal, y luego llamar a Complete para bloquear y esperar a que Job termine.
public struct MyJob : IJob {
public NativeArray<float> result;
public void Execute() {
for (int j = 0; j < result.Length; j++)
result[j] = result[j] * result[j];
}
}
void Update() {
result = new NativeArray<float>(100000, Allocator.TempJob);
MyJob jobData = new MyJob{
result = result
};
handle = jobData.Schedule();
}
private void LateUpdate() {
handle.Complete();
result.Dispose();
}Pero el problema es que usamos Job para una gran cantidad de tareas, esta tarea única no es muy útil. El modo paralelo similar a la GPU es más útil.
Modo paralelo (Parallel Job)
Cambiar el código anterior de heredar de IJob a heredar de IJobParallelFor es el modo paralelo.
public struct MyJob : IJobParallelFor {
public NativeArray<float> result;
public void Execute(int i) {
result[i] = result[i] * result[i];
}
}
void Update() {
result = new NativeArray<float>(100000, Allocator.TempJob);
MyJob jobData = new MyJob{
result = result
};
handle = jobData.Schedule(result.Length, result.Length / 10);
}
private void LateUpdate() {
handle.Complete();
result.Dispose();
}El modo paralelo no requiere que escribas el bucle For tú mismo, ejecuta Execute una vez para cada elemento, similar a Shader.
Schedule(result.Length, result.Length / 10) significa ejecutar Execute para cada unidad desde el índice 0 hasta la longitud result.Length, distribuyéndolo en 10 workers.
Para ver la diferencia de rendimiento entre IJob e IJobParallelFor, consulta las pruebas de rendimiento anteriores.
Limitaciones del paralelismo
En IJobParallelFor solo puedes escribir en el elemento i, y no sabe en qué Array miembro quieres escribir, por lo que todos los Arrays solo pueden escribir en el elemento i. Pero puedes agregar el identificador [NativeDisableParallelForRestriction] a NativeArray para desactivar la verificación de seguridad, asegurándote tú mismo de que no haya conflictos de escritura.
El modo de solo lectura no tiene restricciones para todos los contenedores Native.
Además, IJobParallelFor no puede habilitar la vectorización de bucles, a menos que tu cálculo ya use vectorización (llamando a otras funciones ya vectorizadas), de lo contrario el rendimiento no será óptimo.
Usar contenedores como NativeList en paralelo
Los contenedores distintos de Array, como NativeList, en paralelo solo pueden estar en modo de solo lectura. Entonces, ¿cómo escribir en ellos?
En realidad, en el diseño, NativeList se divide en dos estados de trabajo: Add y Set. El patrón de uso correcto es que un Job realice operaciones Add y un segundo Job realice operaciones Set.
Para Add, se puede usar ParallelWriter y AsParallelWriter, de la siguiente manera:
public struct AddListJob : IJobParallelFor {
public NativeList<float>.ParallelWriter result;
public void Execute(int i) {
result.AddNoResize(i);
}
}
public void RunIJobParallelForList() {
var results = new NativeList<float>(10, Allocator.TempJob);
var jobData = new AddListJob() {
result = results.AsParallelWriter(),
};
var handle = jobData.Schedule(10, 1);
handle.Complete();
Debug.Log(String.Join(",", results.ToArray(Allocator.TempJob)));
results.Dispose();
}En este estado, NativeList tiene capacidad fija, se debe preasignar memoria antes de iniciar, y solo se puede operar con AddNoResize(). Este método se implementa mediante un bloqueo atómico de la propiedad Length, lo cual tiene un costo de rendimiento considerable.
Luego, usa la conversión sin pérdidas de NativeList a NativeArray: NativeList.AsDeferredJobArray(). El NativeArray devuelto por este método es perezoso, solo se convierte cuando Job se ejecuta realmente, por lo que se puede pasar antes de que se ejecuten los 2 Jobs:
var addJob = new AddListJob { result = results.AsParallelWriter() };
var jobHandle = addJob.Schedule(10, 1);
var setJob = new SetListJob { array = results.AsDeferredJobArray() };
setJob.Schedule(10, 1, jobHandle).Complete();Nota: tanto AsDeferredJobArray como AsArray devuelven una Vista, es decir, una vista de los datos originales. Lo que aún se debe Dispose son los datos de origen.
Modo paralelo para arrays bidimensionales
IJobParallelFor solo puede paralelizar por elemento individual del Array, pero en realidad es más útil paralelizar por cada fila de un array bidimensional, y además puede habilitar la vectorización de bucles, obteniendo un rendimiento mayor. Se puede usar IJobParallelForBatch para realizar esta operación.
Primero creamos un array bidimensional plano de [10*15], luego lo programamos con IJobParallelForBatch.Schedule(int length, int batchCount). batchCount indica cuántos datos maneja cada job, Execute se ejecutará length/batchCount veces.
var results = new NativeArray<float>(10*15, Allocator.TempJob);
var jobData = new MyJob2D {
result = results
};
var handle = jobData.Schedule(10*15, 15);
handle.Complete();
Debug.Log(String.Join(",", results));
results.Dispose();Luego está la implementación de MyJob2D.
[BurstCompile]
public struct MyJob2D : IJobParallelForBatch {
public NativeArray<float> result;
public void Execute(int i, int count) {
for (int j = i; j < i + count; j++) {
result[j] = i;
}
}
}Resultado de la ejecución:
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,
6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,
9,9,9,9,9,9,9,9,9,9,9,9,9,9,9
UnityEngine.Debug:Log (object)Este método puede habilitar automáticamente la vectorización de bucles mediante Burst, por lo que en las pruebas de rendimiento, el tiempo para calcular 100,000 veces fue de 0.09 ms, siendo el más rápido.
Otras limitaciones
- No puedes iniciar un Job dentro de otro Job.
Combinación con Async
Los casos de uso anteriores programan el Job en Update y lo completan en LateUpdate, con el objetivo de acelerar el código de Update. Para tareas únicas no es necesario tanto problema, se puede usar el modo Async para esperar directamente sin bloquear el renderizado. Se puede usar el método de extensión CompleteAsync del paquete:
async void GenerateMesh() {
result = new NativeArray<float>(100000, Allocator.Persistent);
MyJob jobData = new MyJob{
result = result
};
handle = jobData.Schedule();
await handle.CompleteAsync();
}Nota: este modo debe usar el asignador Persistent, porque no necesariamente completarás en 1 fotograma.
Burst
Burst, basado en LLVM, es un subconjunto de C# llamado “C# de alto rendimiento”, básicamente es código C, generalmente de 10 a 100 veces más rápido que Mono, lo que también muestra lo lento que es Mono.
Burst puede mejorar aún más la velocidad de ejecución de Job. Para el ejemplo anterior, solo agrega esta línea:
[BurstCompile]
public struct MyJob : IJobParallelFor {
...
}En las pruebas de rendimiento de IJobParallelFor, solo con esta línea, el tiempo de ejecución mejoró de 5.16 ms a 0.21 ms. En este punto, la velocidad de ejecución de Job finalmente superó al bucle For.
Nota: Las pruebas de rendimiento anteriores se realizaron con 10 Workers. Ajustar finamente el número de Workers puede dar resultados diferentes.
Vectorización
La