あめだまふぁくとりー

Boost.Graphとかできますん

Networking TS の Boost.Asio からの変更点 - その 2: async_result

はじめに

前回は Boost.Asio から Networking TS への変更点として Associated allocator について説明しました.

今回は, async_result について説明します.

Boost.Asio における非同期処理スタイル

Boost.Asio の非同期関数はすべて継続渡しスタイルをベースに設計, 実装されており, 継続となる関数オブジェクトを最終引数として受け取ります.

socket.async_read_some(
      asio::buffer(buf)
      // 継続渡し: 読み込み完了時に実行される
    , [&](auto ec, std::size_t nread) {
  asio::async_write(
      socket, asio::buffer(buf, nread)
      // 継続渡し: 書き込み完了時に実行される
    , [&](auto ec, std::size_t) { ... });
});

しかしながら, 非同期処理のスタイルは継続渡し以外にもコルーチンを使用したものなどあり, それぞれの利点, 欠点があります.

そのため, Boost.Asio では継続渡しスタイルをベースにしながらも, 他のスタイルもサポートしています. そのために使用されるのが async_resulthandler_type の二つのクラステンプレートです.

これらの説明をするためにコルーチンを使用した例を見てみます.

// コルーチンの例
asio::spawn(io_service, [&](asio::yield_context yield) {
  unsigned char buf[255];
  auto nread = socket.async_read_some(asio::buffer(buf), yield);
  asio::async_write(socket, asio::buffer(buf, nread), yield);
});

この例では非同期関数に yield_context 型のオブジェクト yield を最終引数に渡していますが, このオブジェクトは関数オブジェクトではありません. 継続渡しスタイルベースで実装されている非同期関数に非関数オブジェクトを渡すことができるのは, 非同期関数が内部でこれを関数オブジェクトに変換しているからです. この変換に使用されるのが handler_type です.

handler_type はテンプレート引数に非同期関数の最終引数の型を受け取ります. この型は CompletionToken と呼ばれ, handler_type は CompletionToken によって特殊化されます.

特殊化された handler_type は関数オブジェクトの型を依存型として返すので, 非同期関数はこれを継続として使用します. ちなみに, handler_type はデフォルトでは CompletionToken をそのまま返すので, 継続渡しスタイルの場合, 継続として渡した関数オブジェクトがそのまま使用されることになります.

async_result

コルーチンの例では非同期関数 async_read_some に戻り値があることがわかります (使用していませんが async_write も戻り値はあります). 一番初めの継続渡しの例では戻り値はありません (コード上からはわかりませんが).

このように非同期関数の戻り値も CompletionToken によって変わります. 戻り値を決めるのが async_result の役目です.

async_result は戻り値の型, 及び戻り値の生成を責務としており, handler_type で変換された関数オブジェクトの型で特殊化されます.

Boost.Asio では各非同期スタイルに対して handler_typeasync_result を特殊化するだけでよいので, 非同期関数はただ一つ定義すればよいことになります.

Networking TS における async_result

Networking TS でも Boost.Asio 同様 CompletionToken の仕組みを使用します.

しかし, Networking TS では handler_type は存在しません. CompletionToken から関数オブジェクトへの変換も async_result が受け持ちます. このため, async_result は変換後の関数オブジェクトの型ではなく CompletionToken で特殊化されます.

この変更がどのように影響するかを次節で見ていきます.

async_result と handler_type 統合による影響

Boost.Asio の場合

async_result への統合への影響を説明するためにまた例を出します.

今度の例は, ある strand 内で非同期関数を呼び出す, という非同期関数です. マルチスレッドを使用している場合, このような処理をしたくなる場合があると思います.

template <class Stream, class ConstBufferSequence, class CompletionToken>
auto async_write_in_some_strand(
      Stream& stream, ConstBufferSequence const& buffers
    , asio::io_service::strand strand
    , CompletionToken&& token)
{
  using handler = typename asio::handler_type<
      CompletionToken, void(system::error_code, std::size_t)>::type;
  handler h{std::forward<CompletionToken>(token)};
  asio::async_result<handler> result{h};

  // strand の中で非同期関数を呼び出す.
  strand.post([&stream, buffers, h]{
    asio::async_write(stream, buffers, h);
  });

  return resutl.get();
}

この関数は一見問題なさそうに見えますが, 実は致命的なバグがあります.

コルーチンを使用してこの関数を呼び出すと未定義の動作を招きます. 大体の場合, この関数を呼び出したコンテキストにコルーチンが戻ってきません.

これは特殊化された async_result が, async_write_in_some_strandasio::async_write の両方で使用されるためです (Boost.Asio の async_resulthandler_type で変換した型で特殊化されることを思い出してください). handler で特殊化された async_resultメンバ関数 get (上記の result.get()) は, コルーチンから一度抜けるように実装されているので, コルーチンから二回抜けようとするわけです.

上記の関数を正しく動作させるためには, asio::async_write で特殊化された async_result が使用されないように, handler_type で取得した関数オブジェクトをラップしてあげる必要があります.

template <class Stream, class ConstBufferSequence, class CompletionToken>
auto async_write_in_some_strand(
      Stream& stream, ConstBufferSequence const& buffers
    , asio::io_service::strand strand
    , CompletionToken&& token)
{
  using handler = typename asio::handler_type<
      CompletionToken, void(system::error_code, std::size_t)>::type;
  handler h{std::forward<CompletionToken>(token)};
  asio::async_result<handler> result{h};

  strand.post([&stream, buffers, h]{
    asio::async_write(
        stream, buffers
        // 特殊化した async_result が使われないようにラップする
      , [h](auto&&... args) { h(std::forward<decletype(args)>(args)...); });
  });

  return resutl.get();
}

上記では簡略化のためラムダ式を使用しましたが, asio_handler_allocate 等の他のカスタマイゼーションポイントを有効にするには きちんとそれらの関数を転送するラッパを書く必要があります.

Networking TS の場合

Networking TS ではこのような誤りは起こりにくくなっています.

template <class Stream, class ConstBufferSequence, class CompletionToken>
auto async_write_in_some_strand(
      Stream& stream, ConstBufferSequence const& buffers
    , asio::io_service::strand strand
    , CompletionToken&& token)
{
  using aresult = asio::async_result<
      CompletionToken, void(system::error_code, std::size_t)>;
  typename aresult::handler_type h{std::forward<CompletionToken>(token)};
  aresult result{h};

  strand.post([&stream, buffers, h]{
    // async_result は CompletionToken に対して特殊化されており,
    // aresult::handler_type に対しては特殊化されていないのでラップする必要はない.
    asio::async_write(stream, buffers, h);
  });

  return result.get();
}

async_result は CompletionToken で特殊化されており, 内部で呼び出す非同期関数には変換後の関数オブジェクトが渡されるため, 特殊化された async_result が再び使用されることはありません.

まとめ

Boost.Asio では継続渡し以外の非同期処理スタイルをサポートするため, async_resulthandler_type の二つの関数テンプレートをカスタマイゼーションポイントとして使用しています. しかし, これらを使用して複雑な非同期処理関数を書く際, 陥りやすい問題があることを説明しました.

Networking TS ではこれらを一つに統合したことで, 複雑な非同期処理関数でも謝りなく記述できるようになりました.

次は Executor 周りについて説明しようと思います.