LoginSignup
14
11

More than 3 years have passed since last update.

15分縛りで非同期, Promise, async/awaitを教える

Last updated at Posted at 2020-08-30

この記事を書いた経緯

 この記事は、2020/08/19に会社で実施した「まつもとさん勉強会」(※Ruby生みの親Matz氏の前でLTしてコメント貰ったりMatz氏に質問したりする会)で私が発表した内容をもとに文字起こししたり、多少ブラッシュアップして記事化したものです。約15分の時間制限の中で、JavaScriptのPromiseasync/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)処理って何?

同期処理、非同期処理、並行処理、並列処理?

画像を見てください。
スクリーンショット 2020-08-29 20.07.52.png

 コードの記述通りに処理が実行される同期処理と、必ずしもコードの記述通りに処理が実行されない非同期処理というものがあります。
 そして非同期処理の中にも種類があり、「本当に同時に実行する」並列(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コアしかなかった場合は、もちろん並列に処理できないため、並行処理になります。

スクリーンショット 2020-08-22 23.23.56.png

 Rubyでは新しいスレッドを作りましたが、しかしJSは長らくシングルスレッドの言語でした。
 最近だとWorkerというものを使って、JSでマルチスレッドで並列な処理も記述できるようになりましたが、今回の話のスコープからは外します。

スクリーンショット 2020-08-22 23.35.03.png

 画像の通り、シングルスレッドでは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のレスポンスよりこれは先に出力される!");

 上のコードを読むと、axiosgetメソッドを使って、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は値をラップしうるオブジェクトです。

スクリーンショット 2020-08-23 14.04.14.png

 そしてもう一つ、Promiseは3つの状態を持つという重要な性質があります。その状態とは、Pending、Fulfilled、Rejectedです。

スクリーンショット 2020-08-23 14.06.16.png

 Promiseは初期状態だとPendingなのですが、例えばaxiosでは、APIから値が返ってくるとFulfilledな状態となり、中身にレスポンス結果を内包した状態になります。

 そして、Fulfilledな状態となったPromiseのインスタンスへは、then()メソッドのコールバックで中身にアクセスすることができます。axiosの例で書いた通りです。もしタイムアウトなどのエラーになった場合はRejectedな状態となり、then()の第2引数のコールバックやcatch()メソッドでエラー処理ができます。catch()の説明については時間の都合で省略します。

ここまでで、きっとaxiosPromiseを使うことはできるようになったと思うので、続いては自分で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の位置です。

スクリーンショット 2020-08-24 10.11.43.png

 赤枠で囲っているのが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/awaitPromiseを理解していればとても簡単です。
 awaitPromiseが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の道標

14
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
11