ホームページ  >  記事  >  ウェブフロントエンド  >  libuv とは何か、libuv でのイベント ポーリングの簡単な分析 (ノード コアの依存関係)

libuv とは何か、libuv でのイベント ポーリングの簡単な分析 (ノード コアの依存関係)

青灯夜游
青灯夜游転載
2022-03-22 19:58:553741ブラウズ

この記事では、Node の中心的な依存関係である libuv を理解し、libuv とは何か、および libuv でのイベント ポーリングについて紹介します。

libuv とは何か、libuv でのイベント ポーリングの簡単な分析 (ノード コアの依存関係)

Node.js のことになると、ほとんどのフロントエンド エンジニアはこれに基づいてサーバーを開発することを考えると思います。 JavaScriptを言語としてマスターする フルスタックエンジニアになる しかし、実はNode.jsの意味はそれだけではありません。

多くの高級言語では、実行権限がオペレーティング システムに到達しますが、ブラウザ側で実行される JavaScript は例外です。ブラウザによって作成されたサンドボックス環境は、フロントエンドを閉じます。プログラミングの世界の象牙の塔に住むエンジニア。しかし、Node.jsの登場によりこの欠点は補われ、フロントエンドエンジニアもコンピュータの世界の底辺に到達できるようになりました。

つまり Nodejs フロントエンド エンジニアにとっての重要性は、フルスタックの開発機能を提供することだけではなく、より重要なことに、フロントエンド用のコンピューターの基盤となる世界への扉を開くことです。エンジニア。この記事では、Node.js の実装原則を分析することでこの扉を開きます。

Node.js ソース コードの構造

Node.js ソース コード ウェアハウスの /deps ディレクトリには、C 言語で書かれたモジュール (libuv、 V8)とJavaScript言語で記述したモジュール(acorn、acorn-pluginsなど)は下図のとおりです。

libuv とは何か、libuv でのイベント ポーリングの簡単な分析 (ノード コアの依存関係)

  • #acorn: JavaScript で書かれた軽量の JavaScript パーサー。
  • acorn-plugins: acorn の拡張モジュール。acorn がクラス宣言などの ES6 機能解析をサポートできるようにします。
  • brotli: C 言語で書かれた Brotli 圧縮アルゴリズム。
  • cares: 非同期 DNS リクエストを処理するために、C 言語で記述された「c-ares」として記述する必要があります。
  • histogram: ヒストグラム生成関数を実装するために C 言語で記述されています。
  • icu-small: C 言語で書かれ、Node.js 用にカスタマイズされた ICU (International Components for Unicode) ライブラリ。Unicode を操作するためのいくつかの関数が含まれています。
  • llhttp: C 言語で書かれた軽量の http パーサー。
  • nghttp2/nghttp3/ngtcp2: HTTP/2、HTTP/3、TCP/2 プロトコルを処理します。
  • node-inspect: Node.js プログラムが CLI デバッグ デバッグ モードをサポートできるようにします。
  • npm: JavaScript で書かれた Node.js モジュール マネージャー。
  • openssl: C 言語で書かれた暗号化関連モジュール。TLS モジュールと暗号化モジュールの両方で使用されます。
  • uv: C 言語で書かれ、ノンブロッキング I/O 操作を使用して、Node.js にシステム リソースにアクセスする機能を提供します。
  • uvwasi: C 言語で書かれ、WASI システム コール API を実装します。
  • v8: C 言語、JavaScript エンジンで書かれています。
  • zlib: 高速圧縮のために、Node.js は zlib を使用して、同期、非同期、およびデータ ストリームの圧縮および解凍インターフェイスを作成します。

最も重要なものは、v8 ディレクトリと uv ディレクトリに対応するモジュールです。 V8 自体には非同期で実行する機能はありませんが、ブラウザー内の他のスレッドの助けを借りて実装されています。js がシングルスレッドであるとよく言われるのは、その解析エンジンが同期解析コードのみをサポートしているためです。 ただし、Node.js では、非同期実装は主に libuv に依存しているため、libuv の実装原理の分析に焦点を当てましょう。

libuv とは

#libuv は、複数のプラットフォームをサポートする C で書かれた非同期 I/O ライブラリであり、主に I/O 操作がブロックされやすい問題を解決します。 元々は Node.js で使用するために特別に開発されましたが、後に Luvit、Julia、pyuv などの他のモジュールでも使用されるようになりました。以下の図はlibuvの構造図です。

libuv とは何か、libuv でのイベント ポーリングの簡単な分析 (ノード コアの依存関係)

#libuv には 2 つの非同期実装メソッドがあります。これらは、上の図の左右の黄色のボックスで選択された 2 つの部分です。

左側の部分はネットワーク I/O モジュールであり、プラットフォームごとに異なる実装メカニズムがあり、Linux システムでは epoll を通じて実装され、OSX およびその他の BSD システムでは KQueue が使用され、SunOS システムではイベント ポートが使用されます。 Windows システムは IOCP を使用します。これにはオペレーティング システムの基盤となる API が関係するため、理解するのがさらに複雑になるため、ここでは紹介しません。

右側の部分には、ファイル I/O モジュール、DNS モジュール、およびスレッド プールを介して非同期操作を実装するユーザー コードが含まれています。ファイル I/O はネットワーク I/O とは異なります。libuv はシステムの基礎となる API に依存せず、代わりに、グローバル スレッド プールでファイル I/O 操作のブロックを実行します。

libuv でのイベント ポーリング

次の図は libuv 公式 Web サイトに掲載されているイベント ポーリングのワークフロー図ですので、コードと合わせて分析してみましょう。

libuv とは何か、libuv でのイベント ポーリングの簡単な分析 (ノード コアの依存関係)

libuv イベント ループのコア コードは uv_run() 関数に実装されています。以下は Unix システムでのコア コードの一部です。 C言語で書かれていますが、JavaScriptと同じような高級言語なので、理解することはそれほど難しくありません。最大の違いはアスタリスクと矢印ですが、アスタリスクは単に無視して問題ありません。たとえば、関数パラメータの uv_loop_t* ループは、uv_loop_t 型の変数ループとして理解できます。矢印「→」はピリオド「.」として理解でき、例えばloop→stop_flagはloop.stop_flagとして理解できる。

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  ... 
r = uv__loop_alive(loop);
if (!r) uv__update_time(loop);
while (r != 0 && loop - >stop_flag == 0) {
    uv__update_time(loop);
    uv__run_timers(loop);
    ran_pending = uv__run_pending(loop);
    uv__run_idle(loop);
    uv__run_prepare(loop);...uv__io_poll(loop, timeout);
    uv__run_check(loop);
    uv__run_closing_handles(loop);...
}...
}

uv__loop_alive

この関数は、イベント ポーリングを続行するかどうかを決定するために使用されます。ループ オブジェクトにアクティブなタスクがない場合は、 0 を返してループを終了します。

C言語では、この「タスク」には「ハンドル」という専門的な名前が付けられており、タスクを指す変数として理解できます。ハンドルは、リクエストとハンドルの 2 つのカテゴリに分類でき、それぞれ短いライフ サイクルのハンドルと長いライフ サイクルのハンドルを表します。具体的なコードは次のとおりです。

static int uv__loop_alive(const uv_loop_t * loop) {
    return uv__has_active_handles(loop) || uv__has_active_reqs(loop) || loop - >closing_handles != NULL;
}

uv__update_time

時間関連のシステムコールの数を減らすために、この関数が使用されます。現在のシステム時間をキャッシュします。精度は非常に高く、ナノ秒レベルに達しますが、単位は依然としてミリ秒です。

具体的なソース コードは次のとおりです。

UV_UNUSED(static void uv__update_time(uv_loop_t * loop)) {
    loop - >time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}

uv__run_timers

setTimeout の時間しきい値に達したコールバックを実行します。 () および setInterval() 関数。この実行プロセスは for ループ トラバーサルによって実装されます。以下のコードからわかるように、タイマー コールバックは最小ヒープ構造のデータに格納されます。最小ヒープが空になるか、時間のしきい値に達しない場合に終了します。サイクル。

タイマー コールバック関数を実行する前にタイマーを削除してください。繰り返しが設定されている場合は、再度最小ヒープに追加する必要があり、その後タイマー コールバックが実行されます。

具体的なコードは次のとおりです。

void uv__run_timers(uv_loop_t * loop) {
    struct heap_node * heap_node;
    uv_timer_t * handle;
    for (;;) {
        heap_node = heap_min(timer_heap(loop));
        if (heap_node == NULL) break;
        handle = container_of(heap_node, uv_timer_t, heap_node);
        if (handle - >timeout > loop - >time) break;
        uv_timer_stop(handle);
        uv_timer_again(handle);
        handle - >timer_cb(handle);
    }
}

uv__run_pending

pending_queue に格納されているすべての I/O コールバック関数を走査します。 when pending_queue が空の場合は 0 を返し、それ以外の場合は pending_queue でコールバック関数を実行した後に 1 を返します。

コードは次のとおりです:

static int uv__run_pending(uv_loop_t * loop) {
    QUEUE * q;
    QUEUE pq;
    uv__io_t * w;
    if (QUEUE_EMPTY( & loop - >pending_queue)) return 0;
    QUEUE_MOVE( & loop - >pending_queue, &pq);
    while (!QUEUE_EMPTY( & pq)) {
        q = QUEUE_HEAD( & pq);
        QUEUE_REMOVE(q);
        QUEUE_INIT(q);
        w = QUEUE_DATA(q, uv__io_t, pending_queue);
        w - >cb(loop, w, POLLOUT);
    }
    return 1;
}

uvrun_idle / uvrun_prepare / uv__run_check

これら 3 つの関数はすべて、マクロ関数 UV_LOOP_WATCHER_DEFINE 定義の場合、マクロ関数はコード テンプレート、または関数を定義するために使用される関数として理解できます。マクロ関数は3回呼び出され、それぞれprepare、check、idleという名前パラメータの値が渡され、同時にuvrun_idle、uvrun_prepare、uv__run_checkの3つの関数が定義されています。

したがって、それらの実行ロジックは一貫しています。すべてループして、先入れ先出しの原則に従ってキューのloop->name##_handles内のオブジェクトを取り出し、対応するコールバックを実行します。関数。

#define UV_LOOP_WATCHER_DEFINE(name, type)
void uv__run_##name(uv_loop_t* loop) {
  uv_##name##_t* h;
  QUEUE queue;
  QUEUE* q;
  QUEUE_MOVE(&loop->name##_handles, &queue);
  while (!QUEUE_EMPTY(&queue)) {
    q = QUEUE_HEAD(&queue);
    h = QUEUE_DATA(q, uv_##name##_t, queue);
    QUEUE_REMOVE(q);
    QUEUE_INSERT_TAIL(&loop->name##_handles, q);
    h->name##_cb(h);
  }
}
UV_LOOP_WATCHER_DEFINE(prepare, PREPARE) 
UV_LOOP_WATCHER_DEFINE(check, CHECK) 
UV_LOOP_WATCHER_DEFINE(idle, IDLE)

uv__io_poll

##uv__io_poll は主に I/O 操作をポーリングするために使用されます。具体的な実装はオペレーティング システムによって異なりますが、ここでは Linux システムを例として分析します。

uv__io_poll 関数には多くのソース コードがあり、コアは 2 つのループ コードであり、コードの一部は次のとおりです:

void uv__io_poll(uv_loop_t * loop, int timeout) {
    while (!QUEUE_EMPTY( & loop - >watcher_queue)) {
        q = QUEUE_HEAD( & loop - >watcher_queue);
        QUEUE_REMOVE(q);
        QUEUE_INIT(q);
        w = QUEUE_DATA(q, uv__io_t, watcher_queue);
        e.events = w - >pevents;
        e.data.fd = w - >fd;
        if (w - >events == 0) op = EPOLL_CTL_ADD;
        else op = EPOLL_CTL_MOD;
        if (epoll_ctl(loop - >backend_fd, op, w - >fd, &e)) {
            if (errno != EEXIST) abort();
            if (epoll_ctl(loop - >backend_fd, EPOLL_CTL_MOD, w - >fd, &e)) abort();
        }
        w - >events = w - >pevents;
    }
    for (;;) {
        for (i = 0; i < nfds; i++) {
            pe = events + i;
            fd = pe - >data.fd;
            w = loop - >watchers[fd];
            pe - >events &= w - >pevents | POLLERR | POLLHUP;
            if (pe - >events == POLLERR || pe - >events == POLLHUP) pe - >events |= w - >pevents & (POLLIN | POLLOUT | UV__POLLRDHUP | UV__POLLPRI);
            if (pe - >events != 0) {
                if (w == &loop - >signal_io_watcher) have_signals = 1;
                else w - >cb(loop, w, pe - >events);
                nevents++;
            }
        }
        if (have_signals != 0) loop - >signal_io_watcher.cb(loop, &loop - >signal_io_watcher, POLLIN);
    }...
}

while ループでは、オブザーバー キュー watcher_queue を走査します。そして、イベントとファイル記述子を取り出します。その値をイベント オブジェクト e に割り当てます。その後、epoll_ctl 関数を呼び出して、epoll イベントを登録または変更します。

for ループでは、epoll で待機しているファイル記述子が最初に取得されて nfds に割り当てられ、次に nfds が走査されてコールバック関数が実行されます。

__run_closed_handles

クローズされるのを待っているキューを走査し、ストリーム、tcp、udp などのハンドルを閉じてから、 close_cb ハンドルに対応します。コードは次のとおりです:

static void uv__run_closing_handles(uv_loop_t * loop) {
    uv_handle_t * p;
    uv_handle_t * q;
    p = loop - >closing_handles;
    loop - >closing_handles = NULL;
    while (p) {
        q = p - >next_closing;
        uv__finish_close(p);
        p = q;
    }
}

process.nextTick と Promise

process.nextTick と Promise はどちらも非同期 API ですが、イベント ポーリングの一部ではなく、独自のタスク キューを持っています。イベントループの各ステップが完了した後に実行されます。したがって、これら 2 つの非同期 API を使用する場合は、受信コールバック関数で長いタスクや再帰が実行されると、イベント ポーリングがブロックされ、I/O 操作が「枯渇」することに注意する必要があります。

次のコードは、fs.readFile のコールバック関数の実行に失敗する prcoess.nextTick への再帰呼び出しの例です。

fs.readFile(&#39;config.json&#39;, (err, data) = >{...
}) const traverse = () = >{
    process.nextTick(traverse)
}

この問題を解決するには、代わりに setImmediate を使用します。これは、setImmediate がイベント ポーリングでコールバック関数キューを実行するためです。 process.nextTick タスク キューは、Promise タスク キューよりも高い優先順位を持っています。具体的な理由については、次のコードを参照してください:

function processTicksAndRejections() {
    let tock;
    do {
        while (tock = queue.shift()) {
            const asyncId = tock[async_id_symbol];
            emitBefore(asyncId, tock[trigger_async_id_symbol], tock);
            try {
                const callback = tock.callback;
                if (tock.args === undefined) {
                    callback();
                } else {
                    const args = tock.args;
                    switch (args.length) {
                    case 1:
                        callback(args[0]);
                        break;
                    case 2:
                        callback(args[0], args[1]);
                        break;
                    case 3:
                        callback(args[0], args[1], args[2]);
                        break;
                    case 4:
                        callback(args[0], args[1], args[2], args[3]);
                        break;
                    default:
                        callback(...args);
                    }
                }
            } finally {
                if (destroyHooksExist()) emitDestroy(asyncId);
            }
            emitAfter(asyncId);
        }
        runMicrotasks();
    } while (! queue . isEmpty () || processPromiseRejections());
    setHasTickScheduled(false);
    setHasRejectionToWarn(false);
}

processTicksAndRejections() 関数からわかるように、最初にコールバック関数while ループでキューの queue を取り出し、process.nextTick でこのキュー内のコールバック関数を追加します。 while ループが終了すると、runMicrotasks() 関数が呼び出され、Promise コールバック関数が実行されます。

概要

libuv に依存する Node.js のコア構造は 2 つの部分に分けることができます。1 つの部分はネットワーク I/O です。基盤となる実装は、次のとおり異なるシステム API に依存します。他の部分は、ファイル I/O、DNS、およびユーザー コードがスレッド プールによって処理されることです。

libuv の非同期操作を処理するための中心的なメカニズムはイベント ポーリングです。イベント ポーリングはいくつかのステップに分かれています。一般的な操作は、キュー内のコールバック関数を走査して実行することです。

最後に、非同期 API process.nextTick と Promise はイベント ポーリングに属さないことに言及しました。不適切に使用すると、イベント ポーリングがブロックされます。解決策の 1 つは、代わりに setImmediate を使用することです。

ノード関連の知識の詳細については、nodejs チュートリアル を参照してください。

以上がlibuv とは何か、libuv でのイベント ポーリングの簡単な分析 (ノード コアの依存関係)の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はjuejin.cnで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。