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 cat
と void 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); // おや? この呼び出しは? }
このコードを実行すると何が起きるでしょうか? そもそもコンパイルできるのでしょうか? 名前空間を理解したので楽勝ですね. 試してみましょう.
どうですか? 期待通りの答えだったでしょうか?
いろいろ言いたいことはあるかもしれませんが次に進みます.
問題 3
#include <iostream> int main() { std::cout << 'A'; }
今度のコードはとてもシンプルですね.
このコードを見た人は「答えは A だ」と即答するかもしれません.
残念違います.
誰もこのコードを実行したら何が起こるかなんて聞いていません. 焦りは禁物です.
質問はこうです.
「このコード上に現れる <<
とは何か」.
あ, 哲学的に考える必要はありませんよ.
演算子オーバロード
std::cout
は std::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::ostream
と char
でした.
よって 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; // ... そのほかの関数定義 }; }
ここでは話を進めやすくするために Vec2
を my_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::angle
を Vec2
に対して使用するようにできました
*1.
この方法の凄いところは, Vec2 にも others_libs::angle にも一切変更を加えていないということです. つまり, どちらのソースも変更できない場合でもこの方法は適用可能なのです.
アダプタ関数
ここで注目すべきなのは others_libs::angle
中の get_x
と get_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)
- 作者: ハーブサッター,浜田光之,Harb Sutter,浜田真理
- 出版社/メーカー: ピアソンエデュケーション
- 発売日: 2000/11
- メディア: 単行本
- 購入: 9人 クリック: 134回
- この商品を含むブログ (63件) を見る
追記: 上記商品紹介中の Herb Sutter 氏の英語スペルが Harb Sutter になっていますが, 正しくは Herb Sutter です (@kariya_mitsuru さんご指摘ありがとうございます!!).
この記事は 初心者 C++er Advent Calendar 2015 14 日目の記事でした.
io_service の使い方
この記事は 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 等を使用する方が影響は大きいです
initiating function 内で yield_context を呼べるようになった
Boost 1.58.0 以降では, Asynchronous operation の initiating function (async_write_some など) 内で直接 yield_context の handler の呼び出しが可能になりました.
以前まではどうだったかというと,
- spawn 外へのコンテキストスイッチ (initiating function から戻る直前に実行),
- spawn 内へのコンテキストスイッチ (handler の呼び出しによって実行),
の順番で処理が実行される必要がありました.
例えば, io_service::dispatch
は内部で handler を呼び出す可能性があるので上記の順番と逆の処理になります.
そのため, この関数に yield_context を渡すのは今までは未定義動作でしたが, 1.58.0 からは問題なく動作します
(そもそもこの関数に yield_context を渡すこと自体意味はないですが).
#include <boost/asio.hpp> #include <boost/asio/spawn.hpp> namespace asio = boost::asio; int main(int argc, char const* argv[]) { asio::io_service io_service{}; asio::spawn(io_service, [&](asio::yield_context yield) { io_service.dispatch(yield); // OK from Boost 1.58.0 }); io_service.run(); return 0; }
この変更によって, 今まで理論上コンテキストスイッチを必要としなかった部分を省略するといった最適化が可能になります (逆にいうと今までは必ずコンテキストスイッチが発生していました).
// ブロックなしで書き込みが可能な場合や, // コネクションが Abort している場合等は // コンテキストスイッチは必要無い. socket.async_write_some(bufs, yield[ec]);
ただし, Asyncronous Operation の要求 では initiating function 内での handler の呼び出しを禁じているので, 今後このような最適化が行われるかはわかりません.
Boost Asio のドキュメントより一部抜粋.
When an asynchronous operation is complete, the handler for the operation will be invoked as if by:
Constructing a bound completion handler bch for the handler, as described below. Calling ios.post(bch) to schedule the handler for deferred invocation, where ios is the associated io_service.
This implies that the handler must not be called directly from within the initiating function, even if the asynchronous operation completes immediately.
もしかしたら, asio_handler_is_continuation
のような helper 関数を使用して実現するといったことも考えられます.
GCC 4.9.1 で List-initialization の評価順が直っていた
List-initialization におけるリストの各要素の評価順は左から右に評価されるように規定されています. しかし, GCC 4.9.0 以前では List-initialization でコンストラクタが呼び出される場合は正しい順序で引数が評価されていませんでした.
#include <iostream> int f(int i) { std::cout << i << std::endl; return i; } struct S { S(int, int, int) {} }; int main() { std::cout << "1 2 3 の順に出力されるはず" << std::endl; int a[3] = {f(0), f(1), f(2)}; (void)a; std::cout << "出力順は規定されていない" << std::endl; S(f(0), f(1), f(2)); std::cout << "1 2 3 の順に出力されるはず" << std::endl; S{f(0), f(1), f(2)}; }
GCC 4.9.0 での出力::
1 2 3 の順に出力されるはず 0 1 2 出力順は規定されていない 2 1 0 1 2 3 の順に出力されるはず 2 1 0
GCC 4.9.1 ではこのバグが修正されたので, 正しい順序で評価されます.
1 2 3 の順に出力されるはず 0 1 2 出力順は規定されていない 2 1 0 1 2 3 の順に出力されるはず 0 1 2
バグ報告されてから修正されるまで 2 年半以上もかかった息の長いバグでした.
Boost 1.59 を GitHub リポジトリのソースからビルド
いつもは zip か, Homebrew でインストールしていたのを使用していましたが, 今回は GitHub から持ってきたのでメモを残しておきます.
# boost のトップリポジトリを取得 git clone https://github.com/boostorg/boost.git # 各ライブラリのリポジトリを取得 cd boost git submodule init git submodule update # Boost 1.59 をチェックアウト git checkout -b boost-1.59.0 boost-1.59.0 git submodule update # Clang C++11 を使用してビルドするように user-config.jam を編集 touch ~/user-config.jam echo 'using clang : 3.6 : /usr/local/opt/llvm/bin/clang++ : <cxxflags>"-std=c++11 -stdlib=libc++" <linkflags>"-stdlib=libc++" ;' >> ~/user-config.jam # Boost.MPI と Boost.Parallel Graph もビルドするように設定 echo "using mpi ;" >> ~/user-config.jam # ヘッダファイル群の構成 b2 headers # ICU_PATH を指定しないと regex のビルドに失敗するので ICU_PATH を指定してビルド b2 --debug-configuration --layout=versioned -sICU_PATH=/usr/local/opt/icu4c stage 2>&1 | tee stage.log
Boost.Build, OpenMPI, ICU は Homebrew でインストール済みでした.
Coroutine2 を使用するには C++14 でビルドする必要がありそうです.
Boost.Function を使わない SCOPE_EXIT_ALL
修正(2015-02-22) RVO を無効化した場合, 指定した処理が複数回実行されてしまうのを修正しました.
Boost.Scope Exit の実装を覗いてみたら, C++11 (or later) 用の BOOST_SCOPE_EXIT_ALL には Boost.Function が使用されていました. Boost.Function は動的メモリの確保を必要とすると思うので, 使わないでいい方法を考えてみました.
#include <iostream> #include <utility> template <class F> struct guard { explicit guard(F&& f) : f_(std::forward<F>(f)), callable_(true) {} guard(guard&& other) : f_(std::forward<F>(other.f_)), callable_(true) { other.callable_ = false; } ~guard() { if (callable_) f_(); } private: F f_; bool callable_; }; namespace detail { struct guard_gen { template <class F> auto operator=(F&& f) const -> guard<F> { return guard<F>{std::forward<F>(f)}; } }; } // namespace detail // 変数名とかキャプチャの処理はここでは省略する #define SCOPE_EXIT_ALL() \ auto scope_exit = detail::guard_gen{} = []() int main(int argc, char const* argv[]) { { SCOPE_EXIT_ALL() { std::cout << "exit" << std::endl; }; std::cout << "start" << std::endl; } return 0; }
実行結果
start exit