Havoc Pennington「同期的コールバック、非同期コールバック 」
ometer.com 2011.07.24のブログエントリ
Callbacks, synchronous and asynchronous : Havoc's Blog
- 2011年7月からnodeコアチーム7人のうちの1人になってたid:koichik(@koichik)さんが良記事認定してたエントリ
- コアチームの人たちは、Joyentからは2人。ryan(@ryah)、npmの人(@izs)。Cloudkickからも2人。Bert Belder(@piscisaureus)にPaul Querna。それにBen NoordhuisとFelix Geisendörferに@koichik。
- informativeな内容だが歯が立たない。けど何とか斜め読んでみた
以下斜め読んだ内容
このエントリ
- コールバック使ったAPIデザインで守った方がいいルール2つ
- 同じテーマで細々したところは前に書いた
- Boolean parameters are wrong : Havoc's Blog
同期的なコールバックと非同期コールバックのデザイン
JVM向けにコードを書いてる人の関心の推移
コールバックベースAPI、同期/非同期APIについて考え始めたきっかけ
- Hammersmithプロジェクトを始めたことが大きい
- Hammersmith
- Scalaで書いたコールバックベースのMongoDBドライバ
- jvm系言語で書いてる人たちには不慣れなトピックだと思う
- イベントループベースのクライアントサイドコードを書いた経験があるのでその辺はすんなり入れた
- The Main Event Loop
- (補足)
- GNOME/GTK+ではイベントループが実装されてる
おさらい。同期的コールバック/非同期コールバック
- 同期的コールバック
- 関数から返る前に、コールバックが呼び出される
- コールバックを受信するAPIはスタックにまだ残ってる
- 例えば、list.foreach(callback)
- リスト(list)の各メンバーに対してコールバックが実行された後で関数(foreach)から戻る
- こういう動きを使う側は当然期待する
- 非同期コールバック
- 遅延(deferred)コールバック、と呼ばれることも
- 関数から戻った後で、コールバックが呼び出される
- あるいは関数から戻る前に別スレッドのスタック上でコールバックが呼び出される
- 遅延のためのメカニズムとしては、スレッドやメインループ
- メインループの別名。イベントループ、dispatcher、executor
- 非同期コールバックをみかけるのは、IO処理するAPI
- 例えば、socket.connect(callback)
- 関数(connect)から戻った時点では、コールバックはまだ呼び出されてない
- こういう動きが期待されてる
- コネクションが貼られてるのを確認してからコールバック呼び出したいから
ガイドライン2つ
- 1:コールバックの仕様は二者択一にする
- 「常に同期的」か「常に非同期」
- 「どっちも」はNG
- 2:非同期コールバックの呼び出し元を限定する
- メインループ、メインのdispatchシステム、etc.が直接呼び出す
- コールバックを呼び出すメカニズムの中に間フレームとかを作らない。不要
- 特にこうした中間フレームがロックすることがあるなら尚更
同期的コールバック、非同期コールバック。どこが違うか
- それぞれが生み出す問題が違う
- 生み出す問題は、API作る側・API使う側それぞれが影響受ける
- 同期的コールバックのポイント
- 非同期コールバックのポイント
- 遅延処理のメカニズムがスレッドベースの場合
- 別スレッドでコールバックが呼び出される、場合あり
- 非同期コールバックからはtouchできないもの
- オリジナルのスレッドやスタックに紐付けられたもの
- 例えば、ローカル変数、スレッド内で保持されてるローカルデータ
- オリジナルのスレッドやスタックに紐付けられたもの
- オリジナルスレッドがロックする可能性があるとき、コールバックはそのスレッドの外で呼び出される
- 他のスレッドやイベントがアプリのステートを変化させる可能性があること。これを前提にしてコールバックを使うべき
- 遅延処理のメカニズムがスレッドベースの場合
- 「非同期の方が偉い」とか、優劣がつく話じゃない。それぞれに使いどころ
- list.foreach(callback)
- 同期的コールバックで出した例
- このコード使って、コールバックが遅延実行されて現在のスレッドでコールバックが何もしなかったら、たいてい驚く
- socket.connetc(callback)
- 非同期コールバックで出した例
- このコード使って、コールバックが遅延実行されなかったら、コールバック使ってる意味がない
- 2つの例が示してること
- コールバックは同期的か非同期か二者択一にする
- 同期ときどき非同期、という風にしない
- 同期的コールバックと非同期コールバックでは目的が違う
- list.foreach(callback)
同期か非同期か。片方だけ選ぶ。両方=NG
- 頻出、ではないがありそうなケース
- たまにコールバックをすぐに呼び出せるようにしてるが、それ以外ではコールバックは遅延実行させる。
- データがすでに入手可能な状態にある場面ならすぐにコールバックを呼び出すとか
- ソケットの準備はたいてい時間がかかるからコールバックは遅延実行
- コールバックが実行できる条件が整ってるときは同期的に実行し、それ以外は非同期で実行するようにデザイン
- こういう柔軟なデザインをやってみたくなる
- これはデザインとしては悪い見本
- 同期コールバックと非同期コールバックが従うルールは別物
- 同期コールバックと非同期コールバックが生み出す問題は別物
- ありそうなケース
- テスト環境ではコールバックは非同期的に呼び出す
- プロダクション環境では、コールバックを同期的に呼び出し、中断する。機会はとても憂くない
- 非同期にも同期的にも振る舞うコールバックをデザインしテストすることはエンジニアにとってとても負担が大きい
- コールバックの呼び出しを遅延させたい場合があるAPI
- こういう場合はいつも呼び出しが遅延されるようにデザインすべし
- お手本としてGIOライブラリのドキュメンテーションの、GSimpleAsyncResultの項目
- GSimpleAsyncResult
- GSimpleAsyncResultは、非同期プログラミングの世界でpromiseとかfutureと呼ばれるものと同等の役割
- complete_in_idle()メソッドとcomplete() メソッド。GSimpleAsyncResultが提供する
- complete_in_idle()
- コールバック呼び出しがメインループへ戻るまで遅延される
- idle handlerがGIOメインループを指す
- complete()
- 同期的にコールバックを呼び出す
- complete_in_idle()とcomplete() の使い分けについてのコメントが秀逸
- ロックが起こらないことが確定してる遅延されたコールバックの中でなら、complete()使ってもOK
- それ以外はcomplete_in_idle()をつかうべし
- GIOのドキュメントは終始こんなノリで書かれてる
- GIOを使ったエンジニアたちが炎上した経験から
同期されるリソースからのコールバック呼び出しは常に遅延されるべし
- 言い方かえると
- ロックする可能性を取り除いてからコールバック呼び出しするべし
- ロック可能性を取り除く一番楽な方法
- コールバックを非同期にしてしまう
- そうすると、スタックが消化されメインループに戻るまで実行が遅延される
- あるいは、コールバックが別スレッドの実行スタックに追加されて実行される
- API開発者が頭に入れておくべきこと
- APIを利用する外部アプリがコールバックからAPIへアクセスする可能性があること
- こういう可能性を忘れたAPIデザインだと
- メインループやスレッドへ戻るまでコールバック実行を遅延させる代わりに、同期されたリソースからはあらゆるロックの可能性を取り除く作業
- できないことはないが、苦しい道
- ロックはスタックの中からポンポン湧いてくる
- スタックにあるあらゆるメソッドがコールバックを返すようにチェックすることに追われることになる
- 内部的には、スタックのバックアップを取って、すべてのコールバックを外部のロックホルダーに渡し、ロックホルダー側で受け取ったコールバックのロックを解除し、コールバックを呼び出す。これを延々と
Hammersmithプロジェクト初期。スレッドプール使ってデッドロックを回避
- Hammersmith
- プロジェクト初期ではデッドロックが生まれるケースがあった
- 「connection.query({ cursor => /* iterate cursor here, touching connection again */ })」
- これは擬似コード。こう書くとデッドロックに
- corsorの繰り返し
- 繰り返すたびにMongoDBのconnectionオブジェクトへ戻る
- クエリーのコールバックはconnectionオブジェクト内部のコードから呼び出される。
- ここでコネクションロックが発生
- 動かない書き方だが、エンジニア視点でみると自然で簡便な書き方
- ライブラリが自動でコールバックを遅延してくれないなら、自力が遅延処理を書かないといけないくなるから
- この手のデッドロックの対処はたいていのエンジニアが不得意
- この手の問題に遭遇して解決を繰り返してるとごちゃごちゃしたコードの中に遅延処理のメカニズムが散在する状態に
- 「connection.query({ cursor => /* iterate cursor here, touching connection again */ })」
- Hammersmithが経験したデッドロックは、Nettyから引き継いでしまったもの
- Hammersmithでは接続にNettyを使ってる
- Nettyにはコールバックを遅延させる仕組みがゼロ
- これは仕方ない
- Netty開発時点でのJavaの作法からすれば当然
- コールバック遅延実行のためのデフォルトとか標準とか通常のやりかとか、効率的方法、といったものが皆無だった
- Hammersmithで見つけたデッドロックへのかつての自分の解決
- アプリのコールバックを実行するためだけにスレッドプールを追加した
- そのときのCommit
- Nettyが提供してるスレッドプール用のクラスはデッドロックを解決できなかった
- 自作でスレッドプール用クラスを書いた
- スレッドプールを使ったデッドロックの解決
- サイズやリソース制限がゼロの状態じゃないとうまくいかない
- スレッドプール追加で一応解決できた
- だがこんなやり方が当たり前なんて状況はかなりまずい
- 開発に使ってるjarファイルにコールバックAPIが入ってて、それぞれが専用のスレッドプールを持ってる状況はひどい
- この辺がおそらくNettyのこの問題への対応がはっきりしない理由
- 低レベルネットワーキングライブラリで方針を固めるのが難しくなってる
HammersmithでAkkaのActorモデルを採用することにした
- スレッドプールよりも優れた解決法を模索
- AkkaつかってHammersmith書き直した
- Akka Project
- Akkaで使われてるAcotrモデル
- Actor model
- コールバックではなくメッセージベース
- メッセージは常に原則的に遅延実行される
- Akka使った開発ではアクターとの通信ではActorRefを使わないといけない
- アクターへのメッセージはディスパッチャー(=イベントループ)を経由する
- 例えば2つのアクターとの通信をしてる場面
- 「!」もしくは「send message」メソッド使って
- メッセージ発行はイベントループ経由
- デッドロック問題がこのモデルで解決することを期待してHammersmith書き直した
- 一掃できなかった。ロックが起こる可能性のあるコールバック呼び出しが残った
- アクターがメッセージ処理してる間はロックが起こる
- Akkaではアクターでは、別のアクターからもしくはFutureオブジェクトからのメッセージを受信可能
- Akkaでもデッドロックは発生する場合あり
- Akkaではオブジェクト内のメッセージ送信者をChannnelと呼んでる
- Channelへのインターフェースの中で「!」メソッドが使える
- アクターを「!」で送信するとき、いつもメッセージはディスパッチャーへ戻るまで遅延される
- しかしfutureオブジェクトへの送信のときの場合、常に遅延されない
- 「Channel.!」はAPIが同期or非同期の定義から独立してる
- -これが問題を生む可能性がある
- アクターモデルでは。アクターの実行は1スレッドに1つ
- -アクターがメッセージを処理してる間はアクターはロックされるので、次のメッセージの処理に進むことができない
- アクターから同期的呼び出しをするのは危険
- アクターでデッドロックが生まれるケース
- 同期的呼び出しがコールバック内部でアクターを再び利用しようとしてる場合、デッドロック発生
- MongoDBのconnectionをアクターでラップしてみたら、Netty使ってたときと同じデッドロックが発生
- クエリのコールバックが再びconnectinにアクセスしてcursorを繰り返そうとする動きをしてた
- クエリのコールバックの呼び出し元は、futureで「!」メソッド
- Channelでの「!」メソッド(Channel.!)はこのエントリで出したガイドラインの1つ目に反してる
- この実装をしてる自分は、この「!」が常に非同期だとみなしてい
- 結果としてガイドラインの2つ目を守ってなかった実例にもなった
- Channelのデザインを変更して解決するという方向
- 自分ならこうする
- 自分ならChannelを最初から常に非同期なAPIとしてデザインする
- 現状のAkkaのデザインの制約で自分が取った解決
- メッセージを返すアクターとメッセージを処理するアプリケーションのハンドラーがアクターから復帰し、アクター再利用するように実装したい場合
- メッセージ返信を遅延させる仕組みを自分で実装しないといけない
- このやり方はベストではないけど、現時点では自分がこのやり方でいってる
- private def asyncSend(channel: AkkaChannel[Any], message: Any) = {Future(channel ! message, self.timeout)(self.dispatcher)}
- このやり方の弱点
- アクターへの応答を2回遅延させて、futureへの1回だけの応答を遅延させる点
Nettyしかないときはスレッドプールしかなかった
Akkaの「!」を使ったディスパッチャーが提供されてること自体はいいこと
Nettyと違ってAkkaにはコールバックを遅延させる方法について見解を持っている
コールバック遅延処理が成功するかのチェックを特定ケースだとやらないといけない
最近Akkaプロジェクト内部でもこの点は解決されつつあるらしい
まとめ
- コールバック使ったAPIは、イベントループと相性いい
- コールバック呼び出しを遅延できるようにしておくことが、コールバック使うAPIでは大事だから
- クライアントサイドjs、node、UIツールキット(ex.GTK+)がうまくコールバックを使えてるのはイベントループのおかげ
- 歴史の浅いJVMでコールバック使ったAPIデザインにはまだ正攻法がない
- イベントループを実装したライブラリ(Akkaは優秀)使うか
- イベントループを実装したライブラリを自作するか
- スレッドプールを無尽蔵に使いまくるか
- コールバック使ったAPIデザインは今流行ってる
- コールバックベースのAPI作った人がいたら優先度高めでウォッチする次第