Two weeks ago we tested the performance of the async and await keywords plus the C# Task system against Unity’s new C# jobs system. This tested the usual combination of async and await with the Task system, but didn’t test the Task system directly against Unity’s C# jobs system. Today we’ll test that and, in so doing, see how to use the Task system without the async and await keywords.

As we saw last week, async and await don’t require the use of Task or Task<T>. We can make our own custom objects to await on instead of a Task. As we’ll see today, the opposite is also true: Task doesn’t require async or await.

Let’s look at just about the simplest task we could create:

void MultithreadedIncrement(out int val)
{
      // Run a task that increments 'val'
      Task task = Task.Run(() => val++);
 
      // Wait for the task to complete
      task.Wait();
}

Nowhere here are we using async or await. We simply create and run a task with Task.Run and then block execution of the main thread until it’s done by calling Wait.

Next, let’s create a Task that creates a “child” Task:

void MultithreadedDoubleIncrement(out int val)
{
      // Run a task that increments 'val' and runs a task that increments 'val'
      Task task = Task.Run(
            () => {
                  val++;
                  Task.Run(() => val++);
            });
 
      // Wait for the task to complete
      task.Wait();
}

However, Task.Run doesn’t allow for the “child” task to “attach” to the “parent” task. This means that the child task won’t wait for the parent task to complete. While that’s not an issue for this simple task, more complex work will require that we express dependencies this way. To do that, we need to use a TaskFactory. Here’s the first step toward that:

void MultithreadedDoubleIncrement(out int val)
{
      // Run a task that increments 'val' and runs a task that increments 'val'
      Task task = Task.Factory.StartNew(
            () => {
                  val++;
                  Task.Factory.StartNew(() => val++);
            });
 
      // Wait for the task to complete
      task.Wait();
}

Still, StartNew doesn’t automatically attach the child task to the parent task. We need to pass a parameter to explicitly request that:

void MultithreadedDoubleIncrement(out int val)
{
      // Run a task that increments 'val' and runs a task that increments 'val'
      Task task = Task.Factory.StartNew(
            () => {
                  val++;
                  Task.Factory.StartNew(
                        () => val++,
                        TaskCreationOptions.AttachedToParent);
            });
 
      // Wait for the task to complete
      task.Wait();
}

Unfortunately, Unity’s Task.Factory is configured by default to not allow attaching child tasks to parent tasks. To work around this, we can create our own TaskFactory:

void MultithreadedDoubleIncrement(out int val)
{
      // Create a TaskFactory that allows attaching child tasks to parent tasks
      TaskFactory taskFactory = new TaskFactory(
            TaskCreationOptions.AttachedToParent,
            TaskContinuationOptions.ExecuteSynchronously);
 
      // Run a task that increments 'val' and runs a task that increments 'val'
      Task task = taskFactory.StartNew(
            () => {
                  val++;
                  taskFactory.StartNew(
                        () => val++,
                        TaskCreationOptions.AttachedToParent);
            });
 
      // Wait for the task to complete
      task.Wait();
}

Finally, this code will run the parent task then the child task when it’s done.

For today’s performance test, we’ll run a chain of 1000 no-op tasks where each is a child of the previous. To do this without hard-coding 1000 lambdas, we can use a simple countdown:

void RunTasks(TaskFactory taskFactory, int numRuns)
{
      Action act = null;
      act = () =>
      {
            numRuns--;
            if (numRuns > 0)
            {
                  taskFactory.StartNew(
                        act,
                        TaskCreationOptions.AttachedToParent);
            }
      };
      Task task = taskFactory.StartNew(act);
      task.Wait();
}

In comparison, here’s how we’ll run the equivalent 1000 no-op jobs:

struct TestJob : IJob
{
      public void Execute()
      {
      }
}
 
void RunJobs(int numRuns)
{
      TestJob job = new TestJob();
      JobHandle jobHandle = job.Schedule();
      for (int i = 1; i < numRuns; ++i)
      {
            jobHandle = job.Schedule(jobHandle);
      }
      JobHandle.ScheduleBatchedJobs();
      jobHandle.Complete();
}

Putting it all together, we end up with this test script that runs four simultaneous chains of 1000 tasks:

using System;
using UnityEngine;
using System.Threading.Tasks;
using System.Diagnostics;
using Unity.Jobs;
 
public class TestScript : MonoBehaviour
{
    Task RunTasks(TaskFactory taskFactory, int numRuns)
    {
        Action act = null;
        act = () =>
        {
            numRuns--;
            if (numRuns > 0)
            {
                taskFactory.StartNew(
                    act,
                    TaskCreationOptions.AttachedToParent);
            }
        };
        return taskFactory.StartNew(act);
    }
 
    struct TestJob : IJob
    {
        public void Execute()
        {
        }
    }
 
    JobHandle RunJobs(int numRuns)
    {
        TestJob job = new TestJob();
        JobHandle jobHandle = job.Schedule();
        for (int i = 1; i < numRuns; ++i)
        {
            jobHandle = job.Schedule(jobHandle);
        }
        return jobHandle;
    }
 
    void Awake()
    {
        const int numRuns = 1000;
        Stopwatch sw = new Stopwatch();
 
        TaskFactory taskFactory = new TaskFactory(
            TaskCreationOptions.AttachedToParent,
            TaskContinuationOptions.ExecuteSynchronously);
        sw.Restart();
        Task task1 = RunTasks(taskFactory, numRuns);
        Task task2 = RunTasks(taskFactory, numRuns);
        Task task3 = RunTasks(taskFactory, numRuns);
        Task task4 = RunTasks(taskFactory, numRuns);
        task1.Wait();
        task2.Wait();
        task3.Wait();
        task4.Wait();
        long taskTime = sw.ElapsedTicks;
 
        sw.Restart();
        JobHandle jobHandle1 = RunJobs(numRuns);
        JobHandle jobHandle2 = RunJobs(numRuns);
        JobHandle jobHandle3 = RunJobs(numRuns);
        JobHandle jobHandle4 = RunJobs(numRuns);
        JobHandle.ScheduleBatchedJobs();
        jobHandle1.Complete();
        jobHandle2.Complete();
        jobHandle3.Complete();
        jobHandle4.Complete();
        long jobTime = sw.ElapsedTicks;
 
        print("System,TimenTask," + taskTime + "nJob," + jobTime);
    }
}

I ran the performance test in this environment:

  • 2.7 Ghz Intel Core i7-6820HQ
  • macOS 10.13.6
  • Unity 2018.2.9f1
  • macOS Standalone
  • .NET 4.x scripting runtime version and API compatibility level
  • IL2CPP
  • Non-development
  • 640×480, Fastest, Windowed

Here are the results I got:

System Time
Task 150360
Job 54110

C# Tasks vs. Unity Jobs

C#’s Task system took 2.78x longer than Unity’s Job system. That’s a better showing than the ~4x difference seen when using async and await too, but still quite a bit slower. Since the tasks and jobs aren’t performing any work at all, this is purely a measurement of the overhead of the two systems.

As usual, it’s best to profile the specific project performing the work as there will be a lot of variability due to the work load, dependencies, hardware, OS, and so forth.