предыдущая часть
При работе с многопоточными вычислениями всегда встает острый вопрос о необходимости синхронизации данных.
...данные в потоках.
Скрытый текст:
Для работы с данными в потоках юнитеки на низком уровне API предоставили контейнеры NativeContainer в которые складываются эти данные. Для обеспечения безопасности они выполнены в виде структур. Контейнеры поддерживают только самые простые типы данных, а также самое важно - не преобразуемыми типы данных, то есть тот же boolean или string вы поместить в них уже не сможете.
Эти контейнеры обеспечивают синхронизацию данных между потоками. Сейчас доступны только NativeArray и NativeSlice контейнеры. В первом случае это массив который вы можете наполнить элементарными данными, во втором случае вспомогающий контейнер для "нарезки" NativeArray на части. В будущем будут введены еще NativeList, NativeHashMap, и NativeQueue для работы с очередями. Также можно создавать свои контейнеры, но вам придется все тщательно протестировать чтобы не возникло проблем. В любом случае для обмена элементарными данными хватит пока и NativeArray.
...почему так!?
Скрытый текст:
В работе все выглядит просто: наполняем контейнер данными, отправляем его на выполнение, после завершения принимаем данные из контейнера обратно.
Примечание! Так как эти контейнеры представляют собой неконтроллируемый ресурс, после выполнения работы необходимо высвобождать их из памяти.
Давайте создадим простой контейнер для начала. Не забывайте импортировать нужные библиотеки!
Синтаксис:
Используется csharp
using Unity.Collections;
using Unity.Jobs;
public class SomeBehaviour : MonoBehaviour {
void Start() {
NativeArray<float> array;
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
using Unity.Jobs;
public class SomeBehaviour : MonoBehaviour {
void Start() {
NativeArray<float> array;
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
Для начала мы указали в скобочках каким типом(float) данных мы будем наполнять контейнер, теперь создадим сам экземпляр.
Синтаксис:
Используется csharp
public class SomeBehaviour : MonoBehaviour {
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
При создании экземпляра нужно указать в его конструкторе несколько параметров:
- размер массива
- тип хранения данных.
Размер массива можно задать каким угодно, а вот тип хранения данных указывает на то как быстро поток сможет воспользоваться данными из контейнера, от этого будет и зависеть скорость выполнения задачи. Есть несколько типов хранения данных:
-Temp. Обеспечивает самый быстрый тип доступа, но тем не менее не слишком потокобезопасен. Рекомендуется для выполнения быстрых задач с минимумом данных.
-TempJob. Обеспечивает доступ к данным чуть медленнее чем тип Temp, рекомендуется для выполнения коротких и сложных задач.
-Persistance. Хранит данные разрозненно, потоку нужно больше времени чтобы собрать эти данные, рекомендуется для задач с большим объемом данных.
К примеру если зададите тип Temp с большим объемом данных - могут случаться провисания, напротив задатите тип Persistance с легкой задачей и она может выполняться неестественно долго.
В примере указан тип Temp, так что задача сможет быстро выполнить чтение и запись данных, буквально за один кадр.
Теперь создадим структуру задачи унаследованную от интерфейса IJobParallelFor.
Синтаксис:
Используется csharp
public struct ArrayJob : IJobParallelFor {
public void Execute(int index) {
}
}
public void Execute(int index) {
}
}
Как вы помните структура это тип данных, и при передаче они копируют себя, так что для передачи контейнера в задачу нам нужно просто скопировать массив в экземпляр структуры.
Синтаксис:
Используется csharp
public struct ArrayJob : IJobParallelFor {
public NativeArray<float> inputArray;
public void Execute(int index) {
}
}
public NativeArray<float> inputArray;
public void Execute(int index) {
}
}
Теперь в методе Start передадим созданный контейнер в структуру.
Синтаксис:
Используется csharp
public class SomeBehaviour : MonoBehaviour {
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
ArrayJob job = new ArrayJob() {inputArray = array};//тоже самое что и job.inputArray = array;
}
}
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
ArrayJob job = new ArrayJob() {inputArray = array};//тоже самое что и job.inputArray = array;
}
}
Теперь создадим JobHandle задачи.
Синтаксис:
Используется csharp
public class SomeBehaviour : MonoBehaviour {
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
ArrayJob job = new ArrayJob() {inputArray = array};//тоже самое что и job.inputArray = array;
JobHandle handle = job.Schedule(array.Length, 10);
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
ArrayJob job = new ArrayJob() {inputArray = array};//тоже самое что и job.inputArray = array;
JobHandle handle = job.Schedule(array.Length, 10);
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
Для запуска задачи в IJobParallel необходим новый метод Schedule который принимает два числовых параметра:
-число повторений задачи.
-размер блоков для выполнения.
Число повторений просто указывает сколько раз выполниться метод Execute в задаче.
Размер блоков нужен уже конкретно для планировщика задач. Дело в том что раньше когда мы запускали задачу в IJob, там она выполнялась только один раз в одном потоке, сейчас же мы хотим чтобы задача выполнялась много раз, для этого планировщик распределяет выполнение этих повторений на разные потоки, а размеры блоков указывают на сколько частей можно поделить и распределить между потоками выполнение этих задач.
К примеру если у вас размер массива 100 и вы укажите размер блоков как 100, то планировщик разложит сотню повторений на сотню блоков, то есть любой поток сможет выполнить только один(1) раз задачу. Это не совсем правильный подход, ведь чем больше будет блоков, тем больше потребуется потоков для выполнения задачи, если блоков наоборот будет слишком мало, а задача слишком сложная то могут возникнут провисания. Здесь нужно искать компромисс между сложностью задачи, и скоростью ее выполнения.
Теперь можно запустить задачу, но прежде нужно указать в методе Execute что мы хотим выполнить с данными массива. К примеру будем умножать индекс на самого себя каждый раз.
Синтаксис:
Используется csharp
public struct ArrayJob : IJobParallelFor {
public NativeArray<float> inputArray;
public void Execute(int index) {
float value = index * index;
this.inputArray[index] = value;
}
}
public NativeArray<float> inputArray;
public void Execute(int index) {
float value = index * index;
this.inputArray[index] = value;
}
}
Как видите, сначала мы провели нужные вычисления и потом записали полученный результат в контейнер под определенным индексом.
Далее вызываем метод Complete который выполнит задачу.
Синтаксис:
Используется csharp
public class SomeBehaviour : MonoBehaviour {
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
ArrayJob job = new ArrayJob() {inputArray = array};//тоже самое что и job.inputArray = array;
JobHandle handle = job.Schedule(array.Length, 10);
handle.Complete();
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
ArrayJob job = new ArrayJob() {inputArray = array};//тоже самое что и job.inputArray = array;
JobHandle handle = job.Schedule(array.Length, 10);
handle.Complete();
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
Задача выполниться практически мгновенно, так что можно будет получить результат сразу же в методе Start.
Выведем результат первой(не нулевой, так как ноль умножить на ноль будет ноль) ячейки массива.
Синтаксис:
Используется csharp
public class SomeBehaviour : MonoBehaviour {
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
ArrayJob job = new ArrayJob() {inputArray = array};//тоже самое что и job.inputArray = array;
JobHandle handle = job.Schedule(array.Length, 10);
handle.Complete();
float result = job.inputArray[1];
print("Result: " + result);
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
void Start() {
NativeArray<float> array = new NativeArray<float>(100, Allocator.Temp);
ArrayJob job = new ArrayJob() {inputArray = array};//тоже самое что и job.inputArray = array;
JobHandle handle = job.Schedule(array.Length, 10);
handle.Complete();
float result = job.inputArray[1];
print("Result: " + result);
array.Dispose();//Высвобождаем занятые ресурсы.
}
}
Если вы проделали все также как и в примере, то у вас будет результат в консоли 1.
Так как контейнеры представляют из себя что то вроде "моста" данных между потоками, то при изменении этих данных в любом потоке результаты также будут видны и в других, конечно же только после выполнения задачи.
...так это же структуры
Скрытый текст:
Теперь вы знаете как работать с данными в потоках чтобы не возникало конфликтов, как их передавать, изменять и принимать обратно результат работы. А также как распределять выполнение одной задачи между несколькими потоками.
В следующей части я расскажу на примере как же все таки использовать эту многопоточность с объектами в unity.
Следующая часть...