#先に結論
- awaitが使われている関数はジェネレータとして内部的に変換され、非同期処理になる。
- その為、async関数である必要がある。
- コンパイラから見ても、asyncが付いていることで効率的にコンパイルできる。
- (追記 2021/07/21)最上位でのawaitは、もうすぐ可能になるかもしれない(一部ブラウザは実装済)。(鶏(async)が先か卵(await)が先かでコメントで教えて頂きました。感謝!)
#というわけで本編
awaitはPromiseを返す関数を呼び出す方法として非常に便利ですが、それを使う箇所にはいちいちasyncを付けて回らなければならないのが面倒だと思ったことはないでしょうか。
そもそもawaitを内部で使っている関数が常にasyncである必要性がいまいちピンと来ない方もいるのではないでしょうか(最初にasync/awaitの構文を見た時、私自身がそうでした)。
例えば次のような何も返さない関数の場合に、asyncの指定は何の為に要るのでしょうか?
async function button1Click(){
try {
let data = await doAjax(url);
displayData(data);
}
catch (e){
alert("ajax error!");
}
}
asyncを付けた関数は、戻り値が自動的にPromise.resolveでラッピングされます。
しかし上記の例だと、button1Click()の呼び出しの後戻り値を使わないのなら、asyncは無理に付けなくてもよいのでは? というわけです。
async/awaitは非同期処理を扱う為の革新的な記法ですが、こういった部分を含めてよくわからないところもあり、なんとなく使っている人もいるでしょう。
恐らく、よく分かっている人は今更説明不要だからだと思われますが、このあたりをまとめて詳しく説明している日本語の情報があまり見つからなかった(個々の仕組みについての解説記事はたくさんあるのですが)為、まとめてみました。
この辺りの仕組みを理解しておくことで、これまでなんとなくasync/awaitを使っていた人は、より確信をもって使うことができるようになると思います。
これらの話を紐解く為に、そもそもの話から順番に整理していきます。
#async/awaitを使うとどう便利か
async/awaitを使うと、例えばfetch関数が返す「Promise」をシンプルに処理できます。
具体的には、メソッドチェーンではなく、逐次的な通常の処理の呼び出しのように記述できます。
awaitを付けた各処理は、まるでそこで処理がブロックされているかのように(つまり、非同期処理にも関わらず、同期的な処理のように)動作します。これが、awaitが革新的である理由です。
それぞれどのように書くか、振り返ってみます。
Promiseのメソッドチェーン
function myproc() {
// urlの呼び出し
return fetch(url)
// urlの戻り値を処理
.then(response => response.json())
.then(emp => {
// urlの戻り値をパラメータにして新しいurl2を呼び出し
return fetch(url2 + emp.empid);
})
// url2の戻り値を処理
.then(response => response.json())
.then(dept => {
console.log(dept.deptname);
});
}
async/awaitを使う場合
async function myproc_async() {
// urlの呼び出し
const response1 = await fetch(url);
const emp = await response1.json();
// urlの戻り値をパラメータにして新しいurl2を呼び出し
const response2 = await fetch(url2 + emp.empid);
const dept = await response2.json();
// url2の戻り値を処理
console.log(dept.deptname);
}
ここではエラー処理については考えません(気になる人の為に書いておくと、awaitがPromise.rejectを受けると、例外がスローされますので、try-catchでcatch可能です)。
#Promise.thenはどのように処理されるか
myproc()を呼び出すと、呼び出しは瞬時に終了します。
myproc()内部で呼び出されたfetch(url)はPromiseを生成し、そのPromiseのthenに匿名関数が渡され、さらにその結果生成されるPromiseにまた次の関数オブジェクトが渡され…と、Promiseのチェーンが瞬時に生成されます。この時、渡された匿名関数の中身はまだ実行されていません(Promiseが完了状態になった時に実行されます)。そんな感じでPromiseインスタンスのチェーンが生成された後、myproc()の実行は正常に終了します。
しかしmyproc()の実行が終わった後もこのPromiseチェーンのインスタンスはまだ生きており、fetchのurl呼び出しが完了してPromiseが解決される度に、渡した匿名関数が順番に実行されていきます。
結果的に、意図した通りの順番で処理が行われるわけです。
メソッドチェーンが内部でどう動くかを想像するにはPromiseの動作をよく理解している必要があり、Promise登場以前のcallback地獄よりはよほどマシながらも、そこまでシンプルな記述ではありません。
それに対してasync/awaitを使った書き方は非常にシンプルで、意図も明快です。
#awaitは処理をブロックしている?(×)
では、myproc_async()は一体どのように内部処理されるのでしょうか。
awaitを付けた箇所で一旦処理が停止しているわけですが、「以降の処理をブロックする」(つまりメインスレッドをそこで停止する)というようなことをjavascriptエンジンが行っているのでしょうか。
しかし、以下のコードを実行すると、そうではないことが分かります。
async function procLongTime(){
await procLongTime1();
console.log("finished procLongTime1");
await procLongTime2();
console.log("finished procLongTime2");
}
procLongTime();
console.log("Let's start!");
出力結果
Let's start!
finished procLongTime1
finished procLongTime2
awaitでメインスレッドが停止しているのなら、"Let's start!"が最後に出力されるはずですが、最初に出力されています。
そもそもjavascriptは、ユーザー入力を常時受け付けられるよう、処理をブロックしないようにイベントループ機構を備えています。
awaitは、処理をブロックしているわけではないようです。
では、awaitはどのように「処理をそこで止めて、Promiseの解決後にその場所から再開」しているのでしょうか。
この件を考える為に「ジェネレータ」について考えてみます。
#ジェネレータとは
ジェネレータとは、イテレータを生成する為の仕組みです。
イテレータとは、配列のような繰り返し構造を持つデータにアクセスする為の仕組みです。
ジェネレータは、関数名の後ろに*
を付け、内部でyield
を実行することで定義することができます。
function* mygenerator(){
let count = 0;
count++;
yield count;
count++;
yield count;
count++;
yield count;
}
このmygeneratorは、次のように使います。
for (const i of mygenerator()) {
console.log(i);
}
ログには次のように出力されます。
1
2
3
これは、次の処理と同じ結果です。
for (const i of [1, 2, 3]) {
console.log(i);
}
ジェネレータがどのように働くか、理解できたでしょうか。
mygeneratorの中でyieldが呼ばれると、mygeneratorは現在のcountを返して一旦処理を停止します。そして、forループから次の値を請求されると、停止した所から処理を再開し、また次のyieldで、値を返します。
mygeneratorの最後まで実行したら、ループ(イテレーション)を終了します。
そう、先ほどの**awaitと同じく、yieldの箇所でも「一旦関数の処理が途中で停止している」**のです。
このような機能を「継続」と呼び、ジェネレータをイテレーション(配列データの繰り返し処理)の為ではなく、コルーチン(処理を一時的に止めたり再開したりする機能)として利用することも可能です。
では、ジェネレータは一体どのように処理を止めたり再開したりを実現しているのでしょうか。もちろん、スレッドをブロックしているわけではありません。
Babelを用いてジェネレータの実装を推測する
function* mygenerator(){
let count = 0;
count++;
yield count;
count++;
yield count;
count++;
yield count;
}
このジェネレータを、Babelを用いてES5向けにトランスパイルしてみます。
Babelでトランスパイルしたジェネレータ
"use strict";
var _marked = /*#__PURE__*/regeneratorRuntime.mark(main);
function mygenerator() {
var count;
return regeneratorRuntime.wrap(function mygenerator$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
count = 0;
count++;
_context.next = 4;
return count;
case 4:
count++;
_context.next = 7;
return count;
case 7:
count++;
_context.next = 10;
return count;
case 10:
case "end":
return _context.stop();
}
}
}, _marked);
}
内部構造がお分かりでしょうか。
詳しく見ていく必要はありませんが、大まかな構造としては、処理をyield呼び出しから次のyield呼び出しまでの単位で分割して番号を振り、1回目の呼び出しはここまで、2回目の呼び出しはここまで、のようにswitch caseで切り替えていく、というものになります。
- 「_context.next」という変数を作って0に初期化する。
- switch caseで_context.next を元に処理を切り替える
- yieldから次のyiledまでが、それぞれのcase文に分断される。
- case文の処理が終わったら、_context.next に次のcaseの値を入れて処理を終わる。
上記のことをやってくれるジェネレータを内部で生成して返している為、処理の継続に必要な「前回の処理の状態」は、クロージャとして参照し続けることができるようになっています。
つまり、ジェネレータは内部で別スレッドを作ってそこで処理をブロックしているわけではなく、「ステップ実行マシン」のようなものを内部に作っているのです。
同様に、async/awaitも、次のように変換されます。
元のjavascript
async function myproc_async() {
// urlの呼び出し
const response1 = await fetch(url);
const emp = await response1.json();
// urlの戻り値をパラメータにして新しいurl2を呼び出し
const response2 = await fetch(url2 + emp.empid);
const dept = await response2.json();
// url2の戻り値を処理
console.log(dept.deptname);
}
Babelで変換後
//※骨子部分のみ抜粋
function _myproc_async() {
_myproc_async = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee() {
var response1, emp, response2, dept;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return fetch(url);
case 2:
response1 = _context.sent;
_context.next = 5;
return response1.json();
case 5:
emp = _context.sent;
_context.next = 8;
return fetch(url2 + emp.empid);
case 8:
response2 = _context.sent;
_context.next = 11;
return response2.json();
case 11:
dept = _context.sent;
// url2の戻り値を処理
console.log(dept.deptname);
case 13:
case "end":
return _context.stop();
}
}
}, _callee);
}));
return _myproc_async.apply(this, arguments);
}
参考@babel/plugin-transform-async-to-generator
fetchをawaitしている箇所で一旦 case 0は終わり、_context.nextに「次に実行するのは2からですよ」とメモをしてから、fetchから戻されるPromiseをreturnします。
恐らくその後、Promiseの解決を待って、case 2へと移るものと思われます。
ジェネレータが、yield呼び出しからyield呼び出しの単位で処理を分割してswitch-caseのステップ実行マシンにしていたように、async/awaitでは、await呼び出しからawait呼び出しの単位で処理を分割していいます。
そうやって作ったジェネレータを、「Promiseが解決したら次のステップへ」というように実行していくようです。
これはBabelのトランスパイル結果ですが、javascriptのコンパイラも同様のことを内部で行っていると推測されます。
つまり、async/awaitは、ジェネレータを使って実装することが可能なのです。言い換えると、**async/awaitは、Promiseとジェネレータを使って実現された非同期処理の継続機構であり、それらのシンタックス・シュガー(糖衣構文)**です。
#最初の疑問「なぜawaitをasyncで囲む必要があるのか」
これまで見てきたことを振り返ると、上記の答えが見えてきます。
恐らく「なぜawaitをasyncで囲む必要があるのか?」という人の頭の中では、awaitの箇所でスレッドがブロック(一時停止)されています。
async function myproc_async() {
// urlの呼び出し
const response1 = await fetch(url); // ここで一旦スレッドが停止?(もちろん違います)
const emp = await response1.json();
// urlの戻り値をパラメータにして新しいurl2を呼び出し
const response2 = await fetch(url2 + emp.empid);
const dept = await response2.json();
// url2の戻り値を処理
console.log(dept.deptname);
}
上記の例だと、myproc_async()はasyncを付けなければ「同期処理」として実行されるはずではないか、と思うわけです。
同期処理で問題ないケースもあるのに、なぜわざわざasyncを付けて非同期処理として振る舞わせる事を強制するのか・・・という事ではないでしょうか。
しかし既にお分かりのように、awaitは内部的にはジェネレータを用いた継続処理として実装されており、myproc_async関数自体はawaitの呼び出し後、awaitの中身の処理が終わる前に一旦終了します。
実際にはまったく同期的ではなく、awaitを使っている以上、それは必ず非同期処理になるのです。
戻り値があるのならばもちろんasyncを付けて「Promiseが返ること」を明示した方が良いでしょうし、戻り値が不要であれば、逆にPromiseを返したところで問題ありません(受け取らなけばいいのです)。
よって、awaitを使っている関数は必ずasyncを付けなければならないのです。もし、つけなくても非同期処理になるなら、それはそれで気持ちが悪いし、コードの可読性も悪くなるでしょう。
#コンパイラ側から見た理由
awaitを使っている関数にasyncを付けるもう一つの理由は、コンパイラの理由です。
awaitが内部で使われている関数は、ジェネレータとして内部変換されます。
ジェネレータ関数には、関数名の後ろに「*」を付けるルールを覚えているでしょうか。
ジェネレータ関数は、yieldを使って値を返却している以外は普通の関数に見えますが、実際にはかなり難しい内部変換を行って「継続」を生成しています。
同様に、awaitを使った関数も、パッと見は普通の関数に見えますが、同様に複雑な内部変換を行って「継続」を生成しています。
generatorの「*」とyieldの関係が、asyncとawaitの関係に対応しています。
コンパイラは、ジェネレータやawait入りの関数を、switch-caseを使った継続に置き換えなくてはなりません。
function定義に「*」なり「async」なりの指定がついていれば、コンパイラはいちいち「関数内部にyieldやawaitがある」ことを調べずとも、その関数をswitch-caseを使った継続に置き換える判断ができます。
「awaitに対するasyncは、yieldに対する*みたいなもんなんだな」と覚えておけば、付け忘れも減るかもしれませんね。
#まとめ
- async/awaitはスレッドをブロックしているわけではない。
- awaitは内部的にはジェネレータによる継続として実装されており、それ自身、非同期処理になる。
- await呼び出し自身が非同期処理なのだから、awaitを含む関数がasyncになるのは自然。
- awaitを内部で使っている関数はコンパイラにとって特別な内部変換を必要とするため、関数の先頭に「async」がついていることはコンパイラにとっても都合がよい。
ということなのではないかと思いました。
以上、なぜawaitをasyncで囲む必要があるのか、でした。
補足や訂正などありましたらコメントでよろしくお願いいたします。
#追記
「鶏(async)が先か卵(await)が先か」を書きました。合わせて読むと理解が進むのではと思います。