17
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

C++20コルーチンで遊ぶ with OpenSiv3D

Last updated at Posted at 2020-12-05

23/08/07
最新実装に更新しました。

はじめに

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_yieldco_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_yieldco_awaitを使えば、必ず中断されるというわけではない

上記の例ではint型をわたしているが、これは

  • await_transform や
  • operator co_await のオーバーロード

を使用することで任意の型をAwaiterの型に変換することができる
(もちろんこれをするのはライブラリ実装者なので、利用者が考えることではない)

co_yieldco_awaitするシンタックスシュガーにすぎず
値をうけとって、Awaiterに変換しているにすぎない

co_await p.yield_value( e )と等価

co_yieldco_awaitはどちらも処理の中断に使用できるが、これらの使い分けは結局のところ実装次第でもあるので、使用するコルーチンごとに変わってきます。

co_return

co_returnは関数のreturn同様に以降の処理をせず、そこでコルーチンを終了させるキーワードです。
また、co_yieldco_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処理ができると便利な場面が多々あるので

今回はこのようなことができるのを目指して実装することに

実装しました

Fiber というstructでコルーチンの実装しました。

wandboxでも確認できます。
https://wandbox.org/permlink/NHL66y9GZpGfeKXw

Fiber.hpp
Fiber.hpp
#pragma once
#include <coroutine>

struct IAwaiter
{
public:
    virtual ~IAwaiter() = default;

    virtual bool resume() = 0;
};

template<class Handle>
concept AwaitableHandler = requires(Handle handle)
{
    requires std::same_as<IAwaiter*, decltype(handle.promise().pAwaiter)>;
    requires std::convertible_to<Handle, std::coroutine_handle<>>;
};

/// <summary>
/// Yield
/// </summary>
struct Yield
{
    constexpr Yield() :
        count(1)
    {}

    constexpr Yield(std::uint32_t _count) :
        count(_count)
    {}

    std::uint32_t count;
};

namespace detail
{
    struct YieldAwaiter : IAwaiter
    {
        YieldAwaiter(const Yield& y):
            yield(y)
        {}
        bool await_ready() const noexcept
        {
            return yield.count == 0;
        }

        template<AwaitableHandler Handle>
        void await_suspend(Handle handle)
        {
            handle.promise().pAwaiter = this;
        }
        void await_resume() {}

        bool resume() override
        {
            return --yield.count > 0;
        }
        Yield yield;
    };
}

/// <summary>
/// operator co_await
/// </summary>
/// <param name="yield"></param>
/// <returns></returns>
inline auto operator co_await(const Yield& yield)
{
    return detail::YieldAwaiter{ yield };
}
namespace detail
{
    template<class T>
    struct PromiseType;
}

/// <summary>
/// Fiber
/// </summary>
template <class T = void>
struct Fiber
{
    using promise_type = detail::PromiseType<T>;
    using Handle = std::coroutine_handle<promise_type>;
public:
    Fiber(Handle h);

    Fiber(Fiber const&) = delete;

    Fiber(Fiber&& rhs) noexcept;

    ~Fiber();
public:
    /// <summary>
    /// 再開
    /// </summary>
    /// <returns></returns>
    bool resume() const;

    /// <summary>
    /// 完了したか
    /// </summary>
    /// <returns></returns>
    [[nodiscard]] bool isDone() const;

    /// <summary>
    /// 取得
    /// </summary>
    /// <returns></returns>
    [[nodiscard]] decltype(auto) get() const;

private:
    Handle m_coro;
};

/// <summary>
/// operator co_await
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="other"></param>
/// <returns></returns>
template<class T>
auto operator co_await(Fiber<T> other);

/// <summary>
/// 両辺のFiberの完了を待つFiberを生成
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="U"></typeparam>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
template<class T, class U>
[[nodiscard]] Fiber<void> operator & (Fiber<T> a, Fiber<U> b);

/// <summary>
/// 両辺のいずれかのFiberの完了を待つFiberを生成
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="U"></typeparam>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
template<class T, class U>
[[nodiscard]] Fiber<void> operator | (Fiber<T> a, Fiber<U> b);

/// <summary>
/// 左辺のFiberの完了を待ったあと右辺のFiberを待つ
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="U"></typeparam>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
template<class T, class U>
[[nodiscard]] Fiber<U> operator + (Fiber<T> a, Fiber<U> b);

template <class T>
inline Fiber<T>::Fiber(Handle h) :
    m_coro(h)
{}

template <class T>
inline Fiber<T>::Fiber(Fiber&& rhs) noexcept
    : m_coro(std::move(rhs.m_coro))
{
    rhs.m_coro = nullptr;
}
template <class T>
inline Fiber<T>::~Fiber()
{
    if (m_coro) {
        m_coro.destroy();
    }
}

template <class T>
inline bool Fiber<T>::resume() const
{
    if (!m_coro) {
        return false;
    }
    if (m_coro.done()) {
        return false;
    }
    // Yield
    {
        if (auto& pAwaiter = m_coro.promise().pAwaiter) {
            if (!pAwaiter->resume()) {
                pAwaiter = nullptr;
            } else {
                return true;
            }
        }
    }
    m_coro.resume();
    return !m_coro.done();
}

template <class T>
inline bool Fiber<T>::isDone() const
{
    if (!m_coro) {
        return true;
    }
    return m_coro.done();
}

template <class T>
inline decltype(auto) Fiber<T>::get() const
{
    return m_coro.promise().getValue();
}

template<class T, class U>
inline Fiber<void> operator & (Fiber<T> a, Fiber<U> b)
{
    while (true) {
        a.resume();
        b.resume();

        if (a.isDone() && b.isDone()) {
            co_return;
        }

        co_yield{};
    }
}

template<class T, class U>
inline Fiber<void> operator | (Fiber<T> a, Fiber<U> b)
{
    while (true) {
        a.resume();
        if (a.isDone()) {
            co_return;
        }
        b.resume();
        if (b.isDone()) {
            co_return;
        }

        co_yield{};
    }
}

template<class T, class U>
inline Fiber<U> operator + (Fiber<T> a, Fiber<U> b)
{
    while (a.resume()) {
        co_yield{};
    }
    while (b.resume()) {
        co_yield{};
    }
    co_return b.get();
}
namespace detail {
    template<class T>
    struct FiberAwaiter;
}

template<class T>
inline auto operator co_await(Fiber<T> other)
{
    return detail::FiberAwaiter{ std::move(other) };
}

namespace detail
{
    /// <summary>
    /// Promise
    /// </summary>
    template<class T>
    struct PromiseValue
    {
        void return_value(const T& _value)
        {
            this->value = _value;
        }

        const T& getValue() const
        {
            return value;
        }
        T value;
    };

    // void特殊化
    template<>
    struct PromiseValue<void>
    {
        void return_void() {}

        void getValue() const
        {}
    };

    template<class T>
    struct PromiseType : PromiseValue<T>
    {
        using FiberType = Fiber<T>;

        auto get_return_object() { return FiberType{ FiberType::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 Yield& _yield)
        {
            return operator co_await(_yield);
        }
        IAwaiter* pAwaiter = nullptr;
    };

    /// <summary>
    /// Awaiter
    /// </summary>
    template<class T>
    struct FiberAwaiter : IAwaiter
    {
        FiberAwaiter(Fiber<T>&& f):
            fiber(std::move(f))
        {}

        bool await_ready() const
        {
            return !fiber.resume();
        }
        template<AwaitableHandler Handle>
        void await_suspend(Handle handle)
        {
            handle.promise().pAwaiter = this;
        }
        decltype(auto) await_resume() const
        {
            return fiber.get();
        }
        bool resume() override
        {
            return fiber.resume();
        }

        Fiber<T> fiber;
    };
}

見ていただくとわかるように実装者側は多くのカスタマイズポイントを使用してゴネゴネ書きまくらないといけない。

利用者側は以下みたいなコードを書くことになる

Fiber<void> Hoge()
{
    // Something A
    // 1秒待機する
    co_await WaitForSeconds(1.0s);
    // Something B
}

今回実装した Fiber の特徴と使い方

メソッドresumeを呼ぶことで処理を再開させる

Main.cpp
Fiber<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.resume(); // 1を出力
    task.resume(); // 2を出力
    task.resume(); // 3を出力

    while (System::Update()) {
    }
}

この特徴を生かし、メインループに混ぜ込むことで
co_yieldを1フレーム待機という意味合いにすることができる

Main.cpp
void Main()
{
    auto task = Sample();
    while (System::Update()) {
        // 毎フレーム再開させることで待機は1フレーム待機の意味になる
        task.resume(); 
    }
}

ちょうどUnityのコルーチンでもyield return nullは1フレーム待機なのでそれと同じようなイメージにできます。

また、Fiberではresumeを明示的に呼ぶことで処理を進めるその特徴から
処理の中断はresumeを呼ぶのをやめるだけで良いので容易にできます。
(インスタンスの実態は残ってるのでメモリ解放などは起こらないが)

Fiberの中で別のFiberを実行することができる

以下は2秒ごとにカウントアップした数値を表示するコルーチンです

Main.cpp
// 指定秒待機
Fiber<void> WaitForSeconds(const s3d::Duration& duration)
{
    Timer timer(duration, StartImmediately::Yes);
    while (!timer.reachedZero()) {
        co_yield{};
    }
}

Fiber<void> Sample()
{
    int32 a = 0;

    while (true) {
        Print << ++a;
        // 2秒待つ
        co_await WaitForSeconds(2s);
    }
}

void Main()
{
    auto task = Sample();

    while (System::Update()) {
        task.resume();
    }
}

このようにco_awaitに別のタスクを指定することで、resumeで再開する際に別のFiber側の処理を進めることができる。

複数のFiberを扱う便利なoperatorオーバーロード

これはほぼ遊びで付けた要素だが、せっかくなので紹介する

operator &

複数のFiberを並行で実行し、両方完了したら完了
(スレッドを分けてるわけではないので、左辺が必ず先に処理させる)

Fiber<void> Sample()
{
    Print << U"1";
    co_yield{};
    Print << U"2";
}
Fiber<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.resume();
    }
}

// 出力
1 A 2 B C

これもまたFiberなので別のFiber内からco_yieldで待つことも可能

operator |

複数のFiberを並行で実行し、いづれか完了したら完了
(スレッドを分けてるわけではないので、左辺が必ず先に処理させる)

Fiber<void> Sample()
{
    Print << U"1";
    co_yield{};
    Print << U"2";
}
Fiber<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.resume();
    }
}

// 出力
1 A 2 B

operator +

右辺のタスクを左辺の後に実行

Fiber<void> Sample()
{
    Print << U"1";
    co_yield{};
    Print << U"2";
}
Fiber<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.resume();
    }
}

// 出力
1 2 A B C

任意の型を返すことができる 

co_returnで返した値をget()メソッド取得可能

Fiber<int> Sample()
{
    co_await WaitForSeconds(2s);
    co_return 10; // co_returnで10を返す
}

void Main()
{
    auto task = Sample();

    while (System::Update()) {
        ClearPrint();
        if (!task.resume()) { // Fiberが完了するとresumeがfalseを返す
            Print << task.get(); // 出力 10
        }
    }
}

Fiber内からであればco_awaitしてそのまま値を受け取れる

Fiber<int> Sample()
{
    co_await WaitForSeconds(2s);
    co_return 10;
}

Fiber<void> Sample2()
{
    int32 a = 0;
    while (true) {
        a += co_await Sample();
        Print << a;
    }
}
void Main()
{
    auto task = Sample2();

    while (System::Update()) {
        task.resume();
    }
}

// 出力
10 20 30 ...

サンプルプリセット紹介

Fiberで実装したコルーチンのサンプルとしていくつか紹介しておきます。

WaitForSeconds

指定秒まつ

    [[nodiscard]] Fiber<void> WaitForSeconds(const s3d::Duration& duration)
    {
        Timer timer(duration, StartImmediately::Yes);
        while (!timer.reachedZero()) {
            co_yield{};
        }
    }

WaitForFrame

指定フレームまつ

    [[nodiscard]] Fiber<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]] Fiber<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]] Fiber<void> WaitUntil(Pred pred)
    {
        while (pred()) {
            co_yield{};
        }
    }

Async

別スレッドで処理し完了するまで待機

    template<class Fty, class... Args>
    [[nodiscard]] auto Async(Fty func, Args&&... args)->Fiber<decltype(func(std::forward<Args>(args)...))>
    {
         auto f = std::async(std::launch::async, std::move(func), std::forward<Args>(args)...);
        while (f.wait_for(0s) != std::future_status::ready) {
            co_yield{};
        }
        co_return f.get();
    }

Fiberの使える場面

やはりディレイ処理など時間に関与するものを実装する際は便利です

  • イージング等のアニメーションの組み合わせ
  • キャラクターの攻撃パターンロジックの実装
  • 定期実行する処理
  • etc...

まとめ

  • C++20コルーチンを使えば↑みたいな感じでプログラミングができる
    • 3つのキーワード co_yield, co_await, co_return
  • コルーチンを使うことでdelay処理などが簡単にでき、ゲームプログラミング等とは相性がよい場面がある
  • 今回紹介したFiberは1例にすぎません、自分だけの最強コルーチンを作ってみてください。
    • 実装者はちょっと大変。使用者には優しく。
17
11
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?