6.非同期プログラミング

11555 ワード

6.非同期プログラミング
NodeJSの最大の売りは、イベントメカニズムと非同期IOであり、開発者には透明ではない.開発者は非同期的にコードを作成しなければならないので、このセールスポイントは使えません.この点はNodeJSの反対者からも非難されました.いずれにしても、非同期プログラムは確実にNodeJSの最大の特徴です.非同期プログラムを把握していないと、NodeJSを本当に習得したとは言えません.この章では、非同期プログラミングに関するさまざまな知識を紹介します.
コールバック
コードの中で、非同期プログラミングの直接的な表現は逆変調です.非同期プログラミングはコールバックによって実現されますが、コールバックを使ってプログラムが非同期化されたとは言えません.まず以下のコードを見てもいいです.
function heavyCompute(n, callback) {
    var count = 0,
        i, j;

    for (i = n; i > 0; --i) {
        for (j = n; j > 0; --j) {
            count += 1;
        }
    }

    callback(count);
}

heavyCompute(10000, function (count) {
    console.log(count);
});

console.log('hello');

-- Console ------------------------------
100000000
hello
 
上記のコードの中のコールバック関数は、後続のコードよりも先に実行されていることがわかる.JS自体はシングルスレッドで実行されており、コードの一部がまだ終わっていないときに別のコードを実行することは不可能であるため、非同期実行の概念は存在しない.
しかし、ある関数が行うことが個々のスレッドやプロセスを作成し、JSのメインスレッドと並行して行うものであれば、仕事が終わったらJSのメインスレッドに通知するということは、また違っています.私たちは次のコードを見ます.
setTimeout(function () {
    console.log('world');
}, 1000);

console.log('hello');

-- Console ------------------------------
hello
world
 
今回は、コールバック関数の後に、後続のコードで実行されることが分かります.上記のように、JS自体はシングルスレッドであり、非同期では実行できないので、setTimeoutのようなJS仕様以外の実行環境によって提供される特殊な関数で行うことは、平行スレッドを作成した直後に戻り、JSメインプロセスが後続コードを実行し、平行プロセスの通知を受けた後に再度コールバック関数を実行することができると考えられます.setTimeoutsetIntervalのような一般的な関数に加えて、このような関数は、fs.readFileのような非同期APIを含む.
また、JSに戻ってもシングルスレッドで実行されているという事実は、JSがコードの一部を実行する前に、コールバック関数を含む他のコードを実行できないことを決定しました.つまり、平行スレッドが動作していても、JSメインスレッドにレス関数を実行するよう通知します.コールバック関数はJSメインラインが空いてから実行します.以下はこのような例です.
function heavyCompute(n) {
    var count = 0,
        i, j;

    for (i = n; i > 0; --i) {
        for (j = n; j > 0; --j) {
            count += 1;
        }
    }
}

var t = new Date();

setTimeout(function () {
    console.log(new Date() - t);
}, 1000);

heavyCompute(50000);

-- Console ------------------------------
8520
 
JSメインラインは他のコードを実行するのに忙しいので、1秒後に呼び出されるはずのコールバック関数が、実際の実行時間を大幅に遅らせていることが分かります.
コード設計モード
非同期プログラミングには多くの特有のコード設計モードがあります.同じ機能を実現するために、同期方式と非同期方式を使って作成したコードは大きな違いがあります.一般的なパターンを紹介します.
関数の戻り値
関数の出力を別の関数として使う入力は一般的な需要であり、同期方式では一般的に以下のようにコードを作成します.
var output = fn1(fn2('input'));
// Do something.
 
非同期方式では、関数の実行結果は戻り値ではなく、コールバック関数によって伝達されるので、コードは一般的に以下のように作成される.
fn2('input', function (output2) {
    fn1(output2, function (output1) {
        // Do something.
    });
});
 
このような方法は、1つのコールバックのセットが多すぎて、セットが多すぎて、>の形のコードが書きやすいということが分かります.
巡回行列
配列を巡回する時、ある関数を使って順番にデータのメンバーにいくつかの処理をするのもよくある需要です.関数が同期して実行される場合、通常は以下のコードが書かれます.
var len = arr.length,
    i = 0;

for (; i < len; ++i) {
    arr[i] = sync(arr[i]);
}

// All array items have processed.
 
関数が非同期で実行されている場合、上記のコードはループ終了後の配列全員の処理が完了したとは保証できません.配列メンバーが連続して処理しなければならない場合、通常は以下のように非同期コードを作成する.
(function next(i, len, callback) {
    if (i < len) {
        async(arr[i], function (value) {
            arr[i] = value;
            next(i + 1, len, callback);
        });
    } else {
        callback();
    }
}(0, arr.length, function () {
    // All array items have processed.
}));
 
上記のコードは非同期関数で一回実行され、実行結果に戻ってから次の配列メンバーに伝えられ、次のラウンドの実行が開始されるまで、全ての配列メンバーが処理が終了した後、チューニングによって後続コードの実行がトリガされることが見られます.
配列メンバーが並列に処理できますが、後続コードはまだ全ての配列メンバーが処理済みで実行される必要がある場合、非同期コードは以下のように調整されます.
(function (i, len, count, callback) {
    for (; i < len; ++i) {
        (function (i) {
            async(arr[i], function (value) {
                arr[i] = value;
                if (++count === len) {
                    callback();
                }
            });
        }(i));
    }
}(0, arr.length, 0, function () {
    // All array items have processed.
}));
 
上記のコードは非同期のシリアルエルゴードバージョンと比較して、すべての配列メンバーを並列処理し、カウンタ変数によっていつすべての配列メンバーが処理済みかを判断することができます.
異常処理
JS自身が提供する異常な捕獲および処理機構、try..catch..は、同期実行のためのコードのみに使用される.以下は一例です.
function sync(fn) {
    return fn();
}

try {
    sync(null);
    // Do something.
} catch (err) {
    console.log('Error: %s', err.message);
}

-- Console ------------------------------
Error: object is not a function
 
異常は、第1のtry文に遭遇したときに捕獲されるまで、コード実行経路に沿って泡が立ち続けることが分かる.しかし、非同期関数がコードの実行経路を中断し、非同期関数が実行中及び実行後に発生した異常な泡が実行経路が中断された位置に来た場合、try文が発生していない場合は、グローバル異常として投げ出します.以下は一例です.
function async(fn, callback) {
    // Code execution path breaks here.
    setTimeout(function () {
        callback(fn());
    }, 0);
}

try {
    async(null, function (data) {
        // Do something.
    });
} catch (err) {
    console.log('Error: %s', err.message);
}

-- Console ------------------------------
/home/user/test.js:4
        callback(fn());
                 ^
TypeError: object is not a function
    at null._onTimeout (/home/user/test.js:4:13)
    at Timer.listOnTimeout [as ontimeout] (timers.js:110:15)
 
コードの実行経路が中断されたので、異常泡が断線に達する前にtry文で異常を捕獲し、捕獲された異常をフィードバック関数で伝える必要がある.そこで私たちは下のように上の例を改造することができます.
function async(fn, callback) {
    // Code execution path breaks here.
    setTimeout(function () {
        try {
            callback(null, fn());
        } catch (err) {
            callback(err);
        }
    }, 0);
}

async(null, function (err, data) {
    if (err) {
        console.log('Error: %s', err.message);
    } else {
        // Do something.
    }
});

-- Console ------------------------------
Error: object is not a function
 
異常が再び捕獲されたことが見られます.ノードJSでは、ほぼすべての非同期APIが上記のように設計されており、コールバック関数の最初のパラメータはerrである.したがって、私たちは自分の非同期関数を作成する時に、このように異常を処理してもいいです.NodeJSの設計スタイルと一致しています.
異常処理方式があったら、普通コードはどう書きますか?基本的には、コードはいくつかのことをして、関数を呼び出して、いくつかのことをして、関数を呼び出します.同期コードを書くと、コードの入り口にtry文を書くだけで、すべての発泡体の異常が捕捉されます.例は以下の通りです.
function main() {
    // Do something.
    syncA();
    // Do something.
    syncB();
    // Do something.
    syncC();
}

try {
    main();
} catch (err) {
    // Deal with exception.
}
 
しかし、もし私たちが非同期コードを書いたら、ほほほしかないです.非同期関数が呼び出されるたびにコードの実行経路を中断し、コールバック関数によってしか異常を伝達できないので、各コールバック関数で異常が発生しているかどうかを判断する必要があります.そこで、非同期関数を3回だけ呼び出したら、次のようなコードが発生します.
function main(callback) {
    // Do something.
    asyncA(function (err, data) {
        if (err) {
            callback(err);
        } else {
            // Do something
            asyncB(function (err, data) {
                if (err) {
                    callback(err);
                } else {
                    // Do something
                    asyncC(function (err, data) {
                        if (err) {
                            callback(err);
                        } else {
                            // Do something
                            callback(null);
                        }
                    });
                }
            });
        }
    });
}

main(function (err) {
    if (err) {
        // Deal with exception.
    }
});
 
コールバック関数はコードを複雑にしていますが、非同期方式では異常な処理がコードの複雑さを増しています.NodeJSの最大の売りが最終的にこのようになったら、NodeJSを使う人がいなくなりますので、NodeJSが提供する解決策を紹介します.
ドメイン(Domain)
公式文書: http://nodejs.org/api/domain.html
ノードJSはdomainモジュールを提供し、非同期コードの異常処理を簡略化することができる.このモジュールを紹介する前に、まず「ドメイン」の概念を理解する必要があります.簡単に言えば、一つのドメインはJS運行環境であり、一つの運行環境において、もし異常が捕獲されなかったら、グローバル異常として投げ出されます.ノードJSは、processオブジェクトを介してグローバル異常を取り込む方法を提供し、コード例は以下の通りである.
process.on('uncaughtException', function (err) {
    console.log('Error: %s', err.message);
});

setTimeout(function (fn) {
    fn();
});

-- Console ------------------------------
Error: undefined is not a function
 
大域異常は捕獲できるところがありますが、大多数の異常に対しては、できるだけ早く捕獲し、結果によってコードの実行経路を決定したいです.私たちは以下のHTTPサーバコードを例として使っています.
function async(request, callback) {
    // Do something.
    asyncA(request, function (err, data) {
        if (err) {
            callback(err);
        } else {
            // Do something
            asyncB(request, function (err, data) {
                if (err) {
                    callback(err);
                } else {
                    // Do something
                    asyncC(request, function (err, data) {
                        if (err) {
                            callback(err);
                        } else {
                            // Do something
                            callback(null, data);
                        }
                    });
                }
            });
        }
    });
}

http.createServer(function (request, response) {
    async(request, function (err, data) {
        if (err) {
            response.writeHead(500);
            response.end();
        } else {
            response.writeHead(200);
            response.end(data);
        }
    });
});
 
以上のコードは要求対象を非同期関数に処理した後、処理結果に基づいて応答します.ここではコールバック関数を使って異常を伝達する方式を採用していますので、async関数の内部にもういくつかの非同期関数が呼び出されたら、コードは上の鬼のようになります.コードを美しくするために、要求を処理するたびに、domainモジュールを使用してサブドメイン(JSサブ実行環境)を作成することができます.サブドメイン内で動作するコードは、任意に例外を投げかけることができ、これらの異常は、サブドメインオブジェクトのerrorイベントによって統一的に捕獲されることができる.以上のコードは次のように改造できます.
function async(request, callback) {
    // Do something.
    asyncA(request, function (data) {
        // Do something
        asyncB(request, function (data) {
            // Do something
            asyncC(request, function (data) {
                // Do something
                callback(data);
            });
        });
    });
}

http.createServer(function (request, response) {
    var d = domain.create();

    d.on('error', function () {
        response.writeHead(500);
        response.end();
    });

    d.run(function () {
        async(request, function (data) {
            response.writeHead(200);
            response.end(data);
        });
    });
});
 
.create法を用いてサブドメインオブジェクトを作成し、.run法を通じてサブドメインで動作する必要があるコードのエントリポイントに入ることが分かる.サブドメインにある異ステップ関数のコールバック関数は、もはや異常を捕らえる必要がないため、コードが一気に多くダイエットされます.
落とし穴processオブジェクトのuncaughtExceptionイベントによって大域異常が発生したかどうかはともかく、サブドメインオブジェクトのerrorイベントによってサブドメイン異常が検出されたかどうかは、NodeJS公式文書では、異常処理が完了したらすぐにプログラムを再開することを強く推奨し、プログラムを実行し続けることではない.公式文書によると、異常が発生した後のプログラムは不確定な動作状態となり、すぐに終了しないと、プログラムに深刻なメモリ漏れが発生する可能性があります.
しかし、ここではいくつかの事実を明らかにする必要があります.JS自体のthrow..try..catch異常処理機構はメモリ漏れを起こさないし、プログラムの実行結果を予想外にさせることもないが、NodeJSは純粋なJSではない.NodeJSでは、多くのAPI内部はC/C++で実現されています.そのため、NodeJSプログラムの実行過程で、コードの実行経路はJSエンジン内部と外部を往復しています.
したがって、uncaughtExceptionまたはdomainを使用して異常を捕捉し、コードの実行経路にC/C++部分のコードが含まれている場合、メモリ漏れの原因になるかどうかを判断できない場合などは、異常を処理した後にプログラムを再開したほうがいいです.try文を使って異常を捕捉した場合は、JS自体の異常が一般的で、控訴問題を心配する必要はない.
結び目
この章では、JSの非同期プログラミングに関する知識を紹介します.まとめてみると、以下の点があります.
  • 非同期プログラミングをマスターしないと、学会NodeJSとは言えません.
  • 非同期プログラムは、コールバックによって実現されるが、コールバックは必ずしも非同期プログラムではない.
  • 非同期プログラミングにおける関数間データ転送、配列遍歴、および異常処理は、同期プログラミングとは大きく異なる.
  • は、domainモジュールを使用して、非同期コードの異常処理を簡略化し、落とし穴に注意する.