Nodejsの同期、非同期について【Promiseとか】

えーと最近はJavaScriptで開発することが多い。
サーバー側ではNodeJSだし。ブラウザ側でもJavaScript使うし。

JavaScriptとJavaは無関係っていうウンチクネタもあるのだが、JavaScriptにはマルチスレッドは無い。

元々、Netscape(Webブラウザ)で動きのあるページを作成する時に使われていたプログラミング言語である。今はないよねNetscapeナビゲーター。昔はというかNetscape Navigatorがブラウザの発祥だからねぇ…
Navigatorに対抗してExplorerが出てきたわけで…

今はネスケもIEもないので時代は変わったんだろうな。

で何が言いたいかというと、JavaScriptはマルチスレッドは使えないのである。Javaの方は仮想マシンで動くから「何でもできる」プロセスだって、スレッドだってできる。でもJavaScriptはブラウザで動くスクリプト言語なのでプロセスやスレッドは無理。

でも擬似的に裏で動いているような感じにすることはできる。

例えば「タイマー使って3秒後に表示を変える」とか、マウスをクリックしたらなんかする。みたいなことができたし、今でもできる。こういうタイマーやイベントを組み合わせて使うと「マルチタスク風で動いている」ように見える訳である。

同期と非同期

プログラミングで同期処理って言ったら「順番に処理をしていく」っていう感じのもの。

2024年の売上合計の計算が終わったら、2025年の売上合計を計算する。っていうのが同期処理。

これが非同期になると…

2024年の売上合計の計算と2025年の売上合計を計算を別々に並列に計算する。っていうのが非同期処理。かなぁ。

CSSアニメーションでやってみると

同期で計算

2024年の計算 2025年の計算


非同期に計算

2024年の計算

2025年の計算

こんな感じだろうか。

正確には非同期じゃなくて並列処理。同期は逐次処理という。逐次処理は順次計算を行っていくのに対して、並列処理は別々に計算していくので、早く計算が終了する。ただし、これは理想的に並列で処理できた場合になる。

同期で計算するのをSQLでやるとしたら以下のようになる。

SELECT SUM(売上) FROM 売上結果 WHERE EXTRACT(YEAR FROM 日付) = 2024 OR EXTRACT(YEAR FROM 日付) = 2025

非同期で2回に分ける場合は以下のようにすれば良い。

SELECT SUM(売上) FROM 売上結果 WHERE EXTRACT(YEAR FROM 日付) = 2024
SELECT SUM(売上) FROM 売上結果 WHERE EXTRACT(YEAR FROM 日付) = 2025

ここでこのふたつのクエリを別接続で非同期で実行させないと意味がない。
実際にやってみると同期のクエリと非同期のクエリでそんなに処理速度は変わらないかも知れない。データベース側にも「パラレルクエリ」なんていう機能があるから下手に分割しなくても十分なパフォーマンスが得られる可能性がある。

パラレルクエリ機能を切った状態で上記のクエリを実行させたら、非同期の方が早く終了するであろう。これを実際にNodeJSでやってみよう。というのが本記事の趣旨である。

NodeJSはシングルスレッド

冒頭で述べた通り、JavaScriptはブラウザ上で動くプログラミング言語として誕生したためか、マルチタスクやマルチスレッドっていう概念がない。ブラウザ内でプログラムを動かすといってもせいぜいボタンが押されたら表示を変えるとか、サーバーにデータを送信するとかだったので「裏で重たい計算をする」や「複数のユーザーを相手にする」なんていうことは考えられていないのである。
しかし、タイマー使って定期的に何かやらせたり、データ送信が終わったら次の処理に進む、みたいなことが必要だったので、イベント割込み的な非同期処理が可能であった。

一方、NodeJSはサーバー側でJavaScriptを実行して動的なWebページを生成しようと考えられたものである。似たものとしてPHPやRubyがある。Webサーバーと言えばマルチタスク、マルチタスクで動いてもらわないと困る。サーバーは多くのリクエストに答える必要があるのだから。でも、NodeJSはシングルタスクなんです。

えー本当に?嘘じゃないの?

と始めは私もそう思いましたが、嘘ではないです。素の状態のNodeJSはシングルタスクなのである。

readFileとreadFileSync

NodeJSでアップロードされてきたファイルをメモリ内に読み込む時、fsモジュールのreadFileを使うのが便利。

const fs = require('fs');

fs.readFile(filepath,  (err, data) => {
  if (err) throw err;
  console.log(data);  // ファイルの中身が入っている
});

console.log('読み込みが終了する前に実行される');

readFileはいわゆる非同期処理(Async)の関数なので、呼出し後、処理が終了していなくても「すぐに戻ってくる」ここが非同期関数の分かりにくいところでもある。readFileは読み込みが終了したらコールバック関数を呼び出す。読み込みが終了する前に戻って来てしまうので、コンソールには以下の順番で出力される。

読み込みが終了する前に実行される
ファイルの中身...

アップロードされてきたファイル内容に対して何かの処理を行う時は、readFileの引数であるコールバック関数の中に処理を書かないといけない。

const fs = require('fs');

fs.readFile(filepath,  (err, data) => {
  if (err) throw err;
  console.log(data);  // ファイルの中身が入っている
  // ここにファイル内容に対する処理を書く必要がある
});

console.log('読み込みが終了する前に実行される');

普通のプログラミング言語に慣れ親しんでいると「関数からは処理が終了したら戻ってくるもの」と思いがち。最初は「なんで読み込みできないのよ」と面食らう。
でもって、readFileSyncを使うと同期(Sync)呼び出しになるので「なんだじゃあreadFileSyncでいいよ」っていうことになる。

const fs = require('fs');

let data = fs.readFileSync(filename);
console.log(data);

console.log('読み込みが終了した後に実行される');
  // ここにファイル内容に対する処理を書くことができる

ファイルの中身...
読み込みが終了した後に実行される

よしよしちゃんとできたじゃない。ということになるのだが、これって実は「問題あり」なのである。

なぜ問題なのか?

NodeJSはシングルタスクでしか動作しない。一見、複数クライアントからのリクエストを並列に処理できているようにも見えるが、プロセス内部で動いているのはひとつのスレッドだけ。一度に実行されるのはひとつのコードだけなのである。並列に平行して処理されているように見えるのは「IO処理待ちを上手く使っているから」。非同期呼び出しにしているのも、IO待ちが発生した時に処理をねじ込みやすいから。

一般的にファイルの読み書きとかネットワーク送受信とかの「IO処理」を行っている間はCPUは働くことなく遊んでいる状態になる。これが「IO待ち(I/O wait)」である。普通はIOの処理速度よりCPUの処理速度の方が圧倒的に速い。だからIO待ちの状態ではCPUが遊ばないようにガンガン別の仕事をさせた方が良い。この方が効率的。

readFileSyncを使ってファイル読み込みを行うとIO待ちが発生しても別の仕事をしてくれなくなる。だって終わるまで待ってないといけないから。

他の仕事がなれけば、readFileSyncを使っても問題ないのだが、アップロードされてきたファイルを読み込むっていうことはWebサーバーとして動いているっていうことで、readFileSyncの間は「他のリクエストが止まっちゃう」っていうことになるので大問題なわけです。

Promiseで回避

この問題を回避するには、readFileSyncじゃなくてreadFileを使えば良いのだけれども「コールバックのネストが深くなるのがイヤ」っていう時は「Promiseを使うと良い」。

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

async function test() {
  let data = await fs.readFile(filename);
  console.log(data);

  console.log('読み込みが終了した後に実行される');
  // ここにファイル内容に対する処理を書くことができる
}

test();

Promiseはコールバック地獄を回避するためのしくみで、非同期呼び出しを同期呼び出しのように記述できる。awaitを使って非同期呼び出しを行うと、同期呼び出しのように処理が終わったら戻ってくるようになる。終了を待つ間にIO処理があれば、ちゃんと割込み処理も行われる。これならOK。

awaitはPromise化された非同期呼び出しの関数に対して付けるもので、readFileSyncにawaitを付けても意味がない。多分エラーかも。

他にもutilモジュールのpromisifyを使う手もある。

クエリでやってみる

さて、前置きが長くなってしまった。お待たせしました、クエリの例で実際にやってみよう。
手元にあるPostgreSQLでやってみる。NodeJS側のモジュールはpgを使う。接続プールを使って複数のコネクションが使えるようにしないと非同期のうまみがない。

同期で1回クエリの例
const { Pool } = require("pg");
const connectionString = 'postgres://user:pass@host:5432/postgres';  // 接続文字列は環境に合わせて修正願います
const pool = new Pool({
  connectionString: connectionString,
  max: 2
});

async function query(sql){
  return new Promise(async function(resolve, reject) {
    try {
      const connect = await pool.connect();
      let result = await connect.query(sql);
      connect.release();
      resolve(result);
    }
    catch(e) {
      reject(e);
    }
 });
}

async function test() {
  console.time('query');
  let result = await query('SELECT SUM(売上) FROM 売上結果 WHERE EXTRACT(YEAR FROM 日付) = 2024 OR EXTRACT(YEAR FROM 日付) = 2025');
  console.timeEnd('query');
  pool.end();
}

test();

同期の例は特別難しくはないと思う。関数queryがPromise化されており、awaitでqueryを呼び出しているので、引数でもらったクエリ(SQL命令)が実行結果を戻すまで関数queryから戻ることはない。
関数testを作成しているのは、async関数内でのみawaitが使用できるから。test関数でqueryをawaitで呼び出して、処理時間をconsole.timeで計測している。pool.endは接続プールの終了を指示している。これが無いとスクリプトが終了しない。データベースへの接続文字列は実際の環境に合わせて修正して欲しい。

非同期で2回クエリの例
const { Pool } = require("pg");
const connectionString = 'postgres://user:pass@host:5432/postgres';  // 接続文字列は環境に合わせて修正願います
const pool = new Pool({
  connectionString: connectionString,
  max: 2
});


async function query(sql){
  return new Promise(async function(resolve, reject) {
    try {
      const connect = await pool.connect();
      let result = await connect.query(sql);
      connect.release();
      resolve(result);
    }
    catch(e) {
      reject(e);
    }
 });
}

async function test() {
  console.time('query');
  let results = await Promise.all([
    query('SELECT SUM(売上) FROM 売上結果 WHERE EXTRACT(YEAR FROM 日付) = 2024'),
    query('SELECT SUM(売上) FROM 売上結果 WHERE EXTRACT(YEAR FROM 日付) = 2025')
  ]);
  console.timeEnd('query');
  pool.end();
}

test();

非同期で2回にわけて呼び出す方は、query関数の呼び出しを配列にして、Promise.allで実行させている。配列要素のすべてが終了したら戻ってくる。この点が異なるだけ。query関数や接続プールの部分は同じものを使っている。

では、テストデータを作成して実行させてみよう。テストデータは

insert into 売上結果
select s.a, 106 from generate_series('2024-01-01 00:00:00'::timestamp, '2024-12-31 00:00:00'::timestamp, '10 minute') s(a);

といった感じで適当に作ってみた。

じゃあやってみる。

$ node query1.js
query: 104.378ms
$ node query2.js
query: 92.759ms

先のquery1.jsが同期で1回。後のquery2.jsが非同期で2回である。
それ程差はないみたいだが、若干ではあるが、query2.jsの方が速い。順番を変えてみよう。

$ node query2.js
query: 93.05ms
$ node query1.js
query: 106.418ms

やはり、query2.jsの方が10ms程早く終了する。データベースがProxmoxの仮想マシンだからこんなもんか。一応2コアってなってるから、並列処理できるはずだけど...

効果は微妙であったが、NodeJSがシングルタスクであっても「非同期処理のおかげでパフォーマンスを上げることが可能である」といったことが、ご理解頂けたのではないだろうか。
何かの参考になれば幸いである。

投稿者プロフィール

asai
asai
システムエンジニア
喋れる言語:日本語、C言語、SQL、JavaScript