k-tokitoh

2021-10-10

Node.jsのI/O

Promise も fs の I/O も非同期というのでfs.fileRead()とかは Promise インスタンスを返すのかと思ったら戻り値が void だった。 整理する。

I/O コールバックと Promise

Node.js コミッターの方のこの記事がわかりやすかった。

以下の queue はそれぞれ異なると。

I/O コールバックの登録それ自体は Promise のような簡潔な書き方を提供していない。

よって I/O コールバックを直列的に繋げたい場合、素朴に書くと以下のようになる。

import * as fs from "fs";

const path = "counter";

fs.writeFile(path, "1", (_err) => {
  fs.readFile(path, (_err, data) => {
    fs.writeFile(path, (Number(data) + 1).toString(), (_err) => {
      fs.readFile(path, (_err, data) => {
        fs.writeFile(path, (Number(data) + 1).toString(), (_err) => {
          fs.readFile(path, (_err, data) => {
            console.log(data.toString()); // 3
          });
        });
      });
    });
  });
});

これは辛いので、Promise でラップしてあげよう。

const writeFile = (path, value) =>
  new Promise<void>((resolve, _reject) =>
    fs.writeFile(path, value, (_err) => resolve())
  );

const readFile = (path) =>
  new Promise<Buffer>((resolve, _reject) =>
    fs.readFile(path, (_err, data) => resolve(data))
  );

writeFile(path, "1")
  .then(() => readFile(path))
  .then((value) => writeFile(path, (Number(value) + 1).toString()))
  .then(() => readFile(path))
  .then((value) => writeFile(path, (Number(value) + 1).toString()))
  .then(() => readFile(path))
  .then((value) => console.log(value.toString())); // 3

ここで以下が起きている。

つまり、Promise が提供する書きっぷりを利用するために、本来的には不要な別の queue への詰替えを毎回経由している。

ちなみに Promise によってラップされた関数は fs によっても提供されている。

fs.promises
  .writeFile(path, "1")
  .then(() => fs.promises.readFile(path))
  .then((value) => fs.promises.writeFile(path, (Number(value) + 1).toString()))
  .then(() => fs.promises.readFile(path))
  .then((value) => fs.promises.writeFile(path, (Number(value) + 1).toString()))
  .then(() => fs.promises.readFile(path))
  .then((value) => console.log(value.toString())); // 3

もちろんこれを async/await で記述することもできる。

(async () => {
  await fs.promises.writeFile(path, "1");
  const value1 = await fs.promises.readFile(path);
  await fs.promises.writeFile(path, (Number(value1) + 1).toString());
  const value2 = await fs.promises.readFile(path);
  await fs.promises.writeFile(path, (Number(value2) + 1).toString());
  const value3 = await fs.promises.readFile(path);
  console.log(value3.toString()); // 3
})();

非同期 I/O と同期 I/O の比較

時間計測用に以下を用意した。

const time = async (func: () => Promise<any> | any) => {
  const start = hrtime.bigint();
  await func();
  console.log(`${Number(hrtime.bigint() - start) / 10e3} microsecs`);
};

直列 I/O * 1

x->x->x->...

fs.writeFileSyncで書く。

time(() => {
  Array(300)
    .fill(null)
    .forEach((_, index) => {
      fs.writeFileSync(`tmp/${index}`, "hi");
    });
}); // 29687.6078 microsecs

続いてfs.promises.writeFileで書く。

time(async () => {
  for await (const index of new Array(300).fill(null).map((_, i) => i)) {
    await fs.promises.writeFile(`tmp/${index}`, "hi");
  }
}); // 32196.3895 microsecs

だいたい同じくらい。

直列 I/O * n

x->x->x->...
x->x->x->...
x->x->x->...

fs.writeFileSyncで書く。

time(() => {
  Array(30)
    .fill(null)
    .forEach((_, index_1) => {
      Array(10)
        .fill(null)
        .forEach((_, index_2) => {
          fs.writeFileSync(`tmp/${index_1}/${index_2}`, "hi");
        });
    });
}); // 29039.4188 microsecs

書き込んでいる回数は”直列 I/O * 1”と同じく 300 回なので、実行時間も同じくらい。

続いてfs.promises.writeFileで書く。

time(async () => {
  Array(30)
    .fill(null)
    .forEach(async (_, index_1) => {
      for await (const index_2 of new Array(10).fill(null).map((_, i) => i)) {
        fs.promises.writeFile(`tmp/${index_1}/${index_2}`, "hi");
      }
    });
}); // 29.4319 microsecs

30 分の 1 くらいになるのかとおもったら 1000 分の 1 になった。なぜかわからん…。