この記事を書いた経緯
この記事は、2020/08/19に会社で実施した「まつもとさん勉強会」(※Ruby生みの親Matz氏の前でLTしてコメント貰ったりMatz氏に質問したりする会)で私が発表した内容をもとに文字起こししたり、多少ブラッシュアップして記事化したものです。約15分の時間制限の中で、JavaScriptのPromise
やasync/await
を知らないRubyプログラマに知ってもらおうという内容です。おそらくRubyを知らない人でもお役に立てるのではないかと思います。
わりと時間ギリギリなのでオタク特有の早口言葉でLTすることにはなりましたが、割と好評だったためQiita化しました。
対象読者
- JSのアロー関数の書き方が分かる
- JSにおけるコールバック関数(あるいは関数を引数に取る高階関数)を理解している
注意点
この記事ではアロー関数での記述が沢山出てきます。ES2015以降、JSではアロー関数での定義は普通に行われるので、モダンJSや関数型プログラミングに慣れていない方には読みにくい箇所があるかもしれません。もし不慣れであった場合は、先にアロー関数の書き方に慣れておいてください。
// 定義
const hello = (name) => console.log(`hello, ${name}!`);
// 呼び出し
hello('Taro');
# ちなみにRubyでもProcを使った似たような書き方がある
# 定義
hello = ->(name) { puts "hello, #{name}!" }
# 呼び出し
hello.call('Taro')
それでは本題に入りましょう。
Promiseって何?
Promise
は**非同期処理(並行処理)**を書く時、知っているべきJSのクラスです。
ES2015(ES6)でPromise
が承認されました。ESはECMAScriptの略です。ECMAScriptはJSの標準規格です。ES2015, ES2016,...ES2020のように、年々新しくなり、JavaScriptの新しい文法が増えていきます。JSが誕生したのは1995年のため、ES2015は比較的最近のJavaScriptであると言えるでしょう。
そもそも非同期(async)処理って何?
同期処理、非同期処理、並行処理、並列処理?
コードの記述通りに処理が実行される同期処理と、必ずしもコードの記述通りに処理が実行されない非同期処理というものがあります。
そして非同期処理の中にも種類があり、「本当に同時に実行する」並列(parallel)処理と、「処理を同時に実行しているっぽく見せている」並行(concurrent)処理があります。
図に書いた通り、並行処理(≒非同期処理) ⊃ 並列処理
のような関係です。
Promise
は「実は同時には実行していないけど処理を同時に実行しているっぽく見せている」並行(concurrent)処理です。
// function1,2,3がそれぞれ、
// 同期処理だったら: function1が完了したらfunction2を実行する。
// 非同期処理だったら: function1が完了する前にfunction2の処理を始めてしまったりする
function1()
function2()
function3()
本当に同時に(並列に)実行している/していない?
本当に同時に実行しているということはどういうことなのか説明します。例えばRubyのプログラムを実行したら、Rubyのプロセスが起動して、通常はそのメインスレッドで処理を行います。メインスレッドの処理の中でThread.new
すると、別のスレッドが起動して、処理を実行できます。
そして、この1スレッドあたり、CPUの論理コア1個にしか命令ができません。なので、2スレッドでRubyのプロセスを実行していた場合は、2コアに命令を下して本当に同時に処理することができます。これが並列処理です。
図の左側はプロセスで、右側はCPUのコア数です。例えばIntel Core i3-10320
というCPUのスペックは4コア/8スレッドです。これは、物理的には4コアしかないけれども、OSからは8コアもあるように見える(=8スレッドまで同時に処理できる)ことを意味します。
ちなみに使用できるコアが1コアしかなかった場合は、もちろん並列に処理できないため、並行処理になります。
Rubyでは新しいスレッドを作りましたが、しかしJSは長らくシングルスレッドの言語でした。
最近だとWorker
というものを使って、JSでマルチスレッドで並列な処理も記述できるようになりましたが、今回の話のスコープからは外します。
画像の通り、シングルスレッドでは1コアにしか命令ができず、本当に同時に別の処理を実行することはできません。なのでJSのPromise
では、並行処理はできても、並列処理は行えません。
1コアなのに同時に動かしているように見せる?
例えば、とあるAPIでユーザー情報を取得するコードがあったとします。きっとそのコードは、APIに、「このIDのユーザーの詳細情報をおくれ」とリクエストを投げるでしょう。もしあなたのコードが全て同期処理であったら、APIにリクエストを投げてレスポンスが返ってくるまでの間中、そのCPUの論理コアはただAPIからのレスポンスを待っているだけで、何も仕事をしないことになります。それは勿体ないですよね。
レスポンスをただ待ってるだけなら、CPUに別の仕事させたくなります。JSの非同期処理はこの欲求を解消してくれます。処理の実行順を入れ替えることで、実際は並列に処理はしていないけれども、並行して処理が進んでいるように見せることができます。
もしあなたが富豪的プログラマなら、「別に複数スレッドにして複数コアを使えば良いのではないか?」と思うかもしれません。しかし、新しいスレッドにメモリを割り当てたりするコストは大きく、APIの例のような「何もせずレスポンスを待ってるだけ」の時は、シングルスレッドの並行処理の方が結果的に速く処理を終えるケースが多いです。
一方で、例えば「フィボナッチ数列を延々計算していく」ようなCPUの計算能力をフルで使う処理の場合は、シングルスレッドの並行処理は無力です。この場合は複数コアを使って並列化しないと実行速度の向上は見込めません。
雑に総括すると
API(とか)の応答を待ってる間にCPUに他のことをさせようぜというのがJSのシングルスレッド並行処理です。
まずはPromiseを使ってみよう!
まずはPromise
を使ってみましょう。使うだけならとても簡単です。
もしかしたら、あなたは気付かぬうちにPromise
を使っているかもしれません。
axiosでAPIにリクエストを投げる
axiosは、よく知られたHTTPクライアントライブラリです。ブラウザやNode.jsで利用できます。もしかしたらこの記事を読んでいる人の中でも使ったことがあるという人も多いのではないでしょうか。
下の例のコードを見てください。
const axios = require('axios');
const url = "https://hogehoge.com/api/users";
const param = "?id=1";
axios.get(url + param)
.then((res) => {
const userName = res['users'][0].name
console.log(`ユーザー名は${userName}です!`);
});
console.log("APIのレスポンスよりこれは先に出力される!");
上のコードを読むと、axios
のget
メソッドを使って、APIにユーザー情報のリクエストを送り、返ってきたユーザー名をコンソールに出力していることが分かると思います。
このコードのどこにもPromise
とは書いてありませんが、Promise
はしっかり登場しています。どこに登場しているかというと、
axios.get(url + param)
ここです。実はこのaxios.get()
が実行されると、返り値としてPromise
のインスタンスが返ってきています。APIから値が返ってくると、私達は**Promise
オブジェクトにくるまれた{"users": [...]}
のようなAPIのレスポンス**を手に入れることができます。
レスポンスが返ってきたPromise
インスタンスに対してはthen()
メソッドが呼べるので、そのthen()
のコールバックの第一引数で中身のAPIレスポンス結果にアクセスできます。
ちなみにaxios.get()
は非同期関数なので、このコードの処理の順番がどうなるかというと、
const axios = require('axios'); // 1
const url = "https://hogehoge.com/api/users"; // 2
const param = "?id=1"; // 3
axios.get(url + param) // 4
.then((res) => { // 6
const userName = res['users'][0].name // 7
console.log(`ユーザー名は${userName}です!`); // 8
});
console.log("APIのレスポンスよりこれは先に出力される!"); // 5 ← 先に実行される
あくまで予測ですが、おそらくはコメントアウトに書いたような順番になります。
axios.get()
でリクエストを投げてレスポンスが返ってくるまでは時間があるので、先に最後の行のコンソール出力をやってしまって、APIのレスポンスが返ってきたらコメントの6番目の処理が始まるはずです。
何が非同期関数か、というのはJSでは覚えゲーです。覚えましょう。
Promiseとは
さて、axios
のコードの例でも登場しましたが、Promise
は値をラップしうるオブジェクトです。
そしてもう一つ、Promise
は3つの状態を持つという重要な性質があります。その状態とは、Pending、Fulfilled、Rejectedです。
Promise
は初期状態だとPendingなのですが、例えばaxiosでは、APIから値が返ってくるとFulfilledな状態となり、中身にレスポンス結果を内包した状態になります。
そして、Fulfilledな状態となったPromise
のインスタンスへは、then()
メソッドのコールバックで中身にアクセスすることができます。axios
の例で書いた通りです。もしタイムアウトなどのエラーになった場合はRejectedな状態となり、then()
の第2引数のコールバックやcatch()
メソッドでエラー処理ができます。catch()
の説明については時間の都合で省略します。
ここまでで、きっとaxios
でPromise
を使うことはできるようになったと思うので、続いては自分でPromise
をインスタンス化してみましょう。
Promiseを自分でインスタンス化し、非同期関数を自分で作ろう!
JSにはご存知の通り、setTimeout(callback, time)
という関数があります。この第1引数にコールバック関数を、第2引数に時間をミリ秒して指定してあげることで、指定ミリ秒後にコールバックの処理を実行できるというものです。
それでは、2秒後に1!
と出力して、そのまた2秒後に2!
...と出力するにはどうしたらいいでしょうか。こうなります。
// 地獄のコールバックピラミッド
setTimeout(() => {
console.log("1!")
setTimeout(() => {
console.log("2!")
setTimeout(() => {
console.log("3!")
}, 2000);
}, 2000);
}, 2000);
どうして↓のように書けないかというと、それは**setTimeoutが非同期関数だから(覚えゲー)**です。↓のように書くと、一行目のsetTimeoutが終わる前に次のsetTimeoutが走り出し、結果として最初に2秒カウントした後にすぐさま1! 2! 3!
と出力されるはずです。
// そもそも動作が要件を満たしていない例
setTimeout(() => {
console.log("1!")
}, 2000);
setTimeout(() => {
console.log("2!")
}, 2000);
setTimeout(() => {
console.log("3!")
}, 2000);
なので、コールバックの中にコールバックを書いてそのその中にまたコールバックを書いて...地獄のコールバックピラミッドを建造せざるをえないのです。
そこで、Promise
を返すsleep(time)
関数を自分で実装することにしました。このsleep関数を使えば、このように記述できます。
// フラットに書ける!
sleep(2000)
.then(() => {
console.log("1!")
return sleep(2000)
})
.then(() => {
console.log("2!")
return sleep(2000)
})
.then(() => {
console.log("3!")
})
フラットに書けて多少嬉しいですね。それではこの関数の実装がどうなっているか見てみましょう。
const sleep = (msec) => {
return new Promise((resolve) => {
setTimeout(() => resolve(), msec);
});
};
// 慣れてる人には1行で良い
// const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
注目すべきはreturn
の位置です。
赤枠で囲っているのがPromise
のインスタンスを作っている場所です。見て分かる通り、このsleep関数は、実行後すぐさまPromise
のインスタンスを返すことが分かります。
先にPendingな状態のPromise
をまず返してしまうのです。ちなみにこのPromise
のような方式を、並行/並列のデザインパターンで「Futureパターン」と呼ぶようです。
Promise
はインスタンス化する時にコールバック関数を渡すことができます。new Promise(callback)
です。このcallback
の引数の形式は決まっており、1つ目にPromise
をFulfilledにするためのresolve
関数, 2つ目にRejectedにするためのreject
関数が来ます。今回はrejectは不要なため省略しました。
sleep関数の例では、おなじみのsetTimeoutを呼んで、msec
ミリ秒後にresolve
を実行していることが分かります。こうすることで、指定ミリ秒後にFulfilledになるPromise
インスタンスを作り出すことができます。
resolveはここではresolve();
として引数無しで呼んでいますが、例えばresolve("hello");
のように呼び出せば、後でthenのコールバックを呼ぶ時にその引数に値を渡すことができます。
そんなわけでsleep関数が出来上がりました。
さて、もう一度sleep関数の呼び出し時の例をここに記載しますが、この呼び出しを見てどう思いますか?
// フラットに書ける!
sleep(2000)
.then(() => {
console.log("1!")
return sleep(2000)
})
.then(() => {
console.log("2!")
return sleep(2000)
})
.then(() => {
console.log("3!")
})
正直なところ、まだキモいと思っていませんか?
実は、先程作ったsleep関数は、更にこう呼び出すことができます。
const hogeFunc = async () => {
await sleep(2000)
console.log("1!")
await sleep(2000)
console.log("2!")
await sleep(2000)
console.log("3!")
};
hogeFunc();
続いては、このasync/await
を使ってPromise
を使った処理をよりセクシーに記述する方法についてお伝えします。
async/awaitって何?
async/await
はJSの非同期処理を書く時、知っているべき記法です。
ES2016でasync/await
が入りました。Promise
はES2015なので、その1年後に入りました。
async/await
はPromise
を理解していればとても簡単です。
await
はPromise
がFulfilledになるのを待つだけです。
そしてawait
を使うためには、その関数に修飾子としてasync
を付けなくてはいけないというだけです。
つまり、function hoge() {}
だったらasync function hoge() {}
、
() => {}
だったらasync () => {}
のようになります。
async/awaitの実例
await
を使えばaxiosで書いたコードの例は↓のようになります。
const asyncMain = async () => {
const url = "https://hogehoge.com/api/users";
const param = { user_id: "1" };
const res = await axios.post(url, param)
const userName = res['users'][0].name
console.log(`ユーザー名は${userName}です!`);
console.log("注意: API応答より後に出力される!!!");
}
asyncMain();
console.log("API応答より先に出力される!");
async/awaitを使う際の注意点
注意点が2つほどあります。
1つ目は、async/await
はあくまで非同期処理を同期的に書くための構文だということです。非同期処理全てをawait
してしまったら結局APIが返ってくるまで処理がブロックされてしまうので、本来並行に処理したかったものも並行に処理されなくなってしまい動作が遅くなる可能性があります。
もう1つは、エラー処理です。Promise
を生のまま使う時は.then()
のように.catch()
をつけるのですが、async/await
の場合はtry/catch
で処理全体を囲ってやる必要があります。
そのため、逆にasync/await
の方が可読性を損なう場合があると判断する人もいます。とはいえこの辺りは好みの問題ですが、実際の仕事アプリケーションを書く時は、async/await
で書くにしろ生Promise
で書くにしろ、エラー処理はしっかり書くようにしましょう。
async/await
についてはそれだけです。
後は時間の許す限り、ざっくりといろいろ紹介していきましょう。
残り時間で雑に紹介コーナー
Promise.all
複数のPromise
を同時に待ち受けることができます。
ES2015から使えます。
Promise.all() - JavaScript | MDN
Promise.race
ほぼ使わないと思いますが、複数のPromise
を競争させられます。
ES2015から使えます。
Promise.race() - JavaScript | MDN
Promise.allSettled
2020/06/16に承認されたES2020で追加されました。
Promise.all
で物足りない人は調べてみてください。
Promise.allSettled() - JavaScript | MDN
こんなproposalもあるらしい
まだStage1なので承認されるか不明ですが、未来にはこんな風に書けるかもしれません。
await.all
// before
await Promise.all(users.map(async x => fetchProfile(x.id)))
// after
await.all users.map(async x => fetchProfile(x.id))
参考リンク
Promise - JavaScript | MDN
parallel と concurrent、並列と並行の違い
【図解】ハイパースレッディング(SMT)の仕組み~メリットとデメリット、悪影響や脆弱性などの問題について~ | SEの道標