あめだまふぁくとりー

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 までで書きたいことは書けたと思いますので, 本シリーズは今回で終了です.

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

はじめに

前回までに説明した Associated allocator や async_result は少し地味目な変更点でした. 今回説明する Executor は Boost.Asio から Networking TS の中でも大きめの変更になっています.

一般化された io_service と strand

Executor は Networking TS で明示された概念であり, 簡単にいうと io_service と strand で共通するインタフェース部分のことです. つまり, ハンドラを実行する dispatch や post, それらを関数呼び出しの形で行うための wrap がそれに該当します.

Executor はハンドラをどのように呼び出すか定義するもので, 例えば strand の場合は複数のハンドラを直列に呼び出すように制御します.

io_service には Executor の機能の他にも, ハンドラを実際に実行する run などの関数があります. これらはハンドラをどこで実行するかを定義するものとみなすことができます. そのため, これを ExecutionContext と Networking TS では呼びます. ExecutionContext にはその ExecutionContext にハンドラを追加するための Executor が必ず対応付けられています.

io_service は Executor と ExecutionContext という概念に分割されたことで, io_context に名前が変わりました.

/* Boost.Asio */
io_service io_svc{};

/* Networking TS */
io_context io_ctx{};
io_context::executor_type executor = io_ctx.get_executor();

これらの一般化の裏には io_service 以外の実行コンテキストも使えるようにするといった方針があると思われます. 実際に Networking TS では io_context の他にも, 暗黙的に別スレッドでハンドラを実行する system_context と system_executor が定義されています.

また, ジェネリックプログラミングを容易にするという目的もあるかもしれません. これまでは io_service はコピーができない等 strand と異なる部分があったため, これらを取り替えるコードを書くには手間がありました. Networking TS では Executor はコピー可能と定義されているため, 任意の Executor を使用するコードは容易に書けます.

実際に Executor 自身もこの一般化によってメンバ関数の一部を非メンバ関数に移動することができています. 以下ではメンバ関数の変更点を見ていきます.

dispatch / post の非メンバ関数

Boost.Asio では io_service や strand のメンバ関数 dispatch と post は他の非同期関数と同様の振る舞いをしていました. 具体的には,

  • CompletionToken を引数に取り, async_result で戻り値を決めハンドラに変換する,
  • メモリを割り当てる場合は, asio_handler_allocate/deallocate を使用する,
  • ハンドラを呼び出す場合は, asio_handler_invoke を通して呼び出す,

ということをしていました.

Networking TS ではこれらの振る舞いはすべて変更され,

  • CompletionToken ではなくハンドラとしての関数を引数に取り, 戻りの型は常に void である,
  • メモリを割り当てる場合は, 第二引数で渡されたアロケータを使用する,
  • ハンドラの呼び出す場合は, h(args) の形でそのまま呼び出す,

となっています.

Boost.Asio 版と同じ振る舞いをさせたい場合は, 非メンバ関数版の dispatch, post を使用します.

/* Boost.Asio */
auto fut0 = io_service.post(use_future); // OK.

/* Networking TS */
auto fut1 = executor.post(use_future); // NG: CompletionToken は変換されず, 
                                       // 戻り値の型は void.
                                       // さらに第二引数も必要.
auto fut2 = post(executor, use_future); // OK.

メンバ関数版はハンドラを右辺値 (xvalue or prvalue) として受け取れればよいとなっているので, メンバ関数は他の非同期関数から間接的に使用されるものであり, 通常は非メンバ関数を使うと思っていいでしょう.

この変更がどう影響してくるのかは次回に説明します.

defer の追加

また, Networking TS では dispatch と post の他に defer という関数が追加されています. これは機能的には post と全く同じですが, defer は処理を遅延させたいことを明示させるために存在するようです. おそらく, Boost.Asio の asio_handler_is_continuation 辺りの機能が関連しているんではないかと思います.

wrap の非メンバ関数

strand::wrap は知っているかもしれませんが, 実は io_service::wrap も存在していました. Executor に一般化したことで wrap はメンバ関数である必要性がなくなり, bind_executor と呼ばれる非メンバ関数に変更されました. また, 戻り値の型も未指定から executor_binder クラステンプレートの実体になりました.

/* Boost.Asio */
auto f0 = io_service.wrap([]{ std::cout << "asio"; });
f0(); // io_service のコンテキストで呼び出される

/* Networking TS */
auto f1 = bind_executor([]{ std::cout << "Networking TS"; }, executor);
f1(); // executor のコンテキストで呼び出され...?

bind_executor で生成された関数オブジェクトは Boost.Asio の wrap で生成したものと振る舞いが違う点もあり, 同じと思って使用すると期待どおりの結果にならない場合もあります. この点の説明も次回に回します.

strand の一般化

io_service 等の概念を Executor に一般化したことで, strand も一般化されます. Boost.Asio では strand は io_service の実行コンテキストにおいて, ハンドラが直列に実行されることを保証しますが, Networking TS では任意の Executor (に関連する ExecutionContext) に対しての保証をします.

/* Boost.Asio */
io_service::strand s0{io_svc};

/* Networking TS */
strand<io_context::executor_type> s1{io_ctx.get_executor()};

io_service 以外にも strand を使用することができるようになりましたが, それによって strand の振る舞いの一部も変更されています.

strand::dispatch がハンドラを即座に呼ぶ条件

Boost.Asio での strand::dispatch は

  1. io_service::run 等が実行されているスレッドで呼び出された, かつ,
  2. この strand に追加されているハンドラが同時に実行されない状況にある,

という二つの条件を満たす場合, 引数で渡されたハンドラを dispatch の呼び出の中で実行します.

この二つ目の条件は, Boost.Asio のドキュメントには「strand に追加したハンドラの中から dispatch を呼び出した場合」と記載されていますが, 他にも strand にハンドラが一つも追加されていない場合でも満たされます.

Networking TS ではこの後者の場合はなくなります. strand が一般化されたことで, 一つ目の条件を満たされているか判断できなくなったためです (前者の場合が満たされている場合については, 自動的に一つ目の条件も満たされるので判断は不要です). Boost.Asio では strand は io_service が密に結合することで, 前者の条件判定を可能にしていました.

内部ハンドラのメモリ割り当て

strand は, strand に追加されたハンドラを実行するための内部ハンドラを一つだけ保持し, その内部ハンドラをベースとなる Executor に追加することで直列実行を実現しています. このため, ベース Executor がそのハンドラを保持しておくためのメモリ割り当てが必要となります.

Boost.Asio では strand と io_service が密に結合していたため, ハンドラ用のメモリ割り当ては strand オブジェクトを生成したときだけで十分でした (必要なメモリのサイズ, データ構造を strand が知っていた).

Networking TS では strand は任意の Executor に対応する必要があるので, 内部ハンドラ追加のたびにメモリ割り当てが必要です. このことから

  • 動的にメモリを割り当て / 解放するためのコスト,
  • メモリ割り当て失敗による例外に対する例外安全性,

といった課題が予想されます.

幸い, 現状の Networking TS の実装は一度割り当てたメモリをできるだけ再利用するので, 割り当てコストは小さく, 例外が投げられる可能性が発生する機会も多くないようです.

まとめ

Networking TS では io_service と strand の共通部分は Executor に, io_service の残りの部分は ExecutionContext に一般化されました. それに伴い, 各メンバ関数が非メンバ関数になったり, それらの機能の振る舞いにも変更が入りました.

また, io_service を前提としていた strand は Executor に対して一般化されました. 本記事では紹介しませんでしたが, 他にも io_service::work が一般化されていたりします.

Boost.Asio から Networking TS に移行する場合はこれらの変更点がソースコード上に明確に現れることでしょう.

次回は Associated Executor の説明をして, 本記事で説明した dispatch や bind_executor がどう影響するのかを見ていこうと思います.

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 周りについて説明しようと思います.

Boost.Asio でのタイマのキャンセル処理の仕方

先日, マイナンバーのカード管理システムの障害の原因と対応についての発表がありました.

地方公共団体情報システム機構 カード管理システムの中継サーバに生じた障害原因の特定と対応について

その原因 2 では, Windows のタイマをキャンセルしたのにタイムアウト処理が実行されたとあります.

Boost.Asio を使った場合でも同様のことが発生しうるので, 本記事では正しいキャンセルの方法を紹介します.

正しくないキャンセルの方法

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

int main()
{
  using namespace boost::asio;
    
  io_service io_srv{};

  steady_timer timer{io_srv, std::chrono::nanoseconds{1}};
  timer.async_wait([](boost::system::error_code ec) {
    if (ec) {
      std::cout << "timer is cancelled - " << ec << std::endl;
    }
    else {
      std::cout << "timer is not canncelled" << std::endl;
    }
  });

  io_srv.post([&]{
    std::cout << "start work" << std::endl;
    timer.cancel();
    std::cout << "cancel timer" << std::endl;
  });

  io_srv.run();
}

上記のコードを実行すると timer.cancel() の後にタイムアウト処理が実行されてしまいます (タイムアウト用のハンドラに渡すエラーコードがキャンセルを示す値にならない).

melpon.org

これはキャンセル処理実行以前にタイムアウトが発生し, タイムアウトハンドラが実行待ちキューに積まれている場合に発生します. メンバ関数 cancel は実行待ちキューに積まれたハンドラに対しては影響がありません.

複数スレッドで io_service を走らせる場合, このようなことは容易に起こりえることが想像できますが, 上記のコードはシングルスレッドのコードです. シングルスレッドのコードでもキャンセルが間に合わないことがあることに注意してください.

タイマを再利用しない場合

タイマオブジェクトを再利用しない場合, 状態変数を追加せずにキャンセルを検知できます.

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

int main()
{
  using namespace boost::asio;
    
  io_service io_srv{};

  steady_timer timer{io_srv, std::chrono::nanoseconds{1}};
  timer.async_wait([&](boost::system::error_code ec) {
    if (ec || steady_timer::clock_type::now() < timer.expires_at()) {
      std::cout << "timer is cancelled - " << ec << std::endl;
    }
    else {
      std::cout << "timer is not canncelled" << std::endl;
    }
  });

  io_srv.post([&]{
    std::cout << "start work" << std::endl;
    timer.expires_at(steady_timer::time_point::max());
    std::cout << "cancel timer" << std::endl;
  });

  io_srv.run();
}

大きな変更点は二箇所で, 一つはキャンセルの仕方です. メンバ関数 cancel の代わりに, タイムアウト時間を設定する expires_at を使用します. 使用するタイマオブジェクトが取り得る最大時間をこの関数に渡しています.

もう一つの変更点はタイムアウトハンドラでのタイムアウトを判定する条件です. 現在時刻とタイマオブジェクトのタイムアウト時間を比較するようにしています.

キャンセルした場合, タイマオブジェクトには遥か遠い未来の時間が設定されているので, 現在時刻と比較することでキャンセルされたかどうかがわかります. 逆にキャンセルしていない場合は, 現在時刻はタイムアウト時間よりも後になります.

なお, タイムアウト時間を設定する expires_at にはメンバ関数 cancel と同様に タイムアウト待ちのハンドラをキャンセルにするので, エラーコードがキャンセル値になる場合はあります.

この方法で一つ注意しておくことは, タイマオブジェクトの寿命です. タイムアウトハンドラの中でタイマオブジェクトを操作するので, 動的にタイマオブジェクトを生成する場合は そのタイマを指す shared_ptr をハンドラに持たせる必要があるでしょう.

タイマを再利用する場合

一つのタイマオブジェクトを何度も再利用する場合は, 各タイムアウト処理に対して ID を振ってあげるとよいです.

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

int main()
{
  using namespace boost::asio;
    
  io_service io_srv{};

  steady_timer timer{io_srv, std::chrono::nanoseconds{1}};
  std::size_t timer_id = 0;
    
  timer.async_wait([&, current_id = timer_id](boost::system::error_code ec) {
    if (ec || current_id != timer_id) {
      std::cout << "timer is cancelled - " << ec  << std::endl;
    }
    else {
      std::cout << "timer is not canncelled" << std::endl;
    }
  });

  io_srv.post([&]{
    std::cout << "start work" << std::endl;
    ++timer_id;
    timer.cancel();
    std::cout << "cancel timer" << std::endl;
  });

  io_srv.run();
}

タイマオブジェクトごとにタイマ ID を意味するカウンタ変数を定義し, タイムアウトハンドラに現在の ID を持たせ, タイムアウトの判定処理では 保持するタイマ ID と現在のタイマ ID が一致するかチェックします.

キャンセル処理ではタイマ ID をインクリメントしてあげれば, ハンドラが保持する ID と現在の ID に差が生じるのでタイムアウトを検知できます.

まとめ

Boost.Asio でタイマキャンセルを正しく実行する方法を紹介しました.

タイマのキャンセルが期待通りに実行できるかという問題は他のライブラリ, 言語でも起こりえるものだと思います. ドキュメントに目を通したり, 試験用のコードを書いてみるなどして, 理解を深めてからシステムの実装に当たることをおすすめします.

Networking TS の Boost.Asio からの変更点 - その 1: Associated allocator

はじめに

現在 TS に提案されている Networking Library は Boost.Asio をベースにしていますが, Boost.Asio そのままというわけではなく様々な変更が含まれています. 本記事ではそれらの変更点によって何が変わったのかを説明しようと思います. 最初は Associated allocator について説明します.

なお, 本記事の内容は何かの議論等の議事録を見て書いたということはなく, ただの筆者の考えで誤りがある可能性が多分にありますのでその点注意してください.

本記事で参照する Networking Library の文書は, Networking Library (Revision 7) です.

Boost.Asio でのメモリアロケーション

Boost.Asio では非同期関数 (socket::async_write_some や timer::async_wait など) を呼び出した際に, 引数で渡したハンドラ等を保持するためにメモリ確保を行います. そして, 非同期処理が完了してハンドラが呼び出される直前で確保したメモリを解放します.

このメモリの確保と解放はそれぞれ asio_handler_allocate と asio_handler_deallocate という非メンバ関数によって行われます. どちらの関数も引数にハンドラを指すポインタを取るので, これらのメモリ確保/解放は ADL によってカスタマイズ可能となっています*1.

Networking TS でのメモリアロケーション

Networking TS でも Boost.Asio のときと同様にメモリの確保/解放が行われます. しかし, 前述した asio_handler_allocate/deallocate は存在しておらず, 代わりに Associated allocator を使用してメモリの確保/解放を行います.

Associated Allocator とはハンドラに関連付けされたアロケータのことで, 具体的には associated_allocator というクラステンプレートの static メンバ関数 get で取得できるアロケータです.

my_handler h;
auto alloc = associated_allocator<my_handler>::get(h);

ユーザは以下の方法でアロケーションをカスタマイズできます.

  • associated_allocator を自身が作成したハンドラの型で特殊化する, または,
  • ハンドラの型にメンバ関数 get_allocator と allocator_type というネスト型を定義する.

カスタマイズしない場合は std::allocator が使用されます.

Associated allocator に変更された理由

Boost.Asio におけるハンドラの CopyConstructible の制約

Boost.Asio ではハンドラの型はコピーコンストラト可能である必要があると ドキュメント に記載されています. この記載はムーブがない時代に書かれたからではありません.

このコピーの理由を理解するために非同期関数の内部でどのように asio_handler_allocate/deallocate を使用しているかを見てみます. 以下がそのコードです*2.

handler h;
typedef ... inner_handler;
void* p = asio_handler_allocate(sizeof(inner_handler), std::addressof(h));
try {
  inner_handler* inner_h = new(p) inner_handler(h, ...); // コピーコンストラクト
}
catch (...) {
  asio_handler_deallocate(p, std::addressof(h)); // ここで h を必要としている
  throw;
}

inner_handler 型は非同期関数の内部で使用される型で, メンバ変数としてその非同期関数に必要な情報 (Socket の書き込みバッファなど) とユーザが指定したハンドラを保持しています. そしてこのメンバ変数のハンドラはコピーコンストラクトによって構築されます.

コピーの代わりにムーブを使う場合を考えてみます. inner_handler のコンストラクタに h をムーブで渡すと h の状態は不定になります. そして, もし, inner_handler のコンストラクタで例外が投げられると, asio_handler_deallocate は状態が不定な h にアクセスすることになってしまいます. 例外安全の基本保証を満たすにはムーブではなくコピーで inner_handler を構築する必要があるのです.

上記のことはハンドラ呼び出し直前のメモリ解放処理にも当てはまります.

bool need_deallocate = true;
try {
  handler h(inner_h->handler); // コピーコンストラクト
  need_deallocate = false;
  inner_h->~inner_handler();
  asio_handler_deallocate(inner_h, &h);
  h(args);
}
catch (...) {
  if (need_deallocate) {
    void* p = inner_h;
    asio_handler_deallocate(p, &inner_h->handler); // inner_h->handlr が必要
  }
  throw;
}

Associated allocator の場合

Associated allocator を使った場合どうなるか見てみましょう.

handler h;
auto alloc = get_associated_allocator(h);
using inner_handler = ...;
void* p = alloc.allocate(sizoef(inner_handler));
try {
   auto inner_h = new(p) inner_handler(std::move(h), ...); // ムーブコンストラクト
}
catch (...) {
   alloc.deallocate(p); // h は必要ない
   throw;
}

Networking TS の場合, メモリ管理の責務をハンドラオブジェクトから別オブジェクト (Associated Allocator) に切り出しています. そのため, メモリを解放する際にはもうハンドラ h は必要ありません. このため, inner_handler の構築時に h をムーブしても問題ないことになります.

アロケータ内部の作りがハンドラに依存している場合は再び同じ問題にぶつかります. しかし, メモリ確保にハンドラ h のアロケータを使用して, ハンドラ呼び出し直前の解放処理では inner_h->handler のアロケータを使用するのでそのようなアロケータの実装は考えにくいです (確保と解放のアロケータそれぞれが異なるオブジェクトに依存することになる).

まとめ

Networking TS ではカスタムメモリアロケーションの仕組みにアロケータという層を挟むことで, メモリの確保/解放処理とハンドラの状態とを分離することに成功しました. これにより, 従来ハンドラの型はコピーコンストラクト可能であるという制約を, ムーブコンストラクト可能にまで緩和しました.

Boost.Asio ではハンドラに shared_from_this() で生成した shared_ptr を持たせるケースが多々あるので, shared_ptr のコピーを避けられるという意味でこの変更は実行速度の面でも意味のあるものになるのではないかと思います.

次は async_result の変更点について説明しようと思います.

*1:Custom Memory Allocation - 1.60.0

*2:実際の Boost.Asio のコードでは try-catch ではなく RAII を使用しています

名前解決から始める C++

この記事は 初心者 C++er Advent Calendar 2015 14 日目の記事です.

いきなりですが次のコードを見てください.

#include <iostream>

struct cat
{
  std::string name;
};

void print_name(cat const& neko) // const 参照渡し!
{
  std::cout << "this cat name is " << neko.name << std::endl;
}

int main()
{
  cat neko{"katsuo"}; // 波括弧初期化!!

  print_name(neko);
}

const 参照渡しと波括弧初期化はそれぞれ 8 日目2 日目の記事で説明があったので, ここまでの記事を読んだ人なら上記のコードはわかるはずです (struct cat をちゃんとしたクラスにしようと思いましたけど長くなるのでやめました. クラスについては 5 日目の記事を参照しくてださい).

ではここで問題です. このコードを実行すると何が起きるでしょうか?

さっそく回答です.

this cat name is katsuo

どうですか? 難しかったでしょうか?

問題 2

それでは続いて次のコードです.

#include <iostream>

namespace animals { // 名前空間

  struct cat
  {
    std::string name;
  };

  void print_name(cat const& neko) // const 参照渡し!!
  {
    std::cout << "this cat name is " << neko.name << std::endl;
  }

} // namespace animals

int main()
{
  animals::cat neko{"yamada"}; // 波括弧初期化!!!!

  print_name(neko); // おや? この呼び出しは?
}

struct catvoid print_name(cat const&)animals という名前空間に入れました.

それではこのコードを実行すると何が起きるでしょうか? と, その前に名前空間について少しだけ説明しましょう.

名前空間とは?

変数や関数等の名前をまとめる機構です. クラスでも名前をまとめることができますが, クラスの場合はそのクラスの役割に必要なものだけをまとめるのが普通です.

一方, 名前空間はもっと緩い意味で名前をまとめます. 例えば, 標準ライブラリの std のように特定のライブラリをまとめたものだったり, 関数の内部詳細をまとめたものだったりします.

名前空間の中と外ではスコープが違うので同じ名前のエンティティを定義できます.

int g; // グローバル変数の定義

namespace ns {

  int g; // スコープが違うので同じ名前の変数を定義できる

}

名前空間の中の名前を参照する場合はスコープ解決演算子 :: を使用します. これは <名前空間の名前>::<参照されるエンティティの名前> のように書きます.

namespace ns {

  void f(int i) {}

}

int main()
{
  f(3); // コンパイルエラー! X(
  ns::f(3); // OK! :)
}

問題 2 のコードでも main 関数から struct cat を参照する場合は animals::cat とスコープ解決演算子を使用していますね.

コードの続き

名前空間がなんとなくわかったところで先ほどのコードの続きです.

int main()
{
  animals::cat neko{"yamada"}; // 波括弧初期化!!!!

  print_name(neko); // おや? この呼び出しは?
}

このコードを実行すると何が起きるでしょうか? そもそもコンパイルできるのでしょうか? 名前空間を理解したので楽勝ですね. 試してみましょう.

melpon.org

どうですか? 期待通りの答えだったでしょうか?



いろいろ言いたいことはあるかもしれませんが次に進みます.

問題 3

#include <iostream>

int main()
{
  std::cout << 'A';
}

今度のコードはとてもシンプルですね.

このコードを見た人は「答えは A だ」と即答するかもしれません.


残念違います.


誰もこのコードを実行したら何が起こるかなんて聞いていません. 焦りは禁物です.

質問はこうです.

「このコード上に現れる << とは何か」.

あ, 哲学的に考える必要はありませんよ.

演算子オーバロード

std::coutstd::ostream 型を持つオブジェクトです. これにちなんで << をストリーム演算子と呼ぶ書籍もあったりします (多分).

というわけで <<演算子っぽいです. これはかなりいい線いっています. std::cout'A' を整数に置き換えてみましょう.

int main()
{
  8 << 2;
}

あ, これシフト演算子.

正確には上記コードの << は右シフト演算子演算子オーバロードした関数です.

この関数は以下のように定義されています (いろいろ正確でない部分がありますがここではこれで十分です).

namespace std {

  ostream cout;

  std::ostream& operator<<(std::ostream& os, char c)
  {
    // ... output c to stdout
  }

}

演算子 @ の使用は operator@ の呼び出しに置き換えられます (ここで @ は何かの演算子).

問題 4

上記のオーバロード演算子の定義と質問のコードとつなげてみましょう.

namespace std {

  ostream cout;

  std::ostream& operator<<(std::ostream& os, char c)
  {
    // ... output c to stdout
  }

}

int main()
{
  std::cout << 'A';
}

そしてシフト演算子を置き換えると関数の呼び出しに置き換えると...

namespace std {

  ostream cout;

  std::ostream& operator<<(std::ostream& os, char c)
  {
    // ... output c to stdout
  }

}

int main()
{
  operator<<(std::cout, 'A'); // 置き換え!
}

どことなく問題 2 のコードに似ていますね. それでは最後の問題です.

「問題 3 のコードでなぜ 'A' が出力されると思った?」

置き換え後は, std::operator<<(std::cout, 'A') ではなく operator<<(std::cout, 'A') になります. なぜなら, << のどこにも std なんて付いていないのですから.

このコードはまちがいなく問題 3 のコードと等価ですが, 名前空間の修飾なしに関数呼び出しを行っています.

つまり問題 2 のコードがコンパイルエラーになると思うのなら, 問題 3 のコードもコンパイルエラーになると思うべきなのです.

ADL (Argument-Dependent name Lookup)

この謎を解くのが ADL です.

ADL とは簡単にいうと, 引数の型と同じ名前空間にある関数も呼び出しの対象になりますよというものです.

問題 2 のコードでは print_name の引数の型は animals::cat です. なので ADL によって名前空間 animals 内の print_name も呼び出し対象に含まれます.

問題 4 のコードでは引数の型は std::ostreamchar でした. よって ADL によって名前空間 std 内の operator<< も呼び出し対象になるのです.

これが上記二つのコードがコンパイルエラーにならず実行できた理由です.

ちなみに組み込み型には名前空間がないので char 型の引数は特に影響しません.

また, ADL が行われるのは関数の名前にスコープ解決演算子が付いてないときだけです.

namespace animals {

  struct cat;

  void print_name(cat const& neko);

}

namespace ns {

  void print_name(int a); // 引数の型は int

}

void print_name(int a); // 引数の型は int

int main()
{
  animals::cat neko{"norisuke"};

  ns::print_name(neko); // コンパイルエラー!! ADL なし

  ::print_name(neko); // コンパイルエラー!! この場合も ADL なし

  print_name(neko); // OK! ADL あり
                    // 引数の型から ::print_name ではなく
                    // animals::print_name を呼び出す
}

このように C++ では一見コンパイルエラーに見える関数呼び出しも ADL によって問題なく実行できる場合があります.

問題 4 で示した通り, 演算子オーバロードを自然な形で呼び出すことができるのは ADL のおかげなのです.

ADL によるアルゴリズムのカスタマイズ

ここからは中級者への一歩. ADL を使ったアルゴリズムのカスタマイズを見ていきます.

以下のコードを見てください.

namespace others_libs {

  // 座標取得関数
  template <class Vec>
  double get_x(Vec const& v) { return v[0]; }
  template <class Vec>
  double get_y(Vec const& v) { return v[1]; }

  // 二つのベクトルのなす角を計算
  template <class Vec>
  double angle(Vec const& v1, Vec const& v2)
  {
    double const x1 = get_x(v1);
    double const y1 = get_y(v1);
    double const length1 = std::sqrt(x1 * x1 + y1 * y1);

    double const x2 = get_x(v2);
    double const y2 = get_y(v2);
    double const length2 = std::sqrt(x2 * x2 + y2 * y2);

    return std::acos(
      (x1 * x2 + y1 * y2) / (length1 * length2));
  }

}

これは誰かが提供してくれた二つのベクトルのなす角を計算するライブラリです.

このライブラリではベクトルの各座標は配列のようにインデックスでアクセスできることを想定してます. なので, ベクトルを表現する型が配列や std::vector などインデックスアクセスがサポートされる任意の型で使用可能です.

ベクトルといえば 5 日目 の記事で Vec2 クラスを定義しました.

namespace my_libs {

    struct Vec2
    {
        double x;
        double y;

        // ... そのほかの関数定義
    };

}

ここでは話を進めやすくするために Vec2my_libs という名前空間の中に入れました.

ベクトルを使用するなら配列よりも, ベクトルとしてきちんと定義した型を使用したいですね. しかしながら, Vec2 はインデックスアクセスをサポートしていないため others_libs::angle と組み合わせることができません.

Vec2 をインデックスアクセスできるように変更すべきなのでしょうか?

いいえ. この問題を ADL が解決してくれます.

namespace my_libs {

    struct Vec2
    {
        double x;
        double y;

        // ... そのほかの関数定義
    };

}

namespace my_libs {

    // Vec2 と同じ名前空間に定義
    double get_x(Vec2 const& vec) { return vec.x; }
    double get_y(Vec2 const& vec) { return vec.y; }

}

Vec2 と同じ名前空間に関数を追加するだけで others_libs::angleVec2 に対して使用するようにできました *1.

この方法の凄いところは, Vec2 にも others_libs::angle にも一切変更を加えていないということです. つまり, どちらのソースも変更できない場合でもこの方法は適用可能なのです.

アダプタ関数

ここで注目すべきなのは others_libs::angle 中の get_xget_yアダプタとして機能していることです.

これらの関数の呼び出しではスコープ解決演算子を使用していません. つまり, ADL を利用してカスタマイズすることを想定しているのです *2.

このように, ADL はアルゴリズムのカスタマイズポイントとして利用ができます.

この ADL によるカスタマイズポイントは C++11 以降の範囲 for ループや STL, Boost ライブラリ等広く利用されています. そしてその際たる例が, 拡張可能なグラフライブラリとして設計された Boost.Graph なのです!! (本記事の目的達成).

まとめ

本記事ではもともとは shared_ptr のコストについて書く予定だったのですが全然初心者っぽくなかったので, Boost.Graph の宣伝も兼ねて ADL の説明をしました.

ADL にはプラスの面だけでなくマイナスの面もありますが, それは誰かが説明してくれるでしょう.

ADL についてより理解を深めたい方は Exceptional C++ の第 5 章も読むとよいと思います *3... と思ったらこの本絶版? 丸善さん再出版お願いしますー.

Exceptional C++―47のクイズ形式によるプログラム問題と解法 (C++ in‐Depth Series)

Exceptional C++―47のクイズ形式によるプログラム問題と解法 (C++ in‐Depth Series)

  • 作者: ハーブサッター,浜田光之,Harb Sutter,浜田真理
  • 出版社/メーカー: ピアソンエデュケーション
  • 発売日: 2000/11
  • メディア: 単行本
  • 購入: 9人 クリック: 134回
  • この商品を含むブログ (63件) を見る

追記: 上記商品紹介中の Herb Sutter 氏の英語スペルが Harb Sutter になっていますが, 正しくは Herb Sutter です (@kariya_mitsuru さんご指摘ありがとうございます!!).


この記事は 初心者 C++er Advent Calendar 2015 14 日目の記事でした.

*1:Vec2 がグローバル名前空間に定義されている場合, 同様にグローバル名前空間に関数を追加すれば ADL が機能します.

*2:逆にカスタマイズを想定しない場合は名前空間で関数名を修飾すべきです.

*3:この本では ADL は Koenig の自動照合と呼ばれています

io_service の使い方

この記事は C++ Advent Calendar 2015 14 日目の記事です.

13 日目は okdshin さんの Boost.Computeでグラボを燃やす - クリアボックス でした.

本記事の内容

本記事では io_service をどう使うかについて書きます. 具体的には, io_service の使用モデルを整理, 比較して, それぞれのモデルを選択する上での指標を示します.

io_service の使用モデルについては以下の Christopher Kohlhoff 氏の発表内容でも触れられていますので こちらも見てみることをお勧めします.

なお, LinuxMac でしか調査していませんので Windows 等を使用している方は 本記事の内容は参考程度にとどめておいてください.

io_service の使用モデル

io_service の使用モデルは以下の四つに大別できます.

  • 単一スレッドで単一の io_service を使用する.
  • 複数のスレッドで io_service を共有する.
  • スレッドごとに io_service を用意する.
  • 複数のスレッドで複数の io_service を使用する.

以降の節ではこれらのモデルを簡単に説明していきます. なお最後のモデルは真ん中二つのモデルの組み合わせなので本記事では省略します.

シングルスレッドモデル

すべてのはじまり.

asio::io_service io_service{1};
io_service.run();

このモデルでは data race 等を意識する必要がないので他のモデルに比べてコードを単純にできます *1.

また, io_service のコンストラクタconcurrency_hint を 1 に指定することで, シングルスレッドでは不要な排他処理を減らしてパフォーマンスを少しだけ向上させることができます *2.

時間のかかる処理

このモデルで注意すべきことは各ハンドラの実行時間を短くすることです. 重い処理はワーカースレッドを用意してそちらで実行しましょう.

Christopher Kohlhoff 氏の発表でも紹介されていますが, これには asio::io_service::work が使用できます.

void handler()
{
    auto work = asio::io_service::work{io_service};
    // work を保持させる
    worker_thread.post([work]{
        long_running_task(); // 何か時間がかかる処理
        work.get_io_service().post(next_task);
    });

    // io_service のキューが空でも work が存在するので
    // io_service::run は終了せず次のハンドラ (next_task) を待ち続ける.
}

スレッドプールモデル

このモデルで使用する io_service はひとつです. このインスタンスメンバ関数 io_service::run をスレッドプール中のスレッドが呼び出します.

asio::io_service io_service{};
std::vector<std::thread> thread_pool{};
for (auto i = std::size_t{0}; i < nthreads; ++i) {
    thread_pool.emplace_back([&io_service]{
        io_service.run(); // invoke run for each thread
    });
}

このモデルは Boost.Asio の Strand のチュートリアル にも載っているので, 見たことある方も多いのではないかと思います.

このモデルの利点として, シングルスレッドモデルに比べてハンドラの実行時間を意識する必要がないことが挙げられます. ハンドラの処理が少し重くても後続のハンドラは待たされることなく別スレッドで実行されます.

また, スレッドプールのスレッド数を 1 にした場合, io_service::run が別スレッドで実行されるという点を除けばシングルスレッドモデルと完全に一致します. 逆に言うと, シングルスレッドモデルからこのモデルへの移行は容易ということです.

一方, 基本的に Asio で提供される I/O Object はスレッドセーフではないため, Strand または Mutex を使用してスレッド間の同期を行う必要があります.

Mutex による I/O Object のガード

I/O Object は asio::async_write 等の composed operation が存在するので 普通に I/O Object の使用の前後で Mutex を Lock/Unlock するだけでは不十分です.

Mutex で I/O Object を保護する例は Christopher Kohlhoff 氏の発表で紹介されています (https://github.com/boostcon/2011_presentations/raw/master/mon/thinking_asynchronously.pdf の pp.95-98).

氏の発表では std::mutex などの通常の Mutex を使用する例が紹介していましたが, 本記事では再帰的な Lock が可能な Recursive Mutex の使用をお勧めします *3.

なぜ Recursive Mutex なのかというと, Asio のハンドラ呼び出し機構の都合上, 再帰的な Lock を避けられないケースが存在するからです.

しかし Strand を使用した方が手間もなくスレッドのブロックも抑えることができるので, 基本的に I/O Object と Mutex を組み合わせる必要はないです *4.

io_service per スレッドモデル

このモデルではスレッド毎にひとつの io_service が存在します.

std::vector<asio::io_service> io_service_pool(nio_services);
std::vector<std::thread> threads{};
for (auto& io_service : io_service_pool) {
    threads.emplace_back[&io_service]{ // bind each io_service
        io_service.run();
    }
}

このモデルの場合, 各 I/O Object はひとつのスレッドに属することになるので, 基本的に Strand 等を使用した同期は必要ありません *5.

シングルスレッドモデルの説明で上がった concurrency_hint も適用できますし, io_service の数 (スレッド数) を CPU 数に合わせればパフォーマンス的にはスレッドプールモデルよりも良さそうです.

一方, シングルスレッドモデルと同様にハンドラの実行時間に注意する必要があります.

スレッドプールモデル vs io_service per スレッドモデル

ライブラリ / フレームワークの比較

スレッドプールモデルと io_service per スレッドモデルはどちらもマルチスレッドを組み合わせたモデルですが, どちらを採用すべきなのでしょうか?

Boost.Asio を使用しているライブラリ / フレームワークの実装を少し調べてみました.

なお本記事の調査では, Strand を使用している == スレッドプールで使用する意思があるとしています (Strand の使用状況に加えて, io_service::run を複数スレッドで呼び出せるかはチェックしています). しかし, Strand を正しく使用していない可能性があるので, これらのライブラリを使用する際は各自でドキュメントや実装を参照してください.

cpp-netlib 同期版サーバ

cpp-netlib/cpp-netlib · GitHub

単一の io_service を使用しています. Strand を内部で使用しているので*6, スレッドプールモデルで使用可能です.

cpp-netlib 非同期版サーバ

I/O に単一の io_service, ハンドラの実行に別の io_service をスレッドプールで使用しています (いわゆる Half-Sync/Half-Aync パターン). Strand を内部で使用しているのでスレッドプールモデルで使用可能です.

ただし, 書き込み処理にバグがありますし, ちゃんと使い方を知らないと使うのは難しい感じがしました.

WebSocket++

zaphoyd/websocketpp · GitHub

単一の io_service を使用しています. 設定次第では Strand を内部で使用するのでスレッドプールモデルで使用可能です.

AZMQ Boost Asio + ZeroMQ

zeromq/azmq · GitHub

単に I/O Object を提供しているだけなので任意のモデルでも使用可能 (のはず) です. 実装見た感じシングルスレッドで使用した方が幸せそうな感じがしました.

Cinder-Asio

BanTheRewind/Cinder-Asio · GitHub

単一の io_service を使用しています. Strand を内部で使用しているのでスレッドプールモデルで使用可能です. 多分 socket の書き込み処理がバグっています.

Simple-Web-Server

eidheim/Simple-Web-Server · GitHub

スレッドプールモデルを使用しています.

Boost.HTTP

BoostGSoC14/boost.http · GitHub

単に I/O Object を提供しているライブラリです. のくせに, Strand と組み合わせることもできないのでシングルスレッド相当でしか使えません *7.

nghttp2 - libnghttp2_asio

tatsuhiro-t/nghttp2 · GitHub

io_service-per-スレッドモデル を使用しています.

性能評価

こうやって見てみるとスレッドプールしか意識していないライブラリ / フレームワークが圧倒的に多いです.

多くのライブラリ / フレームワークがスレッドプールモデルを選択しているなら, スレッドプールを選択すればよいと考えるかもしれません.

が, その前に両モデルのパフォーマンスを計測してみてましょう. 本記事ではいくつかのケースで両モデルのパフォーマンスを計測してみました.

以下の計測には, 物理マシン Macbook (Early 2015), VirtualBox 上の Ubuntu 14.04 LTS (メモリ 1G, CPU x 4) を使用しました. コンパイラは clang 3.6, -std=c++11 -stdlib=libc++ -pedantic -O3 -DNDEBUG をオプションに指定しました.

スレッド数と 1 ハンドラあたりの実行時間の関係

このテストケースではあらかじめ io_service に 1,000,000 個のハンドラを登録し, io_service::run が終了するまでの時間を計測します. io_service-per-スレッドモデルの場合は合計 1,000,000 個のハンドラを各 io_service に均等に割り振っています.

計測に使用したコードは こちら になります.

ハンドラの実行時間を調整するためにハンドラ内で空ループを回しています.

空ループ数が 1,000 の結果が以下になります.

f:id:amedama41:20151125215044p:plain

io_service-per-スレッドモデルではスレッド数に対してスケールしていますが (スレッド数 3 以上が微妙ですが), スレッドプールモデルではスレッド数を 3 にすると遅くなっています.

今度は空ループ数が 5,000 の場合の結果を見てみます.

f:id:amedama41:20151125215059p:plain

どちらのモデルもほぼ同じ結果になりました.

スレッドプールモデルのこの結果は, io_service 上でのスレッド間の同期によるものと考えられます.

ハンドラが軽い場合は時間あたりの io_service へのアクセス時間の割合が大きくなるためスレッド間の競合が頻発します. ハンドラを遅くすると io_service へのアクセス時間よりもハンドラ処理の時間が全体の時間を占めるため, 競合が減って同期コストが小さくなったと考えられます.

一応, Mac 上での実行結果も載せておきます.

f:id:amedama41:20151125215051p:plain f:id:amedama41:20151125215104p:plain

Mac の mutex の実装はスピンロックを使用していないらしく, 同期のコストはかなり大きいです.

その他のテストケース

このほかにも producer/consumer や, ハンドラの中でハンドラを登録していくケースも試してみましたが, すべて似たような結果になりました.

結果として, io_service の同期コストは思ったよりも大きいことがわかりました.

まとめ

以下まとめです.

  • シングルスレッドで十分ならシングルスレッドモデルにする.

    • シングルスレッドで十分なパフォーマンスが得られるなら無駄に複雑にする必要はありません.
    • 時間のかかる処理はワーカースレッドで実行しましょう.
  • スレッドプールモデルよりも io_service-per-スレッドモデルを優先する.

    • 最大限スループットを出すならこのモデルです.
    • 色んなライブラリがスレッドプールを選択しているけど気にする必要はありません (nghttp2 は良く分かってそうな感じ).
  • スレッドプールモデルはハンドラの実行時間が短くない場合に検討する.

    • io_service の同期コストは小さくありません.
    • cpp-netlib の非同期サーバはスレッドプールも io_service 間のメッセージパッシングもしていますが, これは時間のかかる処理をすることを前提にした設計だからです.

io_service の同期コストはパフォーマンスにとって非常に影響が出ます.

実際, 実行時間が短いハンドラが主なサーバに cpp-netlib の真似をして Half-Sync/Half-Aync パターンの実装にしたら スレッド数 1 が最高スループットという残念な結果になったこともありました しかしその後 io_service-per-スレッドモデルに変更したらスループットが 2 倍以上向上しました *8.

io_service を使用する際は, 対象の特性をよく理解し用法, 用量を守って正しくお使いください.


この記事は C++ Advent Calendar 2015 14 日目の記事でした.

15 日目は hira_kuni_45 さんの記事です.

*1:非同期オペレーションのキャンセルを正しく行うのは思ったより単純ではないですが

*2:LinuxMac では 1 以外の値を指定する場合, 値の違いに特に意味はありません

*3:Recursive Mutex など必要ないと言ったがあれは嘘だ

*4:Recursive Mutex など必要ないと言ったがあれは嘘だと言ったがあれは嘘だ

*5:ただし, I/O Object が属さないスレッドからその I/O Object を操作する場合は, io_service::post 等を経由して操作する必要はあります

*6:多分この Strand は不要

*7:実装がいろいろおかしいので多分作者は Boost.Asio を正しく理解していないと思われます

*8:レイテンシに関してはモデルよりも io_service::poll 等を使用する方が影響は大きいです