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');
100000000 hello
ご覧のとおり、上記のコードのコールバック関数は後続のコードの前に実行されます。 JS 自体は単一のスレッドで実行され、コードの一部の実行が終了する前に他のコードを実行することは不可能であるため、非同期実行の概念はありません。
ただし、関数の実行内容が、別のスレッドまたはプロセスを作成し、JS メイン スレッドと並行して何かを実行し、それが完了したときに JS メイン スレッドに通知することである場合、状況は異なります。次のコードを見てみましょう。
setTimeout(function () { console.log('world'); }, 1000); console.log('hello');
hello world
今回は、後続のコードの後にコールバック関数が実行されていることがわかります。前述したように、JS 自体はシングルスレッドであり、非同期実行はできません。そのため、setTimeout などの JS 仕様外の実行環境が提供する特別な関数は、並列スレッドを作成してすぐに返すことであり、 JS マスターからプロセスは、並列プロセスから通知を受信した後、後続のコードを実行し、コールバック関数を実行できます。このような関数には、setTimeout や setInterval などの一般的な関数に加えて、fs.readFile などの NodeJS によって提供される非同期 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);
8520
ご覧のとおり、JS メイン スレッドが他のコードの実行でビジー状態だったために、1 秒後に呼び出されるはずだったコールバック関数の実際の実行時間は大幅に遅れました。
コード設計パターン
非同期プログラミングには、同じ機能を実現するために、同期モードと非同期モードで記述されるコードは大きく異なります。よくあるパターンをいくつか紹介します。
関数の戻り値
同期モードでは、ある関数の出力を別の関数の入力として使用することが非常に一般的な要件であり、コードは通常次のように記述されます。
var output = fn1(fn2('input')); // Do something.
fn2('input', function (output2) { fn1(output2, function (output1) { // Do something. }); });
配列を走査するとき、関数を使用してデータ メンバーに対して何らかの処理を順番に実行することも一般的な要件です。関数が同期的に実行される場合、通常は次のコードが記述されます:
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); }
Error: object is not a function
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); }
因为代码执行路径被打断了,我们就需要在异常冒泡到断点之前用 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. } });
Error: object is not a function
可以看到,异常再次被捕获住了。在 NodeJS 中,几乎所有异步 API 都按照以上方式设计,回调函数中第一个参数都是 err。因此我们在编写自己的异步函数时,也可以按照这种方式来处理异常,与 NodeJS 的设计风格保持一致。
有了异常处理方式后,我们接着可以想一想一般我们是怎么写代码的。基本上,我们的代码都是做一些事情,然后调用一个函数,然后再做一些事情,然后再调用一个函数,如此循环。如果我们写的是同步代码,只需要在代码入口点写一个 try 语句就能捕获所有冒泡上来的异常,示例如下。
function main() { // Do something. syncA(); // Do something. syncB(); // Do something. syncC(); } try { main(); } catch (err) { // Deal with exception. }
但是,如果我们写的是异步代码,就只有呵呵了。由于每次异步函数调用都会打断代码执行路径,只能通过回调函数来传递异常,于是我们就需要在每个回调函数里判断是否有异常发生,于是只用三次异步函数调用,就会产生下边这种代码。
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. } });
可以看到,回调函数已经让代码变得复杂了,而异步方式下对异常的处理更加剧了代码的复杂度。