Pull to refresh

Корутины в UnrealEngine

Level of difficultyMedium
Reading time22 min
Views10K

Привет, Хабр!

На дворе уже 2023 год, а значит пора использовать новшества C++20 и в геймдеве по полной.

В этой статье я расскажу, как можно улучшить читаемость асинхронного кода и сократить немалое, на мой взгляд, количество писанины. И это благодаря такой возможности стандарта уже минувшего года, как сопрограммы. На Хабре опубликовано некоторое количество статей об этой интересной языковой технологии, но я хочу показать практическое применение в проектах UnrealEngine, а так же приоткрыть завесу сопрограмм для тех, кто ещё не в курсе.

Статья ориентирована на любознательных разработчиков гейм индустрии, работающих в UnrealEngine. Не важно, имели ли вы какой-либо опыт с сопрограммами ранее. Я хочу показать как с этим работать в этом прекрасном игровом движке, ведь в том же Unity давно существует подобное и является довольно ходовым инструментом.

Но хочу сразу предупредить, что статья явно не подходит тем, кто совсем недавно перешел из Blueprint в C++ будучи новичком. В статье много шаблонного кода, который неподготовленному читателю может показаться попросту непонятным.

Если вас не интересует внутреннее устройство сопрограмм, можете пропустить главы "Создаём собственную реализацию сопрограмм" и "Future", всегда можно посмотреть на примеры и воспользоваться готовым кодом. Ссылка на github в конце статьи.

Введение

Сопрограмма (корутина, coroutine) - это такая функция, которая может приостанавливать своё выполнение и возобновлять его при наступлении некоторого события. Данная возможность существует уже давно во многих языках. Сопрограммы в C++ и некоторых других языках, например Python, можно разделить на два типа:

  • Генератор - функция-сопрограмма, которую мы возобновляем сами, например с шагом цикла, а оператор yieldвозвращает значение, прерывая сопрограмму.

  • Асинхронная функция (задача, таск) - функция-сопрограмма, которая передаёт своё управление другой сопрограмме при помощи оператора await, при этом первая сопрограмма прерывается, а другая начинает своё выполнение.

В статье я затрону только второй тип, так как это наиболее интересно при написании асинхронного кода в UnrealEngine.

А что является сопрограммой в C++?

В C++ сопрограммой становится любая функция, в теле которой упомянули хотя бы одно из перечисленных ключевых слов языка: co_yield, co_await, co_return. При этом важно, чтобы тип возвращаемого значения этой функции отвечал некоторым требованиям, о которых мы поговорим позже. Но сначала вернёмся к UnrealEngine.

Асинхронность в UnrealEngine

Среди наиболее распространенных примеров асинхронности в UnrealEngine можно выделить следующие:

  • Таймеры, задержки

  • Загрузка ассетов с диска (Soft Object Pointers)

  • Вызов GameplayTask'ов (Gameplay Ability System, AI)

  • Обращение к веб-сервису через модуль HTTP

  • Ожидание условий (Latent Actions)

Асинхронный код на UnrealEngine, как правило, использует делегаты как callbacks (делегаты по сути хранят указатели на функции и опционально объекты, на которых они вызываются - функции-члены). Типичный асинхронный код на примере таймеров выглядит таким образом:


void UMyClass::Foo()
{
  FTimerHandle TimerHandle;
  FTimerDelegate TimerDelegate = FTimerDelegate::CreateUObject(this, &ThisClass::Bar);
  GetWorld()->GetTimerManager().SetTimer(TimerHandle, TimerDelegate, 5.f);
}

void UMyClass::Bar()
{
	UE_LOG(LogTemp, Log, TEXT("Hello from Bar"));
}

Данный код вызывает функцию Bar через 5 секунд. Знатоки скажут, что для данного случая делегаты вовсе не обязательно использовать, однако, раз я заговорил о делегатах, то начну именно с такого примера. И так или иначе под капотом всё равно будут делегаты, просто иногда удаётся их скрыть от пользователя, но указатель на метод класса всё равно придётся делать:

FTimerHandle TimerHandle;
GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &ThisClass::Bar, 5.f);
// Под капотом перегрузка функции SetTimer всё равно использует делегат

Использование делегатов также позволяет пробрасывать в вызываемую позже функцию некоторые дополнительные данные, что даёт нам возможность принять информацию в этой функции оттуда, где этот делегат был создан - поведение аналогично std::bind:

void UMyClass::Foo()
{
  FTimerHandle TimerHandle;
  FString MyName = TEXT("Vasya Pupkin");
  FTimerDelegate TimerDelegate = FTimerDelegate::CreateUObject(this, &ThisClass::Bar, MyName);
  GetWorld()->GetTimerManager().SetTimer(TimerHandle, TimerDelegate, 5.f);
}

void UMyClass::Bar(FString Name)
{
  UE_LOG(LogTemp, Log, TEXT("Hello from Bar, %s"), *Name);
}

А можно не заморачиваться и вовсе использовать лямбду, тогда можно просто захватить эти данные в Capture List:

void UMyClass::Foo()
{
    FTimerHandle TimerHandle;
	FString MyName = TEXT("Ivan");
	auto Bar = [MyName]
	{
		UE_LOG(LogTemp, Log, TEXT("Hello from Bar, %s"), *MyName);
	};
	FTimerDelegate TimerDelegate = FTimerDelegate::CreateWeakLambda(this, Bar);
	GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &ThisClass::Bar, 5.f);
}

Всё это очень утомляет: делегаты, лямбды, пробрасывание параметров. И по сей день, на моей практике, вариантов других при разработке на UnrealEngine я не находил. Мы можем, конечно, использовать Blueprint: там есть нода Delay, которая приостанавливает выполнение потока* выполнения блупринта, а как только проходит время, он возобновляется. Ничего не напоминает? Могли бы мы сделать так же, но в C++? Легко, следите за пальцами:

TAsyncTask<> UMyClass::Foo()
{
	FString MyName = TEXT("Ivan");
    co_await Delay(this, 5.f);
	UE_LOG(LogTemp, Log, TEXT("Hello from Bar, %s"), *MyName);
}

Код не раздробился на колбеки! Все делегаты исчезли! На самом деле не исчезли, просто их теперь не видно, но нам и не надо теперь их видеть.

* - Поток - не тот поток, который принадлежит CPU. В контексте UnrealEngine Blueprint поток (Flow) - течение выполнения кода в виртуальной машине Blueprint. Так как у движка есть свой Event Loop, при каком-либо прерывании кода в Blueprint, будь то Delay или другой Latent Action, это течение просто на время выходит из этого блупринта, а потом возвращается по истечении некоторого времени. За это время могут быть выполнены другие действия, но все они будут в пределах одного потока CPU.

Включение использования сопрограмм в ваш проект

По умолчанию, по крайней мере UnrealEngine 5.1, использует C++17, а сопрограммы в стандарте официально представлены в C++20. К счастью вам необязательно переключать стандарт для этих целей на C++20. 17-го вполне достаточно, так как этот стандарт предоставляет сопрограммы на экспериментальном уровне. Хотя на моём опыте разницы особой нет, но если вы знаете фундаментальную разницу, напишите в комментариях.

Первое, что мы должны сделать, так это найти ваш ProjectName.Target.cs файл и прописать туда код, включающий сопрограммы в вашем проекте. Делается это с помощью флага bEnableCppCoroutinesForEvaluation = true;.

Как мы видим, разработчики UnrealEngine с настороженностью относятся к использованию сопрограмм, и как бы намекает вам названием этой опции. К тому же будет выведено сообщение в логе сборки:

NOTE: C++ coroutine support is considered experimental and should be used for evaluation purposes only (bEnableCppCoroutinesForEvaluation)

Если не определились между C++17 и C++20

В зависимости от выбранного стандарта, нам понадобится некоторое ветвление в исходном коде:

#if __has_include(<coroutine>)
	#include <coroutine>
#elif __has_include(<experimental/coroutine>)
	#include <experimental/coroutine>
	namespace std {
	    using std::experimental::coroutine_handle;
	    using std::experimental::suspend_always;
	    using std::experimental::suspend_never;
	}
#else
	#error "Coroutines not supported"
#endif

Таким образом мы предусмотрели оба стандарта в одном файле, где мы собираемся писать наши сопрограммы. Ну и заодно сообщим программисту ошибку, если он вдруг решил использовать сопрограммы в более раннем стандарте.

Создаём собственную реализацию сопрограмм

Что в действительности происходит в коде, когда мы доходим до co_await Delay(this, 5.f)? Чтобы ответить на этот вопрос, постараюсь вкратце изложить как создать свой собственный класс сопрограммы. Я не буду углубляться в супер-дебри сопрограмм (их там и достаточно), но общую информацию постараюсь передать для читателя. Сразу предупреждаю, что дальше будет непростой для понимания шаблонный код, такова природа сопрограмм. Но надеюсь, что смогу передать свои знания настолько чисто, насколько это возможно. И, конечно же, это не является учебным пособием как именно писать классы сопрограмм. Я лишь делюсь своим собственным кейсом использования и реализации.

Давайте для начала поговорим об обещаниях (promise, промиз).

Promise - это такой объект, который участвует в управлении поведением сопрограммы, он предоставляет некое API, методы которого вызываются в некоторые моменты во время выполнения сопрограммы. И нас интересуют следующие методы:

  • return_void - метод, который будет вызван, когда сопрограмма завершилась и вернула void.

  • return_value - аналогично return_void, но при завершении вернула значение конкретного типа.

  • initial_suspend - функция, которая возвращает awaitable объект, который будет вызван при входе в сопрограмму.

  • final_suspend - аналогично initial_suspend, но при выходе из сопрограммы.

  • get_return_object - возвращает такой объект, который был использован на месте в коде, где мы воспользовались co_await, то есть на вызывающей стороне.

Мы хотим, чтобы наша сопрограмма всегда приостанавливалась при входе в неё (для ожидания дальнейших условий) и никогда не приостанавливалась при выходе из неё. В таких случаях есть специально заготовленные awaitable-объекты из стандартной библиотеки: std::suspend_always и std::suspend_never. В других сценариях возможны и другие варианты, но мы остановимся именно на этом, универсальном на мой взгляд, в данных задачах.

Так же мы хотим определить некоторый делегат, который будет вызываться, когда сопрограмма будет заканчивать своё выполнение. А заканчивает своё выполнение сопрограмма либо на return_value, либо на return_void, там то мы и пристроим вызов делегата. Чтобы понять цель этого делегата, мы ещё вернёмся к нему позже. Просто рассуждайте пока так, что вызов данного делегата будет побуждать продолжение другой сопрограммы, которая вызвала текущую сопрограмму.

// Общая реализация
template<typename ReturnType>
struct TPromise_Base
{
	DECLARE_DELEGATE_OneParam(FOnDone, ReturnType);
	FOnDone OnDone;

	void return_value(ReturnType Result)
	{
		OnDone.ExecuteIfBound(Result);
	}
};

// Специализация для void
template<>
struct TPromise_Base<void>
{
	DECLARE_DELEGATE(FOnDone);
	FOnDone OnDone;

	void return_void() const
	{
		OnDone.ExecuteIfBound();
	}
};
Почему бы не сделать один класс? Зачем специализация?

Это запрещено. Даже SFINAE не решит данную ситуацию. Специализация обязательна, такова специфика работы компилятора при использовании оператора co_return.

Как можно заметить, мы разделили реализацию для void и возвращаемого значения. Теперь мы можем выбирать одну из этих двух специализаций в зависимости от того, есть ли необходимость в возвращаемом значении и сделаем обобщение в один класс:

template<typename ReturnType, typename TaskType>
struct TPromise : TPromise_Base<ReturnType, TaskType>
{
	using Super = TPromise_Base<ReturnType, TaskType>;
	
	auto initial_suspend() const noexcept
	{
		return std::suspend_always();
	}
	auto final_suspend() const noexcept
	{
		return std::suspend_never();
	}
	
	auto get_return_object() const noexcept
	{
		return static_cast<TaskType>(TaskType::HandleType::from_promise(*this));
	}
	
	Super::FOnDone& GetOnDoneEvent()
	{
		return Super::OnDone;
	}
};

Довольно много вопросов вызывает get_return_object. Здесь какой-то странный каст, да ещё какой-то параметр шаблона, о котором мы не говорили - TaskType.

Дело в том, что данный Promise будет принадлежать задаче (Task), о которой мы поговорим ниже. А функция from_promise создаёт этот Task (вызывает конструктор), потому что он был заявлен как возвращаемое значение у сопрограммы.

Что должно быть в Task? Вот список необходимого API, который должна предоставлять задача:

  • await_ready - функция определяет, должна ли происходить приостановка сопрограммы при ожидании данного объекта (при вызове оператора co_awaitна данном объекте)

  • await_resume - данная функция вызывается автоматически, при возобновлении приостановленной сопрограммы, она должна возвращать значение или void в зависимости от требований. И возвращает его она туда, где было произведена остановка. И результат этой функции будет чему-то присвоен, а именно выражению до co_await.

  • await_suspend - данная функция вызывается автоматически при остановке сопрограммы. Здесь мы можем выполнить некоторые действия, которые в дальнейшем могут привести к побуждению возобновления текущей сопрограммы. Так же данная функция принимает в качестве параметра специальный объект типаstd::coroutine_handle, который и является интерфейсом управления самой сопрограммой.

  • Так же стоит упомянуть тип, который должен обязательно находиться в теле класса - это promise_type. Данный тип будет использоваться, когда будет запускаться сопрограмма. Компилятор автоматически создаёт Promise этого типа, когда мы входим в сопрограмму.

И ещё пару слов о std::coroutine_handle. Когда мы входим в сопрограмму, всегда создаётся объект такого типа. Это ничто иное, как интерфейс взаимодействия с сопрограммой, именно с помощью этого объекта мы можем возобновлять выполнение сопрограммы (см. метод resume или оператор ()).

Аналогично Promise, мы разделяем класс для void и для возвращаемого значения:

// Общая реализация (с возвращаемым значением)
template<typename R>
struct TTask_Base
{
	TOptional<R> Result;
};

// Специализация, если у нас void
template<>
struct TTask_Base<void>
{
	bool bIsFinished;
};

Здесь я использую TOptional, чтобы хранить возвращаемое значение. В момент, когда задача запущена, очевидно готового значения мы ещё не имеем. В случае void, нам лишь достаточно знать, задача закончилась или нет.

Перейдём к описанию главного класса задачи:

template<typename R = void>
struct TTask : TTask_Base<R>
{
	using ReturnType = R;

public:
	// Алиас для доступа к родительским полям
	using Super = TTask_Base<R>;

	// Это - необходимый тип, он используется компилятором для создания инстанса Promise
	using promise_type = TPromise<ReturnType, TTask<R>>;

	// Тип хэндла сопрограммы всегда должен содержать в качестве шаблонного параметра promise_type
	using HandleType = std::coroutine_handle<promise_type>;

	TTask(HandleType InHandle = nullptr)
		: Handle(InHandle)
		, bLaunched(false)
	{
	}

	Super::FOnDone& GetOnDone() const
	{
		return Handle.promise().GetOnDoneEvent();
	}

	bool await_ready()
	{
		return false;
	}

	// Вызывается при возобновлении работы сопрограммы
	ReturnType await_resume()
	{
		if constexpr (TIsSame<R, void>::Value)
		{
			return;  // возобновление предусматривает возврат void
		}
		else
		{
			ReturnType Value = Super::Result.GetValue();
			return Value;  // либо возврат значения (результата выпонения задачи)
		}
	}

	// Вызывается при остановке сопрограммы
	// В этом случае мы имеем доступ к интерфейсу сопрограммы, который передаётся в качестве параметра
	template<typename P>
	void await_suspend(std::coroutine_handle<P> Continuation)
	{
		auto& Promise = Handle.promise();  // Через этот интерфейс мы можем получить само обещание
		
		if constexpr (TIsSame<R, void>::Value)
		{
			// Мы подписываемся на делегат при остановке, чтобы при вызове этого делегата, произошло возобновление сопрограммы
			Promise.OnDone.BindLambda([this, Continuation] mutable
			{
				TTask_Base<R>::bIsFinished = true;
				Continuation.resume();  // при вызове этой функции, мы непременно возобновляем сопрограмму
			});
		}
		else
		{
			Promise.OnDone.BindLambda([this, Continuation] (R InResult) mutable
			{
				TTask_Base<R>::Result = InResult;
				Continuation.resume();
			});	
		}
	}

	// С помощью данной функции мы запускаем приостановленную ранее (в момент initial_suspend) сопрограмму
	bool Launch()
	{
		check(Handle != nullptr);
		const bool bWasLaunched = bLaunched;
		bLaunched = true;
		if ensureMsgf(!bWasLaunched, TEXT("Task already launched"))
		{
			Handle.resume();
		}
		return bLaunched;
	}

	// Данный оператор вызывается в самый первый момент, когда мы используем co_await
	// Здесь мы можем сделать какие-либо предварительные действия
	auto& operator co_await()
	{
		Launch(); // В данном случае мы хотим запустить приостановленную сопрограмму
		return *this;
	}
	
protected:
	// Хэндл самой сопрограммы. Мы его храним для дальнейших взаимодействий с ней
	HandleType Handle;
	bool bLaunched;
};

Здесь приведен довольно большой кусок кода и, чтобы его переварить, нужно немножко отпотеть. Но в целом этого уже достаточно, чтобы писать свои таски. Но нам предстоит разобраться ещё с одним интересным классом: фьючер (Или фьючерс? Поправьте меня). Буду писать просто по-английски - Future.

Future

Future - это, как и задача, awaitable объект, но, в отличие от задачи, это лишь объект, который необходим, чтобы побудить возобновление сопрограммы позже и передать ей некоторый результат в момент возобновления.

А что же такое awaitable объект?

Объект является awaitable, если он используется в co_await-выражении. То есть это такой объект, который мы можем ожидать, пока какая-либо внешняя логика не подаст сигнал о продолжении

Как и в случае задачи, мы должны предоставить со стороны Future некоторый API (типичный awaitable-объект):

  • await_ready - можно ли возобновлять сопрограмму? Если false, то произойдет приостановка, если true, то продолжится выполнение

  • await_suspend - действие, выполняемое при остановке сопрограммы

  • await_resume - действие, выполняемое при возобновлении сопрограммы, здесь мы обязаны вернуть некоторый результат, либо void (в зависимости от типа)

// База, храним общую информацию
struct FFuture_Base
{
	bool await_ready() { return false; }

	template<typename PromiseType>
	void await_suspend(std::coroutine_handle<PromiseType> Continuation)
	{
		CoroutineHandle = Continuation;
	}

protected:  
   	void Resume()
	{
		if ensureMsgf(!bResumed, TEXT("Future already resumed"))
			CoroutineHandle.resume();
		bResumed = true;
	}
      
	std::coroutine_handle<> CoroutineHandle;
	bool bResumed = false;
}

// В общем шаблонном классе мы храним результат
template<typename TReturnValue>
struct TFuture_Base : FFuture_Base
{
	TOptional<TReturnValue> Result;
};

// А в специализаации для void только факт завершения 
template<>
struct TFuture_Base<void> : FFuture_Base
{
	bool bHasResult;
};

// Обобщенный шаблонный класс предоставляет возможность пользователю 
// возобновлять приостановленную сопрограмму вызывая функцию SetResult
// А при возобновлении сопрограмма забирает этот результат посредством функции await_resume
template<typename TReturnValue>
struct TFuture : public TFuture_Base<TReturnValue>
{
	auto await_resume()
	{		
		return GetResult();
	}

	// Выбираем один из двух SetResult, в зависимости от того какого типа у нас возвращаемое значение
	template<typename T = TReturnValue>
	typename TEnableIf<!TIsSame<T, void>::Value, void>::Type
	SetResult(T&& InResult)
	{
		const bool bHasResult = Super::Result.IsSet(); 
		check(!bHasResult);
		if (bHasResult)
			return;
		
		Super::Result.Emplace(Forward<T>(InResult));
		Super::Resume();
	}

	// Если вы используете C++20, то вместо SFINAE было бы неплохо использовать концепты
	template<typename T = TReturnValue>
	typename TEnableIf<TIsSame<T, void>::Value, void>::Type
	SetResult()
	{
		const bool bHasResult = Super::bHasResult; 
		check(!bHasResult);
		if (bHasResult)
			return;
		
		Super::bHasResult = true;
		Super::Resume();
	}

protected:

	TReturnValue GetResult()
	{
		if constexpr (TIsSame<TReturnValue, void>::Value)
		{
			return;
		} else
		{
			check(Super::Result.IsSet());
			return Super::Result.GetValue();
		}
	}
}

Ну что ж. Можно вытереть пот со лба. Да, этого всего будет достаточно, чтобы теперь превращать в сопрограммы почти любой асинхронный код! Да бросьте, дальше уже не так страшно.

Использование Task

Задачи можно ожидать и они могут возвращать значения. Здесь я приведу короткие примеры вызова и ожидания задач, а так же использования возвращаемых значений.

// Задача #1 (просто возвращает кортеж)
CoroTasks::TTask<TTuple<int32, FString>> Baz()
{
    co_await Delay(4.f); // делаем умный вид, что происходят какие-то длительные асинхронные действия
    co_return MakeTuple(32, TEXT("Vasya.Pupkin"));
}
// Задача #2 - вызывает и ждёт завершения задачи #1 и принимает в качестве результата кортеж
// после чего возвращает целочисленное значение
CoroTasks::TTask<int32> Foo()
{
    const auto [Age, Name] = co_await Baz();
    co_return Age;
}
// Задача #3 - вызывает и ждёт завершения задачи #2 и принимает целочисленное значение
CoroTasks::TTask<> Bar()
{
    int32 Age = co_await Foo();
}

Таким образом мы можем писать асинхронный код, если где-то требуется продолжительное ожидание. Это удобно. Особенно импонирует использование кортежей, которые отлично передают концепцию множественных возвращаемых значений в асинхронном программировании. Оператор co_await, по сути, передаёт управление в новый таск, который в свою очередь может делать то же самое. При передаче управления выполнение может прерваться вовсе. Взгляните на схему того, как работает передача управления из одной сопрограммы в другую:


Сопрограмма просто делегирует своё управление в другую посредством ключевого слова co_await
Сопрограмма просто делегирует своё управление в другую посредством ключевого слова co_await

Выглядит, как обычные вызовы функций, с той лишь разницей, что на месте блоков-стрелок может происходить реальная приостановка сопрограммы, то есть вернуться в неё можно как сразу, так и спустя продолжительное время, а можно не вернуться вовсе и поток CPU продолжит заниматься своими другими делами. И как раз такие Awaitable как Future явно показывают это. Взгляните на следующую схему:

Здесь Awaitable - это типичный Future, который может быть использован другой (внешней) логикой
Здесь Awaitable - это типичный Future, который может быть использован другой (внешней) логикой

Future для таймера

В начале статьи я приводил примеры с таймером и поэтому, первым делом, давайте разберёмся с ним. Первое, что мы должны сделать - это создать Future для таймера. Заодно мы создадим задачу и совершим ожидание в этой задаче.

Итак, Future для таймера:

static TSharedRef<CoroTasks::TFuture<void>> Delay(UObject* Context, float Time)
{
	auto Future = MakeShared<CoroTasks::TFuture<void>>();
	const FTimerDelegate Delegate = FTimerDelegate::CreateWeakLambda(Context, [Future]
	{
		Future->SetResult();
	});
	FTimerHandle TimerHandle;
	Context->GetWorld()->GetTimerManager().SetTimer(TimerHandle, Delegate, Time, false);
	return Future;
}

Данный код гласит: мы создаём объект Future, запускаем таймер, который должен будет выставить результат для этой Future и просто-напросто возвращаем эту Future. Дабы избежать копирования, мы используем умную ссылку из фреймворка UnrealEngine.

Для чего параметр Context?

Обратите внимание также на параметр Context. Наверное многие сталкивались с тем, что при использовании лямбды в делегатах, которые могут вызваться не просто невесть когда, а уже в момент, когда объект мёртв. Для таких случаев мы хотим сделать предохранитель: чтобы продолжения сопрограммы не происходило, если объект умер, и поэтому мы делаем BindWeakLambda нашему делегату с этим контекстом. Weak - значит лямбда разрушится в момент разрушения объекта-контекста и вызвана впоследствии не будет. В таком случае сопрограмма останется висеть, если её не уничтожить. Поэтому при написании собственных систем на основе сопрограмм имейте это в виду.

co_await TSharedRef?

Чтобы код скомпилировался, давайте определим оператор co_await для TSharedPtr<TFuture<T>>

template<typename T>
CoroTasks::TFuture<T>& operator co_await(TSharedRef<CoroTasks::TFuture<T>> InSharedRef)
{
	return InSharedRef.Get();
}

Вообще оператор co_await можно дать почти чему угодно, кроме сырого указателя. Поэтому сделать co_await дляUObject* у вас не получится, однако в UnrealEngine существует специальный умный указатель TObjectPtr, то есть свободный оператор можно сделать и с Unreal-объектом.

Вообще смысл данного оператора - выдать реальный awaitable объект, который уже может использоваться в co_await-выражении.

А теперь мы попробуем создать первый асинхронный таск с обычным ожиданием в несколько секунд и выводом текста на экран.

void UMyObject::LaunchTask()
{
	auto Task = WaitForSomeTimeTask();
	Task.Launch();  // Запускаем наш таск!
}

CoroTasks::TTask<> WaitForSomeTimeTask()
{
	UE_LOG(LogTemp, Log, TEXT("Task started"));
	co_await Delay(this, 5.f);
	UE_LOG(LogTemp, Log, TEXT("Task finished"));
}

Красиво? На мой взгляд это потрясно. Так, а что там насчёт асинхронной загрузки ресурсов?

Асинхронная загрузка ресурсов

На мой взгляд это одна из самых важных задач по асинхронной работе в UnrealEngine. Лично я ненавижу эти делегаты. Хочу я, допустим, дать игроку предмет, а он лежит на диске. Я просто хочу написать короткий код, который достанет ассет предмета с диска и быстренько создаст инстанс. В обычной ситуации мне придётся звать на помощь StreamableManager. Но можно ли как-нибудь обернуть это дело в сопрограммы? Вот как выглядит обычная ситуация, когда мне необходимо получить ассет:

void AsyncLoadAsset_Request(TSoftObjectPtr<UDataAsset> Asset)
{
	// 1. Получаем ссылку на StreamableManager
	FStreamableManager& Streamable = UAssetManager::GetStreamableManager();
	
	// 2. Создаём делегат
	const auto OnLoadedDelegate = FStreamableDelegate::CreateUObject(this, &ThisClass::AsyncLoadAsset_Response, Asset);

	// 3. Используя этот делегат, запрашиваем асинхронную загрузку
	const TSharedPtr<FStreamableHandle> Handle = Streamable.RequestAsyncLoad({Asset.ToSoftObjectPath()},
		OnLoadedDelegate, FStreamableManager::DefaultAsyncLoadPriority);
}

// 4. Объявляем колбек-функцию, вызываемую делегатом в момент, когда ассет загрузился
void AsyncLoadAsset_Response(TSoftObjectPtr<UDataAsset> AssetSoftPtr)
{
	// 5. Получаем указатель на ассет в памяти
	UDataAsset* Asset = AssetSoftPtr.Get();

	// Пользуемся ассетом
}

А теперь давайте посмотрим как это будет выглядеть при использовании сопрограмм:

// 1. Объявляем задачу, которую потом запускаем
CoroTasks::TTask<> TestCoro()
{
	TSoftObjectPtr<UDataAsset> SoftAssetPtr;

	// 2. Просто вызываем функцию CoroTasks::AsyncLoadObject на мягкой ссылке ассета
	UDataAsset* Asset = co_await CoroTasks::AsyncLoadObject(SoftAssetPtr);

	// Ассет загружен, пользуемся
}

Согласитесь, что впечатляет?

Так же привожу в пример код AsyncLoadObject

template<typename T>
static TSharedRef<CoroTasks::TFuture<T*>> AsyncLoadObject(const TSoftObjectPtr<T>& SoftObjectPtr, UObject* OptionalContext = nullptr)
{
	auto Future = MakeShared<CoroTasks::TFuture<T*>>();
	auto AsyncLoad_Response = [SoftObjectPtr, Future = CopyTemp(Future)]
	{
		if (!SoftObjectPtr.IsNull())
			check(SoftObjectPtr.Get()->template IsA<T>());
		Future->SetResult((T*)(SoftObjectPtr.Get()));
	};
	AsyncLoad_Request({SoftObjectPtr.ToSoftObjectPath()}, MoveTempIfPossible(AsyncLoad_Response), OptionalContext);
	return Future;
}

template<typename LambdaType>
static void AsyncLoad_Request(const TArray<FSoftObjectPath>& ObjectPaths, LambdaType&& Lambda, UObject* Context)
{
	FStreamableManager& Streamable = UAssetManager::GetStreamableManager();
	auto Delegate = FStreamableDelegate::CreateWeakLambda(Context, Forward<LambdaType>(Lambda));
	const TSharedPtr<FStreamableHandle> Handle = Streamable.RequestAsyncLoad(ObjectPaths, Delegate, FStreamableManager::DefaultAsyncLoadPriority);
}

Данный код взят с моего гитхаба. Ниже будет ссылка на код, где вы можете всё внимательно посмотреть целиком. Как и в случае с таймером, мы также создаём Future и по делегату выставляем результат.

Вызов сопрограмм из Blueprint

А как же блупринты? И блупринты тоже можно включить в работу сопрограмм. По крайней мере мы можем вызывать сопрограммы из блупринтов, чтобы не плодить промежуточные функции. Да и ожидание результата мы тоже можем провернуть в блупринтах, но это не в этой статье.

Данный пример даёт возможность вызывать void-таски из блупринтов:

USTRUCT(BlueprintType)
struct FCoroTask
#if CPP
	: public CoroTasks::TTask<void>
#endif
{
	GENERATED_BODY()

public:
	using promise_type = CoroTasks::TPromise<void, FCoroTask>;
};

"Что за черная магия!?" - скажете вы. И будете правы. На самом деле это обычный наследник от задачи, которую я описывал выше. Просто, так как это Unreal-тип (USTRUCT), мы не имеем права наследоваться от не-Unreal-структур. Поэтому мы обманываем UnrealHeaderTool чтобы он не паясничал заметил подвоха с помощью директивы #if CPP, которую UHT понимает как "здесь не анализируй код".

Теперь мы можем создавать сопрограммы и вызываемые из Blueprint:

UFUNCTION(BlueprintCallable)
FCoroTask MyBlueprintCoroTask(TSoftObjectPtr<UItem> ItemAsset)
{
	UDataAsset* Asset = co_await CoroTasks::AsyncLoadObject(SoftAssetPtr);
	...
}

Однако пытливые читатели тут могут подметить: Оно же не будет выполнено, ведь выполнение данной функции будет приостановлено ещё на момент входа! Помните про std::suspend_always?

Да, по этой причине мы должны возобновить работу этой функции вручную, написав статическую функцию для FCoroTask:

UFUNCTION(BlueprintCallable)
static void LaunchTask(UPARAM(Ref) FCoroTask& Task)
{
	Task.Launch();
}

Можно ещё написать K2Node для таких случаев, чтобы Launch происходил автоматически, под капотом, а сами функции делать BlueprintInternalUseOnly.

Также не забываем, что мы можем модифицировать код так, чтобы конкретно FCoroTask мог не совершать остановку, как исключительная ситуация, используя std::suspend_never.

Кстати, об исключениях.

Исключения

В UnrealEngine не принято пользоваться исключениями. Но ведь это тоже прекрасный инструмент! И с сопрограммами этот инструмент играет особыми красками. А ещё более яркими красками могут заиграть автоматизированные тесты (Unreal Automation Tests) с сопрограммами и исключениями.

Зачем исключения?!

У исключений есть серьёзный недостаток. Разворачивание стека - жуть какая тяжелая операция и по этой причине есть мнение, что в разработке игр пользоваться ими плохо.

Но исключения можно использовать не обязательно для самого билда игры. Такие вещи как AutomationTests можно запросто использовать и в отдельном модуле. И в таком случае исключениями можно прерывать тесты с разного рода ошибками, которые будут копиться в логах по мере их бросков.

Для начала необходимо понять, в каких же случаях мы можем кидать исключения? И я вижу два таких случая:

  1. Ручной бросок throw в любой момент внутри сопрограммы-задачи.

  2. Установка исключения во Future: какое-то действие завершилось, но завершилось оно с ошибкой и мы хотим уведомить об этой ошибке сопрограмму.

Примечательная особенность сопрограммы в том, что она сама ловит исключения, но при этом программист всё равно будет уведомлён о том, что исключение произошло. И происходит это в функции Promise: unhandled_exception. А если мы хотим сами ловить исключения в сопрограмме? В таком случае всё просто, мы просто снова бросаем его. И делается это следующим образом:

struct TPromise : TPromise_Base<ReturnType, TaskType>
{
	void unhandled_exception()
	{
		std::rethrow_exception(std::current_exception());
	}
	...
};
Кстати, как же выглядит сопрограмма со стороны компилятора?

Вместе с исключениями, у нас должно сформироваться примерное видение во что преобразовывается сопрограмма во время компиляции. И выглядит это так:

  // Создаётся Promise при входе в сопрограмму
  TTask<T>::promise_type promise;

  // Здесь создаётся Task с помощью from_promise
  TTask<T> return_object = promise.get_return_object(); 
 
  co_await promise.initial_suspend();

  try
  {
    'function body'
    promise.return_void() or promise.return_value()
  }
  catch (...)
  {
    promise.unhandled_exception(); 
  }

  co_await promise.final_suspend();

Как мы видим, unhandled_exception вызывается компилятором при любом исключении.

Данный код не является точным примером, во что преобразовывается вызов сопрограммы, однако даёт понимание, что примерно происходит.

В случае же Future, мы хотим какой-то дополнительный метод, наподобие SetResult, но устанавливающий исключение. А ещё исключение надо как-то хранить, не зная явно его тип. К счастью есть такой тип данных: std::exception_ptr, им мы и воспользуемся.

struct FFuture_Base
{
	std::exception_ptr Exception;

	template<typename TException>
	void SetException(TException&& Exception)
	{
		Exception = std::make_exception_ptr(MoveTemp(Exception));
	}
  
	...
}

Помните метод await_resume у Future, который возвращает её результат? Теперь мы можем в этой функции и вызвать исключение. Суть в том, что await_resume вызываемся в уже в самой сопрограмме, которая вознамерилась продолжить своё выполнение. Тут-то мы, не ожидающей подвоха сопрограмме, подбрасываем заготовленное ранее пакость исключение:

	auto await_resume()
	{		
		if (Excepton != nullptr)
        	std::rethrow_exception(Exception);
		return GetResult();
	}

Ну и конечно же пример, как бы это потенциально могло бы выглядеть при написании авто-тестов. В примере можно увидеть некий класс FAsyncAutomationTest, будем считать, что он наследник от FAutomationTestBase, но с вот таким асинхронным методом, который будет запускаться вместо обычного синхронного завершения результата.

bool FAsyncAutomationTest::RunTest(const FString& Parameters)
{
    auto Task = RunAutoTest(Parameters);
    Task.Launch();
    return true;
}

CoroTasks::TTask<> FAsyncAutomationTest::RunAutoTest(const FString Parameters)
{
	SetSuccessState(true);
	bSuppressLogs = true;
	try
	{
		co_await AsyncRunAutoTest(Parameters);
	} catch (const FAutotestFailure& Exc)
	{
		const FString ErrorMessage = Exc.GetMessage();
		UE_LOG(LogTemp, Error, TEXT("Test failed with reason: %s"), *ErrorMessage);
		AddError(ErrorMessage);
		SetSuccessState(false);
	}
	bSuppressLogs = false;
}

Примечание: Будьте аккуратны с передачей строк по ссылке (&) в сопрограммы. Такие строки будут уничтожены при выходе из Scope, вызывающего сопрограмму.

Теперь мы можем писать асинхронные авто-тесты и в разных исключительных ситуациях видеть какие ошибки у нас в OutputLog при окончании теста.

Потенциальный пример использования:

IMPLEMENT_AUTOMATION_TEST_ASYNC(FMyAsyncTest,
                                    "AsyncTest.MyTest",
                                    EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter, 2, "MyAsyncTest")

CoroTasks::TTask<void> FMyAsyncTest::AsyncRunNetworkedTest(const FString Parameters)
{
  if (UAsyncTests::IsServer())
  {
    UWorld* World = UAsyncTests::GetServerWorld();
    ATestActor* Actor = World->SpawnActor<ATestActor>()
  }
  
  co_await UAsyncTests::JoinPlayers();
  
  if (UAsyncTests::IsClient(0))
  {
    UWorld* World = UAsyncTests::GetClientWorld(0);
    TArray<AActor*> Actors;
    UGameplayStatics::GetAllActorsOfClass(World, ATestActor::StaticClass(), Actors);
    if (Actors.Num() == 0)
      throw FNetError("Can't find actor that spawned on server. It seems not replicated");
  }
}

Test failed due to NetError: Can't find actor that spawned on server. It seems not replicated

Выводы и заключение

Лично я считаю, что сопрограммы имеют огромный потенциал в разработке на UnrealEngine. Примеры, которые я привёл, явно это показывают.

Хочется отметить, что вместе со всем этим делом не хватает RPC в системе синхронизации клиент-сервера UnrealEngine. Поэтому хотелось бы, чтобы в будущем сопрограммы понимал и UnrealHeaderTool. Тогда мы могли бы совершать удалённый вызов UFUNCTION и ждать результата. Но пока, подобное можно реализовывать, разрабатывая свои собственные веб-сервисы, с которыми может взаимодействовать игра.

Кстати разработчики UnrealEngine сами развивают тему с сопрограммами, но пока на уровне Experimental. Если интересно, то можно заглянуть сюда Core\Public\Experimental\Coroutine. Их реализация задач (Task) несколько отличается от того, что я привёл здесь, поэтому не получится ими воспользоваться в тех задачах, которые привёл я.

Хочу также упомянуть, что LiveCoding не работает с сопрограммами на момент UnrealEngine 5.1, но в 5.2 это должны исправить: https://github.com/MolecularMatters/lpp_public/issues/3#issuecomment-1422557992

Репозиторий с кодом, который используется в этой статье, где можно пощупать сопрограммы в UnrealEngine: https://github.com/broly/UnrealCoroTasks

Также большая активность по сопрограммам у автора проекта UE5Coro. Думаю, что заинтересованным, точно стоит туда заглянуть.

Tags:
Hubs:
Total votes 12: ↑12 and ↓0+12
Comments8

Articles