JavaScriptの非同期処理、理解の糸口となるニュアンスを分かりやすく紹介 〜コールバック, async/await, I/O 〜
プログラム自学案内の32回目です。今回は、JavaScriptのコールバックとasync/await構文を紹介します。この記事が、チマタに膨大にあるこれらへの解説を理解するための、糸口になればと思います。なお、前回までの記事はこちらです。
はじめに
JavaScriptプログラミングでは、コールバック と async/await、これについての理解が重要です。どちらも、非同期処理 を扱うための書き方です。
「なんで非同期処理を扱わなきゃいけないの?」
「というかそもそも、非同期処理ってなにさ?」
「でもって、コールバック と async/awaitってどういうこと?」
この記事は、これらの疑問に対する、なるべくシンプルで、エレガントな説明を目指します。
非同期処理を扱う理由
最初の問いから見てみましょう。
「なんで非同期処理を扱わなきゃいけないの?」
JavaScriptでは、非同期処理を使わざるをえない事情があるからです。じつはこの事情、次の二つのケースでびみょうに異なっており、それぞれべつの理由で、非同期処理を扱うはめになるのですね。
サーバ側のNode.jsプログラミングの事情
フロントエンド(ブラウザ)側のJavaScriptプログラミングの事情
話をシンプルにするため、この記事ではサーバ側のNode.jsプログラミングの事情に絞って説明します。
Node.jsの事情: シングルスレッドでサーバの仕事をしたい
JavaScriptには、「2つ以上のプログラムが同時に動くことはない」と言う特徴があります。(これを シングルスレッド (single thread) であると言ったりします)
一方、Webアプリは サーバ として動きます。つまり、Node.jsアプリは、同時に1つのことしか処理できないのに、聖徳太子のように複数の仕事を並行して処理しなければならないのです。
したがって、そのために工夫が必要になる、というのがNode.jsの事情です。
「なんで工夫が要るの? コンピューターだから1人でもめちゃ早く処理できるんじゃないの? 実際このときには工夫が必要ではありませんでしたよね?」
そのときはそうでした。でも、自分がいくらめちゃ早く処理できても、それだけでは複数の仕事を同時に処理するのに困る場合があるのです。PostgreSQLとの連携なんかは、まさにその場合にあたるのですね。
たとえ話:ワンオペ・ホールスタッフむけマニュアル
ここで、たとえ話をさせて下さい。たとえ話は、精確な理解は望めないかわりに、理解の糸口となるニュアンスをつかみやすくする、はずですので。
前々々回の記事は、排他制御を「複数の人が協働する」実世界の問題になぞらえて考えました。一方、今回のJavaScriptの非同期処理は、「1人が複数の仕事を並行して同時にこなす」実世界の問題になぞらえて考えることができます。
ここでは、厨房にはスタッフが何人もいる、客は何組も入る、しかしホールスタッフが 1人しかいない 飲食店で、このワンオペ・ホールスタッフにどういうマニュアルを書いてあげれば良いか考えてみましょう。
ダメな例その1:同期処理
たとえばこんなふうなマニュアルがあるとします。
function 客によばれたら() {
客席に行く();
客から注文を聞き取る();
厨房に行く();
注文内容を調理してもらう(); //実はここに問題あり
料理を客席に運ぶ();
ホールの待機場所に戻る();
}
パッと見良さそうで、じつは問題があります。
「注文内容を調理してもらう」がくせものです。というのも、このマニュアルに従い、ホールスタッフがずーっと厨房で料理が出来上がるのを待つとどうなるでしょう。
ホールは完全に放置され、そのあとから入店した客は全員、1人目の料理ができあがるまで、ひたすら待たされ注文も受けてもらえないことになります。どれだけこのホールスタッフの歩くスピードが早く、正確迅速に注文を取れたところで、厨房で料理ができるまで待ちぼうけしているようでは、全てが台無しになるのです。
ここで、「注文内容を調理してもらう」ことは、依頼先(厨房)の仕事が終わるまで待つという意味あいをとらえて、同期処理(synchronous process)、ブロッキング処理(blocking process) などと呼ばれます。ニュアンスとしてはブロッキング処理の方が分かりやすいですね。ホールスタッフの次の仕事を妨げる(ブロックする)からです。
ダメな例その2:非同期処理
ではこれではどうでしょう。
function 客によばれたら() {
客席に行く();
客から注文を聞き取る();
厨房に行く();
注文内容を厨房に伝える(); //待たないようにする
料理を客席に運ぶ(); //料理が出来ていないのに?
ホールの待機場所に戻る();
}
料理ができるまで待つのが問題だったので、「注文内容を調理してもらう」を「注文内容を厨房に伝える」に変えました。
これも、うまくいきません。というのは、注文を厨房に伝えただけでは、料理は出来てませんので。出来ていない料理を客席には運べません。空のトレイを客席に持って行って「ごゆっくり召し上がりください」なんて言おうものなら、お客はぷんぷんです。
ここで、「注文内容を厨房に伝える」ことは、依頼先(厨房)の仕事が終わるまで待たないという意味あいをとらえて、非同期処理(asynchronous process)、ノン・ブロッキング処理(non-blocking process) などと呼ばれます。
解決法1:非同期処理+コールバック
では正しいやりかたを紹介します。厨房には料理ができあがったら自分を呼んでもらうよう頼んでおき、客によばれたときと、料理ができたときの二つに、マニュアルを分ければ良いのです。
function 客によばれたら() {
客席に行く();
客から注文を聞き取る();
厨房に行く();
注文内容の調理を頼んでおく(終わったら「料理が出来たら」を呼んでもらう);
ホールの待機場所に戻る();
}
function 料理が出来たら() {
厨房に行く();
料理を客席に運ぶ();
ホールの待機場所に戻る();
}
こうすると、ホールスタッフは注文を伝え終わったあとでも、料理を運びおわったあとでも、ホールに待機していることになります。つまり、あるお客さんの注文の調理中に、ホールスタッフは他のお客さんの注文をうけつけることができます。
厨房から自分を呼んでもらうという仕組み、
注文内容の調理を頼んでおく(終わったら「料理が出来たら」を呼んでもらう);
この部分を コールバック(callback) と言います。
解決法1の課題: コールバックの読みづらさ
「終わったら自分を呼び戻してもらう」ような動作は、調理以外にも色々もあります。たとえば客にメニューを渡し料理を選ばせ、決まったら呼び戻してもらったりとか。
するとマニュアルはこうなります。
function 客が入店したら() {
客を客席に案内();
客に注文を選んでもらっておく(終わったら「注文が決まったら」を呼んでもらう);
ホールの待機場所に戻る();
}
function 注文が決まったら() {
客席に行く();
客から注文を聞き取る();
厨房に行く();
注文内容の調理を頼んでおく(終わったら「料理が出来たら」を呼んでもらう);
ホールの待機場所に戻る();
}
function 料理が出来たら() {
厨房に行く();
料理を客席に運ぶ();
ホールの待機場所に戻る();
}
これはこれでいいのですが、このままだと、マニュアルが際限なく細切れになりそうですね。「客に注文を決めてもらい、決まった注文を厨房に伝え、厨房が作った料理を客に運ぶ」という、ホールスタッフ業務の一連のストーリー が、分かりづらい。
これらを一連の流れにまとめるために、こう書くこともできます。
function 客が入店したら() {
客を客席に案内();
客に注文を選んでもらっておく(終わったら () => {
客席に行く();
客から注文を聞き取る();
厨房に行く();
注文内容の調理を頼んでおく(終わったら () => {
厨房に行く();
料理を客席に運ぶ();
ホールの待機場所に戻る();
});
ホールの待機場所に戻る();
});
ホールの待機場所に戻る();
}
こんどは、まとまった代わりに、なんか、ぐじゃっと してしまいました。このぐじゃっとした感じのことを、俗にコールバック地獄と言います。
この地獄の一番こまるのは、行の実行順序がとても分かりづらいという点にあります。ためしに、実行される順に番号を振るとこうなります。
function 客が入店したら() {
客を客席に案内(); //01
客に注文を選んでもらっておく/*02*/(終わったら () => {
客席に行く(); //04
客から注文を聞き取る(); //05
厨房に行く(); //06
注文内容の調理を頼んでおく/*07*/(終わったら () => {
厨房に行く(); //09
料理を客席に運ぶ(); //10
ホールの待機場所に戻る(); //11
});
ホールの待機場所に戻る(); //08
});
ホールの待機場所に戻る(); //03
}
まるでパズルです。なんだか、漢文の一二点とか上中下点とかの問題を解かされているような気分になりますね。
解決法2: 非同期処理+async/await構文
そこで、ぐじゃっとさせずに、客の入店から退店までを、一連の流れとして記述するための構文がasync/await構文です。 asyncは非同期処理の略語、awaitは英語で待つという意味です。
まず気をつけてほしいことがあります。それは、「非同期処理を待つ」という文には、大きな矛盾があるということです。なぜなら、非同期処理とは「仕事の終わりまで待たない」ものを言うのですから。
この矛盾に気づかず、async/awaitを理解しようとすると、構文の意味を取り違えてしまうので注意してください。
さて、async/await構文では、こんな感じに書きます。
async function 客が入店したら() {
客を客席に案内();
await 客に注文を選んでもらっておく();
客席に行く();
客から注文を聞き取る();
厨房に行く();
await 注文内容の調理を頼んでおく();
厨房に行く();
料理を客席に運ぶ();
}
うんとシンプルになり、ストーリーが見通せるようになりました。なぜこの書き方で、ワンオペ・ホールスタッフでも客を待たせずに回るようになるのでしょうか。
ポイントは ここでの await の意味です。 ただの「待つ」という意味ではありません。 もしそうだとすると先に述べた「非同期処理を待つ」という矛盾が起きてしまいます。
ここでのawaitの意味は、こうです。
「相手に~~~してもらっているあいだ、相手の仕事が終わらなくても、自分はいったんホールに戻り、このマニュアルを中断して別の仕事に対応する。そして、相手が~~~し終わると呼び戻して知らせてくれるので、そしたらこのマニュアルを再開し、次に書いてある処理に進む」
1語に、めちゃくちゃ沢山の意味が込められていますね。でも、一度覚えてしまえば、とても便利な書き方です。なぜなら、沢山の意味が込められたこの「await」は、例えば次の絵に示すように、現代人にとっては日常にありふれた、とても馴染みのある考え方だからです。
ところで、これは何をたとえている?
コールバック、async/await、非同期処理の3つのキーワードと、「Node.jsで非同期処理が必要な理由」のニュアンスを伝えるためのたとえ話はこれで終わりです。
それぞれ次のとおりに、なぞらえています。
Webサイトの訪問者:飲食店の客
Node.jsプログラムの処理:ホールスタッフのオペレーション
PostgreSQLの読み書き:厨房による調理
node-postgresの機能が非同期処理なわけ
PostgreSQLの読み書きは、Node.jsの処理よりうんと時間がかかります。飲食店で、注文取りや配膳よりも、調理にうんと時間がかかるように。
というわけで、前回の記事の node-postgres の取説 でしょっぱなに、「with async/await」「with callbacks」の二つのやり方が説明されていた理由は、「このライブラリが行うPostgreSQLの読み書きには時間がかかるので、非同期にしないと、Node.jsの処理を足止めしてしまうから」というわけです。納得いただけましたでしょうか。
I/Oには時間がかかる
ついでに、ちょっと覚えておいてほしいのですが、DBやファイルの読み書き、他のマシンとのあいだの通信などを総称してよく I/O と言います。人間の目からはこれらの処理は 一瞬で終わるように見えますが、実際はそれ以外の普通の計算処理と比べ、膨大な時間 がかかる処理とされています。
たとえば、I/Oには1000分の1秒しかかからないとしても、足し算にかかる時間が1億分の1秒だとしたら、I/Oは足し算の10万倍という気の遠くなるような時間がかかる処理に見えるというわけです。
さて、理屈はこれくらいにして、実際にコードを動かして、理解をたしかめてみましょう。
非同期処理をJavaScriptのコードで実践
ここではサンプルコードのみ紹介し、コードの具体的な説明は省略します。できればただ動かしてみるだけでなく、コードにちょこちょこ手を加えて、予想通りの動きをするかどうか試してみてください。そして、コードの書き方の仕組みを精確に理解したくなったり、予想に納得が行かない箇所があったら、他の書籍、雑誌、ネット上の記事やChatGPTに説明を求めてみてください。
次のコードは全部同じディレクトリにファイルを作成します。実行には、Node.jsが必要です。
準備
どのケースでも共通的に使われるコードをさきに準備しておきます。
ひとつめです。
waiter.js
const SELECT_TIME_MILLIS = 3000;
function guideToSeat(guest) {
console.log(`給仕「いらっしゃいませ ${guest}さん こちらへ」`);
}
function selectOrderCallback(guest, callback) {
console.log(`給仕「${guest}さん メニューです 注文きまりましたらお呼びください」`);
setTimeout(() => callback(guest), SELECT_TIME_MILLIS);
}
function selectOrderAsync(guest) {
return new Promise((resolve) => {
selectOrderCallback(guest, resolve);
})
}
function takeOrder(guest) {
console.log(`給仕「${guest}さん 注文どうぞ」`);
const order = ['', '飯', '酢豚', '小籠包', '青椒肉絲', '担々刀削麺'][guest.length];
console.log(`${guest}「${order}くださいな」`);
return order;
}
function serve(dish, guest) {
console.log(`給仕「${dish} です めしあがれ」`);
console.log(`${guest}「おいしそうな ${dish} いただきます」`);
console.log(`${guest}「もぐもぐ」`);
}
module.exports = {
guideToSeat, selectOrderCallback, selectOrderAsync, takeOrder, serve
};
ふたつめです。
kitchen.js
const COOKING_TIME_MILLIS = 10000;
function tellOrder(order) {
console.log(`給仕「注文 ${order} 1つ」`);
console.log(`厨房「${order} 1つ 了解」`);
console.log(`厨房「--- ${order} 調理中 ---」`);
}
function cookBlocking(order) {
tellOrder(order);
setTimeoutBlocking(COOKING_TIME_MILLIS);
const dish = finishCook(order);
return dish;
}
function cookCallback(order, callback) {
tellOrder(order);
setTimeout(() => {
const dish = finishCook(order);
return callback(dish);
}, COOKING_TIME_MILLIS);
}
function cookAsync(order) {
return new Promise((resolve) => {
cookCallback(order, resolve);
});
}
function finishCook(order) {
const dish = "ほかほかの" + order;
console.log(`厨房「${dish} できたよー」`);
return dish;
}
function setTimeoutBlocking(time) {
const start = new Date().getTime();
while (new Date().getTime() < start + time) {
;
}
}
module.exports = {
tellOrder, cookBlocking,cookCallback, cookAsync
};
ダメな例その1:同期処理
では動かしてみます。まずはダメな例からです。
badBlocking.js
const waiter = require("./waiter.js");
const kitchen = require("./kitchen.js");
function onEnter(guest) {
console.log(`${guest}「こんにちはー」`);
}
function onCallFrom(guest) {
const order = waiter.takeOrder(guest);
const dish = kitchen.cookBlocking(order);
waiter.serve(dish, guest);
}
const guests = ['太郎', '権兵衛', '五右衛門'];
guests.forEach(onEnter);
guests.forEach(onCallFrom);
このファイルを保存したら、
node badBlocking.js
でその動きを確かめてください。太郎の料理が出来上がるまで、権兵衛と五右衛門が放置されることがわかると思います。
ダメな例その2:非同期処理
続いてもう一つダメな例です。
badNonBlocking.js
const waiter = require("./waiter.js");
const kitchen = require("./kitchen.js");
function onEnter(guest) {
console.log(`${guest}「こんにちはー」`);
}
function onCallFrom(guest) {
const order = waiter.takeOrder(guest);
const dish = kitchen.tellOrder(order);
waiter.serve(dish, guest);
}
const guests = ['太郎', '権兵衛', '五右衛門'];
guests.forEach(onEnter);
guests.forEach(onCallFrom);
このファイルを保存したら、
node badNonBlocking.js
でその動きを確かめてください。太郎、権兵衛、五右衛門に得体のしれないものが供されることがわかると思います。
解決法その1:非同期処理+コールバック
望ましい動きをする例です。
callback.js
const waiter = require("./waiter.js");
const kitchen = require("./kitchen.js");
function onEnter(guest) {
console.log(`${guest}「こんにちはー」`);
}
function onCallFrom(guest) {
const order = waiter.takeOrder(guest);
kitchen.cookCallback(order, dish => onFoodReady(guest, dish));
}
function onFoodReady(guest, dish) {
waiter.serve(dish, guest);
}
const guests = ['太郎', '権兵衛', '五右衛門'];
guests.forEach(onEnter);
guests.forEach(onCallFrom);
このファイルを保存したら、
node callback.js
でその動きを確かめてください。給仕は料理が出来上がるのを待たず、太郎、権兵衛、五右衛門に順々に注文をとり、厨房に伝えるのが分かるかと思います。
解決法その1の2:非同期処理+コールバック地獄
望ましい動きをするけれど、読みづらいコードの例です。
callbackHell.js
const waiter = require("./waiter.js");
const kitchen = require("./kitchen.js");
function onEnter(guest) {
console.log(`${guest}「こんにちはー」`)
waiter.guideToSeat(guest);
waiter.selectOrderCallback(guest, guest => {
const order = waiter.takeOrder(guest);
kitchen.cookCallback(order, dish => {
waiter.serve(dish, guest);
});
});
}
const guests = ['太郎', '権兵衛', '五右衛門'];
guests.forEach(onEnter);
このファイルを保存したら、
node callbackHell.js
でその動きを確かめてください。
解決法その2:非同期処理+async/await
望ましい動きをし、かつ簡潔に書かれたコードの例です。
asyncAwait.js
const waiter = require("./waiter.js");
const kitchen = require("./kitchen.js");
async function onEnter(guest) {
console.log(`${guest}「こんにちはー」`);
waiter.guideToSeat(guest);
await waiter.selectOrderAsync(guest);
const order = waiter.takeOrder(guest);
const dish = await kitchen.cookAsync(order);
waiter.serve(dish, guest);
}
const guests = ['太郎', '権兵衛', '五右衛門'];
guests.forEach(onEnter);
このファイルを保存したら、
node asyncAwait.js
でその動きを確かめてください。
余談:なぜasync/awaitの歴史が浅いか
async/await構文は、比較的歴史の浅い技術です。
なぜ歴史が浅いのでしょう? それは、この技術が、比較的最近ひろまった言語であるJavaScriptが持つ制約(欠点) を、解決するために生み出された工夫だからです。
たとえば、この制約がないJava言語では、「async/await」といったことをやる必要がありません。また、PHPはシングルスレッドですが、ほとんどの場合、WebリクエストごとにPHPの実行環境が作られるため、やはり「async/await」をせずに済むのです。どちらも、客一組ごとに接客係が一人一人、つくようなイメージです。
つまり、「async/await」がなくても、どうにかなっていたのですね。
昔より不便になった?
「じゃあ、Java言語より不便なワンオペ言語のJavaScriptがあとから広まってしまったせいで、プログラマはこの記事に書かれたasync/awaitを学ぶはめになったということ? なんで昔より不便になってんの、それ、技術が退化してんじゃん」
おっしゃる通り、その意味では、まさに退化しているのです! 私自身、こんなややこしいテクニックを覚えるはめになるとは、まったく、かつては思いもよらなかったことですよ。
でも、私にはJavaScriptをディスるつもりはありません。
革新的なモノは、従来のモノの上位互換ではない
私がJavaScriptをディスらない理由は、その欠点を抱えたまま広まったというところにこそ、その技術の革新性があると思うからです。
そもそも、真に革新的な技術や製品や理論というのは、その多くが、登場したときには「従来のモノの上位互換」ではなく、「従来のモノと比べて 欠点を多く抱えた出来損ない」 のように見えるらしいのですね。『イノベーションのジレンマ』とか、『科学革命の構造』とかいう本では、革新的なモノが出来損ないとして扱われる例がいくつか紹介されているようです。
思えばわれわれ人類だって、そのはじまりは「木登りが下手な、出来損ないのサル」でしかなかったはずですしね。それを補うために、苦し紛れに2本足で歩いてみたりしているうち、結果として頭が良くなってしまったというわけ(※諸説あると思いますが)。
JavaScriptにおいて、欠点を補うために生まれたasync/await構文。ただの苦し紛れにも見えますが、これは上に挙げたたとえでみるとおり、主婦や給仕の普段の振る舞いと共通した考え方ですので、じつは未来永劫残る、普遍的な大進化ではないかという感じもするのです。
まとめと次回予告
今回の記事では、JavaScriptの非同期処理とasync/await構文の紹介をしました。
核となるニュアンスやアイデアを伝えることのみに注力し、完全な理解を目指したり、いつかは知っておかなければいけない、Promise()の使い方、書き方などを説明したりというのは端折りました。理由は、我々が本当にやりたいことはJavaScriptの非同期処理構文の精確な理解ではなく、あくまでもExpress.jsアプリで PostgreSQLを使いたいだけだからです。それとはべつに、深入りすると際限がなくなるから、という理由ももちろんあります。
「不完全な理解かもしれないけど、あとで困ったら、その時にまた調べて、理解の解像度を高める機会にしよう」くらいの気楽な心構えでいきましょう。
次回はいよいよ Express.jsアプリによる PostgreSQLの読み書きをはじめます。
#コラム #プログラミング #JavaScript #非同期 #async /await #イノベーションのジレンマ
この記事が気に入ったらサポートをしてみませんか?