Skip to content

Add async/await guidelines to C# basics #8296

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions tutorials/scripting/c_sharp/c_sharp_basics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -388,3 +388,234 @@ The following tools may be used for performance and memory profiling of your man
- Visual Studio.

Profiling managed and unmanaged code at once is possible with both JetBrains tools and Visual Studio, but limited to Windows.

Using ``async``/``await``
-------------------------

You might face a scenario where you must ``await`` a method call.
You will notice that when you use ``await``, you are required to mark the method you use it in as ``async``,
and change the return type to an awaitable type, such as ``Task`` or ``Task<T>``.
Consequently, you must call your now ``async`` method using ``await``,
which propagates the problem all the way up the call chain.
This is why many people compare ``async``/``await`` to a "zombie virus",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use this analogy, could be good to quote the source articles here with other best practices: (first searchable reference these days here: https://learn.microsoft.com/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming - though this later article has an origin attributing a C# team member: https://learn.microsoft.com/archive/msdn-magazine/2015/november/asynchronous-programming-async-from-the-start)

because it tends to spread once introduced.

In Godot, the conclusion to this spread is the entry point methods of a node, such as ``_Ready()`` or ``_Process()``.
You will notice that the return types of these methods are ``void`` rather than ``Task``.
It is considered conventional wisdom in C# to avoid ``async void`` at all times, with the exception of event handlers.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm personally not a big fan of the "because wisdom". I'd rather have that explained, or at least linked to an explanation on the Microsoft docs.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is one of the things I ended up trimming due to length. I would prefer to explain the reasoning behind it, then explain why doing so in Godot does not cause any issues, but that got too long and not basic for what I felt belonged in the page.

I can add a link to an explanation of why to avoid it, but I would also like to include a paragraph explaining why it doesn't apply in Godot.

The problem is that it is impossible to change the signatures of these methods since they are defined by the classes they inherit.

There are a couple options to address this problem, but each option comes with its own caveats and considerations.
To compare these options, we will work with the following script:

.. code-block:: csharp

using Godot;
using System;
using System.Threading.Tasks;

public partial class AsyncTestNode : Node
{
private int _taskCount = 0;
private DateTime start;
public override void _Ready()
{
start = DateTime.Now;
}

public override void _Process(double delta)
{

}

// Prints the amount of time since _Ready started, the current thread, and the name of the calling method
// It prints this once when DoStuffAsync is first called, then once again after `duration` in seconds
private async Task DoStuffAsync(double duration, string methodName)
{
var taskId = ++_taskCount;
PrintCurrentThread($"Task {taskId} started from {methodName}");
await Task.Delay(TimeSpan.FromSeconds(duration));
PrintCurrentThread($"Task {taskId} completed");
}

private void PrintCurrentThread(string info)
{
var timeStamp = DateTime.Now - start;
GD.PrintT(timeStamp.ToString(@"mm\:ss\.ff"), $"Thread: {System.Environment.CurrentManagedThreadId}", info);
}
}

The first option is to start the task through the Task factory.

.. code-block:: csharp

// This function can be put in a global static class for convenience
public static Task StartTaskFromFactory(Func<Task> newTask)
{
return Task.Factory.StartNew(newTask,
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.FromCurrentSynchronizationContext());
}

public override void _Ready()
{
start = DateTime.Now;

StartTaskFromFactory(async () => await DoStuffAsync(.5, nameof(_Ready)));
}

public override void _Process(double delta)
{
if (_taskCount < 3)
StartTaskFromFactory(async () => await DoStuffAsync(.5, nameof(_Process)));
}

The second option is to mark the entry point method as async anyway.

.. code-block:: csharp

public override async void _Ready()
{
start = DateTime.Now;
await DoStuffAsync(.5, nameof(_Ready));
}


public override async void _Process(double delta)
{
if (_taskCount < 3)
await DoStuffAsync(.5, nameof(_Process));
}

Both the manual task starting method and the ``async void`` method
behave identically to an equivalent script written in GDScript
that uses its version of the ``await`` keyword;
the method pauses once it reaches the ``await``-ed method call.
The game loop will run until the task completes, at which point execution will continue on the main thread.

Let's look at the output from the above code:

.. code-block::

00:00.00 Thread: 1 Task 1 started from _Ready
00:00.02 Thread: 1 Task 2 started from _Process
00:00.03 Thread: 1 Task 3 started from _Process
00:00.50 Thread: 1 Task 1 completed
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task 1 should complete before _Process starts.
Not waiting for Task 1 to complete may result in incomlete scene initalization (for example if we were loading something from internet in _Ready function)

Copy link

@tvardero tvardero Feb 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now solution is to block _Ready thread:

public override _Ready() {
  DoStuffAsync().GetAwaiter().GetResult();
}

I don't thing it is good to exit _Ready() if the node is not yet ready de-facto. Telling users, who knows programming weakly, to implement IsNodeInitialized or IsInstanceValid might confuse them and make their games bugged.

00:00.53 Thread: 1 Task 2 completed
00:00.53 Thread: 1 Task 3 completed

The first observation from the output is that the game loop continues
without waiting for the completion of the ``_Ready()`` method.
This continuation can introduce issues, especially if methods like ``_Process()``
rely on variables or objects that get initialized only after the ``await`` call in ``_Ready()``.
Such asynchronous timing problems are termed `Race Condition <https://en.wikipedia.org/wiki/Race_condition#In_software/>`_,
which is one of the main hazards when working with asynchronous code.
To avoid errors due to race conditions, be sure to check that values are initialized before you use them,
and use ``IsInstanceValid()`` after you ``await`` a function.

Here is a pattern you can adopt to avoid race conditions:

.. code-block:: csharp

public partial class SampleAsyncNode : Node
{
[Signal] public delegate void InitializedEventHandler();
[Export] public int EntityID { get; set; } = 1;

readonly SomeCustomRepository _db = new();
private int _health;
private bool _init;

// We will check IsInvalid after we await async methods
// Otherwise we risk the continuation running in a disposed context
private bool IsInvalid => !IsInstanceValid(this) || this.IsQueuedForDeletion();

public override async void _Ready()
{
var entity = await _db.FindAsync(EntityID);

// Even though we are still in _Ready(), we need to check IsInvalid
// It's possible that this node was freed by its parent, or some other source while awaiting
if (IsInvalid)
return;

_health = entity.Health;
_init = true;
EmitSignal(SignalName.Initialized);
}

public async Task DealDamage(int damage)
{
// DealDamage depends on _health being inititalized
// Awaiting Initialized will cause all calls to DealDamage to queue up
// Once Initialized is emitted, all queued DealDamage calls will continue at once
await ToSignal(this, SignalName.Initialized);

// If you don't want to queue calls while waiting for initialization, just return if not initialized
// if (!_init)
// return;

if (IsInvalid)
return;

_health -= damage;

// If the number of queued calls to DealDamage is greater than the initial value of _health...
// This line will free the node before all calls to DealDamage are continued
// That is why it is important to check IsInvalid after awaiting
if (_health < 0)
QueueFree();
}

public override void _ExitTree()
{
// If this unit was freed before initialization completed...
// Emit the Initialized signal so that everything awaiting it will be released
if (!_init)
EmitSignal(SignalName.Initialized);

_db.Dispose();
}
}

The third option is to execute the ``async`` method synchronously.
This is most commonly done when you need to use an asynchronous
method from a third party library that has no synchronous equivalent,
and it is not feasible to convert everything upstream to ``async``.

.. code-block:: csharp

public override void _Ready()
{
start = DateTime.Now;

Task.Run(async () => await DoStuffAsync(.5, nameof(_Ready))).GetAwaiter().GetResult();
}

public override void _Process(double delta)
{
if (_taskCount < 3)
Task.Run(async () => await DoStuffAsync(.5, nameof(_Process))).GetAwaiter().GetResult();
}

Let's look at the output from the above code:

.. code-block::

00:00.00 Thread: 4 Task 1 started from _Ready
00:00.50 Thread: 4 Task 1 completed
00:00.52 Thread: 4 Task 2 started from _Process
00:01.02 Thread: 4 Task 2 completed
00:01.03 Thread: 4 Task 3 started from _Process
00:01.53 Thread: 4 Task 3 completed

The output from running the tasks synchronously shows that
the tasks executed in the expected order for synchronous operations.
The output also shows that the code was executed on Thread 4,
rather than Thread 1 like in the first two options.
This is important to keep in mind, because any code that is not
executed on the main thread (Thread 1) cannot interact with the scene tree, as it is not thread safe.
You should use ``CallDeferred``/``SetDeferred``, ``CallThreadSafe``/``SetThreadSafe``,
or ``CallDeferredThreadGroup``/``SetDeferredThreadGroup`` to interact with thread
safe objects or APIs from threads other than the main thread.