C++ Advent Calendar 2013 9日目 「Boost.AsioとBoost.Coroutineで脱コールバック」

本記事は C++ Advent Calendar 2013 9日目として書かれました。

はじめに

お久しぶりです。 @fjnli です。
そういえば、去年はC++関係のAdvent Calendarを書いていませんでした。
最近C++をあまり触っておらず、BoostとかC++14とかのフォローがあまりできていませんし、C++力の劣化が著しいと感じる今日この頃です。
C++書きたいです。

さて、本記事では、Boost.AsioとBoost.Coroutineについて取り上げます。AsioとCoroutineを使うと、非同期処理でコールバックを用いることによるプログラムの見通しの悪さを改善できます。サンプルプログラムをベースに、基本的な使い方を紹介していきたいと思います。なお,Boost.AsioとBoost.Coroutineがそれぞれどういう役割をするライブラリなのかについては,長くなってしまいますので本記事では扱いません.ご了承ください.

ボケた事を書いているかもしれませんので,コメントなどありましたら @fjnli までお願いします.Boost.Asioありきで話が進んでおり,他の選択肢との比較が弱い点は今後の課題です.Boost勉強会などで機会があれば,性能について調査し発表するかもしれません.

Version

Boost.CoroutineはBoost 1.53から、Boost.AsioのspawnはBoost 1.54から導入されています。
本記事の範囲ですと、Boost.Coroutineを直接触らないため影響はありませんが、Boost 1.55からCoroutineのインターフェイスがv2となり、以前のものと互換性がなくなっています。注意してください。
また、サンプルプログラムはC++11で書いていますので、C++11対応環境でしかコンパイルできません。

Callback

Asioで非同期処理を行う場合は、非同期処理を登録する際に、処理が完了した後に読んでほしいコールバックを渡します。プログラムの処理を止めないために非同期処理を行うのですから、コールバックを渡すというインターフェイスは自然で理にかなっていると思います。しかしながら、コールバックを用いると、ソースコード上で処理が分散してしまい、プログラムの見通しが悪くなるという問題点があります。

上記の図のような処理があるとします.この処理を,同期的に書くと次の様になります.

A();
read();
B();
write();
C();

C++では,プログラムはソースコードの上から下に向けて実行されますので,直観的でわかりやすい配置です.次にコールバックを用いて非同期処理風に書いてみます.

void after_read() {
  B();
  async_write(after_write);
}

void after_write() {
  C();
}

A();
read_async(after_read);

このようになりました.A, B, Cと順々に処理がしたいだけにもかかわらず,2つの関数が必要となり,処理が分散してしまい,プログラムの可読性が低下しています.では,C++11で導入されたlambda式を使うとどうなるでしょうか.

A();
read_async([&] {
  B();
  write_async([&] {
    C();
  });
});

コールバックを関数として定義するバージョンと比べれば,処理A, B, Cが順に並んでいるため,わかりやすくなりました.しかしながら,lambda式を用いる方式にも問題点があります.同期版のようなフラットな構造ではなく,入れ子な構造となっていることです.例えば,連鎖させたい処理が増えるとソースコードが右に寄ってしまうという問題があります.また,ソースコード上での順番と,実際の実行順番が異なっているという問題があります.サンプルコードに実行される順番をコメントとして追加します.

// (1)
A();
// (2)
read_async([&] {
  // (4)
  B();
  // (5)
  write_async([&] {
    // (7)
    C();
    // (8)
  });
  // (6)
});
// (3)

3, 6番の位置に注目してください.直観に反する実行順序となっています.3, 6番のところに処理を書かなければいい,という考え方も可能ですが,どうしてもこえられない壁があります.それは変数の寿命です.C++にはGCがありませんので,GCがある言語よりもやっかいです.

int x;
// (1)
A();
// (2)
read_async([&] {
  int y;
  // (4)
  B(x); // ここではもうxは死んでいる
  // (5)
  write_async([&] {
    // (7)
    C(y); // ここではもうyは死んでいる
    // (8)
  });
  // (6)
});
// (3)

サンプルコードに変数xとyを追加しました.変数xは4番の位置から利用できませんし,変数yは7番の位置から利用できません.変数xを4番で利用する時には既に3番が実行された後であり,変数xの寿命が切れています.変数を参照するのではなくコピーしたり,すべてをshared_ptrで包んだり,等の解決方法はありますが,どちらもコストがかかることに変りはありません.

最後にCoroutineを使った例を示します.

A();
read_yield();
B();
write_yield();
C();

read_yieldとwrite_yieldは,スレッドをブロックするかわりに,他のcoroutineに処理を譲る(yield)ような実装になっているものとします.Coroutine版は非常にすっきりとしました.Coroutineが銀の弾丸かといわれると,そうではないと思います.プリエンプション式ではないためyieldをしない限り他のCoroutineに処理が移らないという点に注意しなければなりません.readとwriteがCoroutineに対応していなければなりません.うっかり,普通のreadやwriteを呼んでしまうと,Coroutineのメリットがまったくなくなってしまいます.

Boost.AsioでCoroutineの使い方

Boost.AsioでCoroutineを使うには,boost::asio::spawnを使います.spawnに渡したコールバックがCoroutine上で動きます.引数としてyield_contextが渡されますが,これがAsioでCoroutineを使う鍵となるオブジェクトです.

asio::spawn(io_service, [&] (asio::yield_context yield) {
});

そして,Asioの非同期関数のコールバックのかわりに,yield_contextを渡します.

asio::async_write(socket, buf, yield);

async_writeが開始されるとCoroutineがyieldされます.そして,async_writeが完了すると,あたかも同期処理のようにasync_writeから処理が帰ってきます.

処理の最中でエラーが発生すると,boost::system::system_error例外が発生します.例外ではなく,エラーコードを受け取りたい場合は,yield_context::operator []を使います.

boost::system::error_code ec;
asio::async_write(socket, buf), yield[ec]);

このように書くと,async_writeがエラーになった際に,例外が投げられるかわりに,変数ecにエラーコードが格納されます.

サンプルコード

ソケットを使って通信する簡単なプログラムを作成しました.サーバー側でCoroutineによる非同期処理を使用しています.ソースコード全体は Gist にアップロードしています.

サンプルプログラムは,サーバーがクライアントから送られてきた数字を加算していくという内容です.動作例を見て頂いた方がわかりやすいと思います.「>>」で始まる行が入力,「-->」で始まる行がサーバーからのレスポンスを示します.

サーバー側の処理のメインとなるのは,以下のループです (なお,エラー処理はまったくしていません).

for (;;) {
    auto const n = asio::async_read_until(s, buf, "\n", yield[ec]);
    if (ec) break;
    
    /* (略) */
    
    acc += value;

    /* (略) */
    
    asio::async_write(s, asio::buffer(str), yield[ec]);
    if (ec) break;
}

クライアントからデータを受け取り,変数accに足していきます.そして,変数accの値をクライアントに返します (データは文字列としてやり取りをしているため,変換処理が間に入ります).yield_contextを用いることで,コールバックがまったくないことに注目してください.サーバー側は1スレッドで実行されていますが,Coroutineの力で非同期に実行されているため,複数のクライアントからの接続を同時に受けて処理できますし,変数accはCoroutine毎に分離しているため,値がまざるようなこともありません.

まとめ

同期版のコードを別スレッドで実行すれば,見通しも良く,処理もブロックしないため,万事解決のように見えますし,多くの場合ではそれで十分であると思います.Boost.Coroutineのメリットは,カーネルの介在がないため,作成や破棄のオーバーヘッドが小さいことと,スレッド切り替えが高速であることです.スレッド数を増やすと違いが表われると考えられます.スレッドの性能差だけではなく,Asioのオーバーヘッドもありますし,Asioはタイマーやシグナルといったものも統一的に扱えます.したがって,性能だけでなく使い勝手も含めて比較をしなければいけないところですが,今のところそれらのデータはありません.今後の課題です.

宣伝

プログラミングの魔導書 Vol.3に、10ページほどの短い記事ですが寄稿しています。Vol.3のテーマは「並行、並列、分散」となっており、並行世界の魔物を倒すためにはどうすればいいかについての記事が掲載されています。僕はOpenACCというフレームワークについて書いています。OpenACCはGPUのようなアクセラレータで汎用計算を行うプログラミングを記述するためのフレームワークです。OpenACCを用いると、メモリ管理やデータ転送といった面倒な部分をコンパイラに任せられ、プログラムの生産性があがることに加えて、OpenACCコンパイラを持つ異なるアクセラレータ間でのプログラムの可搬性 (Performance Portability) が向上します。仕様が策定されてからあまり時間がたっておらず、発展途上な仕様であり、機能面や各種コンパイラの対応が弱いといった問題はありますが、今後の発展が期待されるフレームワークです。

もし、平行世界の魔物に興味があるならば、リンク先を見て頂けると幸いです。なお、書籍版は予約のみの限定販売なようですので、お早めに…!