この記事は C++ Advent Calendar 2015 14 日目の記事です.
13 日目は okdshin さんの Boost.Computeでグラボを燃やす - クリアボックス でした.
本記事の内容
本記事では io_service をどう使うかについて書きます. 具体的には, io_service の使用モデルを整理, 比較して, それぞれのモデルを選択する上での指標を示します.
io_service の使用モデルについては以下の Christopher Kohlhoff 氏の発表内容でも触れられていますので こちらも見てみることをお勧めします.
- Threads are an illusion - asynchronous programming with boost::asio - Chris Kohlhoff - YouTube
- Christopher Kohlhoff: Thinking Asynchronously: Designing Applications with Boost.Asio - YouTube
なお, Linux と Mac でしか調査していませんので 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++
単一の io_service を使用しています. 設定次第では Strand を内部で使用するのでスレッドプールモデルで使用可能です.
AZMQ Boost Asio + ZeroMQ
単に 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
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 の結果が以下になります.
io_service-per-スレッドモデルではスレッド数に対してスケールしていますが (スレッド数 3 以上が微妙ですが), スレッドプールモデルではスレッド数を 3 にすると遅くなっています.
今度は空ループ数が 5,000 の場合の結果を見てみます.
どちらのモデルもほぼ同じ結果になりました.
スレッドプールモデルのこの結果は, io_service 上でのスレッド間の同期によるものと考えられます.
ハンドラが軽い場合は時間あたりの io_service へのアクセス時間の割合が大きくなるためスレッド間の競合が頻発します. ハンドラを遅くすると io_service へのアクセス時間よりもハンドラ処理の時間が全体の時間を占めるため, 競合が減って同期コストが小さくなったと考えられます.
一応, Mac 上での実行結果も載せておきます.
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:Linux と Mac では 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 等を使用する方が影響は大きいです