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() を利用する)

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