Huy Minh Ha

Software development, Tech and other stuff

Mon 08 May 2017

Introducing UAsync

Posted by Ha.Minh in Unity   

Nowadays, if you want to use a structured way for your flow control in Unity, you basically have 4 options:

  • Write your own Task library (which might use coroutines)
  • Use coroutines. This means that you StartCoroutine in a lot of places and insert try catch code when errors occur. This works for small games. For larger games, not being able to catch nested exception is a big NO NO.
  • Use C-Sharp-Promise. If you're familiar with JS promises, this comes natural to you. It handles exceptions pretty well. You can try combining this with coroutine, but the API is probably verbose.
  • Use UniRx. This is simply the best choice because it supports control flow, exception handling, progress report and coroutine.

So we should always use UniRx, right? Unfortunately, sometimes the efforts to use UniRx is just too much that we can't afford. In that case, it's better to use existing solution, but with more robust code. (C-Sharp-Promise is ofcourse another option, but it is not compatible with coroutine and existing coroutine code without some custom modifications).

UAsync Unity Async is a library that helps you write Unity code using callback style of Node.js and async library. The TaskRunner part is taken from Svelto.Tasks with some modifications to make it support catching exceptions and returning errors. The UAsync class adds several functions on top of TaskRunner to support execution of tasks in parallel or serial with returned results at the end of the execution. For the moment, it does not support Thread because it focuses on control flow, not enhancing performance by distributing work to multiple cores.

To include UAsync into your project, you can use npm method of unity package management described here.

Usage

TaskRunner

First of all, it's quite well-known that 2 main disadvantages of coroutine are: 1) it cannot return value and 2) it cannot handle nested exception. There's a simple way to wrap coroutine so we can support those 2 features, as detailed in this article. TaskRunner also supports returning value and catching exceptions using callback style. You use it like so:

using UAsync;
...
TaskRunner.Instance.Run (task, onComplete);
// public TaskRoutine Run (IEnumerator task, CallbackDelegate onComplete = null)

In the code above, task is a IEnumerator and onComplete is a delegate of type CallbackDelegate (object err = null, object res = null). Any exceptions occur will be passed via err. The last yield in task will be passed to res. You might want to use TaskRunner.Instance.Run when you have a sequence of actions to be performed in a fixed order.

UAsync

UAsync is a port of Node's async module to Unity environment. It can be used to turn a set of synchronous functions or coroutine to run sequentially or concurrently. Even though you can already run a set of tasks sequentially using coroutine, passing values between these tasks are proven to be difficult. You have to use external variables to hold the return values which creates coupling between functions, and it's not convenient. UAsync can solve this problem by allowing coroutine to return value, as well as catching exceptions if any.

Let's look at an example:

var series =
    UAsync.Async.Series (
        SeriesFunc.FromAction ("one", SeriesFunc1),
        SeriesFunc.FromEnumerator ("two", SeriesFunc2),
        SeriesFunc.FromAction ("three", SeriesFunc3),
        UAsyncFinalFunc.From ((object err, Dictionary<string, object> res) => {
            Debug.Log ("Finish " + err);
            if (err == null) {
                Debug.Log ("res " + res ["one"] + " " + res ["two"] + " " + res ["three"]);
            }
        })
    );

void SeriesFunc1 (CallbackDelegate cb, Dictionary<string, object> res)
{
    cb (null, 100);
}

IEnumerator SeriesFunc2 (CallbackDelegate cb, Dictionary<string, object> res)
{
    yield return new WaitForSeconds (1);
    cb (null, 200);
}

void SeriesFunc3 (CallbackDelegate cb, Dictionary<string, object> res)
{
    cb (null, 300);
}

Here, SeriesFunc.FromAction and SeriesFunc.FromEnumerator are just convenient functions to wrap synchronous functions and coroutines. Each of the functions SeriesFunc1, SeriesFunc2 and SeriesFunc3 will receive a callback parameter and a res parameter. To complete the execution of each function, you must call cb with 2 parameters: err representing the error, and result representing the returned value. In the code above, there is no error. If any of the code in those function throws exception, cb will also be called automatically with the exception as the first parameter.

The second parameter passed to each of the functions SeriesFunc1, SeriesFunc2 and SeriesFunc3 is quite important. It is a dicionary which contains all the results from previous functions, so SeriesFunc2 will receive result from SeriesFunc1, SeriesFunc3 will receive results from SeriesFunc1 and SeriesFunc2. The key of the dictionary are declared when creating the series, e.g. SeriesFunc.FromAction ("one", SeriesFunc1) means the result of SeriesFunc1 will have key "one". This is a powerful way to pass results between functions without creating high coupling between them.

After all the functions have been executed, there is a final function UAsyncFinalFunc which will receive all the results and execute some logic accordingly. If any of the functions above throws exceptions or calls callback with a err parameter, the error will be passed to the final function to deal with.

The UAsync.Async.Parallel function is similar to the Series function, except that there will be no results from previous functions since they're executed concurrently.

Finally, you can cancel a running sequence once it's started. This is not obvious with coroutine because even though you can call MonoBehaviour.StopCoroutine


    
 
 

Comments