Job structs can’t contain managed types like string, class instances, or delegates. This is currently a pain as a lot of the Unity API relies on these and so we’re forced to deal with them. Today we’ll talk about how we can use managed types in our jobs to bridge the gap.

Update: A Russian translation of this article is available.

Managed Approach

To illustrate what we’d like to do, let’s start with a job that uses tons of managed types. Its purpose is to choose the text to display for the results of a game.

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 we died, we lose
      if (Player.Health <= 0)
      {
         ChosenText[0] = LoseText;
         return;
      }
 
      // Get the highest points of any alive player except us
      Player mostPointsPlayer = new Player { Id = 0, Points = int.MinValue };
      foreach (Player player in AllPlayers)
      {
         // Dead
         if (player.Health <= 0)
         {
            continue;
         }
 
         // Us
         if (player.Id == Player.Id)
         {
            continue;
         }
 
         // High score
         if (player.Points > mostPointsPlayer.Points)
         {
            mostPointsPlayer = player;
         }
      }
 
      // We have more points than the player with the most points... win
      if (Player.Points > mostPointsPlayer.Points)
      {
         ChosenText[0] = WinText;
      }
      // We have less points than the player with the most points... lose
      else if (Player.Points < mostPointsPlayer.Points)
      {
         ChosenText[0] = LoseText;
      }
      // We have the same points than the player with the most points... draw
      else
      {
         ChosenText[0] = DrawText;
      }
   }
}

The logic doesn’t really matter here. The important part is that the job wants to pick one of the string fields (WinText, LoseText, DrawText) and set it to ChosenText[0] which is a managed array of string.

This code violates the requirement that jobs, even ones not compiled by Burst, don’t access managed types like string and managed arrays like string[]. Still, let’s try to run it anyhow:

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]);
   }
}

The call to ChooseTextJobManaged.Run causes Unity to throw an exception:

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 complains about AllPlayers being a managed (“reference”) type since it is a managed array. If we were to make it into a NativeArray<Player>, we’d get another exception about the other fields like WinText.

Managed References

To work around this issue, we’re going to need to replace our managed object and managed array fields. We can replace the managed arrays easily with NativeArray<T>, but the managed objects don’t have a drop-in replacement.

We’re never going to be able to actually use the managed objects from within the job, but the key realization here is that we just need to refer to them. That is to say ChooseTextJob just picks a string, it doesn’t look at its characters, concatenate it, or build strings.

So all we really need is something that can serve as a reference to a managed object, not the managed object itself. A simple int will do, provided we have a mapping of that int to the managed object available to us outside of the job when we need to use the object.

Let’s take a page from the strongly-typed integer approach and wrap that int in a struct. We won’t overload any operators since the int isn’t meant to be used that way, but this will add strong, named typing instead of using a raw int.

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

Now instead of a string, we can use a ManagedObjectRef<string>. The mere presence of the type name won’t cause Unity to throw an exception. All we really have here is an int and that’s perfectly fine to use in a job.

Next, we need a way to create these references and look them up later. Let’s wrap a simple Dictionary<int, object> to do just that:

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);
    }
}

It’s OK that this is a class, that it uses a Dictionary, and that it uses managed objects because this is only intended to be used outside of jobs.

Here’s how we use ManagedObjectWorld:

// Create the world
ManagedObjectWorld world = new ManagedObjectWorld();
 
// Add a managed object to the world
// Get a reference back
ManagedObjectRef<string> message = world.Add("Hello!");
 
// Get a managed object using a reference
string str = world.Get(message);
print(str); // Hello!
 
// Remove a managed object from the world
world.Remove(message);

Error cases are handled pretty reasonably:

// Get null
ManagedObjectRef<string> nullRef = default(ManagedObjectRef<string>);
string str = world.Get(nullRef); // Exception: ID 0 isn't found
 
// Wrong type
ManagedObjectRef<string> hi = world.Add("Hello!");
ManagedObjectRef<int[]> wrongTypeRef = new ManagedObjectRef<int[]>(hi.Id);
int[] arr = world.Get(wrongTypeRef); // Exception: cast string to int[] fails
 
// Double remove
world.Remove(hi);
world.Remove(hi); // No-op
 
// Get after remove
string hiStr = message.Get(hi); // Exception: ID isn't found (it was removed)
New Job

With ManagedObjectRef and ManagedObjectWorld at our disposal, we can now convert the ChooseTextJobManaged to ChooseTextJobRef by making the following changes:

  • Replace all managed arrays with NativeArray (e.g. string[] to NativeArray<string>)
  • Replace all managed objects with ManagedObjectRef (e.g. string to ManagedObjectRef<string>)
  • Bonus: Replace the foreach with for (for Burst compatibility)

Note that the logic itself is unchanged.

Here’s the final job:

[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 we died, we lose
      if (Player.Health <= 0)
      {
         ChosenText[0] = LoseText;
         return;
      }
 
      // Get the highest points of any alive player except us
      Player mostPointsPlayer = new Player { Id = 0, Points = int.MinValue };
      for (int i = 0; i < AllPlayers.Length; i++)
      {
         Player player = AllPlayers[i];
 
         // Dead
         if (player.Health <= 0)
         {
            continue;
         }
 
         // Us
         if (player.Id == Player.Id)
         {
            continue;
         }
 
         // High score
         if (player.Points > mostPointsPlayer.Points)
         {
            mostPointsPlayer = player;
         }
      }
 
      // We have more points than the player with the most points... win
      if (Player.Points > mostPointsPlayer.Points)
      {
         ChosenText[0] = WinText;
      }
      // We have less points than the player with the most points... lose
      else if (Player.Points < mostPointsPlayer.Points)
      {
         ChosenText[0] = LoseText;
      }
      // We have the same points than the player with the most points... draw
      else
      {
         ChosenText[0] = DrawText;
      }
   }
}

Finally, we tweak the code to run the job to provide NativeArray and 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();
   }
}

Running this prints You win! as expected.

Conclusion

If you only need to refer to managed objects inside a job and not actually use them, it’s relatively easy to replace them with ManagedObjectRef and ManagedObjectWorld. We can do this even when compiling with Burst and we can maintain type safety while we do so using the strongly-typed integer approach. This can help bridge the gap as Unity transitions away from managed types as part of its DOTS initiative.