あめだまふぁくとりー

Boost.Graphとかできますん

Networking TS の Boost.Asio からの変更点 - その 4: Associated Executor

はじめに

Boost 1.66 で Boost.Asio が Networking TS に対応するようです. なのでその 3 からかなり間が空きましたが, その 4 の記事を書きます. 前回 Executor について説明したので, 今回は Associated Executor について説明します.

Boost.Asio でのハンドラ呼び出し

Boost.Asio では非同期処理完了時のハンドラ呼び出しに asio_handler_invoke という非メンバ関数を使用することになっています. この関数はデフォルトでは引数で受け取った関数オブジェクトを単に呼び出すだけです.

template <class Function>
void asio_handler_invoke(Function& function, ...) {
  function();
}

ただし, この関数も asio_handler_allocate 等と同様, 引数として別にハンドラを指すポインタを取るので, 関数の呼び出し方をカスタマイズすることができます.

Strand では実際に asio_handler_invoke をフックすることで, ハンドラが並列に実行されないことを担保しています.

template <class Function, class Handler>
void asio_handler_invoke(
      Function& function
    , wrapped_handler<strand, Handler>* handler) {
  // wrapped_handler は strand.wrap(handler) で得られるハンドラの型
  handler->strand.dispatch(function); // 実際はもう少しいろいろなことをやる
}

Networking TS でのハンドラ呼び出し

Networking TS では asio_handler_invoke は廃止し, 代わりに Associated executor を使用します.

Associated executor は その 1 で説明した Associated allocator と同様にハンドラごとに関連付けられた Executor です.

Associated executor はやはり Associated allocator と似たように, associated_executor を使用して取得できます.

my_handler h;
auto exec = associated_executor<my_handler>::get(h);

Networking TS では非同期処理完了時にこの Associated executor にハンドラを post / dispatch します. なので, 独自に Executor を使用するようにすることで, asio_handler_invoke と同様のことを実現できます. Associated executor が strand なら, ハンドラは呼び出される前に strand.post / dispatch されます.

Executor のハンドラへの関連付け

既存の Executor をハンドラに関連付けたい場合は, bind_executor を使用します.

bind_executor は前回の紹介では strand.wrap に相当するものと説明しましたが, 実際はハンドラに Executor を関連付けること以上のことは行いません.

これはどうゆうことかというと, bind_executor の結果の関数オブジェクトを関数呼び出し演算子で呼び出しても, 関連付けた Executor を通してハンドラを実行しないということです.

この差については少し注意したほうがよさそうですが, 実際はユーザが直接関数呼び出し演算子で呼び出すことはあまりないと思いますので, あまり気にすることもないかと思います.

auto wrapped_handler = strand.wrap(handler);
wrapped_handler(); // strand の中で実行

auto bind_handler = bind_executor(strand, handler);
bind_handler(); // その場で実行. strand の中では実行しない.

Associated executor への変更による影響

Networking TS では非同期処理完了時にハンドラは Associated executor に dispatch, または post されます. 前回説明したように Executor の dispatch / post はハンドラをそのまま実行します (Boost.Asio では asio_invoke_handler はさらに asio_handler_invoke を呼び出す場合があります).

これはどういうことかというと, ハンドラが入れ子になっている場合, 内部の Associated executor は無視されるということです.

下記のコードを例にして説明します.

auto bind_h
  = bind_executor(strand1, bind_executor(strand2, h));
socket.async_read(buffer, bind_h);

このコードでは strand1 と strand2 を二重にバインドして, ソケットからの非同期読み出しを行なっています. bind_h の外側の Associated executor は strand1 であり, 内側は strand2 です. つまり, bind_h の直接の Associated executor は strand1 になります.

そのため, 読み出しが完了すると, strand1.dispatch(func) が実行され, strand1 の中で func() が呼び出されます (func は std::bind(bind_h, read_size, error) に相当する関数オブジェクト). 上記で説明した通り, bind_executor の戻り値の関数オブジェクトは, バインドした Executor を無視してハンドラを呼び出します. よって, bind_executor(strand1, bind_executor(strand2, h))() は即座に bind_executor(strand2, h)() を実行し, それは即座に h() を実行することになります.

つまり, 上記のコードではハンドラは strand1 の中で実行されることになります.

Boost.Asio の場合は, asio_handler_invoke が連鎖する設計になっていたため, 以下のコードでは strand2 の中で実行されます.

auto wrapped_h = strand1.wrap(strand2.wrap(h));
socket.async_read_some(buffer, wrapped_h);

Networking TS と Boost.Asio では最終的に使用される Executor が異なるので注意する必要があります.

Networking TS の改善点

Networking TS では, executor::dispatch / post とbind_executor の設計を見直し, Executor のネストを無視するようにしました. これにより, Boost.Asio に比べて速度の面と安全性の面で改善された箇所があるので, 以降はそれらについて説明します.

Strand の効率化

Boost.Asio の場合

Boost.Asio で strand でラップしたハンドラが非同期処理完了時に呼び出される流れは以下のようになります (ここでは strand.wrap(handler) の型を wrapper とします).

  1. wrapper 用の asio_handler_invoke の中で strand にラップした関数オブジェクトを dispatch.
  2. strand のコンテキストで asio_handler_invoke 経由で wrapper::operator() を実行. strand に handler を dispatch する.
  3. strand のコンテキストで asio_handler_invoke 経由で handler を実行.

このように, 1 と 2 の処理で無駄に一回多く dispatch を行っています.

また, 2 の処理で呼び出す asio_handler_invoke を 1 で呼び出したものと違うものにするため, 実際には 1 の dispatch 時に関数オブジェクトをリラップして関数オブジェクトの型を変更しています. ラップしない場合, 2 で 1 と同じ asio_handler_invoke が呼び出され, 無限再帰になってしまいます.

Networking TS の場合

Networking TS ではこれらが改善され, Strand を通した呼び出しが少しだけ効率的になります.

  1. Associated executor である strand にメンバ関数 dispatch を使用して dispatch.
  2. strand のコンテキストで元のハンドラを実行.

Networking TS では executor_binder::operator() は単に元のハンドラを呼び出すだけなので, 不要な dispatch は発生しませんし, リラップの必要性もありません.

パフォーマンス測定

実際にどれくらい改善されたか測定します. Boost.Asio でも以下のように strand::dispatch をラムダ式の中で呼び出せば, Networking TS と同等の改善が得られます (注意:この方法は async_write 等の composed operation と一緒に使用できないので, プロダクトコードで使用するのはおすすめしません).

socket.async_read_some(buffer, [&] { strand.dispatch(handler); });

測定用のコードは Gist に置いてあります.

ここでは, あらかじめ io_service に 10,000,000 個のハンドラを post しておき, それらがすべて実行されるまでの時間を測りました (使用したコンパイラは clang でオプションは -std=c++11 -stdlib=libc++ -pedantic -Wall -O3 です).

f:id:amedama41:20171209101405p:plain

わずかに改善されているのがわかりますね.

安全性の改善

Strand の効率化の説明で見た通り, Boost.Asio の Strand 用の asio_handler_invoke は無限再帰呼び出しを回避するため注意深く設計されています. ユーザ自身が asio_handler_invoke を定義する場合も, このことに注意して設計, 実装する必要があります.

しかしながら, これは意外と容易ではありません. Boost.Asio では様々な関数が asio_handler_invoke を使い, 様々なクラスが自身の asio_handler_invoke をフックしています. それらの関係図全体が見えていないと, 安全な asio_handler_invoke を設計できたとは自信を持って言うことはできないでしょう.

Networking TS では, Associated executor を使用する関数 (非メンバ関数版の dispatch / post) と使用しない関数 (メンバ関数版の dispatch / post) を分離し, 後者を呼び出すことで再帰を発生しないようにしました.

ユーザは周りに気にせず安全に自身の Associated executor を実装できるようになります.

まとめ

Networking TS では Boost.Asio の asio_handler_invoke を Executor に置き換えました.

また, Executor のネストを無視するように設計変更したことで, Strand の効率や, ユーザによるカスタマイズの安全性を改善しました.

とりあえず, Networking TS の Boost.Asio からの変更点については, その 4 までで書きたいことは書けたと思いますので, 本シリーズは今回で終了です.