🔌

非同期処理を行うクラス

2023/07/29に公開

ラムダ式ではなく、クラスで実装する

では、ラムダ式でシンプルなstackless coroutineの実装を示しました。
しかし、実際の開発ではクラスを使うことが多いと思います。
クラスのメンバ関数として、operator()を持たせることで、そのクラスはラムダ式と同様にcallableとなります。
postによって非同期処理を進めるコードを、クラスを用いて書いてみます。

コード

#include <iostream>
#include <boost/asio.hpp>

namespace as = boost::asio;

#include <boost/asio/yield.hpp>

template <typename Executor>
struct my_app {
    my_app(Executor exe)
        :exe_{std::move(exe)},
         worker_{*this}
    {}

private:
    friend struct worker;
    // workerをcopyableにして、非同期関数に *this を渡す。
    // 保持すべき情報は、外側クラスmy_appへの参照と、
    // as::coroutineのみ。
    // 必要なデータは、my_app側に足していく。
    struct worker {
        worker(my_app& ma)
            :ma_{ma}
        {
            (*this)(); // 最初のoperator()をキック
        }
        void operator()() const {
            std::cout << "operator()()" << std::endl;
            reenter(coro_) {
                std::cout << "1st" << std::endl;
                // 第2引数にcallableとしてのworker自身を渡す。
                yield as::post(ma_.exe_, *this);
                std::cout << "2nd" << std::endl;
                // 第2引数にcallableとしてのworker自身を渡す。
                yield as::post(ma_.exe_, *this);
                std::cout << "3rd" << std::endl;
            }
        }
        my_app& ma_;
        mutable as::coroutine coro_;
    };

    Executor exe_;
    worker worker_;
};

#include <boost/asio/unyield.hpp>

int main() {
    as::io_context ioc;
    my_app ma{ioc.get_executor()};
    ioc.run();
}

出力

godboltでの実行
https://godbolt.org/z/b58PhMKco

operator()()
1st
operator()()
2nd
operator()()
3rd

詳細

非同期処理を行うアプリケーションロジックは

template <typename Executor>
struct my_app {
    my_app(Executor exe)
        :exe_{std::move(exe)},
         worker_{*this}
    {}

に実装しています。

そして、friendなnest classとして、workerを定義しています。

private:
    friend struct worker;
    // workerをcopyableにして、非同期関数に *this を渡す。
    // 保持すべき情報は、外側クラスmy_appへの参照と、
    // as::coroutineのみ。
    // 必要なデータは、my_app側に足していく。
    struct worker {
        worker(my_app& ma)
            :ma_{ma}
        {
            (*this)(); // 最初のoperator()をキック
        }

my_appは、そのコンストラクタで、*thisをwokerのコンストラクタに渡し、wokerはそれを保持します。

workerはメンバ変数として、

        my_app& ma_;
        mutable as::coroutine coro_;

だけを持ちます。ma_を介してmy_appにアクセスし、coro_によって、yieldによる非同期の継続実行を実現します。
coro_はmutableになっています。これは、constメンバ関数の中からでも、書き換えられるようにしたいからです。
coro_の書き換えは、あくまでもreenterブロック内で、どこまで実行したかの情報を書き換えるだけなので、アプリケーションのconst性には影響しないという判断です。
排他制御のためのmutexをmutableにするのと似た感覚です。

        void operator()() const {
            std::cout << "operator()()" << std::endl;
            reenter(coro_) {
                std::cout << "1st" << std::endl;
                // 第2引数にcallableとしてのworker自身を渡す。
                yield as::post(ma_.exe_, *this);

postの引数に*thisを渡すことで、非同期処理完了時に、woker::operator()が呼び出されるという仕組みです。
wokerはコピーで渡されますが、メンバがmy_appへの参照と、as::coroutineだけなのでコストは極めて低いです。
この先、アプリケーションロジックに必要なメンバが追加されていったとしても、my_appにメンバが増えるだけなので、workerのコピーコストは変化しません。

細かい設計判断

workerが、coroutineをメンバとして持つか継承するか

どちらでも実現できます。
継承版はgodboltのリンクとコードの抜粋の説明に留めます。

https://godbolt.org/z/ePEneY1Wj

こうした場合、クラス定義は

    struct worker : private as::coroutine {

となり、

        void operator()() /*const*/ { // ここがconstにできない
            std::cout << "operator()()" << std::endl;
            reenter(*this) {

reenterにcoro_の代わりに*thisを渡します。
ここで注意したいのは、operator()をconstにできないという点です。
これは、基底クラスのcoroutineをmutableにできないためです。

以下はエラーになるコードですが、

    struct worker : private mutable as::coroutine {

もしこのように、基底クラスをmutableにできれば、話は違ったかも知れません。

しかし、本ケースではメンバ変数で済むものを基底クラスにすることにメリットは無いと思います。
EBOが働く状況でもないですし。
さらにいえば、2系統のcoro1_ coro2_ を持ちたいケースも出てくるかも知れません。

そんなわけで、coro_はメンバ変数として持たせる設計判断をしています。

workerクラスを準備してコピーベースの実装にした理由

my_appに直接operator()を実装することをまず考えました。
この場合、post()に*thisを渡すと、my_appの全てのメンバがコピーされてしまいます。
このコストは避けたいです。
では、as::post(exe_, std::move(*this));とmoveしてはどうか?というアイデアが浮かびました。
しかし、*thisのmoveは非常に注意深く実装しなければならず、ミスが起こりやすいです。
例えば、この例でも、exe_はmoved from objectになっているでしょう。
さらに、moveしたからといって必ずしもコピーコストが避けられるとは限りません
my_appのメンバ変数にstd::arrayのようなmoveがcopyにfallbackされるクラスが存在したら、結局コピーは発生します。
さらにさらに、my_appにcopyable but not movable (コピーは可能だがムーブはできない)メンバを配置することもできません。これはなかなか大きな制約です。

これらの問題を解決するために、wokerクラスに、my_appの参照を持たせる、という設計としました。

GitHubで編集を提案

Discussion