Posted On: 2019-02-25
Today's post will give a brief summary of different ways of performing asynchronous operations in Unity. For the purposes of this post, synchronous code is code that follows the Update pattern (code that executes every frame), while asynchronous code is any code that executes on a different schedule from the Update. I will cover four different patterns (Tasks, Promises, Coroutines, and Unity Jobs) and describe what situtations best suits each one, as well as what pitfalls and constraints are associated with them. Finally, I will wrap up by explaining the pattern I am using for handling the dialogue system for my upcoming prototype, which should serve as a useful real-world example.
Often, code is called "multi-threaded" when it executes in parallel, and the Task class in the "System.Threading" namespace is an excellent way to achieve such "multi-threaded" code. A Task represents a unit of work (such opening a file and reading its contents) that may run in parallel with other code. Tasks have a number of constraints on what they can do, however, since they run on a different thread (technically they don't always run on a different thread, but they have the same constraints regardless.) The biggest constraint, in the context of Unity, is that Tasks cannot interact with the Unity engine: they cannot read user input or display anything to the user, because all calls to the Unity Engine must come from the main thread. As such, Tasks are typically used when the code does not have any user interaction, such as procedural generation or reading/writing files on the file system. Tasks are generally unsuitable for things that need to be synchronized with the user interface, such as animations. Additionally, Tasks are capable of reporting progress and returning results to the thread that called them, which is useful for things like progress bars.
As a brief aside: while it is technically possible to create and manipulate Threads directly to achieve multi-threading, Tasks use a much more maintainable abstraction to do the same thing. Additionally, the Task Factory (the primary way of running tasks) is optimized to balance the cost of creating new threads versus re-using existing ones, so it often gets better performance results than using threads directly. As such, I won't write anything about using Threads themselves.
A second aside: what I wrote about not being able to manipulate the Unity Engine across threads might not be technically true, but I consider it functionally true. Ordinarily resource sharing can be achieved using thread synchronization, but I don't know enough on the topic to say whether it would be possible with Unity. I have my doubts (since Unity's C++ engine accesses the managed engine's memory directly) and I don't recommend trying unless you are already very familiar both with thread synchronization and the internals of the Unity engine.
(most notably, promises are available for use in Unity and C# using an external library.) In general, a promise represents a unit of work that has a defined end state, and code can be scheduled to run after the end has been reached (using the
Then method.) Additionally, promises are capable of representing both success and failure, so different code can be scheduled depending on whether or not it succeeded.
Like Tasks, Promises are capable of reporting progress and returning results.
Promises are designed to simplify interdependency in asynchronous systems, so they are best suited to situations where asynchronous systems need to coordinate with one-another.
Additionally, it is important to be aware that Promises by themselves don't actually provide any asynchronous behavior - it is up to the implemented code to do so.
Lastly, since Promises don't implement asynchronity directly, they can be used as an interface for code that may or may not run asynchronously, allowing for
the calling code to treat all systems as though they were asynchronous, regardless of how they are actually implemented.
A coroutine is a unit of work that will be performed in parallel with other coroutines. Unity provides a built-in mechanism for coroutines (typically using StartCoroutine) and when using this system, the coroutines all run on the main thread. Since coroutines run on the main thread, they can freely interact with the Unity Engine (including updating the
user interface and reading user input.) The execution order and timing of the Corroutines are managed by the Unity Engine (the Coroutine scheduler runs each frame, after Update but
before the buffer-swap that renders images to the screen.) Since Coroutines execute on the main thread, they are best suited when asynchronous code needs to coordinate with
the Unity Engine (such as animations or timers.)
Unlike Tasks and Promises, Coroutines are not able to return values or report progress. Additionally, it is not possible to determine if a coroutine is complete
(with a possible exception of
yield return StartCoroutine(...), which allows one coroutine to pause until the completion of another coroutine.) Lastly, it is not possible to have a coroutine execute instantanously: all coroutines must run through
the scheduler, which typically means there will be at least one-frame of delay between the code running and its changes being available for use in other code.
The Unity Job System is a new feature that is currently in Beta. It is a replacement for the Update pattern, instead scheduling to maximize the amount of work done in parallel (although it can technically be used in a project that already has components using the Update pattern.) From what I have read, the Job system expects that developers will use a completely different architecture: the Entity-Component-System architecture. In that architecture, Systems are responsible for managing all functionality, while the visible/interactable objects have their various properties (position, color, etc) stored in Components. Overall, I think this approach has a lot of merit (data-driven architectures, like ECS, are often much more maintainable than Object-Oriented architectures that couple behaviour and data together) but, unfortunately, the Job System currently has a number of barriers inhibiting adoption. The primary obstacle (from what I have read) is that the public-facing signature of the current implementation is still undergoing dramatic changes (to hopefully make writing systems more user-friendly.) As such, I haven't yet used this system myself, though I am very much looking forward to trying it out (assuming they can resolve the lingering usability issues.)
Now that the four different asynchrous mechanisms available in Unity have been described, I will go into detail regarding how I chose which one to use for my current prototype. The particular problem I was trying to tackle involved the dialogue engine of the game: like my Notebook Prototype, I was building on top of Yarn Spinner to deliver dialogue to the player. Yarn Spinner makes extensive use of Coroutines, including managing how Commands are executed in the system. Unfortunately, since I intended to have modular commands, I would likely have a large number of small actions (change a texture, change a label, etc). This, in turn, would result in a lot of wasted frames as each command was run in order (reliance on Coroutines required a minimum of one frame per command, no matter how small.) Since each command was supposed to be small, I was expecting occasions with over 10 commands running in a row - which could add up to enough delay to be perceptable to players.
When I looked at the possible commands, it seemed they were either instantaneous actions (such as making UI changes) or low-cost, multi-frame actions (such as a character playing a cut-scene animation). Since both kinds were tightly coupled to the Unity Engine, I immediately knew Tasks would be a poor fit for this. Additionally, while I considered Unity Jobs, it quickly became clear that swapping the architecture out for one based on ECS was far beyond the scope of this particular challenge. Finally, since I would need to work with both asynchronous and synchronous actions, Promises seemed to be the best choice.
Switching each action to return a promise had several immediate benefits. Firstly, each action could decide, for itself, whether it should run synchronously (and resolve immediately)
or if it should create a coroutine to resolve the promise later. Regardless of which approach the action chose, the calling code (which managed the sequence of actions)
would remain the same. Secondly, making the UI automatically close the dialgoue in the event of an error was relatively simple: if the promise is ever rejected, then the
Catchon the promise chain runs, which logs the error and stops the dialogue. Thirdly, making a dialogue choice fit naturally into the structure of a Promise: since promises can return
values, the promise simply doesn't resolve until the player makes a choice, and then the value of that choice is returned. This led to some nice cleanup of the existing choice code, which
used long-lived state variables to workaround how coroutines can't return values.
Unfortunately, there is one major downside to using promises to manage the sequence of dialogue commands: the exact sequence of commands is not known ahead of time. Promises are generally simple to write as long as the sequence remains consistent, but the code to control their flow becomes much more complex if the resolution of the promise influences which (if any) promise is created next. Unfortunately, the dialogue system is a very extreme example of this (internally, it uses a state machine that loads next step in a sequence based on the current state, which is modified by things like player choices) so I had to spend quite a bit of time writing and debugging the way that promises control sequencing eachother. By contrast, coroutines don't have any structural metadata, just the code itself, so this kind of sequencing is trivial using a coroutine (it's just a while loop with control being yielded whenever necessary.) As such, I think that Yarn Spinner's reliance on coroutines make a lot of sense for anyone that isn't creating an interconnected web of potentially synchronous commands.
While there is much more to say about each of these approaches to asynchronous code in Unity, hopefully this overview and accompanying example are enough to start heading in the right direction if you find yourself faced with such a decision. Tasks, Promises, Corroutines, and Jobs each have their own strengths and weaknesses, so it's valuable to be familiar with all of them, as that is the best way to pick the right tool for each situation.