2021-10-10
Node.jsのI/O
Promise も fs の I/O も非同期というのでfs.fileRead()
とかは Promise インスタンスを返すのかと思ったら戻り値が void だった。
整理する。
I/O コールバックと Promise
Node.js コミッターの方のこの記事がわかりやすかった。
以下の queue はそれぞれ異なると。
- イベントループのなかの対応するフェーズで処理するやつ
setTimeout
,setImmediate
@ timers- I/O @ pending I/O
setImmediate
@ check handlers\*.on('close')...
@ close handlers
- イベントループでフェーズ遷移するたびに都度処理するやつ
- Promise @ microTaskQueue
process.nextTick
@ nextTickQueue
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 によって microTaskQueue に処理が登録され
- microTaskQueue において
fs.readFile
/fs.writeFile
が実行されて I/O コールバックが登録され - I/O が完了したら I/O コールバックに記述された
new Promise(...)
が実行されて microTaskQueue に処理が登録され - microTaskQueue において
fs.readFile
/fs.writeFile
が実行されて I/O コールバックが登録され - I/O が完了したら I/O コールバックに記述された
new Promise(...)
が実行されて microTaskQueue に処理が登録され - …
つまり、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 になった。なぜかわからん…。