(Russian translation from English by Maxim Voloshin)

Структуры, используемые Job System не могут содержать управляемые типы вроде string, class, или делегатов. На данный момент это большая проблема т.к. их повсеместно использует Unity API и мы вынуждены с ними работать. Сегодня мы поговорим о том, как мы можем преодолеть эти ограничения и использовать управляемые типы в задачах.

Управляемый подход

Дабы продемонстрировать, чего мы хотим добиться, начнем с задачи которая использует уйму управляемых типов. Ее цель выбрать текст с результатами, который будет показан в конце игры.

struct Player
{
   public int Id;
   public int Points;
   public int Health;
}
 
struct ChooseTextJobManaged : IJob
{
   public Player Player;
   public Player[] AllPlayers;
   public string WinText;
   public string LoseText;
   public string DrawText;
   public string[] ChosenText;
 
   public void Execute()
   {
      // Если мы умерли, то мы проиграли
      if (Player.Health <= 0)
      {
         ChosenText[0] = LoseText;
         return;
      }
 
      // Выбрать живого игрока с максимальным количеством очков, кроме нас
      Player mostPointsPlayer = new Player { Id = 0, Points = int.MinValue };
      foreach (Player player in AllPlayers)
      {
         // Мертвый
         if (player.Health <= 0)
         {
            continue;
         }
 
         // Мы
         if (player.Id == Player.Id)
         {
            continue;
         }
 
         // Максимум очков
         if (player.Points > mostPointsPlayer.Points)
         {
            mostPointsPlayer = player;
         }
      }
 
      // У нас больше очков чем у кого либо... выиграли
      if (Player.Points > mostPointsPlayer.Points)
      {
         ChosenText[0] = WinText;
      }
      // У нас меньше очков чем у топового игрока… проиграли
      else if (Player.Points < mostPointsPlayer.Points)
      {
         ChosenText[0] = LoseText;
      }
      // У нас столько же очков как и у топового игрока... ничья
      else
      {
         ChosenText[0] = DrawText;
      }
   }
}

На самом деле, сама логика не имеет значения в данном случае. Важно то, что задача пытается взять одно из полей типа string: (WinText, LoseText, DrawText) и его значение установить в ChosenText[0] который, между прочим, элемент управляемого массива строк.

Этот код нарушает требование, что задачи, даже не компилируемые Burst, не должны использовать управляемые типы, такие как string или управляемые массивы, наподобие string[]. Но все равно давайте попробуем запустить его:

class TestScript : MonoBehaviour
{
   void Start()
   {
      Player player = new Player { Id = 1, Health = 10, Points = 10 };
      Player[] allPlayers = {
         player,
         new Player { Id = 2, Health = 10, Points = 5 },
         new Player { Id = 3, Health = 0, Points = 5 }
      };
      string winText = "You win!";
      string loseText = "You lose!";
      string drawText = "You tied!";
      string[] chosenText = new string[1];
      new ChooseTextJobManaged
      {
         Player = player,
         AllPlayers = allPlayers,
         WinText = winText,
         LoseText = loseText,
         DrawText = drawText,
         ChosenText = chosenText
      }.Run();
      print(chosenText[0]);
   }
}

Вызов ChooseTextJobManaged.Run приводит к тому, что Unity бросает исключение:

InvalidOperationException: ChooseTextJobManaged.AllPlayers is not a value type. Job structs may not contain any reference types.
Unity.Jobs.LowLevel.Unsafe.JobsUtility.CreateJobReflectionData (System.Type type, Unity.Jobs.LowLevel.Unsafe.JobType jobType, System.Object managedJobFunction0, System.Object managedJobFunction1, System.Object managedJobFunction2) (at /Users/builduser/buildslave/unity/build/Runtime/Jobs/ScriptBindings/Jobs.bindings.cs:96)
Unity.Jobs.IJobExtensions+JobStruct`1[T].Initialize () (at /Users/builduser/buildslave/unity/build/Runtime/Jobs/Managed/IJob.cs:23)
Unity.Jobs.IJobExtensions.Run[T] (T jobData) (at /Users/builduser/buildslave/unity/build/Runtime/Jobs/Managed/IJob.cs:42)
TestScript.Start () (at Assets/TestScript.cs:75)

Unity жалуется, что AllPlayers является управляемым (“ссылочным”) типом поскольку это управляемый массив. Если бы мы сделали его NativeArray, мы бы получили другое исключение об остальных полях, навроде WinText.

Управляемые ссылки

Чтобы обойти это ограничение, нам надо будет чем-то заменить объекты и управляемый массив. Мы можем легко заменить управляемый массив на NativeArray, но сами объекты не имеют замены из коробки.

Фактически, мы не можем использовать управляемые объекты изнутри задачи, но ключевой момент в том, что нам достаточно просто сослаться на них. То есть ChooseTextJob просто выбирает строку, не работает с символами, не соединяет несколько строк и не создает новую.

Получается, что все, что нам на самом деле нужно, это нечто, что может послужить ссылкой на управляемый объект, а не сам управляемый объект. Простой int сделает это, при условии, что у нас есть отображение этого int на управляемый объект когда нам нужно его использовать.

Вспомним подход из строго типизированные int и обернем int в структуру. Мы не будем перегружать никаких операторов, потому что в данном случае для int это излишне, но это добавит строгости, по сравнению с использованием неименованного int.

public struct ManagedObjectRef<T>
    where T : class
{
    public readonly int Id;
 
    public ManagedObjectRef(int id)
    {
        Id = id;
    }
}

Теперь вместо string, мы можем использовать ManagedObjectRef. Само по себе наличие имени типа не даст Unity выбросить исключение. Все что мы здесь имеем это int и он подходит как нельзя кстати для использования с задачами.

Далее, нам нужно найти способ создать ссылку и обратиться к ней позже. Давайте обернем простой словарь Dictionary чтобы сделать вот это:

using System.Collections.Generic;
 
public class ManagedObjectWorld
{
    private int m_NextId;
    private readonly Dictionary<int, object> m_Objects;
 
    public ManagedObjectWorld(int initialCapacity = 1000)
    {
        m_NextId = 1;
        m_Objects = new Dictionary<int, object>(initialCapacity);
    }
 
    public ManagedObjectRef<T> Add<T>(T obj)
        where T : class
    {
        int id = m_NextId;
        m_NextId++;
        m_Objects[id] = obj;
        return new ManagedObjectRef<T>(id);
    }
 
    public T Get<T>(ManagedObjectRef<T> objRef)
        where T : class
    {
        return (T)m_Objects[objRef.Id];
    }
 
    public void Remove<T>(ManagedObjectRef<T> objRef)
        where T : class
    {
        m_Objects.Remove(objRef.Id);
    }
}

Все отлично, это класс, он использует Dictionary который, в свою очередь, использует управляемые объекты, потому что только он предназначен для работы вне Job System.

Вот как мы будем использовать ManagedObjectWorld:

// Создаем world
ManagedObjectWorld world = new ManagedObjectWorld();
 
// Добавляем управляемый объект
// Получаем обратно ссылку
ManagedObjectRef<string> message = world.Add("Hello!");
 
// Получаем управляемый объект по ссылке
string str = world.Get(message);
print(str); // Hello!
 
// Удаляем объект
world.Remove(message);

Ошибки обрабатываются довольно логично:

// Передать null
ManagedObjectRef<string> nullRef = default(ManagedObjectRef<string>);
string str = world.Get(nullRef); // Exception: ID 0 не найден
 
// Неверный тип
ManagedObjectRef<string> hi = world.Add("Hello!");
ManagedObjectRef<int[]> wrongTypeRef = new ManagedObjectRef<int[]>(hi.Id);
int[] arr = world.Get(wrongTypeRef); // Exception: приведение string в int[] не удалось
 
// Двойное удаление
world.Remove(hi);
world.Remove(hi); // Пустой вызов
 
// Get after remove
string hiStr = message.Get(hi); // Exception: ID isn't found (it was removed)
New Job

Теперь, когда ManagedObjectRef и ManagedObjectWorld в нашем распоряжении, мы можем преобразовать ChooseTextJobManaged в ChooseTextJobRef сделав следующие изменения:

  • Заменить все управляемые массивы на NativeArray (т.е. string[] на NativeArray)
  • Заменить все управляемые объекты на ManagedObjectRef (т.е. string на ManagedObjectRef)
  • Бонус: Заменить foreach на for (для совместимости с Burst)

Обратите внимание, что логика, сама по себе, не изменилась.

Финальная версия задачи:

[BurstCompile]
struct ChooseTextJobRef : IJob
{
   public Player Player;
   public NativeArray<Player> AllPlayers;
   public ManagedObjectRef<string> WinText;
   public ManagedObjectRef<string> LoseText;
   public ManagedObjectRef<string> DrawText;
   public NativeArray<ManagedObjectRef<string>> ChosenText;
 
   public void Execute()
   {
      // Если мы умерли, то мы проиграли
      if (Player.Health <= 0)
      {
         ChosenText[0] = LoseText;
         return;
      }
 
      // Выбрать живого игрока с максимальным количеством очков, кроме нас
      Player mostPointsPlayer = new Player { Id = 0, Points = int.MinValue };
      for (int i = 0; i < AllPlayers.Length; i++)
      {
         Player player = AllPlayers[i];
 
         // Мертвый
         if (player.Health <= 0)
         {
            continue;
         }
 
         // Мы
         if (player.Id == Player.Id)
         {
            continue;
         }
 
         // Максимум очков
         if (player.Points > mostPointsPlayer.Points)
         {
            mostPointsPlayer = player;
         }
      }
 
      // У нас больше очков чем у кого либо... выиграли
      if (Player.Points > mostPointsPlayer.Points)
      {
         ChosenText[0] = WinText;
      }
      // У нас меньше очков чем у топового игрока… проиграли
      else if (Player.Points < mostPointsPlayer.Points)
      {
         ChosenText[0] = LoseText;
      }
      // У нас столько же очков как и у топового игрока... ничья
      else
      {
         ChosenText[0] = DrawText;
      }
   }
}

Наконец, доработаем код запуска задачи чтобы передать NativeArray и ManagedObjectRef:

class TestScript : MonoBehaviour
{
   void Start()
   {
      Player player = new Player { Id = 1, Health = 10, Points = 10 };
      NativeArray<Player> allPlayers
         = new NativeArray<Player>(3, Allocator.TempJob);
      allPlayers[0] = player;
      allPlayers[1] = new Player { Id = 2, Health = 10, Points = 5 };
      allPlayers[2] = new Player { Id = 3, Health = 0, Points = 5 };
      string winText = "You win!";
      string loseText = "You lose!";
      string drawText = "You tied!";
      ManagedObjectWorld world = new ManagedObjectWorld();
      ManagedObjectRef<string> winTextRef = world.Add(winText);
      ManagedObjectRef<string> loseTextRef = world.Add(loseText);
      ManagedObjectRef<string> drawTextRef = world.Add(drawText);
      NativeArray<ManagedObjectRef<string>> chosenText
         = new NativeArray<ManagedObjectRef<string>>(1, Allocator.TempJob);
      new ChooseTextJobRef
      {
         Player = player,
         AllPlayers = allPlayers,
         WinText = winTextRef,
         LoseText = loseTextRef,
         DrawText = drawTextRef,
         ChosenText = chosenText
      }.Run();
      print(world.Get(chosenText[0]));
      allPlayers.Dispose();
      chosenText.Dispose();
   }
}

При запуске программа выведет You win! как и ожидалось.

Заключение

Если Вам надо только сослаться на управляемые объекты внутри задачи и не нужно их использовать, это относительно легко решить их заменой на ManagedObjectRef и ManagedObjectWorld. Мы можем сделать это даже при компиляции с Burst и мы можем поддерживать безопасность типов, используя подход строгой целочисленной типизации. Это может помочь преодолеть отставание API пока Unity уходит от управляемых типов в рамках их инициативы DOTS.