【JavaScript】非同期処理Promiseの直観的解説

express2-2.png

JavaScriptの学習者の間では、私自身を含めて、非同期処理のPromiseのしくみがどうも解りにくい印象があるようです。この記事では、Promiseのしくみを直観的に理解することを目的として、同期処理と非同期処理のおさらいからPromiseまでを、食堂(ショッピングモールなどにあるフードコート)の注文をモチーフにして書いてみたいと思います。

話をシンプルにするためにエラー処理に関してはほぼ記述しないことにします。また、プログラミングのコードは説明目的で書いた架空のコードで、コードそのものはあまり役に立たないかもしれません。

同期食堂

その昔「同期食堂」という食堂がありました。食べ物は美味しいのですが、注文のやりかたが少し不評でした。お客がメニューを注文すると、その場でじっと完成するまで待たされるのです。

同期食堂

1つのメニューに10分待たされるとすると、すべの料理ができるまで40分かかります。

1
2
3
4
5
6
//  12:00に注文開始
var udon_1 = SyncOder("Udon");
var udon_2 = SyncOder("Udon");
var pizza_1 = SyncOder("Pizza");
var pizza_2 = SyncOder("Pizza");
// 12:40にすべて完成

お客さんは待っている間に他のことが何もできなくなって(ブロッキングされて)しまうのです。そこでその問題を解決すべく、非同期という考えを使った新たな食堂が生まれました。

コールバック食堂

コールバック食堂では「非同期」という概念が取り入れられて、お客さんは注文を済ませたらその場を離れてもいいというルールになりました。

その場を離れていても、料理が完成した時にお店の人が「お客さーん、できましたよ」と呼び戻してくれるので、呼ばれるまでの時間は他のことをして自由に過ごすことができるのです。(ノンブロッキング)

コールバック食堂

しかし、少し分かりにくいという声もありました。注文をするときに、「料理を受け取ったらどうするか」という計画を「コールバック関数」という形でお店に渡す必要があるのですが、その書き方がどうも直感的に分かりにくいのです。

タイミングがわかりにくい問題

先ず、直感的に少し分かりにいという声があった点は、コールバック関数の実行タイミングについてでした。例えば、次のような非同期に実行結果を返すasyncOrder関数があったとします。

1
2
3
4
5
6
function asyncOrder(menu, callback){
var meals = {"Pizza":"ピザ","Udon":"うどん"};
setTimeout(function(){
callback(meals[menu]);
},1000);
}

この関数を以下のように呼び出してみます。

1
2
3
4
5
console.log("注文");
asyncOrder("Pizza", function (pizza_1){
console.log(pizza_1+"を受け取って食べる");
});
console.log("完了");

このコードを実行すると、以下の順番でメッセージが表示されます。

1
2
3
注文
完了
ピザを受け取って食べる

「ピザを受け取って食べる」は非同期処理が完了してから最後に遅れて表示されますが、この点がどうも間違いやすい気がします。

コールバック関数が深くなる問題

さらに、コールバック食堂に問題がでてきました。それは複数のメニューを「直列に」注文するときに起こりました。「直列」というのは例えば、先ずピザを注文して、出来上がったピザを見てから次の料理を注文するというように、時間軸に対して直列に、注文→完成→次の注文というイメージです。

もし、かつての同期食堂だったら以下のように書くだけで済むので、記述のわかりやすさという点ではこちらの方がよかったかもしれません。

1
2
3
4
5
6
7
8
9
/*
同期食堂
*/
// 1枚目のピザを注文する
var pizza_1 = syncOrder("Pizza");
//ピザが小さかったら2枚目を注文する。
if(pizza_1.lengh < 2 ){
var pizza_2 = syncOrder("Pizza");
}

これを、コールバック食堂では次のように書きます。

1
2
3
4
5
6
7
8
9
10
11
/*
コールバック食堂
*/
// 1枚目のピザを注文する
asyncOrder("Pizza", function (pizza_1){
//ピザが小さかったら2枚目を注文する。
if(pizza_1.lengh < 2 ){
syncOrder("Pizza",function(pizza_2){
});
}
);

この書き方だと、直列の注文が増えるとコールバック関数のネストがどんどん深くなってしまいます。これらの問題は、お客たちの間で「コールバック地獄」と揶揄されるようになってしまいました。そして数年の月日が流れ、この問題を解決すべく新たな食堂が誕生しました。

プロミス食堂

プロミス食堂では、お客が料理を注文をしたときに「プロミスオブジェクト」という呼び出しベルのようなものを渡すことにしました。お客さんはそのオブジェクトを受け取って、料理が完成するのを待ちます。

プロミス食堂

このオブジェクトには調理の状況(status)を表すランプが3つ付いています。
・準備中(Pending)
・完成(Fulfilled)
・失敗(Rejected)

プロミスオブジェクトを受け取ったときの初期値はPendingになっています。料理が完成したときに、コックさんが「resolve でーす」と言って料理を差し出すとstatusはFulfilledに変わります。お店側で何かトラブルが発生して料理ができないときには「reject します」と言って、statusはRejectedに変わります。

プロミス食堂2

また、このプロミスオブジェクトはただの呼び出しベルとは違い、then()というメソッドを持っています。then()は料理ができた時に実行され、料理を届けてくれます。

Promise thenメソッド

プロミス食堂では、料理を受け取るときの処理(コールバック関数)をthen()のところに書きます。

1
2
3
4
5
6
7
8
// 1枚目のピザを注文する
var promise_1 = asyncOrderPromise("Pizza"); // asyncOrderPromise は後述します
var promise_2 = promise_1.then(function(pizza_1){
//ピザが思ったより小さかったら2枚目を注文する。
if(pizza_1.length < 3 ){
return asyncOrderPromise("Pizza");
}
});

また、then関数→次の処理→then関数→次の処理→というようにチェーンを使って書くこともできます(Chaining)。なぜ、Chainingできるのかというと、then関数の戻り値でプロミスオブジェクトを返し、また次のthen関数の戻り値でもプロミスオブジェクトを返すというようなしくみになっているからです。

then関数のチェーン

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1枚目のピザを注文する
asyncOrderPromise("Pizza")
.then(function(pizza_1){
//ピザが小さかったら2枚目を注文する。
if(pizza_1.length < 3 ){
return asyncOrderPromise("Pizza");
}
})
.then(function(pizza_2){
//2枚目のピザを受け取ったときの処理
}).catch(function(e){
console.log(e)
});

このようにプロミス食堂では深いネストを使わずに処理を書くことができるようになりました。

上の例では、お店の客側の立場としての処理を書きましたが、料理を作る側の処理も少し見ておきましょう。

お店側の流れ

お店側はどのような流れになっているでしょうか。注文をとったときにプロミスオブジェクトを返すところを見てみましょう。

プロミス食堂お店側の流れ

  1. ピザの注文が入る
  2. レシピを「プロミスオブジェクト生成機(Promiseクラス)」に渡す。レシピには調理の手順が書いてある。完成したらresolve()を実行し、失敗したらreject() を実行する。
  3. プロミスオブジェクトをnewする
  4. プロミスオブジェクトを返す(お客さんに渡す)
1
2
3
4
5
6
7
8
9
/*
お店側の注文処理
*/
function asyncOrderPromise(menu){
// プロミスを生成
const p = new Promise(--ここにレシピの関数を挿入--);
// 生成したプロミスを返す
return p;
}

レシピとなる関数の目的は調理の計画を登録することで、基本的なフォーマットは決まっています。
先ず、パラメータを2つ受け取ります。

第1引数:料理が成功した時に実行するresolve関数。
第2引数:料理が失敗した時に実行するreject関数。

※この部分は引数名なので、他の名前(例:res,rej)にすることもできます。

1
2
3
4
5
6
// レシピ
function(resolve, reject) {



}

そして、調理の手順を書いて。。。
成功したら resolve() 、失敗したら reject() を実行します。

1
2
3
4
5
6
7
8
9
10
// レシピ
function(resolve, reject) {
setTimeout(function() {
// ここで調理
var meals = {"Pizza":"ピザ","Udon":"うどん"};
var meal = meals[menu];
console.log("リゾルブします("+meal+")")
resolve(meal);
}, 1000);
}

このレシピ関数を、「プロミスオブジェクト生成機(Promiseクラス)」へのパラメータとして渡すので、asyncOrderPromise関数は以下のような形になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
お店側の注文処理
*/
function asyncOrderPromise(menu) {
var p = new Promise(function(resolve, reject) {
setTimeout(function() {
// ここで調理
var meals = {"Pizza":"ピザ","Udon":"うどん"};
var meal = meals[menu];
console.log("リゾルブします("+meal+")")
resolve(meal);
}, 1000);
});
return p;
}

なかなか複雑ですね。

プロミス食堂のコードまとめ

プロミス食堂のコードをまとめると以下のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*
お客の注文処理
*/
// 1枚目のピザを注文する
asyncOrderPromise("Pizza")
.then(function(pizza_1){
console.log(pizza_1+"を受け取った");
//ピザが小さかったら2枚目を注文する。
if(pizza_1.length < 3 ){
return asyncOrderPromise("Pizza");
}
})
.then(function(pizza_2){
console.log(pizza_2+"を受け取った");
//2枚目のピザを受け取ったときの処理
}).catch(function(e){
console.log(e)
});
/*
お店側の注文処理
*/
function asyncOrderPromise(menu) {
var p = new Promise(function(resolve, reject) {
setTimeout(function() {
var meals = {"Pizza":"ピザ","Udon":"うどん"};
// ここで調理
var meal = meals[menu];
console.log("リゾルブします("+meal+")")
resolve(meal);
}, 1000);
});
return p;
}

実行結果は以下のようになります。

1
2
3
4
5

リゾルブします(ピザ)
ピザを受け取った
リゾルブします(ピザ)
ピザを受け取った

これで、Promise.all の話をする準備が整いました。

Promise.all

プロミス食堂に家族連れが来て「ピザ2枚とうどん2杯」を注文しました。
今回は4つの料理を同時に作ってもいいという話です。つまり直列ではなく並列です。

Promise.all

注文の受け付けでは、すぐに4つのプロミスオブジェクトを返しました。

1
2
3
4
var promise_1  = asyncOrderPromise("Pizza");// ピザ1枚目
var promise_2 = asyncOrderPromise("Pizza");// ピザ2枚目
var promise_3 = asyncOrderPromise("Udon");// うどん1杯目
var promise_4 = asyncOrderPromise("Udon");// うどん2杯目

お客さんは4つのプロミスオブジェクトを受け取りました。

ここで、2つのパターンの受け取りかたを考えてみます。

1つずつ受け取るパターン

1つ目は、料理が完成した都度、個別に料理を受け取るパターンです。
最初にピザが1枚だけ完成した場合は、こんな感じのイメージ図になります。

1つずつ受け取るパターンg

まとめて受け取るパターン

2つめのパターンは、料理が全て揃うまで待ってから受け取るパターンです。4つの料理ができる順番はどうでも構いません。
このように、複数の並列処理が全て完了するのを待ってから次の処理に移りたいときに、とても便利な関数があります。それがPromise.all です。

まとめて受け取るパターン

Promise.all は、複数のプロミスオブジェクトを1つにまとめて、5番目のプロミスオブジェクトをつくってくれます。

1
2

var promise_5 = Promise.all([promise_1, promise_2, promise_3, promise_4])

しかも気が利いているのは、5番目のthenでは、全ての料理を配列にいれて渡してくれるのです。

Promise all 配列

1
2
3
promise_5.then(function(values){
console.log(values); // Array [ビザ,ピザ,うどん,うどん]
});


今回の記事では、JavaScriptの非同期のしくみを食堂をモチーフに説明してみました。 次回は、この流れでasync/awaitの話を記事にしてみたいと思います。







writer | 太田直毅
株式会社OTAシステム開発。フルスタックエンジニア。 仙台市生まれ。大学卒業後に米国系通信会社にエンジニアとして就職。 1998年に独立し、WEBアプリケーションの開発を中心に活動。2児の父。趣味は家族キャンプ。
この記事をシェアする