Asynchronous programming in Unity: Coroutines vs. async/await
This article aims to contrast two different strategies for writing asynchronous C# code in Unity projects: Unity’s Coroutines and C#’s asynchronous programming model via async
/await
/Task
. We start by quickly introducing both concepts. Following, we discuss how game developers have a different relationship with asynchronous operations. Then, we describe several differences between the two approaches. Next, an alternative to C#’s vanilla Task
class is presented. Finally, the conclusion wraps the article up.
ℹ️ If you are familiar with Coroutines and
async
/await
and you’re just looking for the differences between them, jump to the Coroutines vs. async/await section.
Coroutines
Coroutines are a handy tool to conveniently write code that spans over multiple frames. They can be used, for example, to dim a light until it’s completely off.
Writing one synchronous method (like the one below) will not accomplish the dim effect we’re looking for because the method will start and end its execution within the same frame. Consequently, instead of dimming the light over time, it will instantly (to the player’s eyes) turn it off.
1
2
3
4
5
6
7
8
9
10
[SerializeField] private Light _light;
public void DimLight()
{
const int steps = 100;
var originalIntensity = _light.intensity;
var decrement = originalIntensity / steps;
for (var i = 0; i < steps; i++)
_light.intensity -= decrement;
}
Instead, we need to change the light’s intensity progressively, over multiple frames. Naturally, we can accomplish that with the Update
method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[SerializeField] private Light _light;
private const float Steps = 180;
private bool _dimming;
private float _decrement;
private int _stepCount;
public void StartDimming()
{
_decrement = _light.intensity / Steps;
_dimming = true;
}
private void Update()
{
if (!_dimming) return;
if (_stepCount >= Steps)
{
_dimming = false;
return;
}
_light.intensity -= _decrement;
_stepCount++;
}
Even though this approach is definitely valid, it has some drawbacks. First, it’s not exactly easy to read—the reader must look at three different places (private fields, StartDimming
and Update
) to understand the behavior. Second, it pollutes the Update
method, specially if the class implements more of these multi-frame operations. Third, it requires an Update
method to begin with, which has a performance cost, even when the dimming is not being performed. Lastly, disabling the script instance will pause the dimming, which might be undesirable.
That’s where Coroutines come in. They are a handy tool provided by the Unity API to write operations that span across frames in a contained, readable and maintainable manner. Here’s how the light dimming would be implemented with a Coroutine, where the DimLight
method is the Coroutine, and StartDimming
is the method that starts it by calling StartCoroutine
(Unity’s API method).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void StartDimming()
{
StartCoroutine(DimLight());
Debug.LogWarning("Dimming started!");
}
private IEnumerator DimLight()
{
const int steps = 180;
var originalIntensity = _light.intensity;
var decrement = originalIntensity / steps;
for (var i = 0; i < steps; i++)
{
_light.intensity -= decrement;
yield return null;
}
}
It’s important to point out the asynchronous nature of a Coroutine: the code inside DimLight
will run asynchronously, once every frame (yield return null
”skips” to the next frame). On the other hand, the call to StartCoroutine
inside StartDimming
will complete execution within the same frame it was called (in other words, it’s synchronous), and so will the call to Debug.LogWarning
.
Coroutines can be stopped, chained, and Unity’s API offers support for this asynchronous pattern in many ways. Even though there is a lot you can do with Coroutines, the aim of this article is not to dive deep into them, but to contrast them with an alternative.
The async
/await
pattern
The async
and await
keywords are part of C#’s asynchronous programming model, which follows the Task-based Asynchronous Pattern (TAP). In short, it is a bundle of language features (keywords, types, methods and runtime support) that aim to ease asynchronous programming.
Motivation
The motivation behind asynchronous programming in traditional software development is simple: some operations (most notably I/O operations) depend on external resources and conditions to complete. We call such operations “asynchronous”. Receiving network packages via the Socket.Receive
method is an example of a potentially asynchronous operation: we depend on external resources (in this case, on actual data to be available) to complete the operation. Whenever traditional, synchronous code tries to execute an asynchronous operation, it has no other choice: it must wait until the asynchronous operation is performed, “blocking” execution until a certain condition (e.g., a network packet has arrived) is met. In this context, “blocking” means that the thread executing the synchronous code will pause execution, causing a context switch (an expensive operation).
Asynchronous programming came to solve the resource management issue: instead of blocking the thread while waiting for the asynchronous operation to complete, signal that you’re waiting for it, say what you’d like to be done after it’s completed, and release the thread so it can perform other tasks. Whenever the asynchronous operation is completed, the runtime will fetch a thread to continue the execution from where it left off.
History
Previously, writing asynchronous C# code meant dealing with lots of callbacks, state objects, and completion events. Reading asynchronous code was not a linear operation and it involved following a callback chain. More often than not, most of the time and effort spent writing asynchronous code was dedicated to managing the asynchronous aspect of the code, instead of completing the task at hand.
Task-based Asynchronous Pattern (TAP)
Introduced in .NET Framework 4.5, the TAP simplifies writing and reading asynchronous code. Asynchronous operations are represented by the Task
and Task<T>
classes. Instead of following a callback chain, tasks can be read sequentially, line after line. In addition, tasks can be composed and canceled. Waiting for an asynchronous operation is accomplished via the await
keyword. A method can be awaited if it returns an “awaitable” type (e.g., Task
). Asynchronous methods (i.e., methods that wait for asynchronous operations) must be tagged with the async
keyword.
TAP in Unity
The Task-based Asynchronous Pattern has been supported in Unity since version 2017.1. Therefore, we can rewrite the light dimming example from the previous section using TAP’s constructs:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Although it is good practice to suffix an asynchronous method's
// name with `Async`, doing so doesn't trigger any language features.
public async Task DimLightAsync()
{
const int steps = 180;
var originalIntensity = _light.intensity;
var decrement = originalIntensity / steps;
for (var i = 0; i < steps; i++)
{
_light.intensity -= decrement;
await Task.Yield();
}
}
The code above is extremely similar to the dimming’s Coroutine approach from the previous section. In fact, the only differences are the method’s return type, the addition of the async
keywords, and the replacement of the yield return null
statement with await Task.Yield()
(which also “skips” to the next frame). When compared to the Coroutine approach, the snippet above doesn’t improve readability, it doesn’t eliminate the need for any special feature, neither improves performance. Then why the hell are we considering replacing our old friend Coroutine with this new, shiny construct?
There are some valid points worth considering when comparing the two approaches. But before we jump into the differences, I would like to discuss how asynchronous programming in game development differs from traditional software development.
Asynchronous programming in game development
In “traditional” software, asynchronous operations are a necessary evil that we can’t get gif of. Let’s say, for example, that we’re waiting for a network socket to receive some data and there’s none available. It’s impossible to eliminate the issue at hand (there’s no data to receive). We can’t just magically place the data into the socket buffer, unless it has actually been received. There’s no other choice besides waiting until the data is available to continue execution. In an ideal world, asynchronous operations would not exist and every operation would be synchronous. Thread blocking would be eradicated and everything would be amazing. But that’s not the world we live in, and we have to cope with asynchronous calls. Solutions like TAP and async
/await
aim to ease writing and reading asynchronous code, but they don’t eliminate their existence.
Even though the same holds for game development, we don’t just cope with asynchronous operations. We add asynchronicity to operations that technically don’t need it. The light dimming example from the previous sections is a perfect example of this practice. We can easily write synchronous code that progressively dims a light using iterations. In fact, that’s what we did in this article’s first code snippet. But doing so synchronously isn’t interesting in a video game (or any kind of interactive media) because users would not be able to watch the dimming, and would experience the operation as an instant shutdown.
This distinction might seem obvious, but it has some impact on how game developers deal with asynchronous operations. While non-game developers try to avoid asynchronous operations at all costs, game developers embrace them. Instead of a necessary evil, they become a tool to model operations that span across multiple frames. Consequently, game developers might not only encounter, but also write asynchronous code much more often than traditional developers. I believe, therefore, that like any other tool, we should understand it, compare the available options and learn which ones are best for each task.
Coroutines vs. async
/await
Even though both strategies are capable of modeling asynchronous operations, there are some important differences between them. This section presents the ones I judge most important, split into different aspects and in no particular order.
Availability
Unlike C#’s Task-based Asynchronous Pattern (TAP) and its async
, await
and Task
, Unity’s Coroutine is not a language/runtime feature. Instead, it’s part of Unity’s API and runtime. The methods used for Coroutine management (i.e., StartCoroutine
, StopCoroutine
, etc) are part of the MonoBehaviour
class. In addition, a Coroutine is directly tied to the MonoBehaviour
that started it.
This means that Coroutines can only run in instances of classes that inherit from MonoBehaviour
, effectively limiting the types of classes that have access to this tool. There are some ways to get around this limitation, but they usually use a dedicated MonoBehaviour
just to run Coroutines created by non-MonoBehaviour
classes, which is far from an ideal solution, in my opinion.
C#’s TAP (Task-based Asynchronous Pattern) and its types (e.g., Task
), on the other hand, can be used in any class, turning it into a default choice for non-MonoBehaviour
classes.
Outcome accessibility
Coroutines return an instance of IEnumerator
, which will be used by Unity’s runtime to run and manage the Coroutine. Consequently, we can’t use Coroutines to return some value without reverting to other old-school asynchronous strategies like callbacks—which won’t actually return a value, just perform an operation at a given point, without any guarantees.
Even though this lack of return value isn’t a problem in many scenarios, we would sometimes like to communicate the outcome of an asynchronous operation. For example, whether the operation was a success (bool
), how look it took to complete (float
) or how many attempts it took to succeed (int
).
On the other hand, C#’s TAP offers the Task<T>
type, which represents an asynchronous operation that returns a value of type T
upon completion. The value will be available once the task is completed and can be conveniently accessed in tandem with the await
keyword.
1
2
3
private Task<bool> TryToDoSomething() { ... }
bool success = await TryToDoSometing();
Stopping and cancellation
Both Coroutines and Task
offer mechanisms to stop asynchronous operations. The MonoBehaviour
class offers the following methods for stopping Coroutines:
-
StopAllCoroutines
to stop all Coroutines running on that instance. -
StopCoroutine
**to stop a specific Coroutine (3 overloads are offered).
In C#’s TAP, we don’t say a task was stopped, we say it was canceled, and cancellation tokens are used to signal task cancellation. It’s a drastically different approach when compared to Coroutine’s, with a steeper learning curve that includes learning about CancellationToken
, CancellationTokenSource
, OperationCanceledException
and AggregateException
. At the same time, it allows for two extremely useful concepts that are not available with Coroutines:
- The running
Task
is aware that it got canceled, and therefore, it can perform some cleanup operations. For example, a file download can be canceled by the user via the application UI, and the download, being aware of the cancellation, can close sockets and file handlers. With Coroutines, calls toStopCoroutine
are often followed by cleanup calls, which expose implementation details to the caller (e.g., the download operation is now spread into multiple calls) and are more error-prone (given that the cleanup calls can be mistakenly left out). - Likewise, the code that is awaiting a canceled
Task
is also aware of the cancellation—if best practices are followed. Provided with that information, the caller might decide to go down a different path than the successful one. Following the download example above, a download progress window might call aDownloadAsync
method and display a message if the download completes, but dismiss itself if it was canceled (by someone else). All that is possible within one enclosed method in the download window class, without the need for any extra data to signal completion. With Coroutines, we need to use dedicated fields that communicate whether a Coroutine completed successfully, checking its value before continuing. This approach is less readable, less maintainable and more error-prone (updating the supporting fields becomes a chore).
Although the Task
approach to stopping/cancellation isn’t as simple as the Coroutine one, it is much more powerful. Once tamed, it becomes particularly useful in more complex scenarios with multiple nested levels of asynchronous calls.
Lifetime management
A Coroutine is tightly coupled to the MonoBehaviour
that started it. If its MonoBehaviour
is destroyed, the Coroutine stops automatically. A Coroutine will also stop running (not pause!) whenever the game object that holds its MonoBehaviour
is disabled. Re-enabling the game object will not resume the Coroutine. At the same time, disabling the script instance that started the Coroutine has no effect on its lifetime. Even though this behavior might seem handy at times (it protects developers who forget to stop a Coroutine), it might have some undesired effects, particularly when disabling game objects. Even worse, there is no way to avoid this automatic stopping.
Task
, on the other hand, runs on a dedicated scheduler and requires manual lifetime management by default. A Task
won’t stop running if the script instance that created it is disabled or destroyed. It also won’t stop if the game object that contains the script instance that started it is destroyed. Hell, it won’t stop running in the Unity editor even if you exit play mode. The developer is responsible for explicitly managing the lifetime of a Task
, and for cancelling it whenever necessary. In Unity applications, this often requires calls to the CancellationTokenSource.Cancel
and Dispose
methods inside OnDestroy
.
Even though C#’s TAP’s approach requires manual lifetime management, it doesn’t hide potential surprises, and it offers more flexibility than Coroutine’s approach. Additionally, the explicit task cancellation calls serve as documentation about the exact circumstances under which asynchronous operations should stop running. Finally, TAP’s cancellation token approach offers granular control over the order in which tasks are canceled.
Error handling
C#’s error handling mechanism composed of runtime exceptions, try
/catch
/finally
blocks and execution flow interruptions are a great tool in a developer’s arsenal.
Unfortunately, Coroutines and error handling don’t go very well together because yield
statements can’t be placed inside try
/catch
/finally
blocks. Consequently, there is no way for a method waiting for a Coroutine to finish executing (using a yield return
statement) to get notified about the thrown exception. Take the following code as an example:
1
2
3
4
5
6
7
8
9
10
private IEnumerator MoveTarget() { ... }
private IEnumerator DimLight() { ... }
private IEnumerator RunComposed()
{
// There is no need to call StartCoroutine when yielding Coroutines
// to accomplish sequential composition.
yield return MoveTarget();
yield return DimLight();
}
Since a yield
statement can’t be wrapped by a try
/catch
block, there is no way for RunComposed
to handle an exception thrown by either MoveTarget
or DimLight
. Therefore, special care must be taken when writing Coroutines, especially when exogenous exceptions might be thrown.
C#’s TAP and Task
, on the other hand, do not suffer from the same limitation as Coroutines. Calls to await
can be placed inside try
/catch
/finally
blocks. Here’s an example:
1
2
3
4
5
6
7
8
9
10
11
private async Task RunComposedAsync()
{
try
{
await MoveTargetAsync();
}
catch (ArithmeticException)
{
// Cleanup the mess
}
}
If MoveTargetAsync
throws an ArithmeticException
, it will be caught. In the end, there are no limitations to error handling when using TAP constructs, including the await
keyword. In fact, not only is error handling fully compatible with C#’s TAP, but the entire Task
cancellation workflow is based on exception handling.
Multithreading support
Multithreading is a great way of leveraging the multicore capability of modern processors to improve performance, and its proper use might lead to major performance gains in video games. As some of you might know, Unity isn’t exactly friendly towards multithreaded code and offers an alternative to C#’s vanilla threads for multithreading solutions: the Job System. In this section, I would not like to focus on C#’s threads versus Unity’s jobs discussion. Each tool has its merits, and there’s enough to talk about to fill up its own blog post.
Instead, I would like to focus on C#’s threads and on a characteristic of multithreaded code: asynchronicity. Because multithreaded code runs on another thread (shocking!), it is, by nature, asynchronous. We have no guarantees about when it’s going to finish executing, and we often don’t even know which thread will execute it. With that in mind, developers could greatly benefit from using tools meant for asynchronous programming alongside multithreaded code, effectively benefiting from a single tool to rule all asynchronous constructs.
Unfortunately, Coroutines are not a good match for multithreaded code. There is no API support to mix threads and Coroutines together and as Unity’s documentation reminds us, Coroutines are not threads and their code still executes on the main thread.
On the other hand, C#’s TAP offers great support for multithreading. A Task
can execute on a thread from the thread pool using Task.Run
:
1
2
3
private Task DownloadAndDecompressImage() { ... }
Task.Run(DownloadAndDecompressImage);
Additionally, Task.Run
also returns a Task
, which can be awaited:
1
2
3
private async Task<bool> DownloadAndDecompressImage() { ../ }
var success = await Task.Run(DownloadAndDecompressImage);
Task cancellation, error handling, outcome accessibility… all the points discussed so far are also supported in a multithreaded context. In the end, C#’s TAP has a clear edge over Unity’s Coroutines when it comes to handling the asynchronous nature of multithreaded code.
Fire and forget
Although waiting for asynchronous operations is essential to working with them, there are some scenarios in which it’s not interesting to do so. For example, displaying a fireworks animation once the player scores and not waiting for its completion to perform any other action. This category of asynchronous operations is often referred to as “fire and forget”.
With Coroutines, fire and forget is as simple as it can be: just start the Coroutine, and forget about it! Well, with some caveats. As we’ve seen before, the Coroutine will stop running automatically if the MonoBehaviour
instance or the game object that holds the instance gets destroyed, which is great because the developer doesn’t have to manually stop it. But at the same time, if the game object gets disabled, the Coroutine will also stop running. Thus, we need to account for that and—whenever applicable—restart the Coroutine once the game object is re-enabled.
With C#’s TAP, it’s not that simple for two reasons:
- As we’ve seen before,
Task
lifetime management is completely manual, and the burden lies with the developer. Unlike Coroutines, aTask
won’t stop running unless it’s told so or if an exception is thrown and uncaught. Consequently, we need to inform asynchronousTask
s about game object destruction. That can be accomplished either by using a supporting field (e.g.,bool _isAlive
) or by using TAP’s best practices for task cancellation: cancellation tokens. When compared to Coroutines, it’s definitely more cumbersome. - Fire and forget isn’t as common in standard C#/.NET projects as it is in Unity projects. Just the concept of starting an asynchronous operation to never wait or fetch its outcome sounds weird to most developers. Once again, game development challenges traditional applications of asynchronous code. As a consequence of this uncommonness, TAP doesn’t provide great support for starting “fire and forget” tasks. We can still do it: simply invoke the method without awaiting (e.g.,
RotateAsync();
), but the compiler (and also some IDEs) will warn you about the fact that an asynchronous call is not being awaited.
Unlike Coroutines, tasks that were started by a MonoBehaviour
instance will not stop running if the game object that holds the instance is disabled. Whether this characteristic is considered an advantage or disadvantage is subject to the nature of the task; it might be handy, or it might be a pain that requires a workaround.
Ultimately, the convenience of Coroutines when it comes to starting and stopping “fire and forget” asynchronous operations is clearly superior. At the same time, the fact that Coroutines automatically stop when their game object is disabled might be a liability. C#’s TAP offers more control over the game object disabling scenario, but is evidently less convenient when it comes to starting and stopping tasks. With that said, check the session on UniTask later in this post to see how a new player brings that convenience back to C#’s TAP in Unity.
Memory allocation
We must keep an eye on allocated memory whenever developing games in a programming language that provides automatic memory management via a Garbage Collector (GC) like C#. We benefit from keeping GC allocations as low as possible in two ways. First, the overall application’s memory usage is reduced—often a marginal gain, considering that assets usually take up most of a game’s memory footprint. Second, and more importantly, it reduces the frequency with which the GC collects garbage; an operation that takes a considerable amount of time and that often causes performance drops.
Both Coroutine
and Task
are classes (a reference type, allocated on the heap by the GC), and creating new instances of them forces the GC to allocate memory. But not every usage of Coroutines and C#’s TAP will. For example, waiting until the next frame:
1
2
3
4
5
// Coroutine
yield return null;
// TAP
await Task.Yield();
None of the calls above will allocate memory on the heap. The first one returns null (which obviously doesn’t allocate). Despite Task.Yield
living in the Task
class, it doesn’t return a Task
, but a YieldAwaitable
—a struct.
Waiting for a given amount of time, on the other hand, does allocate memory on the heap. In the code below, for example, both calls will allocate memory, although the Task
call will allocate considerably more.
1
2
3
4
5
// Coroutine
yield return new WaitForSeconds(0.001f); // In seconds
// TAP
await Task.Delay(1); // In milliseconds
The allocation cost of both Coroutines and Tasks might seem nitpicking, but it is wise to consider the fact that these constructs might be used in loops with lifetimes that might span across several frames. We should also consider that, given the asynchronous nature of games we’ve discussed before, an application might have several Coroutines or Tasks running simultaneously. The allocation cost accumulates over time and might cause frequent performance drops caused by garbage collection.
ℹ️ Even though
ValueTask<T>
(another TAP type) might reduce heap allocations when an asynchronous method completes synchronously, the gains might be marginal at best, and harmful at worst. Check Haerion’s thread in the comment section for a short discussion on the subject.
Fortunately, an alternative to C#’s Task
aims to reduce this performance overhead: UniTask. We will dive into this solution in the next section.
UniTask: an alternative to Task
UniTask is a library that, in their words, “provides an efficient allocation free async/await integration for Unity”. It introduces a replacement for the Task
class when using C#’s TAP in Unity: the UniTask
struct. It’s a drop-in replacement that behaves almost exactly like Task
would, with a few exceptions:
- It does not allocate as much memory on the heap as
Task
. Consequently, it reduces the amount of generated garbage and the frequency of garbage collection. The difference can be explained by the fact thatTask
and its internal data types are mostly classes, whileUniTask
and its internal data types are mostly structs. - It brings better support for “fire and forget” asynchronous operations with the
UniTask.Forget
method. - It introduces a rich API that is tailored for Unity development:
WaitForSeconds
,WaitUntil
,WaitWhile
,WaitForEndOfFrame
and others. In addition, it provides methods to composeUniTask
s, likeWhenAll
andWhenAny
. -
It’s fully interoperable with Coroutines.
ToUniTask()
can be called to transform a Coroutine into aUniTask
andToCoroutine()
can be used the other way around. In addition, it addsawait
support for Coroutines andAsyncOperation
:1 2 3 4
private IEnumerator MoveTarget() { ... } await MoveTarget(); await SceneManager.LoadSceneAsync("Menu");
When compared to Task
, UniTask
maintains most of the characteristics discussed in the previous section: outcome accessibility, cancellation, lifetime management, error handling and multithreading support. At the same time, it improves “fire and forget” support and memory allocation. The downside is clear: availability. UniTask is not built into C#’s standard library nor into Unity. In my opinion, it’s a price worth paying for an overall superior solution for writing asynchronous code in Unity.
Conclusion
In this article, we took a deep dive into two solutions for writing asynchronous code in Unity: Coroutines and C#’s Task-based Asynchronous Pattern (TAP) with async
/await
. We compared these approaches against each other in categories that exposed the main differences between them.
Coroutines had an advantage on “fire and forget” support and a slight lead on memory allocation. C#’s TAP with the Task
/async
/await
trio proved to be a better choice when it came to availability, outcome accessibility, stopping/cancellation, error handling and multithreading support. Either could be deemed a more compelling choice when it comes to lifetime management—it depends on who you ask.
UniTask comes to Task
’s rescue and snatches Coroutine’s trophies on “fire and forget” support and memory allocation. It becomes the overall winner in all categories except one: availability. Although Coroutines are still a great solution for “fire and forget” operations, I would avoid mixing different asynchronous techniques in the same code base. Choosing a single approach reduces tech fragmentation, avoids bugs caused by wrong assumptions and improves maintainability.
I’ve been using UniTask for writing asynchronous code in both professional and personal projects for over three years now. It has undoubtedly delivered on its promise, and I can’t see myself writing asynchronous code in Unity without it.
With that said, in the end, it’s a matter of personal choice. If you are happy with Coroutines and don’t want to step into C#’s TAP world, go ahead. If you like async
/await
but don’t want to bother with UniTask, you do you. Would you like to dive deep into UniTask? Be my guest. But be conscious of each tool’s characteristics, limitations and strengths. In the end, we’re all just trying to make fun games, no matter what tools we use.
That’s all for today! As usual, feel free to use the comment section below for questions, suggestions, corrections, or just to say “hi”. See you on the next one!
Comments
Hey there, good read, thank you.
I wanted to add, that the Task library also offers ValueTask, which is a struct, so the “Task consumes always heap” argument is not entirely true.
I have seen many statements of developers in forums who prefer coroutines over everything else, but it can clearly be seen, that they don’t have any experience with the TPL or simply never have been on the application side of programming, where almost every interface now offers a full async support and the number of interactions with third party apis is almost async at every time. Microsoft recommends that every operation, that will probably exceed 30ms runtime, should be covered in an async method, and considering this, it becomes clear, that almost everything aside of increasing some values or doing some basic stuff, will be async.
Even LINQ has full async support, even for deferred execution scenarios, where you don’t fire up a .ToList() at the end of the statement.
New for me was the UniTask library. I will take a closer look at this one now. Again, thanks for this article!
Hi Haerion,
First of all, thank you for your comment. I’m glad you enjoyed the post.
You’re right, I left ValueTask out and in retrospect maybe I should have briefly mentioned it.
And indeed, there are some scenarios in which a Task will not allocate on the heap. For example, if a method returns a Task but completes synchronously. It might also be the case for some tasks with a return value that is easily enumerable (e.g., Task<bool>). Source.
ValueTask (or ValueTask<T>) might be handy in methods that return Task (or Task<T>) and may complete synchronously. In those cases, it will might save the heap allocation. But given the runtime caching of some Task instances (explained on the link in the paragraph above), it might not be advantageous as well. That’s particularly true for the non-generic Task (due to
Task.CompletedTask
).If the method that returns a ValueTask completed asynchronously, the heap allocation will happen regardless because the ValueTask will just wrap the Task instance (which was allocated on the heap) in its struct. In this scenario, ValueTask actually introduces a performance overhead when compared to just Task.
So in retrospect, ValueTask:
Given the asynchronous nature of video games (discussed in its own post session), I could argue that most asynchronous methods in a game project would finish asynchronously. Thus, ValueTask’s relevance in video game projects is dwarfed when compared to—for example—an ASP.NET Core application.
UniTask, in the other hand, does not allocate on the heap regardless of whether the method completed synchronously or asynchronously.
With that said, I still think that a quick reference to ValueTask would be valuable to the blog post, and I will add it soon.
Once again, thank you for your contribution!
PS: I did not know Microsoft’s recommendation for operations that exceed 30ms execution time. I believe that value should be even lower for most video games.