経緯
Node.js は V8 上に構築された JavaScript の実行環境ってのは Wikipedia を読んでなんとなくわかった。Wikipedia の少し下の方にある「クライアント1万台問題」についての記述を読んで見ると
非同期処理のNode.jsではクライアント1万台問題は起きない。
Node.jsでこの問題を解決した技術の中核は、シングルスレッドにおける非同期処理を容易に実装可能にしたイベント駆動型プログラミング環境である。
なるほど、シングルスレッドで動作してるから、プロセスを無制限にフォークすることでコンテキストスイッチが起こりまくって超重くなることはない。詳しくわかってないけど「非同期処理を容易に実装可能にしたイベント駆動型プログラミング」によって同時に処理を捌けるらしい...。
とりあえず node.js だと非同期や同期処理が混ざってるときはどう進むのかだけでも知りたい!
※ 正確に知りたい方はこちらの記事や、libuv
を調べてください。
https://blog.hiroppy.me/entry/nodejs-event-loop#%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E3%83%AB%E3%83%BC%E3%83%97
実際に動かしてみる
とりあえず実際に動かしてみるのが速いだろうということでこんなプログラムを書いてみた。syncFunc
は完全な同期関数で、asyncFunc
は非同期関数 sleep を3回呼び出す関数。
const p = asyncFunc("[01]");
--> syncFunc("A");
--> await asyncFunc("[02]");
--> await p;
と main 関数で実行してどのように文字がプリントされるのかを見てみる。
// 指定した時間後に完了する Promise を返す
function sleep(t) {
return new Promise((r) => setTimeout(r, t * 1000));
}
// 同期処理だけの関数
function syncFunc(name) {
console.log(name + " start syncFunction");
for (let i = 0; i < 10; i++) {
console.log(name + " sync roop i = " + i);
}
console.log(name + " end syncFunction");
}
// 非同期処理が何度か実行される関数
async function asyncFunc(name) {
console.log(name + " begin async func");
for (let i = 0; i < 3; i++) {
console.log(name + " async roop i = " + i);
await sleep(0.01);
}
console.log(name + " end async func");
}
async function main() {
const p = asyncFunc("[01]");
syncFunc("A");
await asyncFunc("[02]");
await p;
console.log("end main");
}
main();
結果
見やすくするためにとそれぞれ以下のような文字がプリントされている。ぱっと見だと同期関数Aは全く割り込みされてないが、非同期関数はバラバラなタイミングで実行されているように見える。
-
非同期関数:
[01]
,[02]
-
同期関数:
A
[01] begin async func
[01] async roop i = 0
A start syncFunction // 全く割り込まれず10回のループが実行できてる
A sync roop i = 0
A sync roop i = 1
A sync roop i = 2
A sync roop i = 3
A sync roop i = 4
A sync roop i = 5
A sync roop i = 6
A sync roop i = 7
A sync roop i = 8
A sync roop i = 9
A end syncFunction
[02] begin async func // バラバラに呼ばれてる?
[02] async roop i = 0
[01] async roop i = 1
[02] async roop i = 1
[01] async roop i = 2
[02] async roop i = 2
[01] end async func
[02] end async func
end main
まずははじめに呼んでいる asyncFunc("[01]");
の出力が2つだけ表示される。
そして、非同期の関数 sleep
が呼ばれた時に、CPUリソースの使用権が次の行の syncFunc("A");
に移り、関数が終了するまでは占領し続ける。
const p = asyncFunc("[01]");
syncFunc("A");
[01] begin async func
[01] async roop i = 0
// await sleep(0.1); が呼ばれる
// ここからずっと A が占領する
A start syncFunction
A sync roop i = 0
A sync roop i = 1
...
A sync roop i = 9
A end syncFunction
この動きを見ると、以下のような感じに動いてることがわかる。
非同期処理(IO待ち)が発生した時に「コンテキストスイッチ」的な動きをするようになっているみたい。
- 基本的に処理は非同期関数だろうとコールされた順番に行われる
- 非同期処理(sleep が返した Promise)を await するとCPUリソースの使用権は他に移る
- 同期処理は終了するまでリソースを占領する
リソースを占領していた syncFunc("A");
が終了した後を見ると、先にコールされた[01]
の方ではなく async("[02]");
がリソースの使用権を得ている。そしてその中で非同期関数 sleep
が呼ばれた時に、やっと [01]
が出てきた。この動作から、非同期関数が呼ばれるまではCPUリソースは手放さないっぽい。
その後は [01]
, [02]
は交互にで出てくる。どうやら、キューのような構造で処理されていそう。(この記事によると、libuv というものに基づいて実行されてるみたいです)
await asyncFunc("[02]");
await p;
[02] begin async func
[02] async roop i = 0
// await sleep(0.1); が呼ばれる
[01] async roop i = 1
[02] async roop i = 1
[01] async roop i = 2
[02] async roop i = 2
[01] end async func
[02] end async func
end main
遅い非同期処理が含まれていたとき
非同期関数(Promise)が実行された時にエンキューされ、キューの先頭にある非同期関数が続きの処理を実行するために CPUリソースが与えられることがわかったので、もし非常に遅い非同期関数があるとどうなるのかを実験してみる。
準備したのは似たようなプログラムで非同期関数が2回呼ばれるだけだけど、そのうち一つは先程と同じもの。もう一つは1回だけ非常に長い時間 sleep するもの。これを効率よく実行するには slowAsyncFunc("[01]");
がチンタラしているうちに asyncFunc("[02]")
を全て終わらせてしまうのが良さそう。一般的なマルチスレッドプログラミングだとそうなるはず。
// 指定した時間後に完了する Promise を返す
function sleep(t) {
return new Promise((r) => setTimeout(r, t * 1000));
}
// 速い非同期関数、sleep 0.01s しかしない
async function asyncFunc(name) {
console.log(name + " begin async func");
for (let i = 0; i < 3; i++) {
console.log(name + " async roop i = " + i);
await sleep(0.01);
}
console.log(name + " end async func");
}
// 遅い非同期関数、10s も sleep する
async function slowAsyncFunc(name) {
console.log(name + " begin slow async func");
await sleep(10);
console.log(name + " end slow async func");
}
async function main() {
const a = slowAsyncFunc("[01]");
const b = asyncFunc("[02]");
await a, b;
}
main();
結果
予想通り非常に遅い [01]
を無視して [02]
が先に終了した。HTTPサーバーとして使う時に、1つアップストリームへのリクエストがハングしても、問題なく他のリクエストはちゃんと捌けそうなので安心。
[01] begin slow async func
[02] begin async func
[02] async roop i = 0
[02] async roop i = 1
[02] async roop i = 2
[02] end async func
// 10s 待つ!!
[01] end slow async func
遅い同期処理が含まれていたとき
では、逆に長い時間CPUを占領するような同期関数があった場合どんな動きになるのかも見てみる。これは他の非同期処理もブロックされそう。
// 指定した時間後に完了する Promise を返す
function sleep(t) {
return new Promise((r) => setTimeout(r, t * 1000));
}
// 速い非同期関数、sleep 0.01s しかしない
async function asyncFunc(name) {
console.log(name + " begin async func");
for (let i = 0; i < 3; i++) {
console.log(name + " async roop i = " + i);
await sleep(0.01);
}
console.log(name + " end async func");
}
// 遅い同期関数、100億回ループする
async function slowSyncFunc(name) {
console.log(name + " begin slow sync func");
let sqrt_sum = 0;
for (let i = 0; i < 10e9; i++) {
sqrt_sum += i * i;
}
console.log(name + " end slow sync func, res=" + sqrt_sum);
}
async function main() {
const p = asyncFunc("[02]");
slowSyncFunc("A");
await p;
}
main();
結果
予想通り長時間ブロックされてしまいました。あーあ。
Node.js を使うときは CPU を占領し続けるような処理は、全体のパフォーマンスを落としてしまうので気をつけたい。
もしくは、この記事 で紹介されているように、マルチスレッドで実行すれば OS がいい感じに実行してくれる用になるので、そうした方が良さそう。
[01] begin async func
[01] async roop i = 0
A begin slow sync func
// 7s 位待つ!!
A end slow sync func, res=3.333333332833231e+29
[01] async roop i = 1
[01] async roop i = 2
[01] end async func
まとめ
- 非同期処理を待つ(Promise を await する)ごとに、実行される処理が切り替わる
- なるべく非同期処理を使うほうが効率よく実行できる
- CPU負荷の大きい処理は全体をブロックしてしまう。どうしても必要な場合はマルチスレッドに実行すること。1
- 一般的なライブラリで同期的にも実行できるものがあるが、なるべく非同期的に呼び出したほうが良い
イベントループの libuv
今度ちゃんと調べたいです。
おわり。
-
最大128スレッド。それ以上増やすことも可能そうですが、その先には10K問題が待ち構えています ↩