はじめに
VisualStudio 2019 Communityのほうでもコルーチンが使用できるようになったので早速いろいろ試してみてた。
そんなのねぇよという方は、まずお手元のVisualStudioをアップデートしていただいて、/std:c++latest
をお忘れなく
先に言っておきますと、
この記事はOpenSiv3Dを一緒に扱っていますが、この後例で取り上げるTaskの実装自体はフレームワークにまったく依存してないのでOpenSiv3DにかぎらずC++20環境なら適用できます。
今回のゴールは、コルーチンを理解することというより、
コルーチンを使えばこんなことができる!と知ってもらうことにします。
コルーチンとは?
簡単に言うと処理の途中で中断や再開ができる関数みたいなものですが
ここではコルーチンの詳しい解説をしたいわけではないので参考になるものを張っておきます
https://cpprefjp.github.io/lang/cpp20/coroutines.html
https://www2.slideshare.net/yohhoy/20c20
なんだか難しいと思ったかもしれませんが、安心してください。
利用者が覚えるキーワードは3つだけです。
co_yield
co_await
co_return
そしてこの三つのキーワードを使用した際に、具体的にどのような動作をさせるのかを決めるのはコルーチンライブラリの実装者です。
co_yield co_await
co_yield
、co_await
は処理の中断に使用するするキーワードです
基本的にはどちらも、キーワード単体では使用せず、何かしらの値とセットで使うことになります。
Hoge Coroutine()
{
// co_yieldで中断
{
co_yield 1;
auto a = co_yield 1; // 復帰時に値が変えることもある
}
// co_awaitで中断
{
co_await 1;
auto a = co_await 1; // 復帰時に値が変えることもある
}
}
co_await
にはAwaiterと呼ばれるものを渡すことになる。
このAwaiterが中断するかどうか、中断時、再開時に行う処理や返り値等を決めている。
そのためco_yield
やco_await
を使えば、必ず中断されるというわけではない
上記の例ではint型をわたしているが、これは
- await_transform や
- operator co_await のオーバーロード
を使用することで任意の型をAwaiterの型に変換することができる
(もちろんこれをするのはライブラリ実装者なので、利用者が考えることではない)
co_yield
もco_await
するシンタックスシュガーにすぎず
値をうけとって、Awaiterに変換しているにすぎない
co_await p.yield_value( e )と等価
co_yield
、co_await
はどちらも処理の中断に使用できるが、これらの使い分けは結局のところ実装次第でもあるので、使用するコルーチンごとに変わってきます。
co_return
co_return
は関数のreturn
同様に以降の処理をせず、そこでコルーチンを終了させるキーワードです。
また、co_yield
もco_await
も使用しない際に、これが通常の関数ではなくコルーチンであることを明示する際にも使用します。
// 関数
Hoge Func()
{
return; // error Hoge型の値を返そう
}
// コルーチン
Hoge Coroutine()
{
co_return; // ok (※Hoge型がco_returnでvoidが返せるように実装されているなら)
// 以降の処理しない
}
モチベーション
Unityのコルーチンみたいなもんが欲しい!
IEnumerator Hoge()
{
// Something A
// 1秒待機する
yield return new WaitForSeconds(1.0f);
// Something B
}
Unityを使用したことがある人ならUnityコルーチンを使用したことがある人も多いと思いますが
ゲーム制作などではこのようなdelay処理ができると便利な場面が多々あるので
今回はこのようなことができるのを目指して実装することに
実装しました
Task というstructでコルーチンの実装しました。
Task.hpp
#pragma once
#include <coroutine>
#include <memory>
namespace detail
{
struct Yield
{
constexpr Yield():
count(1)
{}
constexpr Yield(std::uint32_t _count) :
count(_count)
{}
std::uint32_t count;
};
/// <summary>
/// タスク用インターフェース
/// </summary>
struct ITask
{
virtual ~ITask() = default;
virtual bool moveNext() const = 0;
};
}
/// <summary>
/// タスク
/// </summary>
template <class T = void>
struct Task : detail::ITask
{
struct promise_type; // coroutine_traitsを特殊化してないのでスネイクケース
using Handle = std::coroutine_handle<promise_type>;
struct PromiseValue
{
void return_value(const T& value)
{
this->value = value;
}
const T& getValue() const
{
return value;
}
T value;
};
struct promise_type : PromiseValue
{
static Task get_return_object_on_allocation_failure()
{
return Task{ nullptr };
}
auto get_return_object() { return Task{ Task::Handle::from_promise(*this) }; }
auto initial_suspend() { return std::suspend_always{}; }
auto final_suspend() noexcept { return std::suspend_always{}; }
void unhandled_exception() { std::terminate(); }
auto yield_value(const detail::Yield& _yield)
{
struct Awaiter
{
bool await_ready() const noexcept
{
return ready;
}
void await_suspend(std::coroutine_handle<>){}
void await_resume(){}
bool ready = false;
};
if (_yield.count == 0) {
return Awaiter{true};
}
--(this->yield = _yield).count;
return Awaiter{false};
}
template<class U>
auto yield_value(Task<U> other)
{
auto nextTask = std::make_shared<Task<U>>(std::move(other));
auto ready = !nextTask->moveNext();
next = nextTask;
struct Awaiter
{
bool ready = false;
std::shared_ptr<Task<U>> pTask;
bool await_ready() const { return ready; }
decltype(auto) await_resume()
{
return pTask->get();
}
void await_suspend(std::coroutine_handle<>)
{}
};
return Awaiter{ ready, nextTask };
}
detail::Yield yield{0};
std::shared_ptr<detail::ITask> next;
};
Task(Handle h) :
coro(h)
{}
Task(Task const&) = delete;
Task(Task&& rhs) noexcept
: coro(std::move(rhs.coro))
{
rhs.coro = nullptr;
}
~Task()
{
if (coro) {
coro.destroy();
}
}
/// <summary>
/// 再開
/// </summary>
/// <returns></returns>
bool moveNext() const override
{
if (!coro) {
return false;
}
if (coro.done()) {
return false;
}
// Yield
{
auto& yield = coro.promise().yield;
if (yield.count > 0 && yield.count-- > 0) {
// カウンタが残ってるなら
return true;
}
}
// 割り込み別タスク
{
auto& next = coro.promise().next;
if (next) {
if (!next->moveNext()) {
next = nullptr;
} else {
return true;
}
}
}
coro.resume();
return !coro.done();
}
/// <summary>
/// 完了したか
/// </summary>
/// <returns></returns>
[[nodiscard]] bool isDone()const
{
if (!coro) {
return false;
}
return coro.done();
}
/// <summary>
/// 取得
/// </summary>
/// <returns></returns>
[[nodiscard]] decltype(auto) get() const
{
return coro.promise().getValue();
}
private:
Handle coro;
};
// void特殊化
template<>
struct Task<void>::PromiseValue
{
void return_void() {}
void getValue() const
{}
};
template<class T, class U>
[[nodiscard]] Task<void> operator & (Task<T> a, Task<U> b)
{
while (true) {
a.moveNext();
b.moveNext();
if (a.isDone() && b.isDone()) {
co_return;
}
co_yield{};
}
}
template<class T, class U>
[[nodiscard]] Task<void> operator | (Task<T> a, Task<U> b)
{
while (true) {
a.moveNext();
b.moveNext();
if (a.isDone() || b.isDone()) {
co_return;
}
co_yield{};
}
}
template<class T, class U>
[[nodiscard]] Task<U> operator + (Task<T> a, Task<U> b)
{
while (a.moveNext()) {
co_yield{};
}
while (b.moveNext()) {
co_yield{};
}
co_return b.get();
}
見ていただくとわかるように実装者側は多くのカスタマイズポイントを使用してゴネゴネ書きまくらないといけない。
利用者側は以下みたいなコードを書くことになる
Task<void> Hoge()
{
// Something A
// 1秒待機する
co_yield WaitForSeconds(1.0s);
// Something B
}
今回実装した Task の特徴と使い方
メソッドmoveNextを呼ぶことで処理を再開させる
Task<void> Sample()
{
int32 a = 0;
Print << ++a;
co_yield{}; // ただ待機したいときは co_yield{} と記述するだけいいように実装した
Print << ++a;
co_yield{};
Print << ++a;
co_yield{};
}
void Main()
{
auto task = Sample();
task.moveNext(); // 1を出力
task.moveNext(); // 2を出力
task.moveNext(); // 3を出力
while (System::Update()) {
}
}
この特徴を生かし、メインループに混ぜ込むことで
co_yield
を1フレーム待機という意味合いにすることができる
void Main()
{
auto task = Sample();
while (System::Update()) {
// 毎フレーム再開させることで待機は1フレーム待機の意味になる
task.moveNext();
}
}
ちょうどUnityのコルーチンでもyield return null
は1フレーム待機なのでそれと同じようなイメージにできます。
また、TaskではmoveNextを明示的に呼ぶことで処理を進めるその特徴から
処理の中断はmoveNextを呼ぶのをやめるだけで良いので容易にできます。
(インスタンスの実態は残ってるのでメモリ解放などは起こらないが)
Taskの中で別のTaskを実行することができる
以下は2秒ごとにカウントアップした数値を表示するコルーチンです
// 指定秒待機
Task<void> WaitForSeconds(const s3d::Duration& duration)
{
Timer timer(duration, StartImmediately::Yes);
while (!timer.reachedZero()) {
co_yield{};
}
}
Task<void> Sample()
{
int32 a = 0;
while (true) {
Print << ++a;
// 2秒待つ
co_yield WaitForSeconds(2s);
}
}
void Main()
{
auto task = Sample();
while (System::Update()) {
task.moveNext();
}
}
このようにco_yield
に別のタスクを指定することで、moveNextで再開する際に別のタスク側の処理を進めることができる。
Unityの場合はStartCroutineを呼ぶことで別のコルーチンを開始させ、待機することができるが
それを実現しようとするとTask struct単体で解決できないので、今回はこのような仕組みとした。
複数のタスクを扱う便利なoperatorオーバーロード
これはほぼ遊びで付けた要素だが、せっかくなので紹介する
operator &
複数のタスクを並行で実行し、両方完了したら完了
(スレッドを分けてるわけではないので、左辺が必ず先に処理させる)
Task<void> Sample()
{
Print << U"1";
co_yield{};
Print << U"2";
}
Task<void> Sample2()
{
Print << U"A";
co_yield{};
Print << U"B";
co_yield{};
Print << U"C";
}
void Main()
{
auto task = Sample() & Sample2();
while (System::Update()) {
task.moveNext();
}
}
// 出力
1 A 2 B C
これもまたTaskなので別のTask内からco_yieldで待つことも可能
operator |
複数のタスクを並行で実行し、いづれか完了したら完了
(スレッドを分けてるわけではないので、左辺が必ず先に処理させる)
Task<void> Sample()
{
Print << U"1";
co_yield{};
Print << U"2";
}
Task<void> Sample2()
{
Print << U"A";
co_yield{};
Print << U"B";
co_yield{};
Print << U"C";
}
void Main()
{
auto task = Sample() | Sample2();
while (System::Update()) {
task.moveNext();
}
}
// 出力
1 A 2 B
operator +
右辺のタスクを左辺の後に実行
Task<void> Sample()
{
Print << U"1";
co_yield{};
Print << U"2";
}
Task<void> Sample2()
{
Print << U"A";
co_yield{};
Print << U"B";
co_yield{};
Print << U"C";
}
void Main()
{
auto task = Sample() + Sample2();
while (System::Update()) {
task.moveNext();
}
}
// 出力
1 2 A B C
任意の型を返すことができる
co_return
で返した値をget()
メソッド取得可能
Task<int> Sample()
{
co_yield WaitForSeconds(2s);
co_return 10; // co_returnで10を返す
}
void Main()
{
auto task = Sample();
while (System::Update()) {
ClearPrint();
if (!task.moveNext()) { // タスクが完了するとmoveNextがfalseを返す
Print << task.get(); // 出力 10
}
}
}
Task内からであればco_yield
してそのまま値を受け取れる
Task<int> Sample()
{
co_yield WaitForSeconds(2s);
co_return 10;
}
Task<void> Sample2()
{
int32 a = 0;
while (true) {
a += co_yield Sample();
Print << a;
}
}
void Main()
{
auto task = Sample2();
while (System::Update()) {
task.moveNext();
}
}
// 出力
10 20 30 ...
サンプルプリセット紹介
Taskで実装したコルーチンのサンプルとしていくつか紹介しておきます。
WaitForSeconds
指定秒まつ
[[nodiscard]] Task<void> WaitForSeconds(const s3d::Duration& duration)
{
Timer timer(duration, StartImmediately::Yes);
while (!timer.reachedZero()) {
co_yield{};
}
}
WaitForFrame
指定フレームまつ
[[nodiscard]] Task<void> WaitForFrame(s3d::uint32 frame)
{
co_yield{frame};
}
WaitUntil
条件をみたすまで待つ
template<class Pred, std::enable_if_t<std::is_invocable_r_v<bool, Pred>>* = nullptr>
[[nodiscard]] Task<void> WaitUntil(Pred pred)
{
while (!pred()) {
co_yield{};
}
}
WaitWhile
条件をみたす間待つ
template<class Pred, std::enable_if_t<std::is_invocable_r_v<bool, Pred>>* = nullptr>
[[nodiscard]] Task<void> WaitUntil(Pred pred)
{
while (pred()) {
co_yield{};
}
}
Async
別スレッドで処理し完了するまで待機
template<class Fty>
[[nodiscard]] auto Aysnc(Fty func)->Task<decltype(func())>
{
auto f = std::async(std::launch::async, func);
while (f.wait_for(0s) != std::future_status::ready) {
co_yield{};
}
co_return f.get();
}
Taskの使える場面
やはりディレイ処理など時間に関与するものを実装する際は便利です
- イージング等のアニメーションの組み合わせ
- キャラクターの攻撃パターンロジックの実装
- 定期実行する処理
- etc...
まとめ
- C++20コルーチンを使えば↑みたいな感じでプログラミングができる
- 3つのキーワード
co_yield
,co_await
,co_return
- 3つのキーワード
- コルーチンを使うことでdelay処理などが簡単にでき、ゲームプログラミング等とは相性がよい場面がある
- 今回紹介したTaskは1例にすぎません、自分だけの最強コルーチンを作ってみてください。
- 実装者はちょっと大変。使用者には優しく。