Using Managed Types In Jobs
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[]
toNativeArray<string>
) - Replace all managed objects with
ManagedObjectRef
(e.g.string
toManagedObjectRef<string>
) - Bonus: Replace the
foreach
withfor
(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.
#1 by Pennylane on October 31st, 2019 ·
That’s awesome! Simple but clever.
#2 by moldywarpe on December 6th, 2019 ·
Nice work!
Any ideas about passing a dynamic string into a job.
The job does some heavy text manipulation of the passed in string (filename).
Output from job would be an integer indicating completion.
Additionally a status of completion i.e. %complete and able to be read outside the job would be a bonus…
Any suggestions gratefully received :)
#3 by jackson on December 6th, 2019 ·
This approach only allows you to refer to the objects, not use them. So you can’t read the characters of the string in the job or create new strings. Instead, you could use an alternative approach such as converting the string to a
NativeArray
outside of the job, modifying it inside the job, and then converting theNativeArray
back to a string outside the job after it’s done running.#4 by tigereno on January 25th, 2022 ·
Amazing Work!
I have a question how would I use the Functions of ChosenText in a job. e.g. ChosenText.Concat or ChosenText.Split?
C# code
public NativeArray<ManagedObjectRef> ChosenText;
C++ code
#5 by jackson on January 29th, 2022 ·
Like the conclusion says:
So you can’t actually call functions like
Concat
from the job.