Skip to content

Commit ee1c9e9

Browse files
committed
Original contribution
1 parent 0aca6be commit ee1c9e9

File tree

1 file changed

+231
-0
lines changed

1 file changed

+231
-0
lines changed

tutorials/scripting/c_sharp/c_sharp_basics.rst

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,234 @@ The following tools may be used for performance and memory profiling of your man
388388
- Visual Studio.
389389

390390
Profiling managed and unmanaged code at once is possible with both JetBrains tools and Visual Studio, but limited to Windows.
391+
392+
Using ``async``/``await``
393+
-------------------------
394+
395+
You might face a scenario where you must ``await`` a method call.
396+
You will notice that when you use ``await``, you are required to mark the method you use it in as ``async``,
397+
and change the return type to an awaitable type, such as ``Task`` or ``Task<T>``.
398+
Consequently, you must call your now ``async`` method using ``await``,
399+
which propagates the problem all the way up the call chain.
400+
This is why many people compare ``async``/``await`` to a "zombie virus",
401+
because it tends to spread once introduced.
402+
403+
In Godot, the conclusion to this spread is the entry point methods of a node, such as ``_Ready()`` or ``_Process()``.
404+
You will notice that the return types of these methods are ``void`` rather than ``Task``.
405+
It is considered conventional wisdom in C# to avoid ``async void`` at all times, with the exception of event handlers.
406+
The problem is that it is impossible to change the signatures of these methods since they are defined by the classes they inherit.
407+
408+
There are a couple options to address this problem, but each option comes with its own caveats and considerations.
409+
To compare these options, we will work with the following script:
410+
411+
.. code-block:: csharp
412+
413+
using Godot;
414+
using System;
415+
using System.Threading.Tasks;
416+
417+
public partial class AsyncTestNode : Node
418+
{
419+
private int _taskCount = 0;
420+
private DateTime start;
421+
public override void _Ready()
422+
{
423+
start = DateTime.Now;
424+
}
425+
426+
public override void _Process(double delta)
427+
{
428+
429+
}
430+
431+
// Prints the amount of time since _Ready started, the current thread, and the name of the calling method
432+
// It prints this once when DoStuffAsync is first called, then once again after `duration` in seconds
433+
private async Task DoStuffAsync(double duration, string methodName)
434+
{
435+
var taskId = ++_taskCount;
436+
PrintCurrentThread($"Task {taskId} started from {methodName}");
437+
await Task.Delay(TimeSpan.FromSeconds(duration));
438+
PrintCurrentThread($"Task {taskId} completed");
439+
}
440+
441+
private void PrintCurrentThread(string info)
442+
{
443+
var timeStamp = DateTime.Now - start;
444+
GD.PrintT(timeStamp.ToString(@"mm\:ss\.ff"), $"Thread: {System.Environment.CurrentManagedThreadId}", info);
445+
}
446+
}
447+
448+
The first option is to start the task through the Task factory.
449+
450+
.. code-block:: csharp
451+
452+
// This function can be put in a global static class for convenience
453+
public static Task StartTaskFromFactory(Func<Task> newTask)
454+
{
455+
return Task.Factory.StartNew(newTask,
456+
CancellationToken.None,
457+
TaskCreationOptions.None,
458+
TaskScheduler.FromCurrentSynchronizationContext());
459+
}
460+
461+
public override void _Ready()
462+
{
463+
start = DateTime.Now;
464+
465+
StartTaskFromFactory(async () => await DoStuffAsync(.5, nameof(_Ready)));
466+
}
467+
468+
public override void _Process(double delta)
469+
{
470+
if (_taskCount < 3)
471+
StartTaskFromFactory(async () => await DoStuffAsync(.5, nameof(_Process)));
472+
}
473+
474+
The second option is to mark the entry point method as async anyway.
475+
476+
.. code-block:: csharp
477+
478+
public override async void _Ready()
479+
{
480+
start = DateTime.Now;
481+
await DoStuffAsync(.5, nameof(_Ready));
482+
}
483+
484+
485+
public override async void _Process(double delta)
486+
{
487+
if (_taskCount < 3)
488+
await DoStuffAsync(.5, nameof(_Process));
489+
}
490+
491+
Both the manual task starting method and the ``async void`` method
492+
behave identically to an equivalent script written in GDScript
493+
that uses its version of the ``await`` keyword;
494+
the method pauses once it reaches the ``await``-ed method call.
495+
The game loop will run until the task completes, at which point execution will continue on the main thread.
496+
497+
Let's look at the output from the above code:
498+
499+
.. code-block::
500+
501+
00:00.00 Thread: 1 Task 1 started from _Ready
502+
00:00.02 Thread: 1 Task 2 started from _Process
503+
00:00.03 Thread: 1 Task 3 started from _Process
504+
00:00.50 Thread: 1 Task 1 completed
505+
00:00.53 Thread: 1 Task 2 completed
506+
00:00.53 Thread: 1 Task 3 completed
507+
508+
The first observation from the output is that the game loop continues
509+
without waiting for the completion of the ``_Ready()`` method.
510+
This continuation can introduce issues, especially if methods like ``_Process()``
511+
rely on variables or objects that get initialized only after the ``await`` call in ``_Ready()``.
512+
Such asynchronous timing problems are termed `Race Condition <https://en.wikipedia.org/wiki/Race_condition#In_software/>`_,
513+
which is one of the main hazards when working with asynchronous code.
514+
To avoid errors due to race conditions, be sure to check that values are initialized before you use them,
515+
and use ``IsInstanceValid()`` after you ``await`` a function.
516+
517+
Here is a pattern you can adopt to avoid race conditions:
518+
519+
.. code-block:: csharp
520+
521+
public partial class SampleAsyncNode : Node
522+
{
523+
[Signal] public delegate void InitializedEventHandler();
524+
[Export] public int EntityID { get; set; } = 1;
525+
526+
readonly SomeCustomRepository _db = new();
527+
private int _health;
528+
private bool _init;
529+
530+
// We will check IsInvalid after we await async methods
531+
// Otherwise we risk the continuation running in a disposed context
532+
private bool IsInvalid => !IsInstanceValid(this) || this.IsQueuedForDeletion();
533+
534+
public override async void _Ready()
535+
{
536+
var entity = await _db.FindAsync(EntityID);
537+
538+
// Even though we are still in _Ready(), we need to check IsInvalid
539+
// It's possible that this node was freed by its parent, or some other source while awaiting
540+
if (IsInvalid)
541+
return;
542+
543+
_health = entity.Health;
544+
_init = true;
545+
EmitSignal(SignalName.Initialized);
546+
}
547+
548+
public async Task DealDamage(int damage)
549+
{
550+
// DealDamage depends on _health being inititalized
551+
// Awaiting Initialized will cause all calls to DealDamage to queue up
552+
// Once Initialized is emitted, all queued DealDamage calls will continue at once
553+
await ToSignal(this, SignalName.Initialized);
554+
555+
// If you don't want to queue calls while waiting for initialization, just return if not initialized
556+
// if (!_init)
557+
// return;
558+
559+
if (IsInvalid)
560+
return;
561+
562+
_health -= damage;
563+
564+
// If the number of queued calls to DealDamage is greater than the initial value of _health...
565+
// This line will free the node before all calls to DealDamage are continued
566+
// That is why it is important to check IsInvalid after awaiting
567+
if (_health < 0)
568+
QueueFree();
569+
}
570+
571+
public override void _ExitTree()
572+
{
573+
// If this unit was freed before initialization completed...
574+
// Emit the Initialized signal so that everything awaiting it will be released
575+
if (!_init)
576+
EmitSignal(SignalName.Initialized);
577+
578+
_db.Dispose();
579+
}
580+
}
581+
582+
The third option is to execute the ``async`` method synchronously.
583+
This is most commonly done when you need to use an asynchronous
584+
method from a third party library that has no synchronous equivalent,
585+
and it is not feasible to convert everything upstream to ``async``.
586+
587+
.. code-block:: csharp
588+
589+
public override void _Ready()
590+
{
591+
start = DateTime.Now;
592+
593+
Task.Run(async () => await DoStuffAsync(.5, nameof(_Ready))).GetAwaiter().GetResult();
594+
}
595+
596+
public override void _Process(double delta)
597+
{
598+
if (_taskCount < 3)
599+
Task.Run(async () => await DoStuffAsync(.5, nameof(_Process))).GetAwaiter().GetResult();
600+
}
601+
602+
Let's look at the output from the above code:
603+
604+
.. code-block::
605+
606+
00:00.00 Thread: 4 Task 1 started from _Ready
607+
00:00.50 Thread: 4 Task 1 completed
608+
00:00.52 Thread: 4 Task 2 started from _Process
609+
00:01.02 Thread: 4 Task 2 completed
610+
00:01.03 Thread: 4 Task 3 started from _Process
611+
00:01.53 Thread: 4 Task 3 completed
612+
613+
The output from running the tasks synchronously shows that
614+
the tasks executed in the expected order for synchronous operations.
615+
The output also shows that the code was executed on Thread 4,
616+
rather than Thread 1 like in the first two options.
617+
This is important to keep in mind, because any code that is not
618+
executed on the main thread (Thread 1) cannot interact with the scene tree, as it is not thread safe.
619+
You should use ``CallDeferred``/``SetDeferred``, ``CallThreadSafe``/``SetThreadSafe``,
620+
or ``CallDeferredThreadGroup``/``SetDeferredThreadGroup`` to interact with thread
621+
safe objects or APIs from threads other than the main thread.

0 commit comments

Comments
 (0)