メインコンテンツまでスキップ

JS/TSで非同期処理

· 約17分

JavaScriptの非同期処理

JavaScriptはシングルスレッドだ。プログラマは自由にスレッドを作ることができない。メインスレッドにしか処理を記述できない。でも重い処理を並行に実行したいことはある。例えば、ファイルIOだったり、REST APIをコールする場合だ。

そこでJavaScriptでは、このような外部環境とやり取りするときは、その結果を取得するまでメインスレッドをブロックしないように裏で実行される。結果が返ってくるとメインスレッドのイベントが発生する。そのイベントが発生すると、あらかじめ登録しておいたコールバック関数が呼ばれるのだ。

たとえば、何か処理をして3秒後にsuccessという文字列を返す関数を考えてみよう。以下にdoSomethingという関数を定義する。この関数がどんな処理をするかはどうでもいい。非同期処理を説明するのには必要ないからだ。doSomethingのシグニチャはJavaScriptではよく見られる形だ。この関数は引数にonSuccessonFailureを取る。それぞれ処理が終わった時に呼ばれる関数だ。doSuccessは成功した時に、onFailureは失敗したときにそれぞれ呼ばれる。そして重要なのが、この関数はメインスレッドをブロックしないことだ。3秒間で行われる何かの処理は裏で行われる。

function doSomething(onSuccess: (data: string) => void, onFailure?: ((err: unknown) => void) | undefined = undefined): void {
setTimeout(() => onSuccess('success'), 3);
}

この関数を使ってみよう。成功した時に呼び出されるコールバック関数を第1引数に与える。このコールバック関数は単に結果をコンソールに出力するだけだ。

doSomething(data => console.log(data))

3秒後にsuccessという文字列がコンソールに出力されるのが確認できる。

次に、この関数がメインスレッドをブロックしないことを確認するために、2回連続で実行してみよう。

doSomething(data => console.log(data));
doSomething(data => console.log(data));

もし、この関数がメインスレッドをブロックしているならプログラムが終了するまでに6秒かかるはずだ。しかし、約3秒後に2つの関数の結果が返ってくる。2つの関数が非同期に並行に実行されているからだ。

では、この関数を順番に実行させるにはどうすればよいだろうか?以下のようにする。

doSomething(() => {
doSomething(() => {
console.log(`done`);
});
});

こうすることで同期的に2つの関数を実行できる。doneという文字列が6秒後にコンソールに出力されるはずだ。このようにコールバック関数の中で次に実行する関数を呼び出すことで、2つの非同期関数だけでなく、好きなだけ非同期関数を同期的に実行することができる。例えば、doSomethingを5回だけ同期的に実行する場合は以下のように記述する。

doSomething(() => {
doSomething(() => {
doSomething(() => {
doSomething(() => {
doSomething(() => {
console.log(`done`);
});
});
});
});
});

以前は皆がこのようなコードを書いてた。皆がコールバックの中にコールバックを書いていた。どんどんネストが幾重にも深くなっていった。そして可読性が低くなった。誰が言ったか知らないが、この書き方はコールバック地獄と呼ばれるようになった。

このコールバック地獄を解決するために導入されたのが、Promiseであり、async/awaitだ。

Promise

コールバック地獄を解決するための第1段階としてPromiseが導入された。これは完全にはコールバック地獄を解決をしなかったが、ある程度上手くいった。

例えば、上記のdoSomethingを5回だけ実行する場合は、以下のように書き換えられる。ここでdoSomethingAsyncは、doSomethingPromise対応版だ。doSomethingPromise化する方法は後で述べるとして、まずdoSomethingAsyncの使い方を確認しよう。

doSomethingAsync()
.then(() => doSomethingAsync())
.then(() => doSomethingAsync())
.then(() => doSomethingAsync())
.then(() => doSomethingAsync())
.then(() => console.log(`done`))

どうだろうか?コールバック地獄から抜け出せたのがお分かりいただけただろうか?ネストがなくなりかなり見やすくなったことは一目瞭然だ。

とりあえずはPromiseの威力を確認できたので、次にdoSomethingPromise化する方法を説明しよう。doSomethingに限らず、ほとんどの関数でもPromise化する方法は一緒だ。doSomethingのシグネチャは一般的な形をしていることは先に述べた。ここでもう一度確認しよう。

function doSomething(onSuccess: (data: string) => void, onFailure?: (err: unknown) => void = undefined): void

この形の関数は、すべて以下のようにPromise化できる。

function doSomethingAsync(): Promise<string> {
return new Promise<string>(doSomething);
}

とても簡単だ。doSomethingAsyncは、Promise<string>を返す関数だ。Promiseの型パラメータがstringになっているのは、doSomethingのコールバック関数onSuccessstringを受け取るからだ。つまり結果の型だ。

doSomethingAsyncの実装部分を見てみよう。new Promiseは、Promiseクラスのコンストラクタを呼び出して、そのインスタンスを生成している。コンストラクタの引数は、ここではdoSomethingだ。Promiseクラスのコンストラクは、doSomethingのシグネチャと全く同じ関数を引数に取る。

new <T>(executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;

resolverejectdoSomethingで言うところのonSuccessonFailureに相当する。 doSomethingはコールバックを取る非同期関数として一般的な形をしているので、Promiseクラスのコンストラクに、そのまま入れることができる。ここで注意したいのが、rejectreasonの型がanyになっていることである。どのようなエラーが発生するか分からないからanyになっているのだが、anyよりはunknownの方が型的に安全なのでdoSomethingではunknownにしている。

では、違うシグネチャを持つ関数の場合はどのようにPromise化するのだろうか?例えばreadFileは、doSomethingとは違う形をしている。ここでは1つの例として、readFilePromise化してみよう。

readFileのシグネチャは以下の様に定義されている。

readFile(path: string, callback: (err: Error | null, data: Buffer)): void;

doSomethingとの違いは、1つのコールバック関数しか取らないことだ。1つのコールバック関数で正常系と異常系の処理を行うようになっている。したがって、resolverejectは、callbackの中で処理する必要がある。 つまり、以下のような実装になる。

function readFileAsync(path: string): Promise<Buffer> {
new Promise((resolve, reject) => {
readFile(path, (err, data) => {
if (err != null) reject(err);
else resolve(data);
})
});
}

doSomethingAsyncの話に戻ろう。doSomethingAsyncを実行することでPromise<string>を生成する。実行した時点でPromiseの中で処理が開始される。Promiseが処理を包んでいるといった感覚だろうか。処理が完了するとPromiseを通して結果を受け取ることができる。エラー時にも同様だ。処理が失敗した場合もPromiseを通してエラー処理をすることになる。

const result: Promise<string> = doSomethingAsync();

Promisethencatchというメソッドを持っている。Promiseを扱う上で、この2つのメソッドが超重要だ。この2つさえ覚えていればなんとかなる。

thenは、結果を受け取るメソッドだ。doSomethingonSuccessに相当する。同様にthenに結果を受け取るコールバック関数を渡す。例を示そう。

result.then(data => console.log(data));

この例では、結果をコンソールに出力している。コンソールにはsuccessと出力されているはずだ。

catchは、エラー時にエラー内容を受け取るメソッドだ。doSomethingonFailureに相当する。同様にcatchにエラーを受け取るコールバック関数を渡す。例を示そう。

result.catch(err => console.err(err));

この例では、エラーをコンソールに出力している。

通常はthencatchを並べて書く。

result
.then(data => console.log(data))
.catch(err => console.err(err));

複数の非同期処理を同期的に実行するには、thenを何回も呼べばよい。先にも述べたようにdoSomethingAsyncを5回同期的に実行するには以下のように記述する。

doSomethingAsync()
.then(() => doSomethingAsync())
.then(() => doSomethingAsync())
.then(() => doSomethingAsync())
.then(() => doSomethingAsync())
.then(() => console.log(`done`))

ただし、この記述には1つ問題点がある。それは1つ前に実行したdoSomethingAsyncの結果を使いたい場合だ。doSomethingを使って具体的に説明しよう。doSomethingPromise化されてないので、コールバック地獄に陥るのだった。その場合に前の関数の結果を使うには以下のようにする。

doSomething(res1 => {
doSomething(res2 => {
doSomething(res3 => {
doSomething(res4 => {
doSomething(res5 => {
cosole.log(`done: ${res1}, ${res2}, ${res3}, ${res4}, ${res5}`);
});
});
});
});
});

doSomethingの結果はsuccessという文字列を返すのだから、この結果はコンソールには次のように出力される。

done: success, success, success, success, success

successという文字列を5つも出力する意味というのは全く感じられないが、5つのdoSomethingの結果を使用したことを示したかっただけだ。

これをdoSomethingAsyncを使ってやってみよう。

doSomethingAsync().then(res1 =>
doSomethingAsync().then(res2 =>
doSomethingAsync().then(res3 =>
doSomethingAsync().then(res4 =>
doSomethingAsync().then(res5 =>
console.log(`done: ${res1}, ${res2}, ${res3}, ${res4}, ${res5}`)
)
)
)
)
)

Promise化したdoSomethingAsyncを使って書くと上記のようになる。先ほどPromiseを使えばある程度はコールバック地獄を避けられると述べたが、これはどう見てもコールバック地獄は避けられてない。ある程度と注釈をいれたのはこのことだ。この場合は避けられないのだ。いや、以下のようにすれば、避けられないことはないが、それでも少し複雑になる。

doSomethingAsync()
.then(res => doSomethingAsync().then(res2 => `${res}, ${res2}`))
.then(res => doSomethingAsync().then(res3 => `${res}, ${res3}`))
.then(res => doSomethingAsync().then(res4 => `${res}, ${res4}`))
.then(res => doSomethingAsync().then(res5 => `${res}, ${res5}`))
.then(res => console.log(`done: ${res}`));

そこでいよいよasync/awaitの登場である。

async/await

async/awaitは、上述したようにthenを使ってもコールバックを避けられないケースや避けられたとしても複雑になるケース回避するために導入された。JavaScriptが初めてこの仕組みを発明した訳ではない。JavaScriptのPromiseasync/awaitの関係は、Haskellのモナドモナド内包表記の関係と酷似している。Haskellの方がより汎用的だが、若干初心者にはハードルが高い。JavaScriptでは、その汎用性を犠牲にする代わりに、ハードルを下げてより使いやすくした感がある。

まずは、doSomethingAsyncthenを使った場合とasync/awaitを使った場合で比較してみよう。

doSomethingAsync()
.then((res) => console.log(`done: ${res}`));
const res = await doSomething();
console.log(`done: ${res}`);

上の2つの場合は、全く同じことをしているが、書き方が違うだけだ。一方はthenを使い、もう一方はawaitを使っている。awaitthenの代わりをするただのシンタックスシュガーだ。thenawaitに変えて、関数の前に持って来ればよいだけだ。内部的には最終的にawaitを使った書き方は、thenを使った書き方に変換される。だが、どちらが可読性の高かいかと言えば、それは一目瞭然だ。後者のawaitを使った方だ。

awaitを使うと、それが非同期関数でないかのように書くことができる。ただしここで注意してほしいことがある。awaitasync関数の中でしか使えない。したがって、より正確に書くなら以下のようになる。

async function hoge():Promise<void> {
const res = await doSomething();
console.log(`done: ${res}`);
}

それでもasync/awaitの利点は失われない。コールバック地獄に陥るよりは遥かにましだ。先ほどの5回doSomethingAsyncを使う例だとより顕著だ。

async function hoge():Promise<void> {
const res1 = await doSomething();
const res2 = await doSomething();
const res3 = await doSomething();
const res4 = await doSomething();
const res5 = await doSomething();
console.log(`done: ${res1}, ${res2}, ${res3}, ${res4}, ${res5}`);
}

コールバック地獄のようなネストした煩雑さは、もうどこにも見当たらない。とても読みやすいコードになった。

async/awaitを知ると、昔のコールバックを使った非同期関数やthenを使った書き方に戻れなくなる。async/awaitをどんどん使いたくなったのではないだろうか?