Руководство по системе Unity Job и Burst для начинающих: как правильно их использовать?
Если цель — просто многопоточность, то использование Async для переключения потоков или System.Threading — наиболее удобный и понятный способ. Burst также можно вызывать напрямую, не обязательно использовать Job. См. статьи Асинхронное программирование и Прямой вызов. Но если у вас много мелких вычислений, тогда стоит рассмотреть систему Job.
Документация Unity по Job крайне неполная. Эта статья основана на многолетнем опыте использования и должна быть достаточно полной.
Цель и ограничения Job
В отличие от обычного подхода к многопоточности, система Unity Job — это система, которая имитирует высокую пропускную способность GPU с помощью многопоточности. То есть накладные расходы на распределение задач очень малы.
Job может обрабатывать только объем вычислений в пределах одного кадра, как и функции в CUDA/шейдерах — это небольшие ядра. Тяжелые вычисления по-прежнему будут влиять на FPS, потому что Job иногда может выполняться в основном потоке. Если вам нужно выполнять длительные вычисления, либо разбейте их на множество мелких Job, либо используйте потоки .Net — это всё равно лучше.
Возникает вопрос: раз это похоже на GPU, почему бы не использовать ComputeShader напрямую? Верно, ComputeShader может делать всё, что делает Job, и даже удобнее. За исключением случаев:
- Если требуется часто обмениваться данными с CPU, что не является сильной стороной GPU, то Job на основе CPU имеет преимущество.
- Система ECS (DOTS) основана на Job по той же причине.
Обратите внимание, что WebGL не поддерживает Job и ComputeShader, а также ускорение Burst, но:
- WebGL заменяется на WebGPU, который поддерживает ComputeShader, и новые версии браузеров уже поддерживают его (в IOS 17 нужно включить в настройках, в предварительной версии 19 включено по умолчанию).
- В настройках WebAssembly 2023 можно включить многопоточную поддержку для Job, и поддержка браузерами более полная. Но официально заявлено, что это даже не экспериментальная функция, использовать нельзя. В Unity 6 LTS её включение приводит к краху. Многопоточность в wasm вызывает у них споры уже много лет, тем более что сейчас DOTS получает больше внимания, а .Net 8 поддерживает многопоточность в wasm. Возможно, они ждут поддержки .Net 8 в Unity 7.
Производительность
Предоставление показателей производительности помогает понять замысел дизайна и применимость. Вы можете сначала прочитать статью, а затем вернуться к тестам производительности.
Было протестировано 8 проектов. Сначала тестировалась производительность 3 типов аллокаторов:
- BenchAllocatorTemp: Выполнение 100 000 выделений памяти с помощью Allocator.Temp.
- BenchAllocatorTempJob: То же самое, но с аллокатором Allocator.TempJob.
- BenchAllocatorPersistent: То же самое, но с аллокатором Allocator.Persistent. Результат: TempJob самый быстрый, затем Persistent.
Затем тестировалась производительность 4 режимов Job:
- BenchBaseLine: Выполнение 100 000 простых вычислений с помощью цикла For в качестве базового уровня.
- BenchIJob: Время планирования 100 000 Job.
- BenchIJobParallelFor: Время пакетного планирования 100 000 Job в параллельном режиме.
- BenchIJobParallelForBurst: То же самое, но с включенным Burst.
- BenchIJobParallelForBurstLoopVectorization: Планирование 10 Job, каждый из которых выполняет 10 000 вычислений с помощью For, с включенной векторизацией Burst.
Вот результаты тестов на моём ПК:

Median — это медиана времени выполнения тестового проекта, в миллисекундах.
Видно, что накладные расходы на планирование Job малы, система предназначена для выполнения большого количества задач. Конечно, здесь я выполнял только простые операции умножения, поэтому прирост от Job ограничен.
Типы данных
Во-первых, Burst не поддерживает управляемые типы C#. Можно использовать только типы с длиной, как в C, которые можно напрямую копировать через memcpy (без сериализации/маршалинга). Они называются blittable. К ним относятся базовые типы, такие как int (char, string и bool иногда являются управляемыми — не используйте их), а также одномерные массивы в стиле C (new int[5]) из blittable-типов. Поскольку Job обязательно используется в связке с Burst, нужно следовать этому ограничению.
Unity для этого предоставляет потокобезопасный тип NativeArray, специально предназначенный для Job. Эти типы могут совместно использоваться данными с основным потоком без копирования, потому что при копировании передается только указатель на данные, несколько копий ссылаются на одну и ту же область памяти. Существуют производные типы: NativeList, NativeQueue, NativeHashMap, NativeHashSet, NativeText и т.д., но они могут использоваться только в однопоточном режиме.
Внимание: Нельзя использовать код вида nativeArray[0].x = 1.0f или nativeArray[0]++; — значение не изменится, потому что возвращается не ссылка.
Потокобезопасность
Потокобезопасность обеспечивается через ограничения планирования. Один экземпляр NativeArray может быть записан только одним Job, иначе будет выброшено исключение. Если данные можно разделить для параллельной обработки, можно использовать IJobParallelFor для пакетного выполнения над NativeArray. Если данные предназначены только для чтения, можно пометить их при объявлении переменной-члена, например: [ReadOnly] public NativeArray<int> input;.
Когда Job выполняет запись, основной поток не может читать NativeArray, будет ошибка. Нужно дождаться завершения.
Выделение памяти (allocate)
Во-первых, типы Native после использования требуют ручного вызова Dispose(), они не уничтожаются автоматически. Для этого Unity добавила отслеживание утечек памяти.
При создании (new) типов Native нужно выбрать один из 3 типов аллокаторов: Temp, TempJob, Persistent. Скорость выделения от самой быстрой к самой медленной: Temp, TempJob, Persistent. Что это значит?
Tempозначает, что вы используете его внутри текущей функции и должны вызватьDispose()до её завершения. Если забыть Dispose, Unity выдаст ошибку при следующем рендеринге. Но на самом деле это выделение довольно медленное.TempJob— более мягкое условие для ошибки. Фактически всё равно нужно использовать в пределах одного кадра, но Dispose можно вызвать в следующем кадре.Persistent— ошибки не будет, нужно быть осторожным самому.
Предыдущий тест производительности BenchAllocator как раз проверял производительность этих трёх. Видно, что Allocator.Temp занимает в 4 раза больше времени, чем TempJob. В документации сказано, что Temp самый быстрый. Это либо баг, либо проблема режима Editor.
Выполнение однопоточного Job
Весь процесс заключается в написании собственного класса IJob, планировании его (Schedule) из основного потока, а затем вызове Complete для блокирующего ожидания завершения Job.
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();
}Но проблема в том, что мы используем Job для большого количества задач, такая одиночная задача не очень полезна. Более полезно рассмотреть параллельную модель, аналогичную GPU.
Параллельный режим (Parallel Job)
Чтобы перейти к параллельному режиму, измените наследование с IJob на IJobParallelFor в приведённом выше коде.
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();
}В параллельном режиме не нужно писать цикл For самостоятельно. Функция Execute будет выполнена для каждого элемента, подобно шейдеру.
Schedule(result.Length, result.Length / 10) означает выполнение Execute для каждого элемента массива от 0 до result.Length, распределённого между 10 воркерами.
О разнице в производительности между IJob и IJobParallelFor можно судить по предыдущим тестам производительности.
Ограничения параллелизма
В IJobParallelFor вы можете записывать только в элемент i, и система не знает, в какой массив-член вы хотите записать. Поэтому во все массивы можно записывать только в элемент i. Однако можно добавить атрибут [NativeDisableParallelForRestriction] к NativeArray, чтобы отключить проверки безопасности, и самостоятельно гарантировать отсутствие конфликтов записи.
В режиме только для чтения ограничений для всех нативных контейнеров нет.
Кроме того, IJobParallelFor не может включить векторизацию цикла, если только ваши вычисления уже не используют векторизацию (вызов других векторизованных функций). В противном случае производительность всё равно не будет оптимальной.
Использование контейнеров, таких как NativeList, в параллельном режиме
Контейнеры, отличные от Array, такие как NativeList, в параллельном режиме могут работать только в режиме только для чтения. Как же тогда выполнять запись?
На самом деле, в дизайне NativeList разделены операции Add и Set. Правильный шаблон использования: один Job выполняет операцию Add, второй Job — операцию Set.
Для операции Add можно использовать ParallelWriter и AsParallelWriter. Пример использования:
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();
}В этом состоянии NativeList имеет фиксированную ёмкость. Перед запуском необходимо предварительно выделить память, и можно использовать только AddNoResize(). Этот метод реализован через атомарную блокировку свойства Length, что приводит к значительным потерям производительности.
Затем используется преобразование без потерь из NativeList в NativeArray: NativeList.AsDeferredJobArray(). Возвращаемый NativeArray является “ленивым” (lazy), преобразование происходит только при фактическом выполнении Job, поэтому его можно передать до выполнения обоих Job:
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();Обратите внимание, что AsDeferredJobArray или AsArray возвращают View, то есть представление исходных данных. Источник данных (исходный контейнер) по-прежнему необходимо освобождать с помощью Dispose.
Параллельный режим для двумерных массивов
IJobParallelFor может распараллеливать только по отдельным элементам массива. Но на практике более полезно распараллеливать по строкам двумерного массива, что также позволяет включить векторизацию циклов для повышения производительности. Для этого можно использовать IJobParallelForBatch.
Сначала мы создаём плоский двумерный массив размером [10*15], затем планируем его с помощью IJobParallelForBatch.Schedule(int length, int batchCount). batchCount указывает, сколько данных обрабатывает каждый job. Execute будет выполнен length/batchCount раз.
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();Затем реализация 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;
}
}
}Результат выполнения:
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)Этот метод позволяет Burst автоматически включить векторизацию циклов, поэтому в тестах производительности время вычисления 100 000 операций составило 0.09 мс, что является самым быстрым результатом.
Другие ограничения
- Вы не можете запускать Job изнутри другого Job.
Сочетание с Async
В приведённых выше примерах Job планируется в Update, а завершается в LateUpdate, чтобы ускорить код Update. Для разовых задач это не так уж необходимо. Можно использовать асинхронный подход для прямого ожидания, не блокируя рендеринг. Можно использовать метод расширения CompleteAsync из пакета:
async void GenerateMesh() {
result = new NativeArray<float>(100000, Allocator.Persistent);
MyJob jobData = new MyJob{
result = result
};
handle = jobData.Schedule();
await handle.CompleteAsync();
}Обратите внимание, что в этом режиме нужно использовать аллокатор Persistent, потому что выполнение может не уложиться в один кадр.
Burst
Burst, основанный на LLVM, представляет собой подмножество C#, называемое “высокопроизводительным C#”, что по сути является кодом на C. Обычно он в 10-100 раз быстрее Mono, что, конечно, также говорит о медлительности Mono.
Burst может дополнительно увеличить скорость выполнения Job. Для приведённого выше примера достаточно добавить одну строку:
[BurstCompile]
public struct MyJob : IJobParallelFor {
...
}В тестах производительности IJobParallelFor