非同期プロトコルのクライアント
非同期プロトコルとは、サーバーから返ってくる応答が、必ずしも要求した順番通りに返ってこないプロトコル(ソース無し。オレオレ定義)。
順不同で返ってくる応答と要求を対応づけるのはクライアントの仕事で、典型的には要求の中にシーケンス番号を入れておき、サーバーは要求と同じシーケンス番号を応答の中にも含める。
例:MessagePack-RPC
非同期プロトコルの特徴:
- イベント駆動型のサーバーの場合、サーバーの実装が簡単になる
- 同期プロトコルだと順番を揃えてから返さないといけない。サーバーの実装が(要求1つに対してスレッドを割り当てて処理するのではなく)ソケット1つに対してスレッドを割り当てて処理する方式だとあまり関係なくて、特に実装は簡単にならない。
- 処理が重い要求と軽い要求を続けて送っても、重い要求に詰まって後の応答が返ってこなくなることが無い
- 同期プロトコルだと、応答を送り返すにはその前の応答を全部返してからでないと送れない。ネットワーク帯域を効率よく使い切れなかったり、すべての応答を受け取り終わるまでの遅延が増える可能性がある。
- クライアントの実装が複雑になる
- これからこの件について。
以下 分類・名前は勝手に付けた。
コールバック型API
イベント駆動型のサーバーを実装する延長で考えると、応答をコールバック関数で受け取る方式を思いつく。
コールバック関数(...)
{
// 応答が返ってくると呼ばれる
いろいろ処理の続き...
}
いろいろ処理...
call(内容, コールバック関数, コールバック関数に引き継ぐ変数);
call(内容, コールバック関数, コールバック関数に引き継ぐ変数);
call(内容, コールバック関数, コールバック関数に引き継ぐ変数);
要求を送るたびに関数が途切れるので、アプリケーションが非常に書きにくい。
クライアントから受け取った要求を別のサーバーに転送するサーバー(プロキシ)を実装するには便利な方式。逆に言えば非同期プロトコルだと(イベント駆動で並列性の高い)プロキシはとても実装しやすい。
基本的にはマルチスレッドなサーバーで使う方式。
send/join型API
sendで送った要求に対する応答を、joinで待ち受ける(同期する)方式。非同期プロトコルの性能上の利点を最大限に活かせるAPI。
要求1を送信 // 要求1 = send(...); 要求2を送信 // 要求2 = send(...); 何か別の処理を行う... 要求1を待つ // 要求1.join(); 要求1を使った処理を行う... 要求2を待つ // 要求2.join(); 要求2を使った処理を行う...
要求2の応答が返ってくるのが遅れても、要求1を使った処理をしている間に届けば遅延を隠蔽できる = 性能が向上する。
要求を1つしか送らないなら、
send(...).join();
と書けばいいだけで、(同期プロトコルのAPIと同じようにでも使えるという意味で)使い勝手も良い。
memcachedクライアントの cloudy*1 は この方式のAPIを実装 している。memcachedのバイナリプロトコルにはクライアントから送った変数をそのまま返してくれるフィールドがあり、そこにシーケンス番号を入れている。
この方式を実装するには、前回のネットワークプログラムのI/O戦略 で シングルスレッド・イベント駆動 のところに書いた方法を使える:
send(内容) { 要求構造体を生成 // 要求 = new 要求構造体(); // 要求.result = nil; シーケンス番号を生成 // シーケンス番号 = ++roud_robin_variable; 要求構造体をテーブルに登録 // テーブル[シーケンス番号] = 要求; サーバーに要求を送る // write(ソケット, 要求); イベントハンドラを登録 // イベントループ.add(ソケット, 要求); return 要求; } join(要求) { 要求 に対するの応答が届いていない間 { // while( 要求.result == nil ) { イベントループを回す... // イベントループ.next(); } // } return 要求.result; } イベントハンドラ(...) { プロトコルを解析 応答が1つ分届いたら { テーブルから要求構造体を取り出す // 要求 = テーブル[シーケンス番号] && テーブル.remove(シーケンス番号); 要求構造体に結果かエラーを埋める // 要求.result = 結果かエラー; } }
関数呼び出しが多かったり、応答を受け取るたびにテーブルを引く操作が入るので、1つの要求の処理が非常に軽いサービスだとクライアントのCPU負荷が無視できないほど高くなってしまう可能性がある。
でもがんばって最適化すればだいぶ改善できると思う。登録されているイベントハンドラが1つだけならpollせずにreadでブロックする + SO_RCVTIMEOを使う、要求構造体はヒープではなくスタック(sendの呼び出し元)に置くなど。
scatter/gather型API
send/join 方式も良いが、そこまで細かく指定したいという状況はたぶんあまり多くない。たくさんsendして一気に待ち受けられれば十分で、そっちの方が便利だという方式。
// プール = new イベントループ(); 要求1を送信 // プール.add( send(...) ); 要求2を送信 // プール.add( send(...) ); 何か別の処理を行う... すべての応答が返ってくるのを待つ // プール.join(); 要求1や2を使った処理を行う...
この方式はsend/join型 API の延長で実装できる。
cloudyはこの方式のAPIも実装している。
send/fetch型API
要求と応答の対応付けをクライアントライブラリの中に隠蔽せずに、アプリケーション側で対応づける方式。
要求1を送信 // id1 = send(...); 要求2を送信 // id2 = send(...); 2回ループ { // for(i=0; i < 2; ++i) { 応答を受信 // id, 内容 = fetch(); 要求1の応答なら: // if(id == id1) { ... // id1 = 0; ... 要求2の応答なら: // } else if(id == id2) { ... // id2 = 0; ... それ以外なら // } else { エラー // break; // } } // }
クライアントライブラリの実装は簡単になる(テーブルを管理しなくて済む)が、アプリケーションは複雑になる。アプリケーションの知識を使って最大限に最適化できる可能性があるが、クライアントでそんなに最適化するのは面倒なのでやらない気がする。
早く届いた応答から順に処理したい場合には有効。
libmemcached の memcached_mget / memcached_fetch はこの方式…のように見えるが、memcachedのプロトコルは同期プロトコルだから応答は要求した順番に通りに届くため、要求した順番通りに同期できる…と見せかけて、valueが見つからなかったときは「見つからなかった」と返ってくるのではなく単に通りすぎられたりするので結構厄介、
応答が2回ダブって返ってきた場合の対処などは、基本的にはアプリケーション側でエラー処理をする必要がある。クライアントライブラリ側でテーブルを管理してチェックしてもいいかもしれない。
同期型API
要求1を送信して応答を待つ // call(...); 要求2を送信して応答を待つ // call(...);
非同期プロトコルだからと言って常に非同期で使いたいわけではない。実は同期型で使う場合がほとんどだという場合は、send/join型やscatter/gather型を使うとテーブルを引くなどCPUを喰うのでもったいない。そこで同期型のAPIを別に用意すると最適化ができる。
call(内容) { テーブルが空でないなら { // パイプライン化しているとこの最適化は使えない return join( send(内容) ); } シーケンス番号を生成 // シーケンス番号 = ++roud_robin_variable; サーバーに要求を送る // write(ソケット, 要求); 応答が1つ分届くまで { 応答を待ち受ける // read(ソケット, 要求); もしシーケンス番号が違っていたらエラー return 結果 } }
シーケンス番号の生成(インクリメント1つ)と、応答のシーケンス番号が違っていたときのチェック(if文1つ)が入るだけで、同期プロトコルと比べてほとんどオーバーヘッドが無い。
パイプライン化しないのであれば非同期プロトコルは同期プロトコルの上位互換性があると言える。
汎用的なプロトコル
非同期プロトコルで性能を出すには、気合いを入れてクライアントライブラリを実装する必要があるので大変。そこで汎用的なプロトコルを作ってクライアントを実装しておくと便利に使える。そんなわけで MessagePack-RPC でした。