JavaScript中級者への道【4. 非同期関数】
JavaScriptのつまづきやすそうなところ
- 関数はオブジェクトの一種
- 4種類のthis
- 関数スコープ
- 非同期関数 ← いまここ
- コールバック関数
- クロージャ
- プロトタイプ継承
非同期関数について
名前の通り非同期で処理を行う関数です。JavaScriptは基本的にシングルスレッドで動作する言語なので、
時間のかかる処理をそのまま(同期的に)書くと、その他の処理を止めてしまう恐れがあります。
時間のかかる処理の最たる例がHTTP通信で、Ajaxはそれを非同期で行う技術です。
これはユーザーの入力・操作に応じてサーバー側に非同期でHTTPリクエストを送り、
取得したデータを元に画面を動的に書き換えるというアプローチを取っています。
JavaScriptは画面の書き換えとかは得意だったので、これにデータの取得が加わり、
画面の再読み込みを伴わずにコンテンツが書き換わるという、文字通りリッチなコンテンツに昇華した訳です。
(Ajaxの登場以前は人気が衰退していて、これがきっかけで見直されたんだとか。)
今でもJavaScriptの主戦場はクライアントサイドに変わりありませんが、
Node.jsなどのサーバーサイド側でも非同期処理が推奨されているそうなので、
他の処理をブロックしない、非同期な処理にする方法を覚えておきましょう。
非同期な処理を実装する方法として、主な選択肢としては以下の2つがあります。
- 既に用意されている非同期関数を使う
- setTimeout()を使い、自分で非同期関数を実装する
1. 既に用意されている非同期関数を使う
ここではそれらの関数の使い方や内容については取り上げません。
代表的な実装を挙げるならば、jQueryの$.ajax()が有名だと思います。
この関数はデフォルトは非同期で動作し、オプションを指定することで同期的に動作させることが出来ます。
また、Node.jsが提供する関数は基本的に非同期で動作させる前提で作られています。
ファイルシステム関連のモジュールであるfsのドキュメントを見ると、
fs.access(path[, mode], callback)
fs.accessSync(path[, mode])
といったように、非同期関数と同期関数がペアのようになっているものが沢山あります。
最早、Node.jsにおいて非同期関数に「○○Async」というサフィックスが付かないことが当たり前のようです。
なので、特別な理由が無ければ他の処理をブロックしない「Sync」無しの関数を使うと良いでしょう。
2. setTimeout()を使い、自分で非同期関数を実装する
window.setTimeout(MDN)より、APIの概要と構文について。
概要
指定された遅延の後に、コードの断片または関数を実行します。
構文
timeoutID = window.setTimeout(func, delay[, param1, param2, ...]);
timeoutID = window.setTimeout(code, delay);
timeoutID : window.clearTimeout で使われる、数値の ID 。func : delay ミリ秒後に実行したい関数。
code : delay ミリ秒後に実行したいコードの文字列(この書式は、 eval() を使うのと同様の理由で非推奨です)。
delay : 関数呼び出しを遅延させるミリ秒(1/1000 秒)。実際の呼び出しはこれより長くなる場合があります。
このドキュメントのどこにも「非同期な処理になる」とか書いていないのですが、
setTimeout()を使って処理を書くと、非同期処理を作ることが出来ます。
同期的関数と非同期関数の実装
3秒後に「処理が始まった時間、終わった時間、かかった時間」を表示する関数を
同期・非同期それぞれで実装してみます。(単位はミリ秒です)
function syncAfter3second () {
var start = new Date().getTime();
// 無限ループ内で毎回時間を取得し、3秒経過していたらbreakで抜ける
while (true) {
var current = new Date().getTime();
if ((current - start) >= 3000) break;
}
// while文を抜けた時の時間を終了時間とする
var end = current;
console.log('### 同期関数の実行結果 ###');
console.log('start : ' + start);
console.log('end : ' + end);
console.log('end - start : ' + (end - start));
console.log();
}
function asyncAfter3second () {
var start = new Date().getTime();
// setTimeoutを使い、3秒後に引数の無名関数の処理を実行する
setTimeout(function () {
var end = new Date().getTime();
console.log('### 非同期関数の実行結果 ###');
console.log('start : ' + start);
console.log('end : ' + end);
console.log('end - start : ' + (end - start));
console.log();
}, 3000);
}
定義した関数を呼び出してみます。
まずは「同期関数 → 非同期関数」の順で実行してみます。
syncAfter3second();
asyncAfter3second();
/*
### 同期関数の実行結果 ###
start : 1450003136499
end : 1450003139499
end - start : 3000
### 非同期関数の実行結果 ###
start : 1450003139504
end : 1450003142510
end - start : 3006
*/
全体として、大体6秒くらいかかりました。
同期関数の実行を待ってから、非同期関数の処理が実行されていることが分かります。
次に、「非同期関数 → 同期関数」の順で実行してみます。
asyncAfter3second();
syncAfter3second();
/*
### 同期関数の実行結果 ###
start : 1450003555702
end : 1450003558702
end - start : 3000
### 非同期関数の実行結果 ###
start : 1450003555701
end : 1450003558707
end - start : 3006
*/
実際にやってみると分かるのですが、こちらは約3秒で終わります。
そして結果を見ると分かる通り、後から呼び出した同期関数の方が先に終了しています。
実際の処理の流れは以下のような感じになっています。
1. 非同期関数が呼び出される
2. 非同期関数の処理がキューに追加される(処理を予約しておく)
3. 同期関数が呼びだされ、処理を開始する
4. 同期関数の処理が終わり、結果を表示する
5. 非同期関数の処理がキューから取り出され、非同期に処理を開始する
6. 非同期関数の処理が終わり、結果を表示する
(※3,4 と 5,6 は入れ替わる可能性がある)
なんだかゲシュタルト崩壊しそうですね。
非同期処理は「呼び出されたタイミング」と「実際に処理されるタイミング」が違います。
end - start : 3006
という結果を見ると、この6ミリ秒が「呼び出しと実行のタイムラグ」になります。
こういった非同期関数の特徴を知らない場合、予想された結果と実際の結果が異なることが起きかねません。
以下はsetTimeout()を使って0秒後にhogeに文字列を代入するという例です。
var hoge;
// 0秒後にhogeに文字列を代入する
setTimeout(function () {
hoge = 'hoge';
}, 0);
console.log(hoge); // => undefined
タイマーに0秒をセットしたので、即座にhoge = 'hoge'
が実行されると思いきや、
代入より先にconsole.log(hoge);
が実行されるので、undefinedと表示されます。
hogeをちゃんと表示させる為には、無名関数の内側に処理を書く必要があります。
var hoge;
// 0秒後にhogeに文字列を代入する
setTimeout(function () {
hoge = 'hoge';
console.log(hoge); // => 'hoge'
}, 0);
今度は上手く動きました。
しかし、今回は処理を内側に移す対象がconsole.log(hoge);
という単純な処理であった為に簡単でしたが、
「データを非同期に取得 → 取得が完了したら加工 → 加工が完了したら表示」
といった風に、実際にはもっと複雑で順序立てられた処理の実装が要求されるはずです。
こういった「非同期」かつ「順序の整合性を保つ」といった要求を実現する為に、
「コールバック関数」という処理の受け渡しの概念が必要となってきます。
詳しくは次回、取り上げたいと思います。
補足(言い訳)
先の3秒後にhogeと表示させる関数の実行時間について、
「実際にやってみると分かるのですが」という言葉で濁しましたが、
実際にトータル時間を表示させた方が分かりやすいと思いませんでしたか。つまり、
var totalStart = new Date().getTime();
syncAfter3second();
asyncAfter3second();
var totalEnd = new Date().getTime();
console.log('######################');
console.log('total time : ' + (totalEnd - totalStart));
console.log('######################');
こうした方が説得力あるじゃん、馬鹿なの?ということです。
結論から言うと、この思惑は実現出来ず、いずれにせよ3秒と表示されます。
上記のように同期 → 非同期で呼び出した場合、以下のような結果になります。
/*
### 同期関数の実行結果 ###
start : 1450506316774
end : 1450506319774
end - start : 3000
######################
total time : 3006
######################
### 非同期関数の実行結果 ###
start : 1450506319779
end : 1450506322784
end - start : 3005
*/
非同期関数の呼び出し→トータル時間の表示→非同期関数の実行という順序で処理されるため、
トータル時間が先に表示されてしまいました。これも次回のコールバック関数で何とかしたいと思います。
まとめ
・同期処理は他の処理をブロックするが、非同期処理は他の処理をブロックしない
・非同期処理は呼び出されるタイミングと実行されるタイミングが違う
・既に目的の処理を行う非同期関数を提供しているライブラリがあれば、それを使う
・自分で非同期関数を作る必要があれば、setTimeout()
を使う
・非同期かつ順序の整合性を保つ処理が要求される場合、コールバック関数を使う
その他
冒頭で「JavaScriptは基本的にシングルスレッドで動作する」と述べましたが、
Web Worker(MDN)を使えばマルチスレッドで動作させることが出来るそうなので、
時間がかかる同期的な処理をバンバン使って実装することも問題ないらしいです。
ただ、サイ本読んだ限りだと、スレッド間で環境は共有出来ないらしいので、使いづらそうな印象です。
まぁ、自分はコールバック地獄とかは味わったこと無いですけど、
ES6でPromiseも仕様になったらしいので、個人的には非同期で頑張るのが無難な気がします。
非同期処理を自分で作る機会はあまりないような気はしますが、
アンチパターンとして「同期処理 + 時間がかかる処理」は避けるようにしましょう。
Qiitaで記事とか書いてると、マークダウンでの表示が動的に見れたりするんですが、
こういう技術の進歩って改めてすごいなぁ、などと実感しています。(小並感)
以上。