はじめに
第1章 非同期処理
第2章 コールバック関数
第3章 Promise
第4章 Promiseチェーン
第5章 複数のPromiseオブジェクトを扱う
第6章 Promiseの仕組みと実装
第7章 Promiseの発展的な話題
第8章 async/await
第9章 async/awaitの発展的な話題
参考文献
あとがき
JavaScriptによるプログラミングは、ブラウザーにおけるWeb APIのような豊富なAPIを呼び出すスタイルが一般的である。これらのAPIでは、結果がすぐに得られるのではなく、後から得られるという非同期処理になっている場合も多い。このような非同期処理はプログラムの記述が煩雑になるなど、昔から悩みの種であった。
近年のJavaScriptでは、非同期処理をより簡単に扱う機能が追加され、強化されてきている。特にPromiseの機能が標準化されたこと、async/await構文が追加されたことが大きい。これらの使い方は多くの入門書やWeb上の記事が存在するが、その内部的な仕組みや、なぜそのようになっているのかについては、あまりまとまった解説はないようである。本書では、一般的な非同期処理の記述と仕組みの詳細について解説することを目標としている。ECMAScript仕様やHTML仕様に含まれる内容と、その周辺の話題について扱う。
1章では非同期処理の基礎について解説する。2章でコールバック関数について述べる。コールバック関数は、非同期処理の結果を扱う伝統的な方法である。3章から7章にかけてPromiseについて述べる。Promiseは、ECMAScript 2015(ES6)で標準化された、非同期処理の結果を扱う仕組みである。8章と9章でasync/awaitについて述べる。async/awaitは、ECMAScript 2017で標準化された新しい構文であり、非同期処理を同期処理のように記述できる。
・本書は、だいたい「入門書の次に読む本」を想定して作られている。JavaScriptの文法は一通り学んでおり、非同期処理の記述の経験があることを前提とする。不明な点は手持ちの入門者やMDN web docsを参照してもらいたい。
・特に6章、7章、9章は高度な内容も含まれるため、読み進めるのが難しい場合は、理解しやすい部分から拾い読みをしていくとよいかもしれない。
・ライブラリー、フレームワークは使用しない。基本的に素のJavaScriptで完結する範囲で構成している。
・実行環境は最近のブラウザーを想定している。非同期処理についてはNode.jsなどブラウザー以外の環境でも一般的であるため、ブラウザーやHTML仕様に依存する部分はなるべくその旨を記した。
・具体的な非同期処理APIやフレームワーク等の使い方については、ほとんど解説していない。これらは、それぞれのドキュメントを参照してほしい。
本書に記載されている会社名、製品名などは、一般に各社の登録商標または商標、商品名です。会社名、製品名については、本文中では©、®、™マークなどは表示していません。
本書に記載された内容は、情報の提供のみを目的としています。本書を用いた開発、製作、運用は、必ずご自身の責任において行ってください。これらの情報による開発、製作、運用の結果について、著者はいかなる責任も負いません。
ある一連の処理が「同期的に実行される」というのは、前の処理が終わり、結果が得られてから、次の処理の実行が開始するということである。同期的に実行される処理を同期処理という。JavaScriptの関数は、基本的に同期処理である。
同期処理proc1、proc2、proc3があったとき、次の記述を考える。
proc1();
proc2();
proc3();
proc1の実行が完了してからproc2が実行され、proc2の実行が完了してからproc3が実行される。逆にいえば、proc2はproc1の実行が完了するまで実行が開始できない。このように、ある同期処理が後続の処理の実行を妨げることを、ブロッキングと呼ぶ。
同期処理の関数は、その結果が返り値となる。後続の処理では、前の同期処理の結果を利用できる。
var result1 = proc1();
var result2 = proc2(result1);
var result3 = proc3(result2);
proc2では、proc1の結果を利用できる。同様に、proc3ではproc2(と、必要ならばproc1)の結果を利用できる。
ある処理がその実行の完了を待たずに後続の処理を開始できるとき、その処理は「非同期的に実行される」という。この処理を非同期処理と呼ぶ。
非同期処理を行うasyncProc関数があったとする。asyncProc関数は非同期的に実行されるため、次の記述は、asyncProcの結果が得られる前に、proc1が実行される。
asyncProc(); // 非同期処理
proc1(); // 後続のコード
このため、asyncProc関数は結果を返さない。一般に、非同期処理の後続のコードからは、非同期処理の結果を利用することはできない。
非同期処理の結果を取得するには、コールバック関数を使う方法がある。これは、非同期処理を行う関数に対し、その結果を受け取る関数を渡すことで行う。
// 非同期処理の結果を受け取るコールバック関数
function callback(result) {
*** // 結果を利用する処理
}
asyncProc(callback); // コールバック関数を渡す
非同期処理は、結果が得られると、コールバック関数に結果を渡して呼び出す。コールバック関数内では、非同期処理の結果を利用できる。
HTMLでは、イベントという仕組みで結果の通知が行われることがある。非同期処理が完了したとき、イベントが発生し、結果が利用可能になったことが伝えられる。
ブラウザーでJavaScriptを実行している場合を考えよう。ブラウザーはイベントが発生すると、イベントを受け取るDOM要素に対し通知する。イベントを受け取ったDOM要素は、対応するコールバック関数が登録されていれば、それを呼び出す。一般にプログラミングにおいて、特定の条件で呼び出されるように登録されたコールバック関数をハンドラーと呼ぶ。イベントが発生したときに呼び出されるハンドラーを、イベントハンドラーまたはイベントリスナーと呼ぶ。
DOM要素にイベントハンドラーを登録するには、DOM要素のオブジェクトに対してaddEventListenerメソッドを使う。次の例は、domObjectオブジェクトにloadイベントが発生したときに呼び出されるloadイベントハンドラーを登録する。
domObject.addEventListener('load', function(event) {
*** // domObjectのデータのロード時に行う処理
});
domObjectのデータがロードされたとき、loadイベントが発生する。すると、domObjectのloadイベントハンドラーが呼び出される。イベントハンドラー内では、ロードされたデータを使用できる。
イベントハンドラーを登録する方法は他に、オブジェクトのプロパティーにイベントハンドラーを設定する方法もある。この場合、「onイベント名」という名前のプロパティーに設定するのが一般的である。
domObject.onload = function(event) {
*** // domObjectのデータのロード時に行う処理
};
プロパティーに設定する方法は、古くから使われている方法である。この方法のaddEventListenerとの違いは、今のイベントハンドラーが設定した関数で置き換えられることである。addEventListenerでは、既にハンドラーが設定されているオブジェクトに対してハンドラーを追加すると、以前のハンドラーも呼び出される。プロパティーで設定した場合、ハンドラーが置き換えられるので、以前のハンドラーは呼び出されない。
非同期処理は、ブラウザーにおいては画像などの外部リソースの読み込み、タイマ処理などが典型的である。例として、画像を読み込むことを考える。
画像の読み込みは、Imageオブジェクトにsrcプロパティーを設定することで行う。実際の読み込み処理は、ブラウザーにより非同期的に行われる。
var img1 = new Image();
img1.src = "test.png"; // この時点ではまだ画像は読み込まれてはいない
console.log(img1.width); // 0: 画像サイズは取得できない
オブジェクトimg1のsrcプロパティーを設定すると、画像の読み込みが非同期的に開始される。後続のコードは、まだimg1の読み込み結果を使うことはできない。このため、画像のサイズを取得するなどはできない。
画像が実際に読み込まれた後に処理をするには、オブジェクトimg1にコールバック関数を登録する。次のように、オブジェクトimg1のaddEventListenerメソッドを呼び出して登録する。
img1.addEventListener('load', function() {
// 画像が読み込まれた後の処理
console.log(img1.width); // 画像サイズが利用できる
});
画像が読み込まれたとき、そのオブジェクトに対しloadイベントが通知される。loadイベントに対応するコールバック関数が呼び出され、読み込み後の処理が行われる。
なぜ画像の読み込みは非同期処理になっているのだろうか。一般に、ディスクやネットワークのアクセスは、メモリー上のデータに比べてかなり低速である。通常の処理はメモリー上の変数などを扱うので高速に実行できるが、画像を読み込む場合、かなり待たされることになる。なので、読み込み待ちでプログラムの実行を止めてしまうと、後続の処理の実行が遅くなってしまう。画像を使う処理は後で行うことにして、ひとまず、画像を使わない処理を継続して実行できた方が効率的である。
実際、時間がかかる処理を同期的に実行すると、他の処理を止めてしまう。このため、ブラウザーのような実行環境においては、ユーザーからは応答が停止しているように見える。非同期処理にすることにより、時間がかかる処理をJavaScriptの実行の裏で行うことができるようになる。
JavaScriptのコード自体は同期的に実行される。このため、JavaScriptの非同期処理とは、何らかの非同期APIを呼び出すことである。一般には、裏で行われる処理は、システムに組み込まれているものである。自分で記述したJavaScriptのコードで裏の処理を行うには、ブラウザーではWeb Workerを使う必要がある。
JavaScriptの関数は、開始されてから完了するまで、一貫して動作する。途中で別の処理が割り込んできて、変数の値などが変わってしまうことはない。
関数の実行状態は、コールスタックという仕組みで管理されている。関数が実行されると、コールスタックに現在実行中の関数の状態を置く。
関数呼び出しが行われると、実行する関数の状態をコールスタックに積む。コールスタックの先頭は実行中の関数であり、これを実行コンテキストと呼ぶ。関数の実行が完了すると、コールスタックから取り除かれる。関数が別の関数から呼び出されたものであったなら、完了時には元の関数に実行が戻る。そうでなければ、コールスタックは空になり、一連の実行が完了する。
JavaScriptのコードは、実行が開始されてから、コールスタックが空になるまで、同期的に逐次実行される。
実行コンテキストは、JavaScriptを実行するシステムの単位において基本的にひとつである。なので、JavaScriptのコードの同時実行はできない。別のJavaScriptコードの実行を開始したいときは、現在実行中の処理が完了し、コールスタックが空になるのを待たなければならない。
このような実行の性質は、シングルスレッド実行と呼ばれることがある。この性質は、裏で処理が行われて変数などを書き換えてしまうことはないので、扱いやすいという利点がある。
対して、同時に別の処理が実行される可能性があるものをマルチスレッド実行と呼ぶ。マルチスレッド実行については「1.6.2 シングルスレッドとマルチスレッド」で述べる。
非同期処理では、処理が完了する前に、後続のJavaScriptコードが実行される。
console.log("A");
asyncProc1(); // 非同期処理の開始
console.log("B"); // 後続のコード
このように記述した場合、非同期処理asyncProc1の実行と、後続の実行は同時に行われているように見える。
例として、ブラウザーによるタイマ処理を考えよう。setTimeoutにより、1秒間待つ処理を考える。setTimeoutが実行されると、ブラウザーは「1秒間待つ」処理をJavaScriptの実行の裏で行っている。ブラウザーは1秒後に、コールバック関数の実行を開始する。
setTimeout(function() { console.log("timer finished"); }, 1000);
syncProc(); // 何か重い処理
ここで、JavaScriptの後続のコードが、重い処理syncProcを実行していたとする。syncProcの処理とタイマ処理はブラウザーで同時に実行される。
しかし、setTimeoutのコールバック関数が実行され、"timer finished"と表示されるのは、syncProcの実行が完了した後である。同期処理syncProcの実行に1秒より長い時間がかかったとしても、これは同じである。syncProcの実行中にコールバック関数が実行されてしまうことはない。これは、同時に実行できるJavaScriptのコードはひとつだけだからである。
一般的に、JavaScriptコードから非同期処理を行うAPIを呼び出した場合、その処理はシステムによって、JavaScriptの実行と同時に行われる。非同期処理は裏で行われているかもしれないが、JavaScriptコードからそれを知ることはできない。JavaScriptコード側からみれば、非同期処理APIを呼び出すことは、非同期処理の実行開始をリクエストしているだけである。非同期処理の結果が得られるのは、システムによる非同期処理の実行が完了し、コールバック関数が実行されたときである。
JavaScriptコード自体は、複数同時に実行されることはない。現在の一連の処理が完了するまで、すなわち、コールスタックが空になるまで実行は継続する。
前項で述べたように、JavaScriptはシングルスレッド実行である。JavaScriptの一連の処理では、勝手に実行状態が書き換わってしまうことはない。つまり、裏で何らかの処理が行われていようとも、それが現在の処理の実行に影響を与えることはない。
非同期で画像をロードするasyncLoadImage関数があったとする。次のように、JavaScriptでは画像が得られるまでループで待ってから画像の処理を行うことはできない。
// JavaScriptではこのようなことはできない
var image1 = asyncLoadImage("image.png"); // 画像のロード要求
while (!image1) {
// 待つ
}
// ここではimage1が得られている
*** // image1を使った処理
JavaScriptでは、現在の関数の実行が完了するまで、変数が外部から書き換わることはない。なので、whileループで待っている間にimage1が変化することはないのである。
JavaScript標準でも、Web Workerを使うとこの性質が成り立たない場合がある(「1.6.5 Web Worker」)。本書ではことわりのない限り、Web Workerを使わないシングルスレッド実行を前提にする。
非同期処理が裏でどのように行われているかは、ブラウザーなどのシステムがどのようにJavaScriptを実行しているかを考えるとわかりやすい。実際にはシステムによってさまざまであるが、ブラウザーではどのように行われているかの概略を述べる。
ブラウザーはJavaScriptの実行の他、画面のレンダリング、ユーザー入力の処理、ネットワークの入出力など、さまざまな処理を行う。これらの処理は、イベントループという仕組みによって行われている。
イベントループは、タスクキューと呼ばれる処理のセットを持っている。イベントループでは、タスクキューに存在する処理(タスク)のうち、実行可能であるものを取り出して実行すること繰り返す。
このとき、JavaScriptコードを実行するタスクは、一度にひとつのみ実行される。すなわち、複数のJavaScriptコードを同時に実行しない。
ブラウザーは重い処理や、外部システムの処理を実行する場合、それらを同時に実行できる。しかし、同時に実行される処理では、JavaScriptコードからわかる形で、JavaScriptコードが使用するリソースを変更することはできない。同時に実行した処理の結果をJavaScriptコードに通知するには、イベントループでそのためのタスクを実行しなければならない。
イベントループで非同期処理が扱われる際には、典型的には次のように動作する。
1.あるイベントループでのタスクで、JavaScriptコードが実行される。
2.JavaScriptコードにおいて、非同期処理の要求が行われる。
3.ブラウザーは、非同期処理を裏で(JavaScriptコードから見えない形で)実行し、結果を生成する
4.結果を通知するタスクを生成し、タスクキューに追加する
5.JavaScriptのハンドラーが実行され、結果が取得される
この仕組みによって、JavaScriptのシングルスレッド実行に影響することなく、重い処理を同時に裏で実行できる。
画像をロードする例で考えよう。JavaScriptコードでは、Imageオブジェクトのインスタンスにsrcプロパティーを設定することで、ロード要求が行われる。次のようなコードを考えよう。
var img1 = new Image();
img1.src = "test.png";
img1.addEventListener('load', function() {
*** // ロード後の処理
});
*** // 後続の処理
これは、次のように動作すると期待される。
まず、srcプロパティーを設定した段階で、裏で画像のロードが開始される。これは非同期処理であるので、JavaScriptの後続の処理をブロックしない。JavaScriptの後続の処理と、画像のロードは並行して実行される。
画像のロードは時間がかかるので、しばらくするとJavaScriptの後続の処理は終了する。ロードが完了すると、loadイベントが通知される。img1オブジェクトがイベントを受け取ると、対応するハンドラーが呼び出される。
重い処理を非同期で行うとき、典型的に、このような流れになる。では、もしロードが早く完了した場合はどうなるだろうか。これは、画像がネットワークではなく、キャッシュから得られるような場合である。
画像のロードがJavaScriptの後続の処理よりも早く完了した場合、ブラウザーは既に画像が得られているが、JavaScriptにとっては、まだ得られていないものとしなければならない。
ロードが完了した時点で、イベントが通知される。ここでは、イベントハンドラーの実行がタスクキューに追加されるのみである。JavaScriptの実行が完了すると、以降のイベントループにおいて、イベントハンドラーのタスクが実行される。
このような動作はJavaScriptから見て一貫性があるので、プログラミングが簡単になる。もしロードの完了のタイミングにより画像が得られたり得られなかったりしたら、プログラミングが複雑になってしまう。非同期処理の実行後、
・JavaScriptの後続の処理では、結果は得られない
・後で実行されるハンドラーで、結果が得られる
ということが決まっていれば、非同期処理の完了のタイミングにかかわらない記述ができる。
実際のところ、この動作はブラウザーによって微妙に異なる部分がある。Web開発では、特にHTMLのような巨大な外部APIを扱う際には気を付けなければならない。
img1オブジェクトを作成した時点では、画像サイズが不明であるので、img1.heightの値は0となる。これはHTMLの仕様で定められている。読み込みが完全に裏で行われるなら、onloadが呼ばれるまでは0であってほしいだろう。
しかし、現実にはそうならない場合がある。古いEdgeブラウザー(EdgeHTML)では、画像をキャッシュから読み込んでおり、かつimg1オブジェクトが表示中のドキュメントに含まれる場合、srcプロパティーを設定した時点で、img1.heightプロパティーが設定されるという動作であった。
var img1 = new Image();
document.getElementById("out").appendChild(img1); // ドキュメントに登録
console.log(img1.height); // 0
img1.src = "test.png"; // 高さ100pxの画像を読み込み
console.log(img1.height); // 画像がキャッシュから読まれたとき100、そうでなければ0
これは、Web開発においてよく遭遇する、状況によって動いたり動かなかったりする不可解な現象の原因となる。「開発環境では動いていた。本番環境に移すとなぜか動かない。でもリロードすると動いた」というような挙動は、たいていキャッシュが絡んでいる。キャッシュを有効にしたままテストをしてしまうと、問題を見落とすことになる。
実際にこのような問題に対処するには、仕様を確認しておくことが重要であるが、あまりに巨大なAPIだと把握が難しい。さらに、仕様には細かいことは書かれていないということも多い。もっといえば、ブラウザーが仕様通りの動きをすることが保証されているわけでもないのである。開発用ブラウザーで動いたからといって、別のブラウザーで動かないという可能性は常にあるので、環境を変えてのテストは欠かせない。
このように、同じ書き方で実際の動作が異なる可能性へ備えるため、ある程度弾力性のある実装が必要である。同期的にデータが得られる可能性があっても、非同期でしかデータが得られないと想定して実装するなどである。デバッガで見て値が入っているから使える、と判断するのではなく、仕様を確認するというのも重要である。
昔は、画像がキャッシュにある場合、書き方によってはloadイベントが捕捉できない、というブラウザーがあった。これはIE8で起こる問題として典型的なものであった。
var img1 = new Image();
img1.onload = function() { *** }; // IE8対策:srcの設定前にonloadを書くこと!
img1.src = "test.png";
このため、必ずsrcの設定前にonloadを設定する、というような対策が必要であった。この問題は現在では発生しないが、他の場面では、今でもブラウザー間の差が問題になるケースは存在する。
setTimeoutは、ブラウザーでタイマ処理を行うWeb APIのひとつである。本書では非同期処理の例としてsetTimeoutを利用するので、ここで述べる。
setTimeoutはECMAScript標準でなく、HTML標準であるので、実行環境は主にブラウザーを想定する。それ以外の環境では、タイマ処理は異なる場合がある。たとえばNode.jsでは、setTimeoutは存在するが、その動作はブラウザーとは微妙に異なる。本書では一般的なブラウザーの動作を想定する。その他の環境は、各ドキュメントを参照されたい。
ブラウザーにおいて、次のようにすると、delayミリ秒後にfunc関数が呼び出される。
setTimeout(func, delay);
setTimeoutを実行したとき、だいたい次のようなことが起こる。
1.コールバック関数を実行するタスクを生成する
2.タイムアウトIDを返す(これはclearTimeoutでタイマを停止する際に使用される)
~~~ここまでは同期的に実行~~~
3.指定された時間が経過するまで待つ
4.タスクキューに1で生成したタスクを追加する
5.イベントループによりタスクが実行され、コールバック関数が呼び出される
タイマの待ち処理は、ブラウザーにより、他の処理と並列に実行される。JavaScriptから見ると、コールバック関数をsetTimeoutに渡して呼び出すと、タイムアウトIDが返る。ここまでは同期的に実行されている。その後、指定した時間が経過した後、コールバック関数が非同期的に呼び出される。
本書では、時間がかかる非同期処理を模してsetTimeoutを使うことがある。たとえば、結果が得られるまで1秒かかるような非同期処理asyncProc1sがあるとする。次のような記述で、この非同期処理をシミュレートしている。
function asyncProc1s(callback) {
setTimeout(function() {
var result = "result1"; // 結果
callback(result);
}, 1000);
}
非同期処理asyncProc1sにコールバック関数を渡して呼び出すと、1秒後にコールバック関数が実行され、結果"result1"が得られる。
asyncProc1sを使うときは、たとえば次のように記述する。
asyncProc1s(function(result) {
*** // 結果を使う処理
});
実際によくある非同期処理の例は、ネットワークやデータベースへのアクセスである。このようなものは、環境により結果が得られる時間が異なるので、サンプルとしては使いにくい。このため、非同期処理の動作をsetTimeoutで代用する。
JavaScriptにおいては、同期・非同期処理、シングル・マルチスレッド実行、逐次・並行/並列処理といった概念がしばしば混同して述べられることがある。これらは互いに密接に関連してはいるものの、それぞれ独立のものである。本節では、これらの概念を少し整理して述べる。
もしシステムが単純なものであったなら、すなわち、同時に実行される処理が常にひとつのみであったなら、とても簡単であっただろう。処理は常に同期実行で、シングルスレッド実行であり、逐次実行である。しかし、現在のコンピューターシステムは複雑で、ふたつ以上の処理が同時に実行されるのが普通になっている。ブラウザーを考えると、JavaScriptの実行の他、ユーザーの操作への応答、描画処理、ネットワーク処理、ファイルアクセス等々のさまざまな処理が同時に実行される可能性がある。このような同時に実行される処理をうまく扱うために必要なのが、これらの概念なのである。
JavaScriptの処理は基本的に同期実行である。すなわち、ある処理の実行が完了し、結果が得られてから次の処理が実行される。対して非同期実行とは、ある処理の実行の完了を待たずして、別の処理が実行されていくものである。JavaScriptで非同期処理は、非同期処理を行うAPIを呼び出すことによって行われる。非同期処理APIを呼び出すと、その処理はシステム(ブラウザーなど)によって、今のJavaScript実行とは別に行われる。
非同期処理の結果は、JavaScriptの一連の実行(タスク)中においては得ることができない。これは、非同期処理は今のタスクと別のタスクで行われるものであるため、後に述べるJavaScriptのシングルスレッド実行モデルにおいては、その結果を得ることはできないからである。
結果を得るためには、別のJavaScript実行のタスクが必要である。このために、コールバックの仕組みが使われる。結果が得られるとシステムは、コールバック関数を呼び出し結果を利用するJavaScriptのタスクを新たに開始する。
慣用的に、あるJavaScriptの処理を「同期的に実行する」「非同期的に実行する」ということがある。これは、あるJavaScriptのタスク内で処理を行うことを同期的に実行、と表現し、別のタスクで行うことを非同期的に実行する、と表現する。
たとえば、次のようなものである。
function f1() { *** } // ある処理
f1(); // 同期的に実行
setTimeout(f1, 0); // 非同期的に実行
関数f1を直接呼び出した場合、同一タスクで処理が行われる(同期的に実行)。対して、setTimeoutにコールバック関数として渡した場合、後からシステムによって別のタスクで処理が行われる(非同期的に実行)。
同期的に実行した処理の結果は得ることができるが、非同期的に実行した処理の結果は直接得ることができない。
ただし、次のような場合は混乱しないように注意するべきである。
function callback1() { *** } // 結果を利用する処理
someAsyncFunc1(callback1); // 非同期処理を実行
このとき、非同期処理というのはsomeAsyncFunc1自体の処理のことである。callback1関数は非同期的に実行されるが、非同期処理の結果を利用する処理という位置づけである。正確さを欠いて、コールバック関数のことを非同期処理と思ってしまう場合があるので、きちんと整理して理解したい。
JavaScriptのプログラムは、基本的にシングルスレッド実行である。これは、ある処理が実行されているとき、同時に別の処理が裏で動いていないとみなせる、ということである。
近年の計算機やOSは、一般的に複数のプログラムが同時に動くことができる。なので、JavaScriptがシングルスレッドであるということは、より正確には、裏でどんなことが行われていようとも、現在実行しているJavaScriptのプログラムの動作に影響しないことが保証されている、ということである。JavaScriptのプログラムの実行中に、変数の値などが、同時に動いている別のプログラムによって書き換えられてしまうことはない。
このことは当たり前すぎて、理解しにくいかもしれない。次のコードを考えてみよう。
const a = 1;
const b = a + 1;
このときbの値は当然2になるのだが、それはJavaScriptがシングルスレッド実行であるから保証されているといえる。もしこの2行の間に、別のプログラムが変数aを書き換えるような場合、bが2になるとは限らないのである。
シングルスレッド実行でない環境、マルチスレッド実行ではどのようなことが起こりうるか見てみよう。マルチスレッド実行とは、メモリーを共有する複数のプログラム(スレッド)が存在するというものである。
ここでは、次のような仮想的な実行環境を想定する。システムは、一度にひとつのスレッドを任意に選択して実行できるとする。実行中のスレッドはひとつであるが、任意のタイミングで別のスレッドに切り替えることができる。このモデルでは、複数のスレッドが同時に実行されることはないという、いくぶんか単純な実行モデルである。つまり、並列実行(次項で述べる)はしない。しかし、ある処理が完了しないうちに切り替えが行われ、変数を書き換えてしまう、という可能性がある。
この実行モデルのもとでは、複数のスレッドが共有する変数iに対し、
++i;
という単純なインクリメント処理を行うことですら、問題を起こす可能性がある。
この処理は、計算機から見ると、
1.メモリーからiの値を読み込む
2.読み込んだ値に1を加算する
3.メモリーに結果の値を書き戻す
の少なくとも3つの操作を含んでいる。
もし2の操作の後で切り替えが発生し、別のスレッドがiの値をインクリメントしてしまったとしたら、どうなるだろうか。次に元のスレッドに戻って来たときに、3の操作が行われ、iの値が上書きされる。すると、別のスレッドがiに加えた変更はなくなってしまう。この問題は、複数スレッドが共有する資源の管理問題として一般的であり、競合と呼ばれる。
問題が起こるのを防ぐには、あるスレッドが共有資源を書き換えているときは、他のスレッドが読み書きできないような仕組みを導入する必要がある。これを排他制御という。排他制御はマルチスレッドプログラミングでは面倒で厄介なものである。
JavaScriptでは、基本的にシングルスレッド実行であるので、このような問題にユーザーが気を使うことはない。たとえばブラウザーでJavaScriptを実行しているときにも、実際には、裏でネットワークの通信や画面の描画など、さまざまな処理を行っているのである。これらは、JavaScriptのプログラムの実行には影響が生じないことが保証されている。
逐次実行とは、ここでは、複数の処理が順番に実行されていくことである。前の処理の実行が完了してから次の処理が開始されるため、同時に実行されている処理はひとつである。
文脈によっては、別の実行が存在するかにかかわらず、一連の処理が順に実行されることを逐次実行ということもあるが、ここでは並行/並列実行に対する単一実行の意味で逐次実行といっている。
並行と並列の違いは微妙であり、文脈によって使われ方が異なる。一般には、同時に複数の処理が存在するとき並行といい、実際に同時に処理が実行されるとき並列という。
並列実行を行うと、実行時間を短くすることができる。つまり、高速化ができる。並列実行のためには、ハードウェアが対応している必要がある。現在のJavaScriptを実行するような環境では、大抵マルチコアのプロセッサを積んでいることが多くなったので、高速化のための並列実行は現実的になっている。逆に、並列実行に対応していない場合、実行を切り替えながら行う並行処理になる。このときは高速化の恩恵は得られない。
並行/並列実行に関しては、実際のシステムではさまざまな階層、あるいは粒度などのバリエーションがあるので、どのレベルの話をしているのか理解する必要がある。
たとえば、計算の命令列をひとつのプロセッサのレベルで並列実行する、ということが行われる。次のような処理を考えよう。
const r = a + 1; // 加算1
const s = b + c; // 加算2
このとき、一連の処理はひとつであるが、加算1と加算2は互いに独立しているので、並列に実行できる。現在の多くのプロセッサは、これらを並列に実行できる機能を備えている。このような方式をスーパースカラーと呼ぶ。これは、本質的に逐次実行の実行モデルを並列に実行するという感覚であるので、少し特殊であるともいえる。
もっと上の階層での並列実行は想像しやすいだろう。複数の独立した処理があるとき、それらは並列に実行できる。これはマルチコアプロセッサによって並列に実行される。シングルコア(単一スレッド実行)の場合、同時に複数の処理を扱えないので、実行状態(コンテキスト)を切り替えることによって、ひとつずつ実行する。これは並行実行ではあるが、並列実行ではない。
JavaScriptにおいては、多くの場合コードが並列に実行されるかどうかを意識することはない。たとえば、非同期処理APIであるfetch関数を呼び出して、ネットワークからデータをダウンロードする場合を考える。このとき、おそらくはブラウザーではネットワーク処理は並列に実行しているものと考えられる。ブラウザーは、データのダウンロードを行っている間も、ユーザー操作への応答などが行える。しかし、JavaScriptのコード上からは、本当に並列実行されているかを直接確認することはできない。JavaScriptにとっては、fetch関数を呼び出した後、非同期的に(ダウンロードの結果を待たずに)後続の処理が実行できるだけである。
もしJavaScriptの処理を高速化したいと考えるのであれば、並列実行を意識した書き方が必要となる。ブラウザーがさまざまな処理を行うことを理解して、なるべく並列化ができるように機能を呼び出すのが望ましい。並列に実行できるのに、不必要に別の処理の終了を待ってしまうようなことを避けるなどが考えられる。
JavaScriptはシングルスレッド実行であるため、実行中のJavaScript以外の処理(他のスレッド)の存在には感知しない。あるJavaScriptの実行から見て、他の処理は裏で非同期的に行われているものである。
JavaScriptから他の処理を並行して行いたいとき、非同期処理APIを実行して、処理の開始を要求する。新たな処理のタスクが生成され、並行して実行される。システムが並列処理に対応しているなら、複数のタスクを並列に実行し、処理を高速化できる場合がある。
並行して実行している処理の結果を使うには、現在のJavaScriptのタスクが完了した後、新たなタスクを開始する必要がある。そこで、結果を受け取るコールバック関数を実行するという方法をとる。結果が得られると、システムは新たなタスクを開始し、コールバック関数に結果を渡す。
JavaScriptでもWeb Workerという仕組みを使って、複数のJavaScriptのタスクを同時に実行することが一般的になりつつある。Web Workerでは、JavaScriptのタスクを非同期処理として実行できる。
あるタスクが、他のタスクが使う変数を任意に書き換えてしまうと問題が起こる。Web Workerでは、他のタスクが利用する任意の変数を直接書き換えることはできない。通常は、メッセージという仕組みを利用して、コールバック関数を介してデータのコピーを渡す。
しかし、いくらか制限された方法で、複数のタスクで共有のデータを扱うことも可能である。そのために、SharedArrayBufferというデータ型を利用する。SharedArrayBufferの実体は、他のタスクと共有されるメモリー領域である。
データを共有する複数のプログラム(スレッド)を同時に実行しても問題が起こらないという性質を、スレッドセーフと呼ぶ。JavaScriptは、スレッドセーフのために多少制限された仕組みをもっている。
何の制限もなしにデータを書き換えられるならば、データの競合が発生する。このため、SharedArrayBufferではAtomicsという仕組みを介してデータへのアクセスを行うようになっている。atomicとは不可分な、という意味であり、Atomicsは不可分操作を提供するものである。たとえば、「1.6.2 シングルスレッドとマルチスレッド」で見たインクリメント命令は、
Atomics.add(sbuf1, 0, 1); // メモリー領域sbuf1の0番目に1を加算する
のように書ける。この加算処理は不可分操作であり、一連のデータ書き換え処理の最中に別の処理が割り込んできて、データを書き換えてしまうことがない。Atomicsによる共有データへのアクセスは、他のタスクに影響されることなく実行できる。
Web WorkerでSharedArrayBufferを使うと、もはやJavaScriptはシングルスレッド実行ではなく、マルチスレッド実行であると考えなければならない。シングルスレッド実行の場合は、他のタスクの処理の結果は、現在の一連の実行が完了してからしか得ることはできない。しかし、マルチスレッド実行では、実行中でもデータが書き換わることがある。
処理系によっては、実行中でもデータが変更される場合がある。たとえば、ブラウザーにおいて何らかの拡張機能を呼んでいる場合や、WSH(Windows Scripting Host)で外部プロセスにアクセスしている場合である。
var val1 = SomeObject.prop1; // SomeObject.prop1は外部リソースを参照している
// SomeObjectはJavaScriptから変更していないが、しばらくすると…
var val2 = SomeObject.prop1; // ここでval1とval2が異なっている場合がある
これは非標準の動作であり、実行環境に依存したものである。
筆者はブラウザーの拡張機能を使って、他のプロセスと共有データを介した通信ができる仕組みを作ったことがある。しかし、そのようなことをするとプログラムの動作の一貫性に問題が生じることがあるので、細心の注意を要する。
非同期処理の結果を受け取るための基本的な手段は、コールバック関数を使うことである。本章ではコールバック関数の使い方を述べる。
非同期処理は、システムにより裏で実行されるので、その実行状況をJavaScriptから見ることはできない。結果を受け取るためには、コールバック関数を登録しておく必要がある。
典型的な非同期処理APIであるasyncProc1関数があったとする。asyncProc1関数は結果を返さず、コールバック関数を渡すことで取得する。非同期処理の結果はコールバック関数に引数として渡されるものとする。
asyncProc1(callback); // コールバック関数を渡す
function callback(result) {
*** // ここで結果(result)を使用できる
}
このように、コールバック関数を普通の名前付き関数で記述することもできるが、この場でしか使用しない関数であれば、無名関数で記述することが多い。
asyncProc1(function(result) {
*** // ここで結果(result)を使用できる
});
また、ECMAScript 2015(ES6)のアロー関数を使用することもできる。
asyncProc1(result => {
*** // ここで結果(result)を使用できる
});
アロー関数は、記述量を削減できるので好まれる場合も多い。また、後述するが、thisの値がアロー関数定義時の値に固定されるという効果もある(「2.3 this」)。
本書では、アロー関数は特にthisを固定したい場合や、記述を簡略化したい場合のみ使用することにして、通常はfunctionで記述する。
コールバック関数は、動的に生成されるものとして記述することが多い。JavaScriptの関数は、C言語などの静的な関数とは異なる特徴を持つので、ここで述べる。
一般にJavaScriptの関数は、その定義が実行された時点で、その実体である関数オブジェクトが生成される。次の例では、func1関数の内部でcallback関数を宣言している。
function func1() {
function callback() {
***
}
asyncProc1(callback);
}
func1(); // 1回目の呼び出し
func1(); // 2回目の呼び出し
このように記述した場合、callback関数の実体はfunc1関数の呼び出しごとに生成される。なので、1回目の呼び出しで生成されるcallback関数は、2回目の呼び出しで生成されるcallback関数とは別の実体である。
これは、次のように無名関数式で書いた場合も、上記のように関数宣言で書いた場合と同様である。
function func1() {
asyncProc1(function() {
***
});
}
この場合も、func1関数の呼び出しごとに、コールバック関数の実体が生成される。
より詳しくいえば、初めの関数宣言による記述と、2番目の関数式による記述は、関数の実体が生成されるタイミングが異なる。関数宣言は、その記述を含む関数が実行されたときに、関数の実体が生成される。関数式は、それが評価(実行)されたときに、関数の実体が生成される。
この性質のため、関数宣言はいわゆる「巻き上げ」がおこる。すなわち、関数宣言は、それが記述された関数の先頭で実行されるとみなせる。
本項の初めの記述の例では、次のように関数宣言を後に書いても同じである。
function func1() {
asyncProc1(callback);
function callback() {
***
}
}
いずれの記述でも、callback関数宣言はfunc1の先頭で実行されるとみなせる。
コールバック関数を定義したとき、そこで使える変数は、コールバック関数の内部でもそのまま使うことができる。次のコードを考える。
function func1() {
const value1 = ***;
asyncProc1(function() {
*** // ここで変数value1を使用できる
});
}
関数func1では、コールバック関数を定義して非同期処理asyncProc1に渡している。コールバック関数内では、外側の変数value1を使用することができる。
コールバック関数は、func1の実行が完了した後で、別の場所から呼び出される。にもかかわらず、func1内で定義された変数を使用できるのである。
JavaScriptの関数は、それが定義された場所の変数を使用できる仕組みがある。JavaScriptで関数定義を実行すると、関数の実体である関数オブジェクトが生成される。この関数オブジェクトは、関数が定義されたときの使用できる変数を保持している。なので、コールバック関数を上のように定義したとき、変数value1をコールバック関数からも使用できる。
このJavaScriptの性質は、C言語とはかなり異なる。C言語において、関数内で定義した変数は、自動変数と呼ばれるものである。自動変数は、関数を呼び出した時点でスタックと呼ばれるメモリー領域に作成され、関数が終了すると自動的に解放される。JavaScriptでは、変数は環境レコード(Environment Record)と呼ばれるオブジェクト内に保持され、それを参照するコードがある限り解放されない。
一般に、関数とそれが使用できる変数を合わせたものをクロージャーと呼ぶ。JavaScriptでは関数の実体(関数オブジェクト)はクロージャーである。関数定義を実行すると、クロージャーが生成される。
例を示す。
function func2(value, delay) {
setTimeout(function() {
console.log(value);
}, delay);
}
func2("value1", 2000);
func2("value2", 1000);
結果
value2 ←開始から1秒後
value1 ←開始から2秒後
関数func2は、valueとdelayを受け取る。setTimeoutにより、delayミリ秒後にコールバック関数を実行する。コールバック関数では、func2の引数である変数valueの値を出力する。
初めのfunc2の呼び出しでは、valueの値は"value1"になる。コールバック関数の実体である関数オブジェクトが生成され、この時点で使える変数(valueを含む)を保持する。つまり、クロージャーが生成される。setTimeoutにより、2秒後にコールバック関数を実行するように予約される。
2番目の呼び出しでは、valueは"value2"になり、クロージャーが生成され、1秒後に実行するように予約される。
1秒後、システムは、2番目のfunc2の呼び出しで登録されたコールバック関数を呼び出す。このとき、コールバック関数は、その実体が生成された時点の変数を参照する。このコールバック関数の実体が生成されたとき、valueは"value2"であったので、"value2"が出力される。
2秒後、システムは、初めのfunc2の呼び出しで登録されたコールバック関数を呼び出す。コールバック関数は、"value1"を出力する。
この結果より、func2の呼び出し毎にクロージャーが生成され、その時の変数を保持することがわかる。このため、後からシステムによりコールバック関数が呼び出されたときでも、変数が使えるのである。
JavaScriptでは、関数内で変数を参照しようとしたとき、その関数で定義された変数がなければ、その外側の関数に変数を探しに行く。これをスコープチェーンと呼んでいる。クロージャーはスコープチェーンを保持しているため、関数定義時に使える変数をそのまま使うことができる。
C言語に詳しければ、JavaScriptの関数オブジェクトはC言語の関数ポインタを拡張したようなものと考えることができるだろう。関数ポインタは変数を保持しないが、JavaScriptの関数オブジェクトは変数を保持するクロージャーである。