【Node.js】イベントループの概要について
IT技術
Node.jsとは?
「Node.js」をひと言で表すと、「サーバサイドのJavaScriptプラットフォーム」です。
JavaScript ですので、クライアントサイドでのノウハウが活用できます。
クライアントサイドでの JavaScript の経験があれば取っつきやすい反面、他の言語と比較した際の違いに戸惑う人がいるかもしれません。
ここでは、「Node.js」の特徴のひとつであるイベントループの概要について説明します。
Node.jsのシングルスレッドの動作を確認
まずは簡単なコードを動かして、「Node.js がシングルスレッドで動作する」ということを実感しましょう。
ここでは、「http モジュール」のみでリクエストを処理してみます。
アクセス回数を表示するサンプル
'/' にリクエストを受けた回数を画面に返すだけのサンプルを示します。
グローバル変数 count によってアクセスの回数を記録します。
1const http = require('http'); // httpモジュールをrequire
2const PORT = 3000; // 待機するポートは3000
3let count = 0; // アクセスされた回数
4
5const server = http.createServer(); // Serverオブジェクトを作成
6server.on('request', doReq); // requestイベントにdoReqを登録
7server.listen(PORT); // 待機
8
9// requestイベント発火時時に実行する関数
10function doReq(req, res) {
11 if(req.url == '/') count++;
12 res.end(`count:${count}`); // アクセスされた回数をクライアントに返す
13}
実行!
書いたら実行し、ブラウザでアクセスしてみましょう。
コードを「index.js」として保存したのであれば、次のコマンドで実行できます。
1$ node index.js
実行結果
http://localhost:3000/ にブラウザでアクセスすると、画面には次のようにアクセス回数が表示されることでしょう。
count:1
何度かアクセスすると、そのたびに数字が「2, 3, 4」と増えていきます。
ブラウザを変えてアクセスしても、数字は「5, 6, 7」と増えていきます。
結果からわかること
アクセスのたびに増える変数 count は、スクリプトを停止させない限り、メモリ上に保持され続けています。
この挙動から、Node.js がシングルスレッドによって動作していることが確認できます。
7行目の server.listen(PORT); からも見て取れるように、このスクリプト自体がリクエストを受け付けています。
「何者かがリクエストに応じてスクリプトを実行する」というものではありません。
なぜシングルスレッドで処理できるのか
Node.js はシングルスレッドだということが確認できました。
シングルスレッドではアクセスのたびにスレッドが増えないため、メモリ消費量を少なく抑えられるなどのメリットがあります。
ノンブロッキングI/O
ここで、なぜ Node.js がシングルスレッドで多数のアクセスを処理できるのかというと、ノンブロッキング I/O を採用しているためです。
「ネットワークとのやりとり」、「データベースアクセス」、「ファイル入出力」など、時間のかかる処理を他のプロセスに任せ、その間に Node.js 自身ができる処理を進めます。
I/O イベントを取得したタイミングで、「次はこれを実行すること」という形でキューにコールバックを追加し、順次 Node.js が処理します。
サンプルで言うと
先に示したアクセス回数を表示するサンプルで言えば、次の記述です。
1server.on('request', doReq); // requestイベントにdoReqを登録
リクエストのイベントを取得したら、doReq 関数をコールバックとして登録します。
つまり
仮にリクエストが巨大でも、Node.js はその終了を待たずに他の処理を継続することができます。
Node.js は、そのようにして処理を他のプロセスに任せることで効率的に動作しているのです。
Node.jsのイベントループの概要
Node.js は、「何かが起こったら、こういう順番で処理を進める」ということを繰り返しています。
これが、 Node.js の「イベントループ」です。
概要図
簡単な図にすると、次のようになります。
ざっくり言えば、「I/O が発生したら一連の処理を実行し、次の I/O を待つ」ということになります。
Node.js は、このループを延々と繰り返します。
詳しい説明
より詳しい説明は nodejs.org にありますので、興味のある方は参照してください。
【nodejs.org:Node.js コアコンセプト】
https://nodejs.org/ja/docs/guides/event-loop-timers-and-nexttick/
同期処理によるイベントループの停滞
I/O が発生した際、Node.js は実行すべき処理を次々とキューに追加しますが、処理は停滞しません。
タイマーによる処理であっても、特定のタイミングで実行するコールバックを登録しておき、先の処理を進めます。
しかし、実行する処理そのものを同期処理として記述すると、イベントループを停滞させる可能性があります。
ループをブロックしてしまう例
先ほどのサンプルで書いた doReq 関数を書き書き替えて、イベントループの停滞(ブロック)について確認してみましょう。
コード
1function doReq(req, res) {
2 if(req.url == '/') {
3 count++;
4 }else if(req.url == '/heavy') {
5 count++;
6 for(let i = 1; i <= 10000000000; i++) {
7 // この部分で時間がかかる
8 }
9 }
10 res.end(`count:${count}`);
11}
変更内容
'/heavy' にアクセスがあった場合に、変数 count をインクリメントする以外に、100 億回ループするだけの for 文を追加しました。
(ちなみに JavaScript における整数型の最大値は 253-1、つまり 9000 兆以上です)。
'/' にアクセスした際の挙動は先ほどと同じですが、'/heavy' にアクセスした場合はレスポンスが返されるまでに非常に時間がかかります。
そして'/heavy' がレスポンスを返すまで、Node.js は次のリクエストさえ受け付けません。
結果確認
ブラウザで'/heavy' を開き、レスポンスが返ってくる前に別のブラウザで '/' を開いてみましょう。
'/heavy' の同期処理が終了するまでは、'/'にアクセスした結果も返されません。
イベントループの概要で示した図の「コールバックの実行」部分で停滞している状態です。
非同期で処理する
コールバック内に同期処理を記述することにより、イベントループを停滞させる可能性があることがわかりました。
したがって、Node.js では時間のかかる処理をなるべく非同期で記述する必要があります。
ループをブロックさせないために
先ほどのコードを、今度は非同期で実行されるよう書き換えてみます。
コード
1function doReq(req, res) {
2 if(req.url == '/') {
3 count++;
4 }else if(req.url == '/heavy') {
5 count++;
6 let i = 1;
7 // 非同期で実行する
8 const timeout = setInterval(function(){
9 if(i == 10000000000) clearInterval(timeout); // 100億回に達したらsetIntervalを終了
10 i++;
11 },0);
12 }
13 res.end(`count:${count}`);
14}
変更内容
今度は、100 億回の処理を setInterval によって行うようにしました。
setTimeout と同様に、タイマー処理である setInterval は非同期に実行されます。
実行結果
今回のコードを実行すると、100 億回の処理が終了するのを待たずにレスポンスを返します。
setInterval を利用したことにより、処理が終了する前にレスポンスを返すことができました。
ただ、見かけ上は軽快に動作するようになりましたが、レスポンスが返される時点で処理が終了している保証はありません。
処理の終了を待ってレスポンスを返す
それでは、処理が終了したときにレスポンスを返すように、再度コードを書き替えてみましょう。
コード
1function doReq(req, res) {
2 if(req.url == '/') {
3 count++;
4 res.end(`count:${count}`);
5 }else if(req.url == '/heavy') {
6 count++;
7 let i = 1;
8 // 非同期で実行する
9 const timeout = setInterval(function(){
10 if(i == 10000000000) {
11 clearInterval(timeout); // 100億回に達したらsetIntervalを終了
12 res.end(`count:${count}`); // 全てのコールバックを実行したときにレスポンスを返す
13 }
14 i++;
15 },0);
16 }else{
17 res.end(`count:${count}`); // それ以外のパスにアクセスがあった場合もレスポンスは返す
18 }
19}
変更内容
全てのコールバックを実行した時に、レスポンスを返すよう変更しました。
ただし、 setInterval に登録したコールバックを順次実行するには相当な時間を要します。
そのため、100億のコールバックの終了を待ってレスポンスを返す場合は恐らくタイムアウトになるでしょう。
実際に動作を確認する際は、setInterval を終了する条件をゆるく、i が1000に達したくらいで clearInterval するようにして試すのがよいと思います。
なお、'/heavy' のレスポンスが返される前に '/' にアクセスしても、ちゃんとレスポンスが返るよう変更しています。
countを増やすタイミング
'/heavy' のレスポンスが戻ってくるまでの間にも、'/' にアクセスすることは可能です。
ただし上のコードそのままだと、ようやく '/heavy' からレスポンスが返ってきたときに画面に表示されるのは、「最後に '/' が返した内容と同じもの」になります。
'/heavy' がレスポンスを返すまでの間に、 '/' へのアクセスによって変数 count の値が更新されてしまうからです。
「表示するアクセス回数」を「レスポンスが返される順番」と一致させるには、レスポンスを返す直前、res.end(`count:${count}`); の1行前で count を増やすべきでしょう。
もしも「表示するアクセス回数」を「リクエストを受け付けた順番」と一致させるならば、画面に返す値をローカル変数に格納することになるでしょう。
いろいろと考えることが多いですね…!
さいごに
Node.js のイベントループについて、サンプルを交えて説明しました。
なるべく簡単に説明したつもりですが、慣れない人には若干ややこしいかもしれません。
ですが、コツを掴めば Node.js は非常に面白い言語です。
最初のハードルを越えてしまえば、豊富なライブラリを利用して様々な機能を実現できます。
ぜひ頑張って習得しましょう!
次回の記事はこちら
2019.12.05【第3回】Vue.js 入門 〜オプション編〜オプションを覚えよう!宮田(株) ライトコード Webエンジニアの宮田です。前回までで、Vue.jsの主要なディレクテ...
こちらの記事もオススメ!
2020.08.07JavaScript 特集知識編JavaScriptを使ってできることをわかりやすく解説!JavaScriptの歴史【紆余曲折を経たプログラミン...
2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
「好きを仕事にするエンジニア集団」の(株)ライトコードです! ライトコードは、福岡、東京、大阪、名古屋の4拠点で事業展開するIT企業です。 現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。 いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。 システム開発依頼・お見積もり大歓迎! また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」を積極採用中です! インターンや新卒採用も行っております。 以下よりご応募をお待ちしております! https://rightcode.co.jp/recruit