Friends, food, and flourishing

Async

I’ve been wanting to learn about async/await processing in Unity, without using coroutines. Why?

  • Coroutines depend on MonoBehavior, so only work in Unity’s component context. Using new() to make a class not descending from MonoBehavior is Not Supported.  Async/await doesn’t depend on MonoBehavior. It’s easy to create as many objects using async/await as you want, without MonoBehavior overhead.
  • It’s easier to pass data betwixt callers and task runners. This is harder with Coroutines.


This blog post summarizes what I’ve learned so far, through examples. There are seven examples at the moment.

  • Wait for me: a simple program that uses async to wait for short time.
  • A non-MonoBehavior class: same, but with a second class that isn’t derived from MonoBehavior.
  • Parameters: passing parameters from the caller to the async processing method, and back again.
  • A gamey async: a demon and a ghost race each other. The game manager class uses async to wait for a winner. Shows how to cancel async tasks.
  • Fetching JSON (basic): Fetch one chunk of JSON from the web.
  • Fetching JSON (multiple files): fetching several chunks. Returns could overlap, that is, ask the server for file 1 and then file 2, but file 2 arrives before file 1 (maybe it’s smaller). No worries, since we can make a new JSON grabber object for each JSON chunk we want.
  • Fetching JSON (multiple files with error control): same, but with error control. 404, timeout, whatevs.


Please comment if you see errors, or to suggest additions.

Wait for me

The simplest async Unity program. Start() waits for Waiter() to complete. Waiter() waits for Task.Delay() to complete.

Notice Waiter() returns a Task, according to the method’s signature. Err, but there’s no return statement. WTF? C#’s async magic handles that in the background.

This threw me for a bit. In JS, you might get a Promise, something passed back to the caller. C# does that, too, but it’s hidden. This makes some things easier, and other things more difficult, like cancelling tasks.

				
					using UnityEngine;
using System.Threading.Tasks;

public class ManagerController : MonoBehaviour
{
    /// <summary>
    /// Wait for a short time after start. 
    /// </summary>
    async void Start()
    {
        Debug.Log("start start");
        await Waiter();
        Debug.Log("end start");
    }
    
    async Task Waiter() {
        Debug.Log("start waiter");
        await Task.Delay(510);
        Debug.Log("end waiter");
    }
}

				
			

A non-MonoBehavior class

This version has two classes:

  • One derived from MonoBehavior, for doing GameObject stuff.
  • A stand-alone class with the async code.

Debug.Log() is part of MonoBehavior, so I implement a Report() method in the MonoBehavior class, and pass a reference to the non-MonoBehavior class. IReporter helps.

				
					namespace Custom.Scripts
{
    public interface IReporter
    {
        public void Report(string textIn);
    }
}

// ------------ CUT SCREEN HERE -------------

using System.Threading.Tasks;
using UnityEngine;

namespace Custom.Scripts.WaiterNonUnityClass
{
    /// <summary>
    /// Wait for a short time after start. 
    /// </summary>
    public class WaiterNonUnityClassController : MonoBehaviour, IReporter
    {
        async void Start()
        {
            Debug.Log("start start");
            Waiter waiter = new Waiter();
            await waiter.WaitForIt(this);
            Debug.Log("end start");
        }

        public void Report(string textIn)
        {
            Debug.Log(textIn);
        }
    }
}

// ------------ CUT SCREEN HERE -------------

using System.Threading.Tasks;

namespace Custom.Scripts.WaiterNonUnityClass
{
    public class Waiter
    {
        public async Task WaitForIt(IReporter reporter) {
            reporter.Report("start waiter");
            await Task.Delay(510);
            reporter.Report("end waiter");
        }
    
    }
}

				
			

Parameters

Now pass parameters, from the controller to the waiter, and back again. The generic Task<T> makes this work well.

				
					using System.Threading.Tasks;
using UnityEngine;

namespace Custom.Scripts.SimpleParams
{
    public class SimpleParamsController : MonoBehaviour, IReporter
    {
        async void Start()
        {
            Debug.Log("start start");
            Waiter waiter = new Waiter();
            string taskResult = await waiter.WaitForIt(this, "Doggos are the best!");
            Debug.Log($"Got: {taskResult}");
            Debug.Log("end start");
        }

        public void Report(string textIn)
        {
            Debug.Log(textIn);
        }        
    }
}

// ------------ CUT SCREEN HERE -------------

using System.Threading.Tasks;

namespace Custom.Scripts.SimpleParams
{
    public class Waiter
    {
        public async Task<string> WaitForIt(IReporter reporter, string messageIn) {
            reporter.Report("start waiter");
            await Task.Delay(500);
            reporter.Report(messageIn);
            await Task.Delay(500);
            reporter.Report("end waiter");
            return "Burt is a good doggo.";
        }
    
    }
}


				
			

A gamey async

Now a gamey thing. A race between a demon, and a ghost.

Race

Each one has a NavMeshAgent. The speed of the ghost is slightly higher than the demon. There are two destination objects near the white pillars, one for each racer. You can see them in the Hierarchy.

The Manager is the thing that controls the race. It creates a Task for each character, and hangs around until the first one finishes. It cancels the other task after that.

The Manager, destinations, and racers all have scripts. Here’s the code, with comments showing alternative implementations I found in various tutorials.

				
					using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

namespace Custom.Scripts.NavigatingCharacters
{
    /// <summary>
    /// Manage a race betwixt a demon and a ghost.
    /// </summary>
    public class NavigatingCharactersController : MonoBehaviour
    {
        [SerializeField]
        private Destination demonDestination;
        [SerializeField]
        private Destination ghostDestination;
        async void Start()
        {
            // Create a cancellation token needed to cancel tasks. Here, we're using one token source
            // source for all tasks.
            CancellationTokenSource cancelTokenSource = new CancellationTokenSource();
            // Or we could use a separate token for each task...
            // CancellationTokenSource demonCancelTokenSource = new CancellationTokenSource();
            // CancellationTokenSource ghostCancelTokenSource = new CancellationTokenSource();
            
            // Start the tasks, putting them in an array, so we can easily wait for one
            // to complete.
            Task<string>[] tasks = new Task<string>[2];
            tasks[0] = demonDestination.WaitForArrival(cancelTokenSource);
            tasks[1] = ghostDestination.WaitForArrival(cancelTokenSource);
            // Or, if using separate token sources...
            // tasks[0] = demonDestination.WaitForArrival(demonCancelTokenSource);
            // tasks[1] = ghostDestination.WaitForArrival(ghostCancelTokenSource);
            
            // Wait for one of the tasks could complete, returning the one that did.
            // Note: we could send both tasks are individual params of WhenAny...
            // await Task.WhenAny(demonTask, ghostTask);
            // ... but the array way is more general. We could also use a list, instead
            // of an array.
            // Wait for one of the tasks to complete. Cancel the others later.  
            Task<string> winnerTask = await Task.WhenAny(tasks);
            // We could use a try/catch to watch for exceptions, like file read failures, or whatevs.
            // See the code in Destination for more deets on throwing exceptions.
            // Task<string> winnerTask = null;
            // try
            // {
            //     winnerTask = await Task.WhenAny(tasks);
            // }
            // catch (OperationCanceledException e)
            // {
            //     Debug.Log("Task was canceled.");
            // }
            // One of the tasks returned. Get the returned string.
            string winnerName = winnerTask.Result;
            // Report the winner.
            Debug.Log($"We have a winner: {winnerName}");
            // Cancel the tasks that are still running.
            cancelTokenSource.Cancel();
            // If using separate tokens for each task, cancel both.
            // demonCancelTokenSource.Cancel();
            // ghostCancelTokenSource.Cancel();
            cancelTokenSource.Dispose();
            // Dispose of both, if you have two.
        }
        
    }
}

// ------------ CUT SCREEN HERE -------------

using System;
using UnityEngine;
using UnityEngine.AI;

namespace Custom.Scripts.NavigatingCharacters
{
    /// <summary>
    /// A racer.
    /// </summary>
    public class Character : MonoBehaviour
    {
        [Tooltip("Where the character is racing to.")]
        [SerializeField]
        private Destination destination;
        // The agent controlling the racer's movement.
        private NavMeshAgent _navMeshAgent;

        private void Start()
        {
            _navMeshAgent = GetComponent<NavMeshAgent>();
            // Let's go!
            _navMeshAgent.SetDestination(destination.transform.position);
        }

        // If we don't want to use a collider on the destination, we could
        // use this approach. However, I don't like adding Update()
        // methods without good cause.
        // private void Update()
        // {
        //     // Are we there yet?
        //     if (_navMeshAgent.remainingDistance <= _navMeshAgent.stoppingDistance)
        //     {
        //         // Debug.Log("Stopping");
        //         destination.characterArrived = true;
        //     }
        // }
        
    }
}

// ------------ CUT SCREEN HERE -------------

using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

namespace Custom.Scripts.NavigatingCharacters
{
    public class Destination : MonoBehaviour
    {
        [Tooltip("The character racing for this destination.")]
        [SerializeField]
        private GameObject character;
        
        // Has the character arrived?
        private bool _isCharacterArrived = false;

        public void OnTriggerEnter(Collider other)
        {
            if (other.gameObject.CompareTag("Character"))
            {
                if (character == other.gameObject)
                {
                    _isCharacterArrived = true;
                }
            }
        }

        /// <summary>
        /// The task.
        /// </summary>
        /// <param name="cancellationTokenSource"></param>
        /// <returns>The name of the character who won.</returns>
        public async Task<string> WaitForArrival(CancellationTokenSource cancellationTokenSource)
        {
            // Loop until a character has arrived.
            while (! characterArrived)
            {
                // Has the race manager asked that the task be cancelled?
                if (cancellationTokenSource.IsCancellationRequested)
                {
                    // Yes.
                    Debug.Log($"Canceling {character.gameObject.name} ");
                    // Return outta here.
                    return null;
                    // Or throw an exception.
                    // The throw is special, somehow. It doesn't cause Unity to 
                    // make a log entry when it is thrown.
                    // cancellationTokenSource.Token.ThrowIfCancellationRequested();
                }
                await Task.Yield();
            }
            return character.name;
        }
    }
}
				
			

Here’s what the console shows:

Console

Fetching JSON (basic)

This code fetches one JSON file from the interwebs. It’s a starting point. The JSON format is defined in the Monters class. It isn’t included below, since it doesn’t matter for our purposes.

				
					using UnityEngine;

namespace Custom.Scripts.JsonFetchBasic
{
    public class JsonFetchController : MonoBehaviour
    {
        private async void Start()
        {
            // URL of monster data for CryptidKitchen.
            string url = "https://ck-gamecontent.cryptidkitchen.net/storage/monsters.json";
            // Make a new fetcher. A class I made, not based on MonoBehavior,
            // so new()ing one isn't a problem.
            WebStringFetcher webStringFetcher = new WebStringFetcher();
            // Wait for the fetcher to grab the data.
            string fetched = await webStringFetcher.GetStringFromWeb(url);
            // Deserialize.
            Monsters monstersCollection = JsonUtility.FromJson<Monsters>(fetched);
            Monster[] monsters = monstersCollection.monsters;
            // Show the monsters.
            foreach (Monster monster in monsters)
            {
                Debug.Log(monster.label);
            }
        }
    }
}

// ------------ CUT SCREEN HERE -------------

using System.IO;
using System.Net;
using System.Threading.Tasks;

namespace Custom.Scripts.JsonFetchBasic
{
    public class WebStringFetcher
    {
        /// <summary>
        /// Get a string from a URL.
        /// No error control yet.
        /// Adapted from https://www.red-gate.com/simple-talk/development/dotnet-development/calling-restful-apis-unity3d/
        /// </summary>
        /// <returns>The string</returns>
        public async Task<string> GetStringFromWeb(string url)
        {
            HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
            HttpWebResponse response = (HttpWebResponse)(await request.GetResponseAsync());
            StreamReader reader = new StreamReader(response.GetResponseStream());
            string result = reader.ReadToEnd();
            return result;
        }
    }
}

				
			

Fetching JSON (multiple files)

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.

				
					using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;

namespace Custom.Scripts.JsonFetchMultiple
{
    public class JsonFetchMultipleController : MonoBehaviour
    {
        private async void Start()
        {
            // URL of monster data for CryptidKitchen.
            string monstersUrl = "https://ck-gamecontent.cryptidkitchen.net/storage/monsters.json";
            // Jokes URL.
            string catFactsUrl = "https://official-joke-api.appspot.com/random_ten";
            // Make new fetchers, one for each URL.
            WebStringFetcher monstersFetcher = new WebStringFetcher(monstersUrl);
            WebStringFetcher jokesFetcher = new WebStringFetcher(catFactsUrl);
            // Start the tasks, putting them in an array, so we can easily wait for one
            // to complete.
            Task<string>[] tasks = new Task<string>[2];
            tasks[0] = monstersFetcher.GetStringFromWeb();
            tasks[1] = jokesFetcher.GetStringFromWeb();
            // Wait for all to complete.
            string[] fetchedCollection = await Task.WhenAll(tasks);
            // Deserialize monsters.
            Monsters monstersCollection = JsonUtility.FromJson<Monsters>(fetchedCollection[0]);
            Monster[] monsters = monstersCollection.monsters;
            // Show the monsters.
            Debug.Log("Monsters...");
            foreach (Monster monster in monsters)
            {
                Debug.Log(monster.label);
            }
            // Deserialize jokes.
            // We get a serialized array from the server. JsonUtility only works 
            // with objects. So, wrap what we got to make JsonUtility happy.
            string serializedJokes = "{\"jokes\": " + fetchedCollection[1] + "}";
            Jokes jokesCollection = JsonUtility.FromJson<Jokes>(serializedJokes);
            Joke[] jokes = jokesCollection.jokes;
            // Show the jokes.
            Debug.Log("Jokes...");
            foreach (Joke joke in jokes)
            {
                Debug.Log(joke.setup);
                Debug.Log(joke.punchline);
            }
        }
    }
}

// ------------ CUT SCREEN HERE -------------

using System.IO;
using System.Net;
using System.Threading.Tasks;

namespace Custom.Scripts.JsonFetchMultiple
{
    public class WebStringFetcher
    {
        private string _url;
        
        public WebStringFetcher(string urlIn)
        {
            _url = urlIn;
        }
        
        /// <summary>
        /// Get a string from a URL.
        /// No error control yet.
        /// Adapted from https://www.red-gate.com/simple-talk/development/dotnet-development/calling-restful-apis-unity3d/
        /// </summary>
        /// <returns>The string</returns>
        public async Task<string> GetStringFromWeb()
        {
            HttpWebRequest request = (HttpWebRequest)WebRequest.Create(_url);
            HttpWebResponse response = (HttpWebResponse)(await request.GetResponseAsync());
            StreamReader reader = new StreamReader(response.GetResponseStream());
            string result = reader.ReadToEnd();
            return result;
        }
    }
}
				
			

Fetching JSON (multiple files with error control)

This code fetches several JSON files from the interwebs. Each fetch is managed by a separate instance of the fetching class. Each one can report:

  • The string that was fetched, or…
  • An error from the server, like a 404, or…
  • There was a timeout (the server didn’t respond).

The string is interpreted as serialized JSON, but it could be anything.

How complex the error control is depends on how much detail you want about the error. Code could just say “Something bad happened,” or give details about exactly what failed. I’m going for the easy approach here.

The code is mostly the same as the last example. Start() in the main class has a try/catch to handle errors. WebStringFetcher.GetStringFromWeb() adds a timeout to its HttpWebRequest, and throws an exception if it can’t create a StreamReader.

				
					using System;
using System.Threading.Tasks;
using UnityEngine;

namespace Custom.Scripts.JsonFetchMultipleErrorControl
{
    public class JsonFetchMultipleErrorControlController : MonoBehaviour
    {
        private async void Start()
        {
            // URL of monster data for CryptidKitchen.
            string monstersUrl = "https://ck-gamecontent.cryptidkitchen.net/storage/monsters.json";
            // Jokes URL.
            string catFactsUrl = "https://official-joke-api.appspot.com/random_ten";
            string[] fetchedCollections = null;
            // Catch webbish exceptions for all tasks.
            try
            {
                // Make new fetchers, one for each URL.
                WebStringFetcher monstersFetcher = new WebStringFetcher(monstersUrl);
                WebStringFetcher jokesFetcher = new WebStringFetcher(catFactsUrl);
                // Start the tasks, putting them in an array, so we can easily wait for one
                // to complete.
                Task<string>[] tasks = new Task<string>[2];
                tasks[0] = monstersFetcher.GetStringFromWeb();
                tasks[1] = jokesFetcher.GetStringFromWeb();
                // Wait for all to complete.
                fetchedCollections = await Task.WhenAll(tasks);
            }
            catch (Exception e)
            {
                // Not graceful. Make it graceful in a real app.
                Debug.Log($"Argh! Exception! {e.Message}");
                return;
            }
            // Deserialize monsters.
            Monsters monstersCollection = JsonUtility.FromJson<Monsters>(fetchedCollections[0]);
            Monster[] monsters = monstersCollection.monsters;
            // Show the monsters.
            Debug.Log("Monsters...");
            foreach (Monster monster in monsters)
            {
                Debug.Log(monster.label);
            }
            // Deserialize jokes.
            // We get a serialized array from the server. JsonUtility only works 
            // with objects. So, wrap what we got to make JsonUtility happy.
            string serializedJokes = "{\"jokes\": " + fetchedCollections[1] + "}";
            Jokes jokesCollection = JsonUtility.FromJson<Jokes>(serializedJokes);
            Joke[] jokes = jokesCollection.jokes;
            // Show the jokes.
            Debug.Log("Jokes...");
            foreach (Joke joke in jokes)
            {
                Debug.Log(joke.setup);
                Debug.Log(joke.punchline);
            }
        }
    }
}

// ------------ CUT SCREEN HERE -------------

using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;

namespace Custom.Scripts.JsonFetchMultipleErrorControl
{
    public class WebStringFetcher
    {
        // Timeout at 2 minutes.
        private const int TimeoutLengthMs = 120_000;
        private readonly string _url;
        
        /// <summary>
        /// Constructor. Send in a URL.
        /// </summary>
        /// <param name="urlIn"></param>
        public WebStringFetcher(string urlIn)
        {
            _url = urlIn;
        }
        
        /// <summary>
        /// Get a string from a URL.
        /// Adapted from https://www.red-gate.com/simple-talk/development/dotnet-development/calling-restful-apis-unity3d/
        /// </summary>
        /// <exception cref="WebException">A Bad Thing happened.</exception>
        /// <exception cref="InvalidOperationException">Making StreamReader failed.</exception>
        /// <returns>The string</returns>
        public async Task<string> GetStringFromWeb()
        {
            HttpWebRequest request = (HttpWebRequest)WebRequest.Create(_url);
            // Set the timeout. If it is exceeded, request will throw a WebException.
            request.Timeout = TimeoutLengthMs;
            HttpWebResponse response = (HttpWebResponse)(await request.GetResponseAsync());
            StreamReader reader = new StreamReader(response.GetResponseStream() 
                ?? throw new InvalidOperationException());
            string result = await reader.ReadToEndAsync();
            return result;
        }
    }
}

				
			

Leave a Reply

Your email address will not be published. Required fields are marked *

css.php