Node.js異步I/O,事件驅動

文章原文: https://yq.aliyun.com/article...
本文相對於原文有部分修改node

前言

Node.js以高效,輕量著稱,具備非阻塞I/O,事件驅動的特性.
非阻塞I/O很淺顯的解釋就是: 代碼以單線程的方式執行,在遇到I/O操做時Node會開闢新的線程去執行I/O操做,主線程代碼繼續執行.
事件驅動很淺顯的解釋就是: 事件產生者發佈一個事件,事件訂閱者在收到事件後執行某段代碼.
但非阻塞I/O,事件驅動究竟是如何實現的呢,它們跟Node.js的單線程有什麼關係呢?git

Node.js結構

圖片描述

  • Node standard library: 這一層是Node.js提供的標準庫,裏面有提供的各類API接口,是由JavaScript編寫,在源碼中lib目錄下
  • Node bindings: 這一層爲上層提供了底層C/C++的調用,數據交換等,實如今node.cc中
  • C/C++底層:github

    • V8:大名鼎鼎的Google JavaScript VM,這也是Node.js使用js的緣由,它爲js提供了運行環境.
    • libuv:它爲Node.js提供了跨平臺,線程池,事件池,異步I/O等能力.
    • C-ares:提供了異步處理DNS相關的能力.
    • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、數據壓縮等其餘的能力.

Libuv

Libuv(docs,GitHub)是Node.js關鍵的一個組成部分,它爲上層js提供了統一的API調用,兼容了平臺差別,隱藏了底層實現(它來源於libev,然而libev只能運行於Unix-like系統上。爲了可以使Node.js運行在Windows/Unix-like系統上,libuv所以產生了)圖片描述網絡

  • Network I/O: 網絡I/O異步

    • TCP: Transmission Control Protocol 傳輸控制協議
    • UDP: User Datagram Protocol 用戶數據報協議
    • TTY: 大概就是控制檯終端的意思
    • Pipe: 進程間通訊管道
  • File I/O: 文件讀寫
  • DNS Ops: DNS解析
  • User code: 提供線程運行用戶代碼並獲取循環的通知
  • uv_io_t: 沒有找到相關資料
  • epoll: Linux下多路複用IO接口select/poll的加強版本
  • kqueue: UNIX上高效的IO複用技術
  • event ports: 提供事件端口
  • IOCP: 用於高效處理不少不少的客戶端進行數據交換的一個模型
  • Thread Pool: 線程池
    能夠看出,它是一個對開發者友好的工具集,包含定時器,非阻塞的網絡 I/O,異步文件系統訪問,子進程等功能。它封裝了 Libev、Libeio 以及 IOCP,保證了跨平臺的通用性.

一個全流程的例子

舉一個文件操做的例子來闡述Node.js整個的執行流程async

const fs = require("fs")
fs.open("./test.txt", "w", (err, data) => {
    // TODO
});

整個代碼的調用過程大體可描述爲: lib/fs.js -> src/node_file.cc -> uv_fs
圖片描述tcp

具體來講,當咱們調用 fs.open 時,Node.js 經過 process.binding 調用 C/C++ 層面的 Open 函數,而後經過它調用 Libuv 中的具體方法 uv_fs_open,最後執行的結果經過回調的方式傳回,完成流程。在圖中,能夠看到平臺判斷的流程,須要說明的是,這一步是在編譯的時候已經決定好的,並非在運行時中。函數

整體來講,咱們在 Javascript 中調用的方法,最終都會經過 process.binding 傳遞到 C/C++ 層面,最終由他們來執行真正的操做。Node.js 即這樣與操做系統進行互動。工具

經過這個過程,咱們能夠發現,實際上,Node.js 雖說是用的 Javascript,但只是在開發時使用 Javascript 的語法來編寫程序。真正的執行過程仍是由 V8 將 Javascript 解釋,而後由 C/C++ 來執行真正的系統調用,因此並不須要過度擔憂 Javascript 執行效率的問題。能夠看出,Node.js 並非一門語言,而是一個平臺.oop

異步,非阻塞I/O

根據上文的鋪墊,咱們能夠知道真正執行系統操做的是Libuv層,Libuv自己就是異步和事件驅動的,因此,當咱們調用I/O操做時,Libuv開啓線程來執行此次I/O操做,執行完成後傳回給JavaScript進行後續操做.
這裏的I/O包括了文件I/O和網絡I/O,這二者的實現並不相同,文件I/O和DNS等操做都是依託線程池(Thread Pool)來實現,而網絡I/O(包括TCP,UDP,TTY等)是由epoll,IOCP,kqueue來實現的.
一個異步I/O的流程大致以下:

  • 發起I/O調用

    • 開發者經過JavaScript調用Node內置模塊,將參數和回調函數傳入到內置模塊.
    • Node內置模塊會將傳入的參數和回調函數封裝成一個請求對象.
    • 將這個請求對象推入到I/O線程池等待執行.
    • JavaScript發起的異步調用結束,主線程執行後續代碼.
  • 執行回調

    • I/O操做完成後,會將結果存儲到請求對象的result屬性上,病發出操做完成的通知.
    • 每次事件循環時會檢查是否有完成的I/O操做,若是有,則將請求對象加入到I/O觀察者隊列中,以後當作事件處理.
    • 處理 I/O 觀察者事件時,會取出以前封裝在請求對象中的回調函數,執行這個回調函數,並將 result 當參數,以完成 Javascript 回調的目的.
      圖片描述

從這裏,咱們能夠看到,咱們其實對 Node.js 的單線程一直有個誤會。事實上,它的單線程指的是自身 Javascript 運行環境的單線程,Node.js 並無給 Javascript 執行時建立新線程的能力,最終的實際操做,仍是經過 Libuv 以及它的事件循環來執行的。這也就是爲何 Javascript 一個單線程的語言,能在 Node.js 裏面實現異步操做的緣由,二者並不衝突。

事件驅動

當咱們寫了一大堆事件處理函數後,Libuv 如何來執行這些回調呢?這就提到了咱們以前說到的 uv_run,先看一張它的執行流程圖:
圖片描述

uv_run 函數中,會維護一系列的監視器(觀察者隊列):

typedef struct uv_loop_s uv_loop_t;
typedef struct uv_err_s uv_err_t;
typedef struct uv_handle_s uv_handle_t;
typedef struct uv_stream_s uv_stream_t;
typedef struct uv_tcp_s uv_tcp_t;
typedef struct uv_udp_s uv_udp_t;
typedef struct uv_pipe_s uv_pipe_t;
typedef struct uv_tty_s uv_tty_t;
typedef struct uv_poll_s uv_poll_t;
typedef struct uv_timer_s uv_timer_t;
typedef struct uv_prepare_s uv_prepare_t;
typedef struct uv_check_s uv_check_t;
typedef struct uv_idle_s uv_idle_t;
typedef struct uv_async_s uv_async_t;
typedef struct uv_process_s uv_process_t;
typedef struct uv_fs_event_s uv_fs_event_t;
typedef struct uv_fs_poll_s uv_fs_poll_t;
typedef struct uv_signal_s uv_signal_t;

這些監視器都有對應着一種異步操做,它們經過 uv_TYPE_start,來註冊事件監聽以及相應的回調。

uv_run 執行過程當中,它會不斷的檢查這些隊列中是或有 pending 狀態的事件,有則觸發,並且它在這裏只會執行一個回調,避免在多個回調調用時發生競爭關係,由於 Javascript 是單線程的,沒法處理這種狀況。

上面的圖中,對 I/O 操做的事件驅動,表達的比較清楚。除了咱們常提到的 I/O 操做,圖中還表述了一種狀況,timer(定時器)。它與其餘二者不一樣之處在於,它沒有單獨開立新的線程,而是在事件循環中直接完成的。

事件循環除了維護那些觀察者隊列,還維護了一個 time 字段,在初始化時會被賦值爲0,每次循環都會更新這個值。全部與時間相關的操做,都會和這個值進行比較,來決定是否執行。

在圖中,與 timer 相關的過程以下:

  1. 更新當前循環的 time 字段,即當前循環下的「如今」;
  2. 檢查循環中是否還有須要處理的任務(handlers/requests),若是沒有就沒必要循環了,便是否 alive。
  3. 檢查註冊過的timer,若是某一個 timer 中指定的時間落後於當前時間了,說明該 timer 已到期,因而執行其對應的回調函數.
  4. 執行一次`I/O polling(即阻塞住線程,等待 I/O 事件發生),若是在下一個 timer 到期時尚未任何 I/O 完成,則中止等待,執行下一個 timer 的回調。若是發生了 I/O 事件,則執行對應的回調;因爲執行回調的時間裏可能又有 timer 到期了,這裏要再次檢查 timer 並執行回調。
相關文章
相關標籤/搜索