💡

Node.js 非同期処理全8種類まとめ 2024年版

2024/12/09に公開

みなさまこんにちは、こんにちは!エアークローゼットCTOの辻です。
この記事はエアークローゼットのアドベントカレンダー2024の9日目の記事となってますので、ぜひ他の記事も読んでいただけたらと思います!エアークローゼットは2015年からほぼ10年間ずっとNode.jsで開発してきたこともあり、Node.jsの成長をずっと間近で見てきました。
そこで今回はNode.jsの大きな特徴の一つでもある非同期処理について、時系列順にそれぞれの特徴をまとめつつ、最後に最新の動向にも触れられたらと思います。

見出し

Callback

特徴

コールバックは最も原始的な非同期処理です。最後の引数をコールバックとしてセットするのが暗黙のルールになっていて、その第一引数にはエラーを受け取るように設定します。
コールバックは一階層だけならシンプルなんですが、ネストすることで「コールバック地獄」に陥りやすい欠点があります。とくにアプリケーションが複雑になると、IO処理など非同期処理が推奨される処理を多重に実行する必要があり、必然的にコールバック地獄になるので、徐々にPromise系の処理に変わっていきました。
ちなみに、コールバック形式の関数は後述のPromise形式の関数に変更するutil.promisifyがサポートされていて、それを使えばasync/awaitとも一緒に使うこともできます。

  • シンプルな実装
  • ネストが深くなると可読性が低下
  • シングルプロセス/シングルスレッド

サンプルコード

const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  console.log('File content:', data);
});

Stream

特徴

Node.jsの初期から存在する大きなデータを効率的に処理するための仕組みです。データを小さなチャンクに分割して処理するため、メモリ効率が良いです。
大きなファイルを少しずつ処理できるなど他の非同期処理ではできないことができるので、今でもstreamを使わざる得ないタイミングは多々ありますが、event emitter形式なのでちょっと処理にクセがあります。ただ最近ではfor async ... of構文を使うとasync/awaitstreamを扱うことができるようになり、それを使うとかなり直感的に扱えるようになっています。

  • ストリーム操作による効率的なデータ処理
  • データのリアルタイム処理に適する
  • シングルプロセス/シングルスレッド

サンプルコード

古典的なstream

const fs = require('fs');
const readStream = fs.createReadStream('example.txt', 'utf8');
readStream.on('data', chunk => {
  console.log('Received chunk:', chunk);
});
readStream.on('end', () => {
  console.log('Stream ended');
});
readStream.on('error', err => {
  console.error('Stream error:', err);
});

for async ... of 形式

const fs = require('fs');
async function readStreamWithAsync() {
  const readStream = fs.createReadStream('example.txt', 'utf8');

  try {
    for await (const chunk of readStream) {
      console.log('Received chunk:', chunk);
    }
    console.log('Stream ended');
  } catch (err) {
    console.error('Stream error:', err);
  }
}
readStreamWithAsync();

child_process

特徴

Node.jsで別のプロセスを生成し、親プロセスと独立してタスクを実行します。計算負荷の高い処理や外部コマンドの実行に適していて、そもそもNode.jsではないプログラムも実行できます。
完全に別のプロセスとして実行されるためマルチプロセスとして処理されますが、結果として同時にJavaScriptのオブジェクトを共通で扱えないため、処理間のデータのやり取りはJSON形式に変更できるものに限られます。

  • 別プロセスでの処理実行
  • 親プロセスとプロセス間通信が可能
  • マルチプロセス/シングルスレッド

サンプルコード

const { exec } = require('child_process');

exec('ls -l', (err, stdout, stderr) => {
  if (err) {
    console.error('Error executing command:', err);
    return;
  }
  console.log('Command output:', stdout);
});

cluster

特徴

Node.jsのシングルスレッドモデルを補完するため、複数のプロセスを生成して負荷分散を実現します。特にマルチコアCPUを活用する際に有用です、というかマルチコアCPUを利用するために使います。おそらく多くの場合はNode.jsの実行環境をpm2で構築して、意識せずに使うことがほとんどかと思います。

  • マルチコア環境での負荷分散
  • 親プロセスが子プロセスを管理
  • マルチプロセス/シングルスレッド

サンプルコード

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`Master ${process.pid} is running`);

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died`);
  });
} else {
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('Hello World\n');
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

Promise

特徴

コールバックの欠点を克服するために登場した非同期処理のモデルです。もともとはbluebirdや、古くはjQuery.Deferredなど、third partyのライブラリで補完されていましたが、ES6で満を持してネイティブ実装されました。Promise形式の関数であればasync/awaitと一緒に使うことができますし、Promise.allなどasync/awaitだけではできないこともできるのでまだまだ現役です。

  • エラーハンドリングが容易
  • コールバック地獄の解消
  • シングルプロセス/シングルスレッド

サンプルコード

const fs = require('fs').promises;

fs.readFile('example.txt', 'utf8')
  .then(data => {
    console.log('File content:', data);
  })
  .catch(err => {
    console.error('Error reading file:', err);
  });

Generator

特徴

ES6 で導入されたジェネレータ関数を使用して、非同期処理を一時停止・再開できます。非同期フロー制御の一つとして利用されますが、async/awaitに取って代わられつつあり、最近ではRedux Saga以外ではあまり見かけなくなりました。もともとgeneratorはそれだけが目的の処理ではないので、必然の流れかなとも思っています。

  • 非同期処理の一時停止と再開
  • 中断可能な関数を提供
  • シングルプロセス/シングルスレッド

サンプルコード

const fs = require('fs').promises;

function* readFileGenerator() {
  const data = yield fs.readFile('example.txt', 'utf8');
  console.log('File content:', data);
}

const generator = readFileGenerator();
generator.next().value
  .then(data => generator.next(data))
  .catch(err => console.error('Error:', err));

async/await

特徴

Promiseをベースにした構文で、非同期処理を同期処理のように記述できます。コードの可読性が大幅に向上しましたが、全てをasync/awaitで実装してしまうと1行ごとにwaitが発生するため、必要に応じてPromise.allで包んでawaitするなど、工夫をしないとパフォーマンスを悪化させる要因になります。
とはいえ、awaitはたとえばfs.readFileSyncのようにその処理を実行している間、Nodejsのプロセス全てを止めるわけではなく、その後の処理をcall stackに入れるだけで、待ってる間はプロセス自体は動いて別の処理をこなしてくれるので、全体のパフォーマンスを悪化させたりはしません。

  • シンプルで直感的な非同期処理
  • エラーハンドリングが容易
  • シングルプロセス/シングルスレッド

具体的な動作について

async/awaitについて、「Promiseをベースに」と書きましたが、より具体的にどういうことなのかを説明します。
みなさん、もしかしたら呪文のようにfunctionの前にasyncと書いたら、そのfunctionの中でawaitが使えて非同期処理を同期的に実行できる、くらいの認識だったりしませんか?もちろんそれは間違っていないですが、正確に理解することでより柔軟に使うことができるようになります。
というわけで、asyncawaitそれぞれについて見ていきましょう。

async

まずasyncについてです。asyncとfunctionの前に書くことで、そのfunction内でawaitが使えるようになるわけですが、asyncと書くことでそのfunctionがどうなるのか。それはそのfunctionを自動的にPromise関数にしてくれています。より具体的には、return値がPromiseになり、function内でreturnした結果をresolveの引数に、function内でthrowが発生した場合はrejectを呼び出します。

await

次にawaitについてです。awaitは実行する関数のresponseを見て、

  • Promise形式であればその処理が完了するまで処理をそこで止め、
    • resolveされたらその結果を返す。
    • rejectされたらthrowする。
  • Promise形式でなければそのままその処理を実行し、
    • returnが返ってくればそのまま返す。
    • throwされたらそのままthrowする。
      という動作をします。

async/awaitはこういう面倒なことを全部やってくれているので、それはもう便利に決まってますね。

サンプルコード

const fs = require('fs').promises;

async function readFile() {
  try {
    const data = await fs.readFile('example.txt', 'utf8');
    console.log('File content:', data);
  } catch (err) {
    console.error('Error reading file:', err);
  }
}

readFile();

Worker Threads

特徴

Worker Threadsは Node.jsのマルチスレッド環境を実現するAPIで、計算負荷の高いタスクをメインスレッドから分離して実行できます。フロントエンドのWeb Workerと同じようなものと考えてもらえれば大丈夫です。
また、child_processclusterと異なり、JavaScriptのObjectをファイル間でそのままやり取りすることができるため、circuler dependencyのObjectであっても扱うことができます。

  • スレッド間通信が可能
  • 計算負荷の高いタスクに適する
  • シングルプロセス/マルチスレッド

サンプルコード

const { Worker } = require('worker_threads');

function runWorker(filePath) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(filePath);
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', code => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
    });
  });
}

runWorker('./worker.js')
  .then(result => console.log('Worker result:', result))
  .catch(err => console.error('Worker error:', err));

用途別まとめ

DBアクセスやファイルIO、Web API呼び出しなどの非同期処理を管理したい

  • Callback
  • Promise
  • async/await
    この3つは基本的にNodejsの最も一般的な非同期処理を管理するために使われています。
    Nodejsの成長とともに現在ではasync/awaitが最も主流ですが、本文中でも書いたように、Callback形式は'util.Promisify'でPromise化できますし、async/awaitの内部実装はPromiseを良い感じに処理するための機構なので、この3つは問題なく併用することができます。

巨大なファイルをメモリ効率よく扱いたい

  • stream
    streamは巨大なファイルを小さなチャンクに分けて、それごとに処理してくれます。
    例えば数GBを超えるような巨大なCSVを、async/awaitなど、他の処理で扱おうとするとその内容を全てメモリ空間で保持しようとしてエラーになってしまうので、そういった場合に力を発揮します。

サーバのCPUを効率よく使いたい

  • Cluster
    マルチコアCPUの場合に何も考えずにNodejsのプロセスを立ち上げても、その中の1プロセスしか使ってくれません。そのためマルチコアCPUの性能を活かそうとすると必然的にClusterを使う必要があります。基本的にはpm2が勝手にやってくれるので、設定ファイルの書き方だけ覚えましょう。

別プログラムを実行したい

  • child_process
    プログラム間の連携は、通常はWeb APIやらLambdaやらなにかしら処理を部品化して実装することが多いとは思いますが、os依存の処理を実行したかったり、さっと手元だけで実行した場合など、別プログラムをshellのように呼び出したいときに非常に便利です。

複雑な計算処理を高速で実行したい

  • worker threads
    Nodejsは通常シングルスレッドで動作するため、例えばmap/reduce等を使って非同期に計算を行っても、その内部では直列でしか動作しません。
    そんなときはworker threadsを利用すればマルチスレッドで計算処理を行ってくれるため、処理を高速させることができる可能性があります。

補足

ちなみに、それぞれの特徴に(シングル|マルチ)プロセス/(シングル|マルチ)スレッドとしれっと書いていますが、Node.jsってシングルプロセス/シングルスレッドじゃなかったっけ?って思った方いませんか?もしくはプロセスとスレッドの違いってなにって思っていたりしませんか?詳細を説明するとそれだけで別の記事になってしまうので簡単に説明すると、まずNode.jsのシングルスレッドで非同期動作する仕組みはLibuvというC言語で作られたライブラリがベースになっています。
この環境自体を複数プロセス実行するのがchild_processclusterで、プロセスが分かれている都合上メモリも別々に管理されていて、JavaScriptのオブジェクトをそのままやり取りできません。
これに対してworker threadsは、libuvの環境上で複数スレッドを動かす実装になっており、結果としてJavaScriptのオブジェクトをそのままやり取りすることができます。

最後に

以上のようにNode.jsの非同期処理は、初期から比べると見違えたように進化を続けてきました。
ただそれぞれの手法には適したユースケースが存在し、用途や要件に応じて選択することが重要です。特に現在はasync/awaitWorkerの利用が主流となりつつありますが、巨大なファイルを扱うときはstreamが必須ですし、osのマルチコアを活用しようとするとclusterも必要になります。
また、上にも書きましたが、闇雲にasync/awaitだけで実装すると、本来Node.jsの非同期処理の良さが失われてパフォーマンスの悪化を招くので、自分の書いている処理を正しく理解し、様々な手法の中から最適な手法を適用することが大事です。

エアークローゼットではNode.jsで何でも開発したいエンジニアも積極的に募集中ですので、興味のある方は、エアークローゼットのエンジニア採用サイト -エアクロクエスト-の方もご覧いただけたら嬉しいです!
それではここまで読んでいただきありがとうございました。

みなさま良いお年を!

Discussion