Содержание
В этой статье мы разберем неявное использование многопоточности с Unity Job System: узнаем про преимущества использования такого подхода перед явным созданием потоков, поговорим про неуправляемую память, про преимущества неуправляемой памяти перед управляемой и многое другое.
Введение
Job System в Unity представляет собой инструмент, с использованием которого мы можем неявно использовать многопоточность и оптимизировать задачи, требующие сравнительно большого количества ресурсов.
Для работы с Unity Job System подключаем в проект следующий API:
using Unity.Jobs;
По своей природе, Unity - однопоточный движок. Главный поток берет на себя ответственность за создание новых, а также их синхронизацию с собой.
Классическое создание потоков вручную может быть не всегда подходящим вариантом, поскольку иногда всплывает необходимость параллельно производить сразу множество вычислений, а создание и удаление потока под каждое вычисление - это дорогой процесс.
Данную проблему обычно решает реализация паттерна Object Pooling, которая называется Thread Pool или просто пул потоков. Пул потоков предлагает переиспользовать уже некогда созданные потоки, вместо создания новых. Однако зачастую количество потоков в пуле превосходит количество ядер процессора, что может приводить к конкуренции потоков за ресурсы ядер.
Конкуренция потоков
В вычислительной системе у каждого физического ядра процессора есть ограниченный набор ресурсов, которые используются для обработки задач. Когда одновременно несколько потоков требуют выполнения, но свободных ядер в процессоре меньше - возникает конкуренция потоков.
Для управления конкуренцией операционная система (ОС) использует переключение контекста. Этот подход динамически распределяет время для каждого из потоков в свободных ядрах: потоку выделяется определенное количество времени на выполнение в свободном ядре, после чего поток приостанавливается, операционная система сохраняет его состояние и переключает ядро на выполнение другого потока.
Таким образом, переключение контекста - это необходимый процесс, возникающий на фоне нехватки ядер, при котором операционная система старается отводить для каждого из потоков время в ядрах. Чем больше активных потоков существует в один момент времени, тем чаще операционная система вынуждена производить переключение контекста - это плохо сказывается на производительности игры.
Задачи и обработчики
В Unity Job System существует два основных понятия: задачи и обработчики задач. Вместо бездумного создания новых потоков под каждое вычисление, мы создаем задачу, а обработчики задач далее принимают их на выполнение.
Каждый обработчик имеет поток, за который он несет ответственность, а их количество ограничено и обычно вычисляется по формуле «количество ядер процессора минус один», где одно ядро уже занято основным потоком.
При запуске каждой новой задачи происходит проверка на наличие свободных обработчиков задач. В случае положительного результата свободный обработчик берет вычисления в задаче на себя. В противном случае задача помещается в очередь и ждет свой черед.

На рисунке отчетливо видно наличие пяти задач, каждая из которых ожидает своей очереди на выполнение. Выполняются эти задачи обработчиками, а всего этих обработчиков на примере двое.
Дополнительным преимуществом Unity Job System по праву является возможность указывать порядок выполнения задач, чтобы один поток ждал завершения другого. Таким образом мы можем гарантировать, что наши вычисления будут проходить в нужной нам последовательности.
Основные интерфейсы:
Интерфейс IJob - предназначен для создания одиночных задач.
Интерфейс IJobParallelFor - используется для параллельного выполнения одного и того действия для каждого элемента из набора данных (Массив).
Интерфейс IJobParallelForTransform - ориентирован на параллельное обновление трансформаций для каждого элемента из набора данных.
Традиционно создание джоба выглядит так:
// Объявим структуру, которая будет реализовывать один из интерфейсов
public struct SomeJob : IJob
{
// Реализуем метод Execute интерфейса IJob
public void Execute()
{
}
}
Работа с ссылочными типами в многопоточной среде имеет свои нюансы, связанные с риском возникновения гонки состояний (race conditions). Ссылочные типы хранят данные в управляемой куче. Если несколько потоков имеют доступ к одной и той же ссылке и хотя бы один из потоков изменяет состояние этого объекта, возникает гонка, при которой результат операции становится непредсказуемым.
Используя структуры для работы с Unity Job System, мы обходим стороной проблему гонки состояний, ведь структуры передаются не по ссылке, а по значению - это значит, что каждая отдельная задача будет работать с копией объекта.
В методе Execute структуры SomeJob мы будем производить нужные нам параллельные вычисления. Как можно заметить, метод этого интерфейса (IJob) не принимает параметров.
NativeArray: использование нативной памяти
NativeArray - это параметризованная структура, которая позволяет работать с нативной (неуправляемой) областью памяти, то есть той памятью, которая выделяется напрямую вне контроля сборщика мусора (GC) в .NET.
Данная структура относится к коллекциям. Использование NativeArray - единственный безопасный способ обмениваться данными между основным и параллельными потоками, поэтому для нас эта структура будет выступать «мостом», посредством которого мы будем обмениваться данными.
Основные преимущества
Работа с нативной памятью уменьшает накладные расходы на обращение к памяти: при работе с управляемой памятью некоторая часть ресурсов уходит на отслеживание и перемещение объектов - в нативной памяти эти процессы не выполняются.
Нативная память увеличивает наш контроль над освобождением данных: с NativeArray мы можем явно указывать, когда данные будет высвобождаться из памяти - это позволит нам на более точно контролировать расход памяти (Метод Dispose).
Теперь предлагаю посмотреть на синтаксис инициализации данной структуры:
NativeArray<int> bridge = new(2, Allocator.TempJob);
Эта перегрузка конструктора принимает только два обязательных параметра:
Количество элементов коллекции (int length).
Тип аллокатора (Allocator allocator).
Аллокатор определяет политику выделения памяти для коллекции. Он определяет ожидаемый уровень жизни данных. В нашем примере Allocator - это перечисление (enum).
Для работы обычно достаточно всего трех значений:
Allocator.Temp - память выделяется быстрее всего, но имеет наименьшее время жизни размером в один кадр.
Allocator.TempJob - память выделяется чуть медленнее, однако время жизни выше: до четырех кадров.
Allocator.Persistent - самое медленное выделение памяти, но самое длительное время жизни: контейнер может существовать до конца работы приложения.

Важно понимать, что данные не высвобождаются самостоятельно по истечению срока их жизни, поэтому ответственность за то, чтобы высвобождать данные вовремя, лежит на нас, что свойственно при работе с неуправляемой памятью. Если мы не высвобождаем данные вовремя - мы получаем утечку памяти.
Освобождение контейнера NativeArray осуществляется через вызов метода Dispose:
// Инициализируем контейнер
NativeArray<int> array = new(2, Allocator.TempJob);
// Высвобождаем память
array.Dispose();
Чтобы свести к нулю вероятность не дописать метод Dispose, иногда полезно использовать using:
using (NativeArray<float> array = new(2, Allocator.TempJob))
{
// Логика взаимодействия с контейнером
}
При таком подходе метод (Dispose) для высвобождения данных контейнера будет вызван автоматически при выходе из конструкции. Такой подход для безопасного использования NativeArray не единственный, ведь еще можно рассмотреть использование атрибута:
public struct JobFactorial : IJob
{
[DeallocateOnJobCompletion]
public NativeArray<int> Bridge;
// ...
}
Атрибут [DeallocateOnJobCompletion] гарантирует нам, что память контейнера освободиться автоматически тогда, когда выполнение задачи завершиться.
IJob: Пример
Предположим, что нам нужно написать алгоритм поиска факториала. Начнем с написания Job структуры:
public struct JobFactorial : IJob
{
// Объявляем коллекцию, через которую будем обмениваться данными
public NativeArray<int> Bridge;
public void Execute()
{
// Результат вычисления факториала попадет в элемент коллекции с индексом 1
Bridge[1] = Factorial(Bridge[0]);
}
// Метод поиска факториала целого числа
// Избегаем рекурсивного подхода из-за риска переполнения стека
private int Factorial(int number)
{
int result = 1;
for (int i = 2; i <= number; i++)
result *= i;
return result;
}
}
Теперь посмотрим на код создания объекта структуры для нахождения факториала:
public class Example : MonoBehaviour
{
private void Start()
{
// Контейнер с двумя элементами. Время жизни - до 4 кадров
NativeArray<int> bridge = new(2, Allocator.TempJob);
// Элемент с индексом 0 будет содержать входные данные,
// то есть число, факториал которого предстоит найти
bridge[0] = 12;
JobFactorial factorialJob = new()
{
Bridge = bridge
};
// Метод добавляет задачу в пул потоков и возвращает объект JobHandle
JobHandle factorialHandle = factorialJob.Schedule();
// Метод приостанавливает основной поток до выполнения задачи
factorialHandle.Complete();
Debug.Log($"Factorial: {bridge[1]}");
// Высвобождаем данные
bridge.Dispose();
}
}
Примечание
Метод Complete гарантирует нам, что выполнение задачи завершится к моменту продолжения выполнения основного потока. Это позволяет нам безопасно читать результаты выполнения задачи из bridge[1]
В результате выполнения этого кода мы получаем вывод ожидаемого результата в консоль:

Важно понимать, что все примеры в статье были сильно упрощены для большей наглядности. Использование Unity Job System влечет за собой накладные расходы, которые должны оправдываться достаточно тяжелыми вычислениями. В противном же случае использование Job системы может негативно сказываться на производительности.
IJobParallelFor: Пример
Ранее уже было упомянуто, что этот интерфейс используется для параллельного выполнения одного и того же действия по отношению ко всем элементам определенного набора данных. Возьмем задачу, которая была бы похожа на прошлую: будем искать факториал каждого из элементов коллекции.
Описываем Job структуру:
public struct JobFactorial : IJobParallelFor
{
// Объявляем контейнеры
[ReadOnly]
public NativeArray<int> Input;
[WriteOnly]
public NativeArray<int> Output;
public void Execute(int index)
{
// Ищем факториал числа и помещаем результат в Output
Output[index] = Factorial(Input[index]);
}
// Метод поиска факториала целого числа
private int Factorial(int number)
{
int result = 1;
for (int i = 2; i <= number; i++)
result *= i;
return result;
}
}
Примечание
Атрибут [ReadOnly] помечает контейнер NativeArray Input как доступный только для чтения в параллельных потоках. Это мера безопасности, которая гарантирует дальнейшую неизменяемость контейнера.
Атрибут [WriteOnly] помечает контейнер NativeArrat Output как доступный только для записи в параллельных потоках.
Использование этих атрибутов оптимизирует доступ к памяти.
Метод Execute этого интерфейса принимает параметр int index. Данный параметр отражает индекс текущего элемента в процессе итерации по элементам массива.
Теперь рассмотрим использование структуры в коде:
public class Example : MonoBehaviour
{
private void Start()
{
// Объявляем контейнеры
NativeArray<int> input = new(new int[] { 1, 2, 3, 4, 5 }, Allocator.TempJob);
NativeArray<int> output = new(input.Length, Allocator.TempJob);
JobFactorial factorialJob = new()
{
Input = input,
Output = output
};
// Добавляем задачу в очередь
JobHandle factorialHandle = factorialJob.Schedule(input.Length, 1);
// Приостанавливаем основной поток до выполнения
factorialHandle.Complete();
for (int i = 0; i < input.Length; i++)
{
Debug.Log($"Factorial: {output[i]}");
}
// Высвобождаем память
input.Dispose();
output.Dispose();
}
}
Можно обратить внимание, что используемая перегрузка метода Schedule() принимает два параметра:
Количество элементов массива (int arrayLength).
Количество итераций, которые будут объединены в один пакет (int innerloopBatchCount).
Если мы приравняем значение параметра innerloopBatchCount к единице, то под каждый элемент массива будет создаваться отдельная задача. При увеличении значения этого параметра количество задач будет уменьшаться, но размер работы в каждой задаче будет увеличиваться соответственно - это положительно сказывается на оптимизации, однако оказывает отрицательное влияние на точность распределения нагрузки между ядрами.
IJobParallelForTransform: Пример
Для использования данного интерфейса над понадобиться подключить еще одного пространство имен:
using UnityEngine.Jobs;
По умолчанию класс Transform в Unity не предусмотрен для работы в любых потоках, кроме основного, а использование ссылочных типов в джобах ограничено, поэтому интерфейс IJobParallelForTransform предлагает нам работу с трансформациями объектов через структуры TransformAccess и TransformAccessArray.
Определим структуру:
public struct TransformJob : IJobParallelForTransform
{
public void Execute(int index, TransformAccess transform)
{
Vector3 position = transform.position;
position.x += 10;
position.y -= 1;
position.z -= 1;
transform.position = position;
}
}
Теперь рассмотрим пример использования данной структуры:
public class Example : MonoBehaviour
{
// Сериализованный массив Transform объектов.
// Все значение определим в инспекторе
[SerializeField] private Transform[] _transforms;
private void Start()
{
// Создаем TransformAccessArray из массива трансформов
TransformAccessArray transforms = new TransformAccessArray(_transforms);
// Инициализируем структуру
TransformJob job = new();
// Добавляем задачу в очередь
JobHandle jobHandle = job.Schedule(transforms);
// Останавливаем основной поток
jobHandle.Complete();
// Высвобождаем память
// TransformAccessArray требует высвобождения памяти так же, как и NativeArray
transforms.Dispose();
}
}
Дополнительно
Для дальнейшего повышения производительности Job System настоятельно рекомендуется использование Burst Compiler. Этот специальный компилятор преобразует высокоуровневый код джобов в оптимизированный машинный код с применением векторизации, инлайнинга, развертывания циклов и других технологий. Применение Burst Compiler обеспечивается за счет атрибута [BurstCompile]:
[BurstCompile]
public struct JobFactorial : IJobParallelFor {
// ...
}
Более подробно про компилятор можно почитать здесь
Заключение
Unity Job System - мощный инструмент, предлагающий свой подход для безопасной организации и работы с потоками. Однако система не бесплатна, ведь польза от ее применения ощутима только в контексте тяжелых вычислений, где выигрыш в скорости оправдывает накладные расходы.
Комментарии (6)
dyadyaSerezha
18.06.2025 23:02Прочитал пока только начало, но уже несколько комментариев.
1) Для обработки множества задач на ограниченном количестве потоков в C# придуман async/await. Может, unity.jobs был придуман до этого?
2)
Используя структуры для работы с Unity Job System, мы обходим стороной проблему гонки состояний
Не катит, от слова совсем. Если объектом хоть как-то пользуются несколько потоков и хотя бы один поток его изменяет, то вместо race condition для классов мы имеем N копий объекта для структур, что вообще не имеет смысла - все потоки, кроме изменяющего, вообще никогда не видят никаких изменений объекта. А если им и не надо их видеть, то это значит, что объект используется локально в одном потоке и проблемы многопоточности тут вообще ни при чем. Тогда остаётся единственное преимущество структур - они не требуют работы с кучей (heap).
3)
Для многопоточной среды это неприемлемо, поскольку джобы должны обращаться к памяти по фиксированным адресам.
Ничего джобы не должны и прекрасно могли бы обращаться к managed объектам, которые двигаются в памяти сборщиком мусора. Причина тут какая-то другая. Автор, вы как-то плаваете в базе дот-нета.
dyadyaSerezha
18.06.2025 23:024)
При таком подходе метод (Dispose) для высвобождения данных контейнера будет вызван автоматически при выходе из конструкции.
Не в этом дело. Если написать явный вызов Dispose, то он тоже вызовется "автоматически", то есть просто потому, что он там стоит. А дело в том, что тут Dispose вызовется даже если внутри блока код выкинет исключение.
DrRen7
18.06.2025 23:021) Не особо понял вашу мысль, как асинхронность связана с многопоточность? jobs же должен дать параллельное выполнение а async делает прерывание
dyadyaSerezha
18.06.2025 23:02Смотрите мой ответ ниже (не там написал). Ну и async никаких прерываний не делает. И почти все случаи использования async/wait в ASP.NET, При написании контролёров и моделей, это вырождённый случай, когда async-функция вызывается сразу вместе с конструкцией wait.
dyadyaSerezha
18.06.2025 23:02как асинхронность связана с многопоточность?
Связана так, что чтобы что-то, любой user-level код, выполнить асинхронно, нужен другой поток. Низкоуровневые операции ввода-вывода не считаем, так как это не user-level код.
Но, вполне возможно, что async/await не рассчитаны на задачи (не заточены под), специфические для unity.
viruseg
Самое важное про burst не сказано. При работе с этим компилятором нужно забыть про Vetor2/3/4, Mtrix4x4. И вместо них использовать float2/3/4 float4x4/4x3/etc. А также всю математику из Math, Mathf заменить на math из пакета Mathematics. Все оптимизации burst завязаны именно на этих структурах и пакете математики, без них будет просадка по производительности т.к. большинство simd оптимизаций не сработают.