#はじめに
ある処理が完了した後に、それに続いて順番に走らせたい処理の実装をするのに非同期処理の理解が必要だった。JavaScriptにおける非同期処理について理解が浅かったので、改めて復習。
#用語定義
-
並行処理
複数の処理を同時に進行させること -
逐次処理
複数の処理を一つずつ完結させながら順番に進めること -
グローバルコンテキスト
-
実行中のコンテキスト内の変数・関数(JavaScriptファイル内直下の実行環境)
-
グローバルオブジェクト
-
this
-
関数コンテキスト
-
実行中のコンテキスト内の変数・関数(関数宣言内{}に記述されたコードの実行環境)
-
arguments
-
super
-
this
-
外部変数
-
コールスタック
実行中のコードが辿ってきたコードの積み重ねのこと。
JavaScriptエンジンはコールスタックという仕組みを通してJavaScriptがどのように実行されてきたかを追跡している。
コールスタックは「後入れ先出し」LIFO(Last In First Out)である。 -
タスクキュー
実行待ちの非同期処理の行列のこと。キューの仕組みは「先入れ先出し」FIFO(First In First Out)である。 -
I/O
入出力のこと。入出力中は他の処理をせずに待機状態であるI/OをブロッキングI/O、対して入出力中も処理が進んでいくI/OをノンブロッキングI/Oと呼ぶ。そして一般的なwebアプリケーションのようにI/Oを多用するものに関しては、イベントループによる並行処理が適している。
#なぜ非同期処理?
UXの質を高めるためには、基本的にアプリケーションを利用するユーザーには待ち時間を与えたくない。
待ち時間を作らないためには、**複数の処理を同時に進めていくこと(並行処理)**が鍵になる。※処理によっては、ある処理の終了が次に処理の開始の条件となっていることなどもあるため、全て並行処理がいいというわけでもない。
並行処理を実現するための方法としては①マルチスレッド、②イベントループといった方法が挙げられる。JavaScript(Node.js)では②イベントループを用いて並行処理を実現している。
そしてイベントループで並行処理を実行するには、非同期プログラミングが不可欠になる。以下、非同期処理の理解の前に必要な知識について先にまとめる。
#同期処理
同期処理とは、複数のタスクを一つずつ完結させながら順番に進めていく処理である。
以下のsync.jsでは、callA, callB, callC
の順番で処理の実行を呼び出し、結果も順番通りに出力されている。この時、連続して実行される一本の処理の流れのことをスレッドと呼ぶ。
同期処理ではコードの上から順番に処理が進んでいき、上の処理が完了するまでは次の処理に進まないので、コード全体の流れが把握しやすい一方で、途中で重いタスクに差し掛かるとそこで処理に時間がかかってしまい、結果的にユーザーに待ち時間を与えることになり、サイト離脱やストレスの原因になってしまう。
このようにプログラムの進行を一時停止させてしまうような重い処理をブロッキングといい、それがI/Oだった場合はブロッキングI/Oと呼ぶ。
function callA() {
console.log("A called.");
}
function callB() {
console.log("B called.");
}
function callC() {
console.log("C called.");
}
callA();
callB();
callC();
// 出力結果
// A called.
// B called.
// C called.
#マルチスレッド
マルチスレッドでは、プログラムの実行環境が状況に応じて実行対象のスレッドを切り替えることで並列処理を実現する。マルチスレッドは、コードで並行処理を表現しなくてもよいという点が特徴だが、複数スレッドを同時かつ多数使用することで大量のメモリを消費してしまい、それが10,000程度まで増えると、レスポンス性能が著しく落ちてしまう。これをC10K問題という。
#イベントループ
マルチスレッドに起因するスケーラビリティの問題を解決するのが、イベントループである。
イベントループの詳しい仕組みはこちら(15分で理解するJavaScriptのイベントループ)を参考に。図解式で分かりやすい。。
イベントループでの並行処理を簡単に説明すると以下のようなフローで動作する。
- コールスタックにグローバルコンテキストが積まれる。
- コールスタックに関数コンテキストが積まれる。
- 関数コンテキスト内の非同期処理(タスク)がタスクキューに追加される。
- コールスタックが全て処理されて空きができたのをイベントループが検知して、その旨をタスクキューに通知する。
- イベントループからの通知をもって、先にタスクキューに入れられたタスクがコールスタックに積まれていき、後から積まれたものから順々に処理される。
これをまとめると、イベントループとは、**「コールスタックにコンテキストが積まれていないこと」と「非同期APIによってタスクキューに入れられたタスク(コールバック関数)があるか」**を繰り返し監視しながら、現在実行中のタスクがない場合(主にコールスタックが空になったとき)、タスクキューの最初のタスクを取り出し、実行する。
という役割を持つ。
また、Webサーバで代表的なApacheとNginxを比較すると、Apacheはマルチスレッドであり、サーバへの同時接続数が増えるとメモリ消費も多くなりパフォーマンスが悪くなってしまう。
一方、Nginxはイベントループで動作するため、C10K問題のような多数同時接続に起因するパフォーマンス低下や大容量データ通信に耐えることができる。
#イベントループの注意点
イベントループはシングルスレッドで実行されるため、マルチスレッドのようなスケーラビリティの懸念はない一方、コードが複雑になるという問題もある。
また、一般的なwebアプリケーションのようにI/Oを多用するものに関しては、イベントループによる並行処理が適しているが、CPU負荷の高い処理が多いアプリケーションではマルチスレッドの方が適している場合もある。
#非同期処理
非同期処理とは、あるタスクを一時的にスレッドから切り離すことで、後続の処理の進行を止めることなく別のタスクを実行できる処理方式のことである。
以下のasync.jsでは、同じようにcallA, callB, callC
の順番で処理の実行を呼び出しているが、出力結果はA called. → C called. → B called.
の順に出力されている。
同期処理の時と順番が変わっているのは、callB functionでは非同期APIであるsetTimeoutが使用されることにより、一時的にスレッドから切り離され、後続のcallCが先に進み出力されるという流れになるためである。
ここで注意したいのがsetTimeoutの第二引数である待機時間だが、たとえ0秒の設定でもあくまでこれは非同期処理になる。そのためfunction callBの中の処理というのはタスクキューに入り、コールスタックに空きが出来た時点でイベントループから通知を受けて、コールスタックに移り処理されていく。その前にはfunction callCが先にコールスタックに積まれているため、こちらの処理が先にされることになる。
仮にfunction callBがとんでもなく重い処理だったとすると、同期処理ではその処理が終わるまでcallCの処理に進まなかった
のが、非同期処理ではcallBの処理は別で進めておき、後続のcallCの処理も進めることができる。
function callA() {
console.log("A called.");
}
function callB() {
setTimeout(function() {
console.log("B called.");
}, 0);
}
function callC() {
console.log("C called.");
}
callA();
callB();
callC();
// 出力結果
// A called.
// C called.
// B called.
#コールバック
コールバックを利用した非同期処理は、JavaScriptにおいて最も基本的なものである。
コールバック関数とは、ある関数の引数に渡される関数のことであり、処理のフローを制御する際(冒頭で話した「ある処理が完了した後に、それに続いて順番に走らせたい処理の実装」)などに利用される。
以下のcallback.jsは、1回目のparseJSONAsyncWithCache()の呼び出しで内部的にキャッシュを疑似作成し、2回目のparseJSONAsyncWithCache()の呼び出しで、それを利用して結果を出力する例である。(O'reilly ハンズオンNode.jsより抜粋)
これを実行すると「1回目の結果」が出るまでには少し時間がかかるのに対し、「2回目の結果」はキャッシュが効いてすぐに呼び出されるのがわかる。
// JSONをパースする関数
function parseJSONAsync(json, callback) {
// 非同期処理
setTimeout(() => {
try {
callback(null, JSON.parse(json));
} catch (err) {
callback(err);
}
}, 1000);
}
// 空のオブジェクトを定義
const cache = {};
// キャッシュを利用してparseJSONAsyncを呼び出す関数
function parseJSONAsyncWithCache(json, callback) {
// cacheオブジェクトのjsonというキーの値をcachedと定義。
const cached = cache[json];
// 1回目の呼び出し時点では値が空なので処理は走らない。
if (cached) {
callback(cached.err, cached.result);
return;
}
parseJSONAsync(json, (err, result) => {
// ここでjsonキーに「エラーの中身」と「JSONをパースした結果」が入ったオブジェクトを代入。
cache[json] = { err, result };
callback(err, result);
});
}
// 1回目の実行
parseJSONAsyncWithCache(
'{"message": "Hello", "to": "World"}',
(err, result) => {
console.log("1回目の結果", err, result);
// コールバック関数の中で2回目の実行
parseJSONAsyncWithCache(
'{"message": "Hello", "to": "World"}',
(err, result) => {
console.log("2回目の結果", err, result);
}
)
console.log("2回目の呼び出し完了");
}
)
console.log("1回目の呼び出し完了");
/* 出力結果 */
// 1回目の呼び出し完了
// 1回目の結果 null { message: 'Hello', to: 'World' }
// 2回目の結果 null { message: 'Hello', to: 'World' }
// 2回目の呼び出し完了
しかしながら、上記の実装ではif(cached){}内のcallbackが同期的に呼び出されてしまうため、「2回目の呼び出し完了」の出力をブロックし、先に「2回目の結果」が出力されてしまっている。
このように状況によって出力の順番が変わってしまうのは、不具合が起きた際に原因の特定が困難になりやすいため、JavaScriptによる実装のアンチパターンとして挙げられる。
この現象は、一つのプログラムの中でコールバックを同期的に呼び出したり、非同期的に呼び出したり一貫性がないことで生まれる。
そのため、コールバックをパラメータとする関数は、同期的もしくは非同期的に実行することを統一しなければならない。これを解消するための実装は以下のようになる。出力結果も「2回目の呼び出し完了」の後に「2回目の結果」となっている。
###コールバックの呼び出しを非同期に統一し、修正
function parseJSONAsync(json, callback) {
setTimeout(() => {
try {
callback(null, JSON.parse(json));
} catch (err) {
callback(err);
}
}, 1000);
}
const cache = {};
function parseJSONAsyncWithCache(json, callback) {
const cached = cache[json];
if (cached) {
// キャッシュに値が存在する場合でも、非同期的にコールバックを実行する
setTimeout(() => callback(cached.err, cached.result), 0);
return;
}
parseJSONAsync(json, (err, result) => {
cache[json] = { err, result };
callback(err, result);
});
}
parseJSONAsyncWithCache(
'{"message": "Hello", "to": "World"}',
(err, result) => {
console.log("1回目の結果", err, result);
parseJSONAsyncWithCache(
'{"message": "Hello", "to": "World"}',
(err, result) => {
console.log("2回目の結果", err, result);
}
)
console.log("2回目の呼び出し完了");
}
)
console.log("1回目の呼び出し完了");
/* 出力結果 */
// 1回目の呼び出し完了
// 1回目の結果 null { message: 'Hello', to: 'World' }
// 2回目の呼び出し完了
// 2回目の結果 null { message: 'Hello', to: 'World' }
###コールバックヘル
また、複数の非同期処理を逐次的に実行するためにコールバックを多用するとコードのネストが深くなり、可読性や保守性が下がってしまう。これをコールバックヘルという。
したがって、コールバックを使用する際にはコールバックヘルのように非同期処理が連続して呼び出されないようにしなければならない。
これを解消する方法として、ES2015で導入されたPromiseがある。
#Promise
Promiseは、非同期処理の状態と結果を表現するオブジェクトである。
Promiseを使うと、コールバックの時よりも簡単に処理のフローを制御することができる。
以下のPromise.jsは、add関数を呼び出した際に1秒待機して引数に渡された値を足し、その結果を返す例である。
add関数の呼び出し部分の後に続く部分は、add関数の処理結果がresolve(成功)の時にthenメソッド、逆にreject(失敗)の時にはcatchメソッドがそれぞれ非同期で処理される。thenメソッド、catchメソッドが終わったら、共通でfinallyメソッドが呼ばれる。
これらは非同期処理のため、add関数が完了した後に処理される(コールスタックが空になった後、タスクキューからコールスタックに積まれて処理される)ので、結果的に「ある関数の処理が完了したら、次の関数を処理する」
というフローが実現できる。
// 1秒後に、渡された数値に1を足す関数
function add(data) {
// Promiseインスタンスを返す
return new Promise((resolve, reject) => {
// 非同期処理
setTimeout(() => {
// 出力後に1を足す
console.log(data++);
resolve(data);
// reject(new Error("error occurred."));
}, 1000);
});
}
add(0).then((data) => {
// チェーンをつなげるためにadd関数を呼び出し、Promiseインスタンスを返す
return add(data);
}).then((data) => {
return add(data);
}).then((data) => {
return add(data);
}).catch((error) => {
console.error(error);
}).finally(() => {
console.log("finally called.");
});
/* 出力結果 */
// 0
// 1
// 2
// 3
// finally called.
#async/await
async/await構文はES2017で導入され、Promise構文と比べてより直感的に非同期の逐次処理を実現するものである。先ほどのPromise構文をasync/await構文に直すと以下のようになる。
add関数の呼び出しをasync宣言をしたcallAdd関数で囲み、その中でadd関数の呼び出しをawaitを使うことで同じ挙動を再現できる。この場合の戻り値はPromiseなので、変数resultに代入し、それをまたadd()の引数として渡すことでチェーンをつなげることができる。
また、async関数自体(callAdd)もPromiseを返すので、callAdd()の後にthenメソッドを使用しさらにチェーンをつなげることも可能である。
function add(data) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(data++);
resolve(data);
}, 1000);
});
}
async function callAdd() {
let result = await add(0);
result = await add(result);
result = await add(result);
result = await add(result);
// エラーを投げるときはエラーインスタンスをthrowする
// throw new Error();
return result;
}
// async宣言をしたfunction自体もPromiseを返すので、thenメソッドなどでチェーンをつなげれる
callAdd().then(function (result) {
console.log(`実行回数${result}回`);
}).catch(function (err) {
console.error(err);
}).finally(function () {
console.log("finally called.");
})
/* 出力結果 */
// 0
// 1
// 2
// 3
// 実行回数4回
// finally called.
#おわりに
JavaScriptにおける非同期処理は理解が難しいが、内部的にどのようなメカニズムになっているのかが分かると理解しやすいかも。
多分触りの方はふわっと理解できたので、実務にも活かせるようにしていきたい。
記事内で間違えている点ありましたら、教えていただけると嬉しいです。
#参考
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function
https://qiita.com/guanghuihui/items/57dcc7cb867eee951f36
https://meetup-jp.toast.com/896
https://www.udemy.com/course/javascript-essence/