Returning UE5Coro::TCoroutine<T>
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.
TCoroutine<T>
co_returns T, TCoroutine<>
co_returns void.
TCoroutine<>
and TCoroutine<void>
are perfectly equivalent.
All typed TCoroutines implicitly convert to TCoroutine<>, giving you a common
return-type-erased view to a coroutine that may or may not have a return type.
Cancellation support is documented on a separate page.
TCoroutine is thread safe and O(1) copyable (it's a shared pointer inside). Copies of a TCoroutine refer to the same coroutine as the original. TCoroutine<T> has all the functionality of TCoroutine<>, plus additional methods and overloads for the return type.
TCoroutines are comparable (they have a strict, total, but meaningless order), and hashable with GetTypeHash and std::hash. Copies referring to the same coroutine invocation compare equal to each other.
A non-void coroutine return type must be at least DefaultConstructible, MoveAssignable, and Destructible. Full functionality also requires CopyConstructible. It's possible that a coroutine completes without providing a return value. In this case, reading the return value provides T().
FAsyncCoroutine
in the global namespace is a USTRUCT
wrapper for
TCoroutine<>, to be used when reflection support is required, e.g., for latent
UFUNCTIONs.
It implicitly converts from/to TCoroutine<>.
Return values from co_return are not supported due to engine limitations, but
"out" reference parameters still work for BP.
FAsyncCoroutine (but not TCoroutine<>) can be default constructed due to yet
more engine limitations.
Prefer using TCoroutine<> when reflection/BP support is not needed.
Interacting with a default-constructed FAsyncCoroutine or a TCoroutine<> that
was converted from one is undefined behavior.
Obtaining the coroutine's underlying std::coroutine_handle
directly is not
supported and is extremely likely to break.
TCoroutine<>::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.
You might want to macro TCoroutine<>::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.
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.
If your function does not have a FLatentActionInfo
or
FForceLatentCoroutine
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" AsyncTasks and timers.
Async mode coroutines mostly run independently, even after major events like PIE ending. It's the coroutine's responsibility to detect this and act accordingly, e.g., by co_returning early. An exception to this is co_awaiting a latent awaiter, in which case ownership behind the scenes is temporarily passed to the current world's latent action manager that can destroy the running coroutine. This manifests as a co_await not resuming, but instead all local variables' destructors in scope are run as if an exception was thrown.
If your function (probably a UFUNCTION in this case but this is not checked
or required) takes FLatentActionInfo
or FForceLatentCoroutine
, the coroutine
is running in "latent mode".
The world will be fetched from the first UObject* parameter that returns a valid
pointer from GetWorld().
If there's a FLatentActionInfo parameter, its callback target will be used with
the highest priority.
The latent info will be registered with that world's latent action manager,
there's no need to call FLatentActionManager::AddNewAction().
Important
A future update will simplify this logic to make it more performant, reliable,
and match BP behavior even more closely.
To prepare, make sure your world context object is the first parameter (this
for nonstatic members).
The detected world context (most often this
for non-static member UFUNCTIONs)
will act as the latent action's owner, and the coroutine will enjoy a measure of
lifetime tracking and protection from the latent action manager, mostly
eliminating the need to check the validity of this
after each co_await
.
There are still situations where the coroutine can resume on an invalid object,
e.g., if the owning object was destroyed and the destructors of the coroutine's
local variables are being run as a response.
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 canceling the coroutine using any method. The destructors of local variables, etc. will run as usual regardless.
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. Finishing the coroutine is allowed on any thread, but note that in C++, the destructors of locals run on the current thread before the coroutine is considered complete, which might not be desired.
BP will always continue on the game thread after the coroutine state (locals, etc.) is cleaned up.
If the latent action manager decides to delete the latent task and it's currently running on another thread, it is canceled and may continue until the next co_await, after which its locals will be destroyed on the game thread.
Click here for an overview of the various awaiters that come with the plugin.
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.
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.
The awaiter types that are in the UE5Coro::Private
namespace are not
documented and 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();
.
This usage is ideal and recommended for most scenarios.
If you want to store them in a variable (see next section), use auto
for
source compatibility.
If you want to pass them around, these internal types are mostly copyable and are limited to one active co_await across all copies. It's undefined behavior to move an awaiter that's currently being co_awaited. Multiple sequential co_awaits are usually allowed, with the second and beyond succeeding immediately.
Some of these are locked to the game thread. Generally speaking, the same rules and limitations apply as the underlying engine systems that drive the current awaiter and its awaiting coroutine, e.g., awaiters dealing with UObjects or from the Latent namespace are usually locked to the game thread.
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:
FAsyncCoroutine AMyActor::GuaranteedSlowLoad(FLatentActionInfo)
{
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 is already over, it won't wait half a second
}
TCoroutine
s themselves are awaitable, co_awaiting them will resume the
caller when the callee coroutine finishes for any reason, including
UE5Coro::Latent::Cancel()
.
The return type of co_awaiting TCoroutine<T> is T. If the coroutine completed without co_returning a value, the result will be T().
Async coroutines resume on the thread where the awaited coroutine finished. Latent coroutines resume on the next tick after the callee ended. co_awaiting a coroutine that's already complete will not release the current thread and will continue running with the result obtained synchronously.
While coroutines provide a synchronous-looking interface, they do not run 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 "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.
Examples of dangerous code:
using namespace UE5Coro;
FAsyncCoroutine AMyActor::Latent(UObject* Obj, FLatentActionInfo)
{
// You're synchronously running before the first co_await, Obj is as your
// caller passed it in. TWeakObjectPtr is safe to keep outside a UPROPERTY.
TWeakObjectPtr<UObject> ObjPtr(Obj);
co_await Latent::Seconds(1); // Obj might get garbage collected during this!
if (IsValid(Obj)) // Dangerous, Obj could be a dangling pointer by now!
Foo(Obj);
if (auto* Obj2 = ObjPtr.Get()) // This is safe, might be nullptr
Foo(Obj2);
// 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 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:
using namespace UE5Coro;
TCoroutine<> UMyExampleClass::DontDoThisAtHome(UObject* Dangerous)
{
checkf(IsInGameThread(), TEXT("This example needs to start on the GT"));
// You can be sure this remains valid until you co_await
UObject* Obj = NewObject<UObject>();
if (IsValid(Dangerous))
Dangerous->Safe();
// Latent protection applies when co_awaiting Latent awaiters even if you're
// not latent, this might not resume if ActorObj gets destroyed:
co_await Latent::Chain(&ASomeActor::SomethingLatent, ActorObj, 1.0f);
// But not here:
co_await Async::MoveToThread(ENamedThreads::AnyBackgroundThreadNormalTask);
// You're no longer synchronously running on the game thread,
// Obj *IS* eligible for garbage collection and Dangerous might be dangling!
co_await Async::MoveToGameThread();
// You're back on the game thread, but all of these could be destroyed by now:
Dangerous->OhNo();
Obj->Ouch();
SomeMemberVariable++; // Even `this` could be GC'd by now!
}
UE5Coro supports both gc.PendingKillEnabled
1 and the more modern setting of 0.