今回のエンジニアブログを担当する原です。
非同期プログラミングは今や常識となっていますが、
同期プログラミングに慣れきってしまっている方にはイマイチ理解しきれない、という方も多いのではないでしょうか。
その要因の一つとして、非同期IOやスレッドなど、ライブラリや処理系が複雑な処理を隠蔽していて、非同期APIがどのような機構であるか理解がしづらい点があると考えられます。
そこで、非同期APIが分かりやすく、Webブラウザ上で簡単に実行できるDartを使用して、非同期プログラミングを解説したいと思います。1
実行環境はWebブラウザ上でDartコードがインタラクティブに実行できるTry Dartを使用します。
Futureとは?
最近のプログラミング言語の非同期APIやライブラリを使用していると Future という単語をよく目にすると思います。2
意訳すると、先物取引などの「先物」といったところでしょうか。
今現在は値を参照することはできないが、将来その値を手に入れることを期待できる、といったニュアンスです。
このFutureに対してコールバック関数を渡すことで、将来その値を手に入れたときに実行する処理を記述できます。
Dartでは、その将来の値を確定する責務がFutureからCompleterというクラスに切り離されており、理解しやすいと思います。
Try-Dartの左側のエディタ部分に以下のコードを書いてみてください。
import 'dart:async'; main() { var completer = new Completer(); // (1) var future = completer.future; // (2) // (3) var callback = (str) { print("World"); print(str); }; future.then(callback); // (4) print("Hello"); // (5) }
まず、(1)Completerを初期化します。このCompleterがその将来の値を確定する責務を負います。
(2)で、Completerから将来の値への参照となるFutureを取得します。
(3) で将来Futureの値が確定した際に実行するコールバック関数を定義し、callbackという変数に入れています。仮引数には将来確定した値が渡されます。このコールバック関数では、"World"という文字列と共に、将来確定した値を標準出力します。
(4)で、Futureに対してコールバック関数を設定し、Futureの参照する値が将来確定したときにcallback変数の処理が実行されるようにしています。
(5)では、前述のFutureとは関係無く、標準出力に文字列を表示しています。
さて、上記のコードをエディタに書いて右側のコンソールを見ると、Helloとだけ表示されています。
callback関数の中のWorldは表示されていません。
これは、Futureの将来の値がまだ確定していないからです。以下のコードを(5)の後に追加してみてください。
completer.complete("foo"); // (6)
Completerのcompleteメソッドを呼ぶことで、Completerの持つFutureの参照先の値を確定させることができます。引数に渡した値が、確定後のFutureの値になります。ここでは"foo"という文字列を、確定後の値として、completeメソッドに渡しています。
さて、右側のコンソールの「Compiling Dart program」の表示が消えると、以下のような"World"と"foo"の出力が得られます。
(もしかしたら、表示される文字列の順番が違うかもしれません。これはDartのCompleterが内部で非同期にFutureへ値を渡しているためです。)
Hello World foo
想定通り、callbackの関数が実行されたことが確認できました。
さて、実は先ほど追加した(6)の行は、main関数の中で(1)以降のどこの行に書いても構いません。
以下のようにmain関数を修正してみましょう。
var completer = new Completer(); // (1) completer.complete("foo"); // (6) <- (1)の直後に移動 var future = completer.future; // (2) // (3) var callback = (str) { print("World"); print(str); }; future.then(callback); // (4) print("Hello"); // (5) // completer.complete("foo"); // (6) <- コメントアウト
右側のコンソールに、ちゃんと"Hello", "World", "foo"が(順不同で)出力されたことが確認できます。
誤ってcompleter.completeを二度呼んでしまうと、例外が発生します。一度確定した値を上書きすることはでません。
非同期プログラミングの勘所
上記の例では、その将来の値の確定 ( completer.completeの呼び出し ) を自ら行なっていますが、非同期プログラミングの多くのユースケースが、将来の値を確定する役割がライブラリ側にあります。3 また、将来の値を確定する役割の処理自体が別スレッドにあることも少なくありません。
つまり、実際の非同期プログラミングでは、上記の例で(6)の行の移動で表現した将来の値の確定 ( completer.completeの呼び出し ) がより複雑で追いにくい場所で起こりうる、ということです。
ここで注意しておくべきことは、非同期プログラミングの原則として「将来の値を参照する役割 ( Future ) とその将来の値を確定する役割 ( Completer ) の2つから成り、互いに疎であることを前提とする」ことです。
CompleterとFuture ( ないしFutureに設定したcallback関数 ) が深く依存する、つまり「値がいつ確定したかに依存する」ようなコードを書いてしまうと、非常に追いにくいバグを生み出す可能性があります。
例えば、開発環境や端末では正常に動作していたが、実際にプロダクション環境や実端末において原因不明のNULL参照を引き起こしたり、意図しない状態のシングルトンオブジェクトにアクセスしたり、と枚挙に暇がありません。
これらの開発環境とプロダクション環境下における本来想定していない順序での変数やオブジェクトへのアクセスは、他のアプリケーションによって高まったCPU使用率やメモリ使用率の影響により、自分たちのアプリケーションへのリソース割り当てなどがOSによって変更された場合などをきっかけに、低くはない確率で起こりえます。
非同期プログラミングでは、言語や処理系・環境によるコールバック関数内から参照可能な値のスコープをしっかり把握し、より疎結合になるように設計することが非常に重要です。