【UniTask】Introduction to Asynchronous Processing with UniTask

UniTask
Tool Versions
  • Unity: 2022.3.35f1
  • UniTask: 2.5.4

UniTask and Asynchronous Processing

What Is UniTask?

UniTask is a library for Unity and used to handle asynchronous processing efficiently. It is provided with the MIT license, so you can use it even for commercial purposes for free as long as you follow the license.

Asynchronous processing is frequently used to implement dialogue scenes, and ScenarioFlow uses UniTask to handle asynchronous processing. Therefore, you have to learn about UniTask in order to make use of ScenarioFlow.

What Is Asynchronous Processing?

Asynchronous processing means that you operate a process independently of completion of other processes. Such a processing is called asynchronous processing.

Basically, programs are executed one by one, which is called synchronous processing. In synchronous processing, the next process will never start until the previous one finishes. On the other hand, if an asynchronous processing starts, the next processing will start immediately without waiting for its completion.

A good example for asynchronous operation is loading scene. A process for loading some data is often executed in games. Then, if such a process is executed synchronously, the screen will appear to freeze because other processes can’t be executed until the loading process finishes.

If the loading process is executed asynchronously to resolve the problem, it will be possible to start other processes regardless of the completion of its process. So a progress bar for the loading can be shown to the player to reduce their stress, for example.

Eventually, you can think of asynchronous processing as what is applied to processes which take some time to complete.

Why UniTask?

You can choose Coroutine or Task (C# standard) as an option other than UniTask to handle asynchronous processing.

We have the two main reasons to choose UniTask from the options. Firstly UniTask was developed by optimizing the performance of Task (standard function in C#) for Unity, secondly it provides many useful functions for game developments.

Import UniTask

We are going to learn how to use UniTask by writing codes later. But first of all, let’s import UniTask to a project to be able to use it.

The easiest way to install UniTask is to import a .unitypackage file to your project. You can download a .unitypackage file at the Releases section on the GitHub page which you can access with the following URL.

GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.
Provides an efficient allocation free async/await integration for Unity. - Cysharp/UniTask

UniTasks with No Return Values

Delayed Message Program

As our first asynchronous programming with UniTask, we are going to develop an asynchronous method which displays a delayed message on the console, where we specify the waiting time and the message.

Follow the steps below to run a sample code.

  • Add a new empty object on the hierarchy window
  • Create the following C# script, and attach it to the object
  • Run it
Delayed message program
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private void Start()
    {
        Debug.Log("Program started...");
        LogDelayedMessageAsync(3.0f, "Hello, UniTask1!").Forget();
        LogDelayedMessageAsync(3.0f, "Hello, UniTask2!").Forget();
        LogDelayedMessageAsync(3.0f, "Hello, UniTask3!").Forget();
    }

    private async UniTask LogDelayedMessageAsync(float waitTime, string message)
    {
        await UniTask.Delay(TimeSpan.FromSeconds(waitTime));
        Debug.Log(message);
    }
}
C#

After you follow the steps above to run the program, the three messages will be output on the console after 3 seconds.

The result of the delayed message program

Let’s make sure that the timing of message showing will change if we specify the different waiting times.

Delayed message program v2
private void Start()
{
    Debug.Log("Program started...");
    LogDelayedMessageAsync(3.0f, "Hello, UniTask1!").Forget();
    LogDelayedMessageAsync(2.0f, "Hello, UniTask2!").Forget();
    LogDelayedMessageAsync(1.0f, "Hello, UniTask3!").Forget();
}
C#
The result of the delayed message program v2

When an asynchronous process is executed, the next process can start without waiting for its completion.

The 3 messages are shown simultaneously in the first example, and the 3 messages are shown in the reverse program order in the second example. It indicates that the following processes are executed without waiting for the completion of the previous programs.

The name of asynchronous methods

It is an understandable convention that an asynchronous method name ends with “Async” in order to clarify that it is an asynchronous method.

The UniTask Struct

When we create an asynchronous method with UniTask, basically the return type of that method is UniTask. The UniTask struct was developed by optimizing the C# standard Task for Unity, and it is defined in the Cysharp.Threading.Tasks namespace.

One feature of an asynchronous method, in other words an asynchronous process, is to be able start the next process without waiting for its completion. In addition to that, it is possible to wait for the completion of an asynchronous process by specifying the UniTask struct as the return type of the asynchronous method. In this context, we call an asynchronous method whose return type is the UniTask struct simply UniTask.

At this point, when we say “Unitask”, it has one meaning of the following three depending on the context:

  • UniTask as a library
  • UniTask as a return type
  • UniTask as a asynchronous process/method

2 UniTasks, LogDelayedMessageAsync and UniTask.Delay, are called in the sample code. The latter is a UniTask provided by the UniTask library, which inserts a specified time delay. As mentioned earlier, when we call a UniTask, we can choose to wait for its completion before starting the next process and can also choose not to wait. In the sample code, completion of the former is not awaited while completion of the latter is awaited.

UniTask is not equivalent to asynchronous method

UniTask is a kind of asynchronous method, however, an asynchronous method is not always a UniTask. For example, a method whose return type is the Task class in which the UniTask struct originates is also a kind of asynchronous method. Furthermore, a method whose return type is void can also be a asynchronous method.

By the way, we can’t wait for completion of a asynchronous method with void return type unlike that with Task or UniTask return type.

NOT Wait for Completion of UniTasks

You have to attach the Forget method calling if you call an UniTask and don’t wait for its completion; otherwise an warning message is given.

An warning message is given if Forget is not attached

You can also choose to assign an UniTask to an underscore instead of the Forget method calling.

Use an underscore instead of the Forget method
private void Start()
{
    Debug.Log("Program started...");
    _ = LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
    LogDelayedMessageAsync(2.0f, "Hello, UniTask2!").Forget();
    LogDelayedMessageAsync(1.0f, "Hello, UniTask3!").Forget();
}
C#

Wait for completion of UniTasks

We use the await operator to wait for the completion of an UniTask.

In the LogDelayedMessageAsync method, the Debug.Log method is called after the UniTask.Delay method is called. Then, the Debug.Log method will not called until the process of the UniTask.Delay method finishes because the UniTask.Delay is called with the await operator.

Also, if you want to wait for completion of an asynchronous process in a certain method, you have to attach the async modifier to that method.

Let’s try to wait for completion of the LogDelayedMessageAsync method. We change the Start method in the sample code as follows.

Wait for completion of the LogDelayedMessageAsync method
private async void Start()
{
    Debug.Log("Program started...");
    await LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
    await LogDelayedMessageAsync(2.0f, "Hello, UniTask2!");
    await LogDelayedMessageAsync(1.0f, "Hello, UniTask3!");
}
C#

The async modifier is attached to the Start method since the await operators are used inside the method.

Unlike the first example and the second one, the messages are shown one by one in the program order. That is because the completion is awaited due to the await operator every time the LogDelayedMessageAsync method is called.

Awaitable objects

The reason why we can wait for completion of UniTasks with the await operator is that the GetAwaiter method, which returns an object called Awaiter, is defined for the UniTask type. Awaiter and GetAwaiter are functions in C#, so not only UniTask objects but also objects that have their GetAwaiter method are awaitable.

UniTask provides Awaiters and GetAwaiters for some objects. Therefore you can await some objects other than UniTask objects. For example, you can await objects defined in DOTween, which is a library for creating script-based animations. The cooperation between DOTween and UniTask is so powerful.

Awaiting through Variables

You can wait for the completion of an UniTask after storing it in a variable. Let’s try the following code.

Wait for completion of UniTasks through variables
private async void Start()
{
    Debug.Log("Program started...");
    var task1 = LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
    var task2 = LogDelayedMessageAsync(2.0f, "Hello, UniTask2!");
    var task3 = LogDelayedMessageAsync(1.0f, "Hello, UniTask3!");
    await task1;
    await task2;
    await task3;
}
C#

UniTasks are awaited in this example; nevertheless the messages are not shown in the program order, which is different from the example in the previous section.

The reason is that the process of an UniTask starts right after it is assigned to a variable. Basically, the process of an UniTask doesn’t start when it is awaited, but when it is called. The role of the await operator is to specify that the completion of the UniTask is awaited.

You should await a method call directly if you want to wait for its completion at the same time you call the method. Or if you don’t want to call a method and wait for its completion simultaneously, you should await the method through a variable.。

UniTasks are awaited only once

Generally one UniTask can be awaited only once. The following code causes an error. If you have any requirement of awaiting the same UniTask two ore more times, you need to use the special method, Preserve.

Await one UniTask two times
var task1 = LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
await task1;
await task1;
C#
Start process of UniTasks when they are awaited

You can delay the timing of the execution of an UniTask until it is awaited by using the UniTask.Defer method. The result of running the following code is the same as the example in the previous section. You can use the UniTask.Lazy to await an UniTask many times, by the way.

Start the execution of UniTasks when they are awaited by using the Defer method
private async void Start()
{
    Debug.Log("Program started...");
    var task1 = UniTask.Defer(() => LogDelayedMessageAsync(3.0f, "Hello, UniTask1!"));
    var task2 = UniTask.Defer(() => LogDelayedMessageAsync(2.0f, "Hello, UniTask2!"));
    var task3 = UniTask.Defer(() => LogDelayedMessageAsync(1.0f, "Hello, UniTask3!"));
    await task1;
    await task2;
    await task3;
}
C#

UniTaskVoid

If an UniTask will never be awated, you can use the UniTaskVoid type, which is the lightweight version of the UniTask type. For example, the following code works, and running it results in the same result as that of the first example. Note that you can’t await UniTasks with the UniTaskVoid type.

Replace the return types with the UniTaskVoid type
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private void Start()
    {
        Debug.Log("Program started...");
        LogDelayedMessageAsync(3.0f, "Hello, UniTask1!").Forget();
        LogDelayedMessageAsync(3.0f, "Hello, UniTask2!").Forget();
        LogDelayedMessageAsync(3.0f, "Hello, UniTask3!").Forget();
    }

    private async UniTaskVoid LogDelayedMessageAsync(float waitTime, string message)
    {
        await UniTask.Delay(TimeSpan.FromSeconds(waitTime));
        Debug.Log(message);
    }
}
C#

If you await any asynchronous operations in the Start method, you can specify the UniTaskVoid type as the return type. For example, the following code results in the same result as that of the example in which the return type is UniTask.

Replace the return type of the Start method with the UniTaskVoid type
private async UniTaskVoid Start()
{
    Debug.Log("Program started...");
    await LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
    await LogDelayedMessageAsync(2.0f, "Hello, UniTask2!");
    await LogDelayedMessageAsync(1.0f, "Hello, UniTask3!");
}
C#

Now we can say that the UniTaskVoid type is the UniTask version of the void type.

UniTask with Return Values

Delayed Adder Method

In the previous chapter, we developed the UniTask that shows a message after it waits for the specified number of seconds. We can call this UniTask and wait for its completion, but it returns no value.

In this section, we are going to deal with an UniTask with a return value.

As an example, let’s develop a delayed adder method. Create the following script and run it.

Delayed adder program
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        Debug.Log("Program started...");
        var result = await AddNumbersAsync(1, 2);
        Debug.Log($"Result: {result}");
    }

    private async UniTask<int> AddNumbersAsync(int n1, int n2)
    {
        await UniTask.Delay(TimeSpan.FromSeconds(2.0f));
        return n1 + n2;
    }
}
C#
The result of the delayed adder program

It takes a few seconds for the AddNumbersAsync method to finish, and after it finishes, it returns the sum of the two integers passed as the two arguments of the method.

The UniTask<T> Generic Struct

We specify the UniTask<T> generic struct as the return type of a method to deal with an UniTask with a return value, where T is the type of the return value.

We can treat the async modifier and the await operator in the same way as UniTasks with no return value. If we use the await operator in a certain method, we have to attach the async modifier to the method.

The return value is received by calling an UniTask and assigning it to a variable with the await operator.

It is not so difficult to deal with UniTasks with a return value if you understand how to deal with UniTasks without any return values. When the process of a generic version of UniTask starts and the limitation regarding the number of awaiting are the same as normal UniTasks. The only difference is whether it provides its return value.

Awaiting through Variables

We can wait for the completion of an UniTask with a return value as well as an UniTask without any return values. The following sample code results in the same result as that of the previous example.

Awaiting the completion of an UniTask with a return value through a variable
private async UniTaskVoid Start()
{
    Debug.Log("Program started...");
    var task = AddNumbersAsync(1, 2);
    var result = await task;
    Debug.Log($"Result: {result}");
}
C#
Delay the timing of starting an UniTask with return values

The process of the AddNumberAsync method starts right after it is assigned to the task variable. If you want to delay the start of the process until it is awaited, you can use the UniTask.Defer<T> generic method, which is the generic version of the UniTask.Defer method. (You can also use the UniTask.Lazy<T> method to await the process many times.)

Exercise

Named Timer

Develop a timer that counts the specified number of seconds. The timer name and the remaining time have to be shown on the console every second.

Use the template below.

The template for the named timer
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        Debug.Log("Program started...");
        var timerA = StartTimerAsync("Timer A", 3);
        var timerB = StartTimerAsync("Timer B", 5);
        await timerA;
        await timerB;
        Debug.Log("Program finished!");
    }

    private async UniTask StartTimerAsync(string name, int seconds)
    {

    }
}
C#
The result example of the named timer

N-Inputs Delayed Adder

Develop an adder that receives any number of decimals and returns the sum of them. Additionally, it requires 1 second for each decimal passed to finish calculation.

Use the template below.

The template for the n-inputs delayed adder
using Cysharp.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        Debug.Log("Program started...");
        var result1 = await SumAsync(new float[] { 1.6f, 5.4f, 3.0f });
        Debug.Log($"Result1: {result1}");
        var result2 = await SumAsync(new float[] { 2.2f, -5.3f, 2.5f, -7.9f, -6.5f });
        Debug.Log($"Result2: {result2}");
        Debug.Log("Program finished!");
    }

    private async UniTask<float> SumAsync(IEnumerable<float> numbers)
    {

    }
}
C#
The result example of the n-inputs delayed adder

Example Answers

Named timer
Example answer of the named timer
private async UniTask StartTimerAsync(string name, int seconds)
{
    Debug.Log($"{name} started...");
    while (seconds > 0)
    {
        Debug.Log($"{name}: {seconds} sec.");
        seconds--;
        await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
    }
    Debug.Log($"{name} finished!");
}
C#
N-Inputs delayed adder
Example answer of the n-inputs adder
private async UniTask<float> SumAsync(IEnumerable<float> numbers)
{
    await UniTask.Delay(TimeSpan.FromSeconds(1.0f * numbers.Count()));
    return numbers.Sum();
}
C#

Comments