Last week’s article came to the conclusion that allocating Temp memory from within a job was safe. This week we’ll look into that a little deeper to find out that it might not be as safe as it looks!

A comment from Benjamin Guihaire on last week’s article alerts us to danger allocating Temp memory from within a job:

Careful with Allocator.Temp if the job is a lazy job that can last more than one frame !

We had only tested jobs that took less than one frame to complete. Since they appear to be automatically cleared every frame, any job lasting longer than one frame might use memory that’s been returned to the allocator to be re-allocated and used by subsequent allocations. These conflicts would cause data corruption and potentially be very difficult to debug.

So let’s design a basic job that lasts more than one frame and see how it behaves. In fact, we’ll make a job with an infinite loop just to ensure that it runs for many frames. We’ll also allocate 820 4-byte blocks of 4-byte aligned Temp memory to exhaust the fixed-sized block of memory it allocates from and include one “overflow” allocation by the alterate allocator.

using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Jobs;
using UnityEngine;
 
[BurstCompile]
unsafe struct TestJob : IJob
{
    public void Execute()
    {
        for (int i = 0; i < 820; ++i)
        {
            UnsafeUtility.Malloc(4, 4, Allocator.Temp);
        }
        while (true)
        {
        }
    }
}
 
class TestScript : MonoBehaviour
{
    private void Start()
    {
        new TestJob().Schedule();
    }
}

We can enter play mode in the editor and then exit just fine. Entering play mode a second time hangs the editor forever while it waits for the job to complete, which obviously never happens due to the infinite loop.

Now let’s add a little bit more to the test just to check every frame if the job is completed. We know it won’t be, but we’re proceeding with caution and adding just a little bit at a time here.

class TestScript : MonoBehaviour
{
    private JobHandle handle;
 
    private void Start()
    {
        handle = new TestJob().Schedule();
    }
 
    private void Update()
    {
        bool _ = handle.IsCompleted;
    }
}

Notice that the return value of handle.IsCompleted isn’t even used. We’re just calling the property and throwing away whatever status it returns.

When we run this, now Unity continually prints warnings in the Console pane:

Internal: JobTempAlloc has allocations that are more than 4 frames old - this is not allowed and likely a leak
 
To Debug, enable the define: TLA_DEBUG_STACK_LEAK in ThreadsafeLinearAllocator.cpp. This will output the callstacks of the leaked allocations

This is interesting for two reasons! First, why does calling handle.IsCompleted and discarding the return value have any effect at all on the program? What could it possibly have to do with memory allocators? Looking at a decompilation of it, we see this:

/// <summary>
///   <para>Returns false if the task is currently running. Returns true if the task has completed.</para>
/// </summary>
public bool IsCompleted
{
  get
  {
    return JobHandle.ScheduleBatchedJobsAndIsCompleted(ref this);
  }
}

Since this just calls JobHandle.ScheduleBatchedJobsAndIsCompleted, let’s look a few lines down and see it:

[NativeMethod(IsFreeFunction = true)]
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern bool ScheduleBatchedJobsAndIsCompleted(ref JobHandle job);

As an extern function, this isn’t defined in .NET code. It’s implemented inside the Unity engine in closed-source code. Unfortunately, this means we won’t be able to easily figure out why just calling handle.IsCompleted matters.

The second reason these warning messages are interesting is that we see them refer to JobTempAlloc. We also see these same warnings when directly using the TempJob allocator. This indicates clearly what we’ve expected for several articles and about three months now: the alternate allocator that handles “overflow” allocations for the Temp allocator is the TempJob allocator!

Now let’s try changing the job so that it deallocates every allocation to see what effect that has:

[BurstCompile]
unsafe struct TestJob : IJob
{
    public void Execute()
    {
        for (int i = 0; i < 820; ++i)
        {
            void* alloc = UnsafeUtility.Malloc(4, 4, Allocator.Temp);
            UnsafeUtility.Free(alloc, Allocator.Temp);
        }
        while (true)
        {
        }
    }
}

Entering play mode still prints all the same warning messages about JobTempAlloc. Even immediately deallocating all of the allocations, not just the final “overflow” allocation, isn’t enough to stop the warnings. Do we need to deallocate the overflow allocatin by passing TempJob to UnsafeUtility.Free? Let’s try that:

[BurstCompile]
unsafe struct TestJob : IJob
{
    public void Execute()
    {
        for (int i = 0; i < 820; ++i)
        {
            void* alloc = UnsafeUtility.Malloc(4, 4, Allocator.Temp);
            UnsafeUtility.Free(
                alloc,
                i != 819 ? Allocator.Temp : Allocator.TempJob);
        }
        while (true)
        {
        }
    }
}

Now when we enter play mode we no longer see any warnings! We can’t rely on the Temp allocator to properly deallocate from its overflow allocator, so we need to pass TempJob to UnsafeUtility.Free to force the deallocation. Since we don’t get any obvious or convenient indication that an allocation was an overflow or not, it’s very unlikely that we’ll be able to write code that passes the correct Allocator to UnsafeUtility.Free.

We’ve seen today that allocating memory in jobs that last more than one frame is not a safe operation for several reasons:

  1. “Overflow” allocations will not be automatically freed, leading to tons of warnings about memory leaks
  2. “Overflow” allocations will not be freed when passing Allocator.Temp to UnsafeUtility.Free as we’d expect them to be
  3. Passing Allocator.TempJob to UnsafeUtility.Free only for “overflow” allocations is often extremely complex and error-prone
  4. Calling JobHandle.IsComplete mysteriously changes the behavior of memory allocation in the job

For these reasons, job code should be very careful when allocating memory. All other jobs need to be examined closely to ensure that “overflow” allocations never occur. If they do, it’s likely that Unity will leak the memory and endlessly print cryptic warning messages about it.