kkty’s blog

おそらく大学3年生です

libuvとは(特にイベントループについて)

f:id:k-kty:20190111205332p:plain
libuvのロゴ

http://docs.libuv.org/en/v1.x/design.html を読み進めていった際のメモです。

概要

libuvは「クロスプラットフォームサポートのためのライブラリ」で、Node.jsで使われることを想定して開発が進められてきた(現在はNode.js以外でも用いられている)。

「イベント・ドリブンな非同期I/Oモデル」をもとに設計されていて、以下のようなものを提供している

  • プラットフォーム間で異なるI/Oポーリングの仕組みの抽象化
  • (ハンドルとストリームによる)ソケットなどの抽象化
  • ファイルI/Oやスレッド周辺の各種機能

ハンドルとリクエスト

libuvでは、イベントループとともに、ハンドルとリクエストという2つの抽象化された概念が用意されている。

ハンドルは「(アクティブなときに)何らかの操作を行うことができる、長期的に存在するオブジェクト」を表す。その例としては以下のようなものがある。

  • イベントループが一周するたびに一度呼ばれるprepareハンドル
  • 新しいコネクションができるたびにコールバックを実行するTCPサーバーハンドル

リクエストは(一般的に)「短い期間で終わる有効な操作」を表す。 リクエストはハンドルを通して行う(例:writeリクエストはハンドルを通してデータを書き込む)ことも、スタンドアロンで行う(例:getaddrinfoリクエストはハンドルを用いず、ループの中で直接実行される)こともできる。

I/Oループ (イベントループ)

I/Oループ(イベントループ)は、libuvの中核をなしている。 イベントループはすべてのI/O操作を管理し、「一つのイベントループが一つのスレッドに対応する」ことが想定されている。

異なるスレッドで動作する限り、複数のイベントループを利用することができる。 ちなみに、イベントループ(そして、ループやハンドルに関係する他のAPI)は、基本的にはスレッドセーフではない。

イベントループは、I/Oに対してシングルスレッドの同期的な(一般的な)アプローチを用いている。 すべての(ネットワーク)I/Oはノンブロッキングなソケット上で行われ、それをプラットフォームに応じた最善の方法でポールしている(Linux上ではepoll, OSXやその他のBSDではkqueue, SunOSではevents port, WindowsではIOCP)。ループの中において、イベントループは(それ以前にポーラーに追加された)ソケット上でのI/Oアクティビティを待ち、ソケットの状況(読み込み可能, 書き込み可能, ハングアップ)とともにコールバックが呼ばれる、これを用いてハンドルはI/O操作を行える。

イベントループへの理解を深めるために、一回のループでどのようなことが行われているのかを確認する。

  1. 「現在時刻」が更新される。イベントループは、ループの最初に取得した時刻をキャッシュすることで、時刻関係のシステムコールの発行回数を減らしている。
  2. もしもループが生きているのなら、イベントループの周回がはじまる。もしも生きていないのなら、イベントループは終了する。(有効かつ参照されているハンドル、有効なリクエスト、またはclosingハンドルが存在する場合、イベントループは生きているとみなされる。)
  3. 適切なタイマーが実行される。イベントループの「現在時刻」よりも前に設定されていたコールバックが呼ばれる。
  4. pendingコールバックが呼ばれる。多くのI/Oコールバックは、I/Oのポーリングの直後に呼ばれることが多い。しかし、コールバックの実行を(イベントループの)次の周回に遅らせるということもある。そのようにして前の周回で後回しにされたI/Oコールバックが、ここで実行される。
  5. idleコールバックが呼ばれる。
  6. prepareハンドルのコールバックが呼ばれる。これはイベントループがI/Oブロックをする直前に呼ばれる。
  7. pollタイムアウト(どれほどの時間I/Oのブロックを行うか)を計算する。計算は以下の通り行われる。
  8. 一つ前のステップで計算した時間分だけ、I/Oのブロックをする。(読み書きのために)ファイルディスクリプタを監視しているI/O関連のハンドルはここで適当なコールバックを呼ぶ。
  9. checkハンドルコールバックが呼ばれる。checkハンドルのコールバックはI/Oのブロックの後に呼ばれ、prepareハンドルのコールバックと対を成している。
  10. closeコールバックが呼ばれる。もしもハンドルが uv_close() によってcloseされた場合、そのハンドルはcloseコールバックを呼び出す。
  11. UV_RUN_NOWAIT または UV_RUN_ONCE モードでループが走っている場合には、ループは終了する (uv_run() がreturnする)。もしも UV_RUN_DEFAULT モードでループが走っている場合には、ループが生きてれば次の周回に入り、そうでなければループは終了する。

注1: 7の計算は以下のように行われる

  • UV_RUN_NOWAIT フラッグとともにループが走っていた場合、0
  • uv_stop() が呼ばれている場合(イベントループがもうすぐ止まる場合)には0
  • アクティブなハンドルやリクエストが存在しない場合には0
  • もしもアクティブなidleハンドルが存在する場合には0
  • 「closeされるべきハンドル」が存在する場合には0
  • 上の場合全てにマッチしない場合には最も直近のタイマーの時間(もしもそれが存在しない場合には無限)

注2: libuvはファイルI/Oにおいてスレッドプールを用いているが、ネットワークI/Oは常にシングルスレッド(イベントループのスレッド)上で行う。

注3: pollingの機構はUnixとWindowsで大きく違うが、libuvは実行モデルに一貫性をもたせている。

ファイルI/O

ネットワークI/Oとは違い、(設計上の困難さから)ファイルI/Oはスレッドプールを用いてファイルのI/Oを行っている。

libuvは現在グローバルな(全てのイベントループが用いることのできる)スレッドプールを用意している。3種類の操作がこのプールで行われる。

  • ファイルシステムの操作
  • DNS関連の操作(getaddrinfo と getnameinfo)
  • ユーザーが定義した操作 (uv_queue_work() を利用する)

注: スレッドプールのサイズは限られているので、注意を配る必要がある

V8 JavaScriptエンジンとは(そしてその高速化について少し)

f:id:k-kty:20190111205205p:plain
V8のロゴ

V8とは

V8はGoogleが開発しているオープンソースのJavaScript実行エンジン(JavaScriptのコードを解析し、機械語まで落とし込んで実行してくれるもの)である。

C++で書かれていて、Google Chromeで使われているほか、Node.js(サーバーサイド向けのJavaScript実行環境)などにも組み込まれている。

競合?として、MicrosoftのChakraやMozillaのSpiderMonkeyなどがあるが、全体的にパフォーマンスはV8に分がある。

公式サイトは https://v8.dev/

JavaScriptエンジンのパフォーマンスについて

JavaScriptの実行エンジンで最も重要なのは「いかに高速にJavaScriptを実行できるか」である。

「どれだけ広いJavaScriptの仕様に対応できるか」や「デバッグ機能の豊富さ」も忘れてはならない点ではあるものの、やはり一番差が出るのはパフォーマンスであり、そして実行エンジンのパフォーマンスの差はブラウザのパフォーマンスに大きく影響する。

そのために、ブラウザ(熾烈なシェア争いを繰り広げている)の開発元は実行エンジンのパフォーマンスの向上に努めてきた。

そのおかげか、JavaScriptは動的型付けの言語にもかかわらず、実行エンジンのおかげでかなりのハイパフォーマンスで実行することができるようになっている。(似たようなCPUインテンシブな処理をPythonとJavaSriptで書いてみて比較したらJavaScriptのほうが早いことが多い。)

V8の高速化について

実際、V8がどのようにしてJavaScriptの実行を高速化をしているのか(一例だけど)を軽くまとめてみる。

  1. JavaScriptのコードがパースされ、抽象構文木が作られる。
  2. Ignitionというインタープリタが、その抽象構文木を元に、実行を始める(最適化するよりも、とにかく早く実行を開始するという雰囲気)
  3. Ignitionでの実行中に、ホットな関数(多く呼び出される関数)を特定し、さらにその関数がどういった種類(文字列なのか、数値なのか)の引数とともに呼び出されているのか、というデータを収集する
  4. そのデータをもとに、TurboFanがホットな関数をコンパイルし、最適な機械語を生成する
  5. その機械語を用いた実行を始める
  6. もしも異なったタイプの引数が来てしまった場合(最適化した機械語を用いられない場合)は最適化前の処理に戻る

こういった感じでV8はJavaScriptを実行するらしい。

よく言われることだけど、特定の関数の引数には常に同じ型のものを渡してあげるように心がけたい。

Node.js なぜノンブロッキングなコードを書くべきなのか

f:id:k-kty:20190111210413p:plain
Node.jsのロゴ

Node.jsで「イベントループをブロックするコードを書いてはだめで、ノンブロッキングなコードを書かないといけない」とかよく言われる。

Node.jsを用いて実際に開発をしている人にとっては至極当然のことであるし、「まあそうなんだろう」と納得してしまうこともできる。

しかし、なぜノンブロッキングなコードを書かないといけないのかを理解することはとても大切だと思うので、書いてみる。

ブロッキングなコードの例

fsモジュールのreadFileSyncはブロッキングな関数の最たる例。

const fs = require('fs');
const data = fs.readFileSync('/path/to/file');
// dataを使った操作

理解しやすいのだけど、「こういうことはやってはいけません」とよく言われる。

ノンブロッキングなコードの例

fs.readFileはfs.readFileSyncのノンブロッキング版である(fs.readFileSyncがfs.readFileのブロッキング版と言ったほうがいいのかもしれない)。

const fs = require('fs');
fs.readFile('/path/to/file', (err, data) => {
  // dataを用いた操作
});

なぜだめなのか

Node.jsにおいて、ユーザー(開発者)が書いたJavaScriptの処理は基本的に一つのスレッド(メインスレッド)の中で処理される。メインスレッドではイベントループが走っている。

当然、一つのスレッドの中では同時に一つの処理(関数)しか実行できない。だから、fs.readFileSync() の実行中(ファイル読み込み待ち中)は、他の処理が何もできない。例えばこのコードがサーバーアプリケーション内にあったとしたら、fs.readFileSync() の実行中はレスポンスを返すことができない。

fs.readFile() の場合はそうではない。それ自体の関数の実行は一瞬で終わり、Node.js(に組み込まれているlibuv)がファイル読み込みのためのスレッドを立ち上げ、それの処理が完了した時点(正確には少し後)でコールバック ((err, data) => { ... }) がメインスレッドで実行される。よってメインスレッドはファイルの読み込み待ちの間も他の処理をすることができる。

注: ファイルのI/Oは上のようにメインスレッド以外のスレッドで処理をするが、ネットワークI/Oの場合は他のスレッドを用いない処理が多い。

ブロッキングなコードを書いてもいいとき

fs.readFileSync() が存在するくらいなので、ブロッキングなコードを書いてもいいときは存在する。たとえサーバーアプリケーションだとしても。

例えば、プログラムの起動時に config.txt を読み込んで、module.exports を用いて他ファイルから利用(require)したいとする。

そういったときにこういったことはできない。

fs.readFile('./config.txt', (err, data) => {
 // 何らかの処理
 module.expoorts = ...;
});

この問題は fs.readFileSync() などのブロッキングな操作を用いれば解決する。こういった場合が、ブロッキングなコードを書いてよい場合である。

おまけ

const f = (n) => {
  let ret = 0;
  for (let i = 0; i < n; i++) ret += 1;
  return ret;
};

もちろん、このコードはブロッキングな関数である。f(1000000000) みたいなことをするとイベントループはブロックされてしまう。こういうときは(workerを使うか、C++のアドオンを使うかなどして)別スレッドを立てて処理するようにしないといけない。