目次
本書をサポートしていただけますか?購入 (PDF, EPUB, MOBI) または 寄付
(広告、ブロックしないでください。)

5. 非同期関数

ECMAScript 2017の機能「非同期関数」は、Brian Terlsonによって提案されました。

5.1 概要

5.1.1 種類

非同期関数には以下の種類があります。すべての場所でキーワード`async`に注目してください。

5.1.2 非同期関数は常にPromiseを返す

非同期関数のPromiseを解決する

async function asyncFunc() {
    return 123;
}

asyncFunc()
.then(x => console.log(x));
    // 123

非同期関数のPromiseを拒否する

async function asyncFunc() {
    throw new Error('Problem!');
}

asyncFunc()
.catch(err => console.log(err));
    // Error: Problem!

5.1.3 `await`による非同期計算の結果とエラーの処理

演算子`await`(非同期関数内でのみ使用可能)は、オペランドであるPromiseが解決されるまで待機します。

単一の非同期結果の処理

async function asyncFunc() {
    const result = await otherAsyncFunc();
    console.log(result);
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc()
    .then(result => {
        console.log(result);
    });
}

複数の非同期結果を順次処理する

async function asyncFunc() {
    const result1 = await otherAsyncFunc1();
    console.log(result1);
    const result2 = await otherAsyncFunc2();
    console.log(result2);
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc1()
    .then(result1 => {
        console.log(result1);
        return otherAsyncFunc2();
    })
    .then(result2 => {
        console.log(result2);
    });
}

複数の非同期結果を並列に処理する

async function asyncFunc() {
    const [result1, result2] = await Promise.all([
        otherAsyncFunc1(),
        otherAsyncFunc2(),
    ]);
    console.log(result1, result2);
}

// Equivalent to:
function asyncFunc() {
    return Promise.all([
        otherAsyncFunc1(),
        otherAsyncFunc2(),
    ])
    .then([result1, result2] => {
        console.log(result1, result2);
    });
}

エラー処理

async function asyncFunc() {
    try {
        await otherAsyncFunc();
    } catch (err) {
        console.error(err);
    }
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc()
    .catch(err => {
        console.error(err);
    });
}

5.2 非同期関数の理解

非同期関数を説明する前に、Promiseとジェネレーターを組み合わせて、同期的なコードのように見える非同期操作を実行する方法を説明する必要があります。

一括処理の結果を非同期的に計算する関数の場合、ES6の一部であるPromiseが普及しています。一例として、クライアント側の`fetch` APIがあり、これはファイルを検索するためのXMLHttpRequestの代替手段です。使用方法は以下のとおりです。

function fetchJson(url) {
    return fetch(url)
    .then(request => request.text())
    .then(text => {
        return JSON.parse(text);
    })
    .catch(error => {
        console.log(`ERROR: ${error.stack}`);
    });
}
fetchJson('http://example.com/some_file.json')
.then(obj => console.log(obj));

5.2.1 ジェネレーターによる非同期コードの記述

coは、Promiseとジェネレーターを使用して、より同期的に見えるコーディングスタイルを可能にするライブラリです。ただし、動作は前の例で使用されているスタイルと同じです。

const fetchJson = co.wrap(function* (url) {
    try {
        let request = yield fetch(url);
        let text = yield request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
});

コールバック(ジェネレーター関数!)がPromiseをcoにyieldするたびに、コールバックは中断されます。Promiseが解決されると、coはコールバックを再開します。Promiseが解決された場合、`yield`は解決値を返し、拒否された場合、`yield`は拒否エラーをスローします。さらに、coはコールバックによって返された結果をPromise化します(`then()`が行う方法と同様)。

5.2.2 非同期関数による非同期コードの記述

非同期関数は基本的に、coが行うことに特化した構文です。

async function fetchJson(url) {
    try {
        let request = await fetch(url);
        let text = await request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
}

内部的には、非同期関数はジェネレーターと非常によく似ています。

5.2.3 非同期関数は同期的に開始され、非同期的に解決される

非同期関数はこのように実行されます。

  1. 非同期関数の結果は常にPromise `p`です。そのPromiseは、非同期関数の実行開始時に作成されます。
  2. 本体が実行されます。実行は`return`または`throw`によって永続的に終了するか、`await`によって一時的に終了します。その場合、実行は通常後で続行されます。
  3. Promise `p`が返されます。

非同期関数の本体を実行している間、`return x`はPromise `p`を`x`で解決し、`throw err`は`p`を`err`で拒否します。解決の通知は非同期的に行われます。つまり、`then()`と`catch()`のコールバックは、現在のコードが終了した後に常に実行されます。

以下のコードはその動作を示しています。

async function asyncFunc() {
    console.log('asyncFunc()'); // (A)
    return 'abc';
}
asyncFunc().
then(x => console.log(`Resolved: ${x}`)); // (B)
console.log('main'); // (C)

// Output:
// asyncFunc()
// main
// Resolved: abc

以下の順序を信頼できます。

  1. 行 (A): 非同期関数は同期的に開始されます。非同期関数のPromiseは`return`によって解決されます。
  2. 行 (C): 実行が続きます。
  3. 行 (B): Promiseの解決の通知は非同期的に行われます。

5.2.4 返されたPromiseはラップされない

Promiseの解決は標準的な操作です。`return`はそれを用いて非同期関数のPromise `p`を解決します。つまり

  1. 非Promise値を返すことは、その値で`p`を解決します。
  2. Promiseを返すということは、`p`がそのPromiseの状態をミラーリングすることを意味します。

したがって、Promiseを返すことができ、そのPromiseはPromiseでラップされません。

async function asyncFunc() {
    return Promise.resolve(123);
}
asyncFunc()
.then(x => console.log(x)) // 123

興味深いことに、拒否されたPromiseを返すことは、非同期関数の結果が拒否されることにつながります(通常は`throw`を使用します)。

async function asyncFunc() {
    return Promise.reject(new Error('Problem!'));
}
asyncFunc()
.catch(err => console.error(err)); // Error: Problem!

これは、Promiseの解決の仕組みと一致しています。これにより、`await`なしで、別の非同期計算の解決と拒否の両方を転送できます。

async function asyncFunc() {
    return anotherAsyncFunc();
}

前のコードは、次のコード(`anotherAsyncFunc()`のPromiseをラップするだけで再度ラップする)とほぼ似ていますが、より効率的です。

async function asyncFunc() {
    return await anotherAsyncFunc();
}

5.3 `await`の使用に関するヒント

5.3.1 `await`を忘れない

非同期関数で起こりやすいミスの一つに、非同期関数呼び出しを行う際に`await`を忘れることがあります。

async function asyncFunc() {
    const value = otherAsyncFunc(); // missing `await`!
    ···
}

この例では、`value`はPromiseに設定されます。これは通常、非同期関数では望ましいものではありません。

`await`は、非同期関数が何も返さない場合でも意味があります。その場合、そのPromiseは単に呼び出し元に終了を伝えるシグナルとして使用されます。例えば

async function foo() {
    await step1(); // (A)
    ···
}

行 (A) の`await`は、`foo()`の残りの部分が実行される前に`step1()`が完全に終了することを保証します。

5.3.2 「実行して忘れる」場合は`await`は不要

非同期計算をトリガーするだけで、それがいつ終了するのかに関心がない場合があります。次のコードはその例です。

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello'); // don’t wait
    writer.write('world'); // don’t wait
    await writer.close(); // wait for file to close
}

ここでは、個々の書き込みがいつ終了するのかは気にしません。正しい順序で実行されることだけです(APIが保証する必要がありますが、非同期関数の実行モデルによって推奨されています - 前述のように)。

`asyncFunc()`の最後の行の`await`は、ファイルが正常に閉じられた後にのみ関数が解決されることを保証します。

返されたPromiseはラップされないため、`await` `writer.close()`の代わりに`return`を使用することもできます。

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello');
    writer.write('world');
    return writer.close();
}

どちらの方法にも長所と短所があり、`await`を使用する方法の方が少し理解しやすいでしょう。

5.3.3 `await`は逐次処理、`Promise.all()`は並列処理

次のコードは、2つの非同期関数呼び出し`asyncFunc1()`と`asyncFunc2()`を行います。

async function foo() {
    const result1 = await asyncFunc1();
    const result2 = await asyncFunc2();
}

しかし、これらの2つの関数呼び出しは順次実行されます。並列実行すると、速度が向上する傾向があります。`Promise.all()`を使用して並列実行することができます。

async function foo() {
    const [result1, result2] = await Promise.all([
        asyncFunc1(),
        asyncFunc2(),
    ]);
}

2つのPromiseを待つ代わりに、2つの要素を持つ配列のPromiseを待つようになりました。

5.4 非同期関数とコールバック

非同期関数の制限の1つは、`await`が直接囲んでいる非同期関数にのみ影響を与えることです。そのため、非同期関数はコールバック内で`await`を使用できません(ただし、後述のように、コールバック自体は非同期関数にすることができます)。そのため、コールバックベースのユーティリティ関数とメソッドは使いにくくなります。例としては、Arrayメソッド`map()`と`forEach()`があります。

5.4.1 `Array.prototype.map()`

Arrayメソッド`map()`から始めましょう。次のコードでは、URLの配列によって示されるファイルをダウンロードし、それらを配列で返すことを目的としています。

async function downloadContent(urls) {
    return urls.map(url => {
        // Wrong syntax!
        const content = await httpGet(url);
        return content;
    });
}

これは機能しません。`await`は通常の矢印関数内では構文的に無効です。では、非同期矢印関数を使用してみましょうか?

async function downloadContent(urls) {
    return urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
}

このコードには2つの問題があります。

Promiseの配列を配列のPromiseに変換する`Promise.all()`を使用して、両方の問題を解決できます(Promiseによって解決された値を含む)。

async function downloadContent(urls) {
    const promiseArray = urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
    return await Promise.all(promiseArray);
}

`map()`のコールバックは`httpGet()`の結果をほとんど処理せず、単に転送するだけです。そのため、ここでは非同期矢印関数は必要ありません。通常の矢印関数で十分です。

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return await Promise.all(promiseArray);
}

まだ少し改善できる点があります。この非同期関数はやや非効率的です。まず`await`を使用して`Promise.all()`の結果をラップ解除してから、`return`を使用して再度ラップします。`return`はPromiseをラップしないため、`Promise.all()`の結果を直接返すことができます。

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return Promise.all(promiseArray);
}

5.4.2 `Array.prototype.forEach()`

URLを介して指された複数のファイルの内容をログに記録するために、Arrayメソッド`forEach()`を使用してみましょう。

async function logContent(urls) {
    urls.forEach(url => {
        // Wrong syntax
        const content = await httpGet(url);
        console.log(content);
    });
}

繰り返しますが、このコードは構文エラーを生成します。通常の矢印関数内で`await`を使用することはできません。

非同期矢印関数を使用してみましょう。

async function logContent(urls) {
    urls.forEach(async url => {
        const content = await httpGet(url);
        console.log(content);
    });
    // Not finished here
}

これは機能しますが、1つの注意点があります。`httpGet()`によって返されたPromiseは非同期的に解決されるため、`forEach()`が返された時点でコールバックは終了していません。その結果、`logContent()`の終了を待つことはできません。

それが望ましくない場合は、`forEach()`を`for-of`ループに変換できます。

async function logContent(urls) {
    for (const url of urls) {
        const content = await httpGet(url);
        console.log(content);
    }
}

`for-of`ループの後、すべてが終了します。ただし、処理手順は順次実行されます。`httpGet()`は、最初の呼び出しが終了した*後*にのみ2回目に呼び出されます。処理手順を並列実行する場合は、`Promise.all()`を使用する必要があります。

async function logContent(urls) {
    await Promise.all(urls.map(
        async url => {
            const content = await httpGet(url);
            console.log(content);
        }));
}

`map()`を使用してPromiseの配列を作成します。その結果を解決することには関心がなく、すべてが解決されるまで待つだけです。つまり、この非同期関数の終了時点で完全に完了します。`Promise.all()`を返すこともできますが、その場合、関数の結果は、すべての要素が`undefined`である配列になります。

5.5 非同期関数を使用するためのヒント

5.5.1 Promiseを理解する

非同期関数の基礎はPromiseです。そのため、Promiseを理解することが、非同期関数を理解するために不可欠です。特に、Promiseに基づいていない古いコードを非同期関数と接続する場合、Promiseを直接使用せざるを得ないことがよくあります。

例えば、これはXMLHttpRequestの「Promise化」されたバージョンです。

function httpGet(url, responseType="") {
    return new Promise(
        function (resolve, reject) {
            const request = new XMLHttpRequest();
            request.onload = function () {
                if (this.status === 200) {
                    // Success
                    resolve(this.response);
                } else {
                    // Something went wrong (404 etc.)
                    reject(new Error(this.statusText));
                }
            };
            request.onerror = function () {
                reject(new Error(
                    'XMLHttpRequest Error: '+this.statusText));
            };
            request.open('GET', url);
            xhr.responseType = responseType;
            request.send();
        });
}

XMLHttpRequestのAPIはコールバックに基づいています。非同期関数を使用してPromise化することは、コールバック内から関数が返すPromiseを解決または拒否することを意味します。これは不可能です。なぜなら、returnthrowでのみそうできるからです。そして、コールバック内から関数の結果をreturnすることはできません。throwにも同様の制約があります。

したがって、非同期関数の一般的なコーディングスタイルは次のようになります。

さらに読む:「Exploring ES6」の「非同期プログラミングのためのPromise」章。

5.5.2 即時実行非同期関数式

モジュールまたはスクリプトの最上位レベルでawaitを使用できると便利になる場合があります。残念ながら、それは非同期関数内でのみ使用できます。そのため、いくつかの選択肢があります。非同期関数main()を作成し、直後に呼び出すことができます。

async function main() {
    console.log(await asyncFunction());
}
main();

または、即時実行非同期関数式を使用できます。

(async function () {
    console.log(await asyncFunction());
})();

別の選択肢は、即時実行非同期アロー関数です。

(async () => {
    console.log(await asyncFunction());
})();

5.5.3 非同期関数による単体テスト

次のコードは、テストフレームワークmochaを使用して、非同期関数asyncFunc1()asyncFunc2()の単体テストを行います。

import assert from 'assert';

// Bug: the following test always succeeds
test('Testing async code', function () {
    asyncFunc1() // (A)
    .then(result1 => {
        assert.strictEqual(result1, 'a'); // (B)
        return asyncFunc2();
    })
    .then(result2 => {
        assert.strictEqual(result2, 'b'); // (C)
    });
});

しかし、このテストは常に成功します。なぜなら、mochaは(B)行と(C)行のアサーションが実行されるまで待機しないからです。

Promiseチェーンの結果を返すことでこれを修正できます。mochaは、テストがPromiseを返すかどうかを認識し、そのPromiseが解決されるまで待機します(タイムアウトがある場合を除く)。

return asyncFunc1() // (A)

便利にも、非同期関数は常にPromiseを返すため、この種の単体テストに最適です。

import assert from 'assert';
test('Testing async code', async function () {
    const result1 = await asyncFunc1();
    assert.strictEqual(result1, 'a');
    const result2 = await asyncFunc2();
    assert.strictEqual(result2, 'b');
});

したがって、mochaで非同期単体テストに非同期関数を使用することには、コードがより簡潔になり、Promiseの返却も処理されるという2つの利点があります。

5.5.4 未処理の拒否を心配する必要はありません

JavaScriptエンジンは、処理されていない拒否に関する警告を生成するのがますます上手になっています。例えば、次のコードは過去にはしばしばサイレントに失敗していましたが、最新のほとんどのJavaScriptエンジンは、未処理の拒否を報告するようになりました。

async function foo() {
    throw new Error('Problem!');
}
foo();

5.6 さらに読む

次へ:6. 共有メモリとアトミック操作