Skip to content

Commit

Permalink
Documentation and readme updates - Epic fixed UE-22342! 🥳
Browse files Browse the repository at this point in the history
  • Loading branch information
landelare committed Nov 15, 2022
1 parent c049c56 commit a4971b8
Show file tree
Hide file tree
Showing 5 changed files with 347 additions and 205 deletions.
202 changes: 112 additions & 90 deletions Docs/Async.md
Original file line number Diff line number Diff line change
@@ -1,137 +1,159 @@
# Async coroutines

Returning `FAsyncCoroutine` (unfortunately not namespaced due to UHT limitations)
from a function makes it coroutine-enabled and lets you `co_await` various
awaiters provided by this library, found in `namespace UE5Coro::Async` and
`namespace UE5Coro::Latent`. Any async coroutine can use both async and latent
awaiters, but latent awaiters are limited to the game thread.

`FAsyncCoroutine` is a minimal (around `sizeof(void*)` depending on your
platform's implementation of `std::coroutine_handle`) struct that can access
the underlying coroutine, but it doesn't own it. These objects are safe to
discard or keep around for longer than needed, but interacting with a `delete`d
coroutine is undefined behavior as usual.
Coroutines get `delete`d when or shortly after they finish.
from a function makes it coroutine-enabled and lets you co_await various
awaiters provided by this library, found in various namespaces within UE5Coro
such as UE5Coro\:\:Async or UE5Coro\:\:Latent.
Any async coroutine can use any awaiter, but some awaiters are limited to the
game thread.

FAsyncCoroutine is a minimal (usually `sizeof(void*)` depending on your
platform's implementation of std::coroutine_handle) struct that can access the
underlying coroutine, but it doesn't own it.
These objects are cheap to pass by value and safe to discard or keep around for
longer than needed, but interacting with a freed coroutine through them is
undefined behavior.
Coroutines get deleted when or shortly after they finish.

Accessing or manipulating the underlying std::coroutine_handle directly is not
supported and is extremely likely to break.

## Debugging

`FAsyncCoroutine::SetDebugName()` applies a debug name to the currently-running
coroutine's promise object (which is otherwise an implementation detail).
FAsyncCoroutine::SetDebugName() applies a debug name to the currently-running
coroutine's promise object, which is otherwise an implementation detail.
This has no effect at runtime (and does nothing in Shipping), but it's useful
for debug viewing these objects.
Looking at them as part of `__coro_frame_ptr` seems to be unreliable in practice,
moving one level up in the call stack to `Resume()` tends to work better.

You might want to macro `FAsyncCoroutine::SetDebugName(TEXT(__FUNCTION__))`.

Looking at these or promise objects in general as part of `__coro_frame_ptr`
seems to be unreliable in practice, moving one level up in the call stack to
Resume() tends to work better when tested with Visual Studio 2022 17.4 and
JetBrains Rider 2022.3.
A .natvis file is provided to automatically display this debug info.

In debug builds (controlled by `UE5CORO_DEBUG`) a synchronous resume stack is
also kept to aid in debugging complex cases of coroutine resumption, mostly
having to do with `WhenAny` or `WhenAll`.
having to do with WhenAny or WhenAll.

## Execution modes

There are two major execution modes of async coroutinesthey can either run
autonomously or implement a latent `UFUNCTION`, tied to the latent action manager.
There are two major execution modes of async coroutines: they can either run
autonomously or implement a latent UFUNCTION, tied to the latent action manager.

### Async mode

If your function **does not** have a `FLatentActionInfo` parameter, the coroutine
is running in "async mode".
You still have access to awaiters in `namespace UE5Coro::Latent` (locked to the
game thread) but as far as your callers are concerned, the function returns at the
first `co_await` and drives itself after that point.
If your function **does not** have a `FLatentActionInfo` parameter, the
coroutine is running in "async mode".
You still have access to awaiters in the UE5Coro::Latent namespace (locked to
the game thread) but as far as your callers are concerned, the function returns
at the first co_await and drives itself after that point.

This mode is mainly a replacement for "fire and forget" `AsyncTask`s and timers.
This mode is mainly a replacement for "fire and forget" AsyncTasks and timers.

### Latent mode

If your function (probably a `UFUNCTION` in this case but this is **not** checked)
takes `FLatentActionInfo`, the coroutine is running in "latent mode".
The world will be fetched from the first `UObject*` parameter that returns a valid
pointer from `GetWorld()` with `GWorld` used as a last resort fallback, and the
latent info will be registered with that world's latent action manager, there's no
need to call `AddNewAction()`.
If your function (probably a UFUNCTION in this case but this is **not** checked
or required) takes `FLatentActionInfo`, the coroutine is running in "latent mode".
The world will be fetched from the first UObject* parameter that returns a valid
pointer from GetWorld() with GWorld used as a last resort fallback.

The output exec pin will fire in BP when the coroutine `co_return`s (most often
this happens naturally as control leaves the scope of the function), but you can
stop this by issuing `co_await Latent::Cancel();`.
The latent info will be registered with that world's latent action manager,
there's no need to call FLatentActionManager::AddNewAction().

If the `UFUNCTION` is called again with the same callback target/UUID while a
coroutine is already running, a second copy will **not** start similarly to most
built-in engine latent actions.

You may use `Async::MoveToThread` to switch threads, but the coroutine must finish
on the game thread. If the latent action manager decides to delete the latent task
and it's on another thread, it may continue until the next `co_await` after which
your stack will be unwound **on the game thread**.
The output exec pin will fire in BP when the coroutine co_returns (most often
this happens naturally as control leaves the scope of the function), but you can
stop this by issuing `co_await UE5Coro::Latent::Cancel();`.
As an exception, this one will not resume the coroutine but complete it without
execution resuming in BP.
The destructors of local variables, etc. will run as usual.

If the UFUNCTION is called again with the same callback target/UUID while a
coroutine is already running, a second copy will **not** start, matching the
behavior of most of the engine's built-in latent actions.

You may use awaiters such as UE5Coro\:\:Async\:\:MoveToThread or
UE5Coro\:\:Tasks\:\:MoveToTask to switch threads, but the coroutine must finish
on the game thread.
If the latent action manager decides to delete the latent task and it's on
another thread, it may continue until the next co_await after which your stack
will be unwound **on the game thread**.

## Awaiters

[Click here](Awaiters.md) for an overview of the various awaiters that come
with the plugin.

Although it's not directly forbidden to reuse awaiter objects, it's recommended
not to as the effects are rarely what you need and could change in future
versions. Treat them as expired once they've been `co_await`ed.
Most awaiters from this plugin can only be used once and will `check()` if
reused.
There are a few (notably in the UE5Coro::Async namespace) that may be reused,
but these are so cheap to create – around the cost of an int – that you should
be recreating them for consistency.

The awaiter types that are in the `UE5Coro::Private` namespace are subject to
change in any future version. Most of the time, you don't even need to know
about them, e.g. `co_await Something();`, but if you want to store them in
a variable (see below), use `auto`.
It's recommended to treat every awaiter as "moved-from" or invalid after they've
been co_awaited. This includes being co_awaited through wrappers such as WhenAll.

There are some additional situations that could cause unexpected behavior:
* `co_await`ing `namespace UE5Coro::Latent` awaiters off the game thread.
The awaiter types that are in the `UE5Coro::Private` namespace are subject to
change in any future version with no prior deprecation.
Most of the time, you don't even need to know about them, e.g.,
`co_await Something();`.
If you want to store them in a variable (see below), use `auto` for source
compatibility.

There are some additional situations that could cause unexpected behavior, such
as crashes or coroutines "deadlocking":
* co_awaiting UE5Coro::Latent awaiters off the game thread.
* Moving to a named thread that's not enabled, e.g., RHI.
* Expecting to resume a latent awaiter while paused or otherwise not ticking.

Generally speaking, the same rules and limitations apply as the underlying system
that drives the current awaiter.
Generally speaking, the same rules and limitations apply as the underlying
engine systems that drive the current awaiter and its awaiting coroutine.

### Overlapping awaiters

It is possible to run multiple awaiters overlapped, which makes sense for some of
them that perform useful actions and not just wait (but not limited to them):
It is possible to run multiple awaiters overlapped, which makes sense for
(but isn't limited to) some of them that perform useful actions, not just wait:

```cpp
using namespace UE5Coro;

FAsyncCoroutine AMyActor::GuaranteedSlowLoad(int, FLatentActionInfo)
FAsyncCoroutine AMyActor::GuaranteedSlowLoad(FLatentActionInfo)
{
auto Wait1 = Latent::Seconds(1); // The clock starts now!
auto Wait2 = Latent::Seconds(0.5);
auto Load1 = Latent::AsyncLoadObject(MySoftPtr1);
auto Load2 = Latent::AsyncLoadObject(MySoftPtr2);
co_await Load1;
co_await Load2;
auto Wait1 = UE5Coro::Latent::Seconds(1); // The clock starts now!
auto Wait2 = UE5Coro::Latent::Seconds(0.5); // This starts at the same time!
auto Load1 = UE5Coro::Latent::AsyncLoadObject(MySoftPtr1);
auto Load2 = UE5Coro::Latent::AsyncLoadObject(MySoftPtr2);
co_await UE5Coro::WhenAll(Load1, Load2); // Wait for both to be loaded
co_await Wait1; // Waste the remainder of that 1 second
co_await Wait2; // This will resume immediately, not half a second later
co_await Wait2; // This is already over, it won't wait half a second
}
```
### Coroutines
### Other coroutines
`FAsyncCoroutine`s themselves are awaitable, `co_await`ing them will resume the
caller when the callee coroutine finishes for any reason, including
`Latent::Cancel()`. Async coroutines try to resume on a similar thread as they
were on when `co_await` was run (game thread to game thread, render thread to
render thread, etc.), latent coroutines resume on the next tick after the
callee ended.
`FAsyncCoroutine`s themselves are awaitable, co_awaiting them will resume the
caller when the callee coroutine finishes for any reason, **including**
`UE5Coro::Latent::Cancel()`.
Async coroutines try to resume on a similar thread as they were on when co_await
was issued (game thread to game thread, render thread to render thread, etc.),
latent coroutines resume on the next tick after the callee ended.
## Coroutines and UObject lifetimes
While coroutines provide a synchronous-looking interface, they do not run
synchronously, and this can lead to problems that might be harder to spot due to
the friendly linear-looking syntax. Most coroutines will not need to worry about
these issues, but for advanced scenarios it's something you'll need to keep in
mind.
synchronously (that's kind of the point🙂) and this can lead to problems that
might be harder to spot due to the friendly linear-looking syntax.
Most coroutines will not need to worry about these issues, but for advanced
scenarios it's something you'll need to keep in mind.
Your function immediately returns when you `co_await`, which means that the
garbage collector might run before you resume. Your function parameters and local
variables technically live in a "vanilla C++" struct with no UPROPERTY
declarations and therefore are eligible for garbage collection.
Your function immediately returns when you co_await, which means that the
garbage collector might run before you resume.
Your function parameters and local variables technically live in a "raw C++"
struct with no UPROPERTY declarations (generated by the compiler) and therefore
are eligible for garbage collection.
The usual solutions for multithreading and `UObject` access/GC keepalive such as
`AddToRoot`, `FGCObject`, `TStrongObjectPtr`, etc. still apply. If something would
work for `std::vector` it will probably work for coroutines, too.
The usual solutions for multithreading and UObject access/GC keepalive such as
AddToRoot, FGCObject, TStrongObjectPtr, etc. still apply.
If something would work for std::vector it will probably work for coroutines, too.
Examples of dangerous code:
Expand All @@ -146,27 +168,27 @@ FAsyncCoroutine AMyActor::Latent(UObject* Obj, FLatentActionInfo)
co_await Latent::Seconds(1); // Obj might get garbage collected during this!
if (Obj) // This is useless, Obj could be a dangling pointer
if (IsValid(Obj)) // Dangerous, Obj could be a dangling pointer by now!
Foo(Obj);
if (auto* Obj2 = ObjPtr.Get()) // This is safe
if (auto* Obj2 = ObjPtr.Get()) // This is safe, might be nullptr
Foo(Obj2);
// This is also safe! co_await will not resume if `this` is destroyed, so you
// would not reach this line, but your local variables would run their
// destructors and get freed as expected (see "Latent Mode" above).
// This is also safe, but only because of the FLatentActionInfo parameter!
// Destroying an actor cancels all of its latent actions at the engine level,
// so you would never reach this point.
if (SomeUPropertyOnAMyActor)
Foo(this);
// Latent protection extends to awaiting Async awaiters and thread hopping:
co_await Async::MoveToThread(ENamedThreads::AnyBackgroundThreadNormalTask);
// Latent protection extends to other awaiters and thread hopping:
co_await Tasks::MoveToTask();
Foo(this); // Not safe, the GC might run on the game thread
co_await Async::MoveToGameThread();
Foo(this); // But this is OK! The co_await above resumed so `this` is valid.
}
```

Especially dangerous if you're running on another thread, `this` protection
and `co_await` _not_ resuming the coroutine does not apply if you're not latent:
and co_await _not_ resuming the coroutine does not apply if you're not latent:

```cpp
using namespace UE5Coro;
Expand Down
Loading

0 comments on commit a4971b8

Please sign in to comment.