Node.js 是如何異步判斷文件是否存

通常我們在討論 Node.js 的時候都會涉及到異步這個特性。實際上 Node.js 在執行異步調用的時候,不同的場景下有着不同的處理方式。本文將通過 libuv 源碼來分析 Node.js 是如何通過 libuv 的線程池完成異步調用。本文描述的 Node.js 版本爲 v11.15.0,libuv 版本爲 1.24.0

以下面的代碼爲例,它通過調用 fs.access 來異步地判斷文件是否存在並在回調中打印日誌,在 Node.js 中這是一個典型的異步調用。

const fs = require('fs')
const cb = function (err) {
  console.log(`Is myfile exists: ${!err}`)
}
fs.access('myfile', cb)

在分析上面這段代碼的調用過程之前,我們先來了解一些 libuv 概念。

什麼類型的請求 libuv 會把它放到線程池去執行

主動通過 libuv 發起的操作被 libuv 稱爲[請求]請求,libuv 的線程池作用於以下 4 種枚舉的異步請求:

其它的 UV_CONNECTUV_WRITEUDP_SEND 等則並不會通過線程池去執行。

線程池請求分類

這 4 種枚舉請求 libuv 內部把它們分爲 3 種[任務類型]任務類型

  • UV__WORK_CPU:CPU 密集型,UV_WORK 類型的請求被定義爲這種類型。因此根據這個分類,不推薦在 uv_queue_work 中做 I/O 密集的操作。
  • UV__WORK_FAST_IO:快 IO 型,UV_FS 類型的請求被定義爲這種類型。
  • UV__WORK_SLOW_IO:慢 IO 型,UV_GETADDRINFOUV_GETNAMEINFO 類型的請求被定義爲這種類型。

UV__WORK_SLOW_IO 執行不同於 UV__WORK_CPUUV__WORK_FAST_IO ,libuv 執行它的時候流程會有些差異,這個後面會提到。

線程池是如何初始化的

libuv 通過init_threads 函數初始化線程池,初始化時會根據一個名爲 UV_THREADPOOL_SIZE 的環境變量來初始化內部線程池的大小,線程最大數量爲 128 ,默認爲 4 。如果以單進程的架構去部署服務,可以根據服務器 CPU 的核心數量及業務情況來設置線程池大小,達到資源利用的最大化。uv loop 線程在創建 worker 線程時,會初始化以下變量:

  • 信號量 sem:在創建線程時與線程進行同步,每個線程創建好後將會通過這個信號量告知 uv loop 線程自己已經初始化完畢,可以開始處理請求了。當所有線程都初始化完成後這個信號量將被銷燬,即完成線程池的初始化。
  • 條件變量 cond:線程創建完成後通過這個條件變量進入阻塞狀態( uv_cond_wait ),直到其它線程通過 uv_cond_signal 將其喚醒。
  • 互斥量 mutex:對下面 3 個臨界資源進行互斥訪問。
  • 請求隊列 wq:線程池收到 UV__WORK_CPUUV__WORK_FAST_IO 類型的請求後將其插到此隊列的尾部,並通過 uv_cond_signal 喚醒 worker 線程去處理,這是線程池請求的主隊列。
  • 慢 I/O 隊列 slow_io_pending_wq:線程池收到 UV__WORK_SLOW_IO 類型的請求後將其插到此隊列的尾部。
  • 慢 I/O 標誌位節點 run_slow_work_message:當存在慢 I/O 請求時,用來作爲一個標誌位放在請求隊列 wq 中,表示當前有慢 I/O 請求,worker 線程處理請求時需要關注慢 I/O 隊列的請求;當慢 I/O 隊列的請求都處理完畢後這個標誌位將從請求隊列 wq 中移除。

worker 線程的入口函數均爲 worker 函數,這個我們後面再說。 init_threads 實現如下:

static void init_threads(void) {
  unsigned int i;
  const char* val;
  uv_sem_t sem;

  // 6-23 行初始化線程池大小
  nthreads = ARRAY_SIZE(default_threads);
  val = getenv("UV_THREADPOOL_SIZE"); // 根據環境變量設置線程池大小
  if (val != NULL)
    nthreads = atoi(val);
  if (nthreads == 0)
    nthreads = 1;
  if (nthreads > MAX_THREADPOOL_SIZE)
    nthreads = MAX_THREADPOOL_SIZE;

  threads = default_threads;
  if (nthreads > ARRAY_SIZE(default_threads)) {
    threads = uv__malloc(nthreads * sizeof(threads[0]));
    if (threads == NULL) {
      nthreads = ARRAY_SIZE(default_threads);
      threads = default_threads;
    }
  }
  // 初始化條件變量
  if (uv_cond_init(&cond))
    abort();

  // 初始化互斥量
  if (uv_mutex_init(&mutex))
    abort();

  // 初始化隊列和節點
  QUEUE_INIT(&wq); // 工作隊列
  QUEUE_INIT(&slow_io_pending_wq); // 慢 I/O 隊列
  QUEUE_INIT(&run_slow_work_message); // 如果有慢 I/O 請求,將此節點作爲標誌位插入到 wq 中

  // 初始化信號量
  if (uv_sem_init(&sem, 0))
    abort(); // 後續線程同步需要依賴這個信號量,因此這個信號量創建失敗了則終止進程

  // 創建 worker 線程
  for (i = 0; i < nthreads; i++)
    if (uv_thread_create(threads + i, worker, &sem)) // 初始化 worker 線程
      abort(); // woker 線程創建錯誤原因爲 EAGAIN、EINVAL、EPERM 其中之一,具體請參考 man3
  
  // 等待 worker 創建完成
  for (i = 0; i < nthreads; i++)
    uv_sem_wait(&sem); // 等待 worker 線程創建完畢

  // 回收信號量資源
  uv_sem_destroy(&sem);
}

請求是如何放到線程池去執行的

libuv 有兩個函數可以創建多線程請求:

uv__work_submit 函數主要做 2 件事:

  1. 調用 init_threads 初始化線程池,因爲線程池的創建是惰性的,只有用到的時候纔會創建。
  2. 調用內部的 post 函數將請求插入到請求隊列中。

實現如下:

void uv__work_submit(uv_loop_t* loop,
                     struct uv__work* w,
                     enum uv__work_kind kind,
                     void (*work)(struct uv__work* w),
                     void (*done)(struct uv__work* w, int status)) {
  // 在收到請求後纔開始初始化線程池,但是隻會初始化一次
  uv_once(&once, init_once);
  w->loop = loop;
  w->work = work;
  w->done = done;
  post(&w->wq, kind);
}

static void init_once(void) {
  // fork 後子進程的 mutex 、condition variables 等 pthread 變量的狀態是父進程 fork 時的複製,所以子進程創建時需要重置狀態
  // 具體請參考 http://man7.org/linux/man-pages/man2/fork.2.html
  if (pthread_atfork(NULL, NULL, &reset_once))
    abort();
  // 初始化線程池
  init_threads();
}

static void reset_once(void) {
  // 重置 once 變量
  uv_once_t child_once = UV_ONCE_INIT;
  memcpy(&once, &child_once, sizeof(child_once));
}

post 函數主要做 2 件事:

  1. 判斷請求的請求類型是否是 UV__WORK_SLOW_IO

    • 如果是,將這個請求插到慢 I/O 請求隊列 slow_io_pending_wq 的尾部,同時在請求隊列 wq 的尾部插入一個 run_slow_work_message 節點作爲標誌位,告知請求隊列 wq 當前存在慢 I/O 請求。
    • 如果不是,將請求插到請求隊列 wq 尾部。
  2. 如果有空閒的線程,喚醒某一個去執行請求。

併發的慢 I/O 的請求數量不會超過線程池大小的一半,這樣做的好處是避免多個慢 I/O 的請求在某段時間內把所有線程都佔滿,導致其它能夠快速執行的請求需要排隊。

post 函數實現如下:

static void post(QUEUE* q, enum uv__work_kind kind) {
  // 加鎖
  uv_mutex_lock(&mutex);
  if (kind == UV__WORK_SLOW_IO) {
    /* 插入到慢 I/O 隊列中 */
    QUEUE_INSERT_TAIL(&slow_io_pending_wq, q);
    /* 如果 run_slow_work_message 節點不爲空代表其已在 wq 隊列中,無需再次插入 */
    if (!QUEUE_EMPTY(&run_slow_work_message)) {
      uv_mutex_unlock(&mutex);
      return;
    }
    // 不在 wq 隊列中則將 run_slow_work_message 作爲標誌位插到 wq 尾部
    q = &run_slow_work_message;
  }
  // 將請求插到請求隊列尾部
  QUEUE_INSERT_TAIL(&wq, q);
  // 如果有空閒的線程,喚醒某一個去執行請求
  if (idle_threads > 0)
    uv_cond_signal(&cond); // 喚醒一個 worker 線程
  uv_mutex_unlock(&mutex);
}

worker 線程的入口函數 worker 在線程創建好並初始化完成後將按照下面的步驟不斷的循環:

  1. 等待喚醒。
  2. 取出請求隊列 wq 或者慢 I/O 請求隊列的頭部請求去執行。
  3. 通知 uv loop 線程完成了一個請求的處理。
  4. 回到 1 。
static void worker(void* arg) {
  struct uv__work* w;
  QUEUE* q;
  int is_slow_work;

  // 通知 uv loop 線程此 worker 線程已創建完畢
  uv_sem_post((uv_sem_t*) arg);
  arg = NULL;

  uv_mutex_lock(&mutex);
  // 通過這個死循環來不斷的執行請求
  for (;;) {
    /*
        這個 while 有2個判斷
        1. 在多核處理器下,pthread_cond_signal 可能會激活多於一個線程,通過一個 while 來避免這種情況導致的問題,具體請參考 https://linux.die.net/man/3/pthread_cond_signal
        2. 限制慢 I/O 請求的數量小於線程數量的一半
    */
    while (QUEUE_EMPTY(&wq) ||
           (QUEUE_HEAD(&wq) == &run_slow_work_message &&
            QUEUE_NEXT(&run_slow_work_message) == &wq &&
            slow_io_work_running >= slow_work_thread_threshold())) {
      idle_threads += 1;
      // worker 線程初始化完成或沒有請求執行時進入阻塞狀態,直到被新的請求喚醒
      uv_cond_wait(&cond, &mutex);
      idle_threads -= 1;
    }
    // 喚醒並且達到執行請求的條件後取出隊列頭部的請求
    q = QUEUE_HEAD(&wq);
    // 如果頭部請求是退出,則跳出循環,結束 worker 線程
    if (q == &exit_message) {
      // 繼續喚醒其它 worker 去結束線程
      uv_cond_signal(&cond);
      uv_mutex_unlock(&mutex);
      break;
    }

    // 將這個請求節點從請求隊列 wq 中移除
    QUEUE_REMOVE(q);
    QUEUE_INIT(q);

    is_slow_work = 0;
    // 如果這個請求是慢 I/O 的標誌位
    if (q == &run_slow_work_message) {
      /* 控制慢 I/O 請求數量,超過則插到隊列尾部,等待前面的請求執行完 */
      if (slow_io_work_running >= slow_work_thread_threshold()) {
        QUEUE_INSERT_TAIL(&wq, q);
        continue;
      }

      /* 判斷慢 I/O 請求隊列中是否有請求,請求有可能被取消 */
      if (QUEUE_EMPTY(&slow_io_pending_wq))
        continue;

      is_slow_work = 1;
      slow_io_work_running++;

      // 取出慢 I/O 請求隊列中頭部的請求
      q = QUEUE_HEAD(&slow_io_pending_wq);
      QUEUE_REMOVE(q);
      QUEUE_INIT(q);

      // 如果慢 I/O 請求隊列中還有請求,則將 run_slow_work_message 這個標誌位重新插到請求隊列 wq 的尾部
      if (!QUEUE_EMPTY(&slow_io_pending_wq)) {
        QUEUE_INSERT_TAIL(&wq, &run_slow_work_message);
        if (idle_threads > 0)
          uv_cond_signal(&cond); // 喚醒一個線程繼續執行
      }
    }

    uv_mutex_unlock(&mutex);

    w = QUEUE_DATA(q, struct uv__work, wq);
    // 上面處理了這多,終於在這裏開始執行請求的函數了
    w->work(w);

    uv_mutex_lock(&w->loop->wq_mutex);
    w->work = NULL;
    
    // 爲保證線程安全,請求執行完後不會立即回調請求,而是將完成的請求插到已完成的請求隊列中,在uv loop 線程完成回調
    QUEUE_INSERT_TAIL(&w->loop->wq, &w->wq);
    // 通過 uv_async_send 同步 uv loop 線程:線程池完成了一個請求
    uv_async_send(&w->loop->wq_async);
    uv_mutex_unlock(&w->loop->wq_mutex);

    uv_mutex_lock(&mutex);
    if (is_slow_work) {
      slow_io_work_running--;
    }
  }
}

請求在 worker 執行完後是如何同步 uv loop 所在的線程

uv_loop_init 時,線程池的 [wq_async]wq_async 句柄通過 uv_async_init 初始化並插入到 uv loop 的 async_handles 隊列中,然後在 uv loop 線程中遍歷 async_handles 隊列並完成回調。

worker 線程 和 uv loop 線程通過 uv_async_send 進行同步,而uv_async_send 只做了一件事:向 async_wfd 句柄寫了一個長度爲 1 個字節的字符串(只有 \0 這個字符)。

uv_async_send 實現如下:

int uv_async_send(uv_async_t* handle) {
  if (ACCESS_ONCE(int, handle->pending) != 0)
    return 0;
  // cmpxchgi 函數設置標誌位,如果已經設置過則不會重複調用 uv__async_send
  if (cmpxchgi(&handle->pending, 0, 1) == 0)
    uv__async_send(handle->loop);

  return 0;
}

static void uv__async_send(uv_loop_t* loop) {
  const void* buf;
  ssize_t len;
  int fd;
  int r;

  buf = "";
  len = 1;
  fd = loop->async_wfd;

#if defined(__linux__)
  if (fd == -1) {
    static const uint64_t val = 1;
    buf = &val;
    len = sizeof(val);
    fd = loop->async_io_watcher.fd;  /* eventfd */
  }
#endif

  do
    r = write(fd, buf, len); // 向 fd 寫入內容
  while (r == -1 && errno == EINTR);

  if (r == len)
    return;

  if (r == -1)
    if (errno == EAGAIN || errno == EWOULDBLOCK)
      return;

  abort();
}

async_wfd 寫內容爲什麼能做到同步呢?實際上在 worker 線程對 async_wfd 寫入時,uv loop 線程同時也在不斷的循環去接收處理各種各樣的事件或請求,其中就包括對 async_wfd 可讀事件的監聽。

uv loop 是在 uv_run 函數中執行的,它在 Node.js 啓動時 被調用, uv_run 實現如下:

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);

  while (r != 0 && loop->stop_flag == 0) {
    // 更新計時器時間
    uv__update_time(loop);
    // 回調超時的計時器,setTimeout、setInterval 都是由這個函數回調
    uv__run_timers(loop);
    // 處理某些沒有在 uv__io_poll 完成的回調
    ran_pending = uv__run_pending(loop);
    // 官方解釋:Idle handle is needed only to stop the event loop from blocking in poll.
    // 實際上 napi 中某些函數比如 napi_call_threadsafe_function 會往 idle 隊列中插入回調,然後在這個階段執行
    uv__run_idle(loop);
    // process._startProfilerIdleNotifier 的回調
    uv__run_prepare(loop);

    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop); // 計算 uv__io_poll 超時時間,算法請參考 https://github.com/libuv/libuv/blob/v1.24.0/src/unix/core.c#L318

    // 對 async_wfd 可讀的監聽在 uv__io_poll 這個函數中
    // 第二個參數 timeout 爲上面計算出來,用來設置 epoll_wait 等函數等待 I/O 事件的時間
    uv__io_poll(loop, timeout);
    // setImmediate 的回調
    // ps: 個人覺得從實現上講 setImmediate 和 nextTick 應該互換名字 :-)
    uv__run_check(loop);
    // 關閉句柄是個異步操作
    // 一般結束 uv loop 時會先調用 uv_walk 遍歷所有句柄並關閉它們,然後再執行一次 uv loop 通過這個函數來完成關閉,最後再調用 uv_loop_close,否則的話會出現內存泄露
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      /* UV_RUN_ONCE implies forward progress: at least one callback must have
       * been invoked when it returns. uv__io_poll() can return without doing
       * I/O (meaning: no callbacks) when its timeout expires - which means we
       * have pending timers that satisfy the forward progress constraint.
       *
       * UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
       * the check.
       */
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  /* The if statement lets gcc compile it to a conditional store. Avoids
   * dirtying a cache line.
   */
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}

可以看到 uv loop 裏面其實就是在不斷的循環去更新計時器、處理各種類型的回調、輪詢 I/O 事件,Node.js 的異步便是通過 uv loop 完成的。

libuv 的異步採用的是 Reactor 模型進行多路複用,在 uv__io_poll 中去處理 I/O 相關的事件, uv__io_poll 在不同的平臺下通過 epollkqueue 等不同的方式實現。所以當往 async_wfd 寫入內容時,在 uv__io_poll 中將會輪詢到 async_wfd 可讀的事件,這個事件僅僅是用來通知 uv loop 線程: 非 uv loop 線程有回調需要在 uv loop 線程執行。

當輪詢到 async_wfd 可讀後,uv__io_poll 會回調對應的函數 uv__async_io,它主要做了下面 2 件事:

  1. 讀取數據,確認是否有 uv_async_send 調用,數據內容並不關心。
  2. 遍歷 async_handles 句柄隊列 ,判斷是否有事件,如果有的話執行它的回調。

實現如下:

static void uv__async_io(uv_loop_t* loop, uv__io_t* w, unsigned int events) {
  char buf[1024];
  ssize_t r;
  QUEUE queue;
  QUEUE* q;
  uv_async_t* h;

  assert(w == &loop->async_io_watcher);

  // 這個 for 循環用來確認是否有 uv_async_send 調用
  for (;;) {
    r = read(w->fd, buf, sizeof(buf));

    if (r == sizeof(buf))
      continue;

    if (r != -1)
      break;

    if (errno == EAGAIN || errno == EWOULDBLOCK)
      break;

    if (errno == EINTR)
      continue;

    abort();
  }
 
  // 交換 loop->async_handle 和 queue內容,避免在遍歷 loop->async_handles 時有新的 async_handle 插入到隊列
  // loop->async_handles 隊列中除了線程池的句柄還有其它的
  QUEUE_MOVE(&loop->async_handles, &queue);
  while (!QUEUE_EMPTY(&queue)) {
    q = QUEUE_HEAD(&queue);
    h = QUEUE_DATA(q, uv_async_t, queue);

    QUEUE_REMOVE(q);
    // 將 uv_async_t 重新插入到 loop->async_handles 中,uv_async_t 需要手動調用 uv__async_stop 纔會從隊列中移除
    QUEUE_INSERT_TAIL(&loop->async_handles, q);

    // 確認這個 async_handle 是否需要回調
    if (cmpxchgi(&h->pending, 1, 0) == 0)
      continue;

    if (h->async_cb == NULL)
      continue;

    // 調用通過 uv_async_init 初始化 uv_async_t 時綁定的回調函數
    // 線程池的 uv_async_t 是在 uv_loop_init 時初始化的,它綁定的回調是 uv__work_done
    // 因此如果 h == loop->wq_async,這裏 h->async_cb 實際是調用了 uv__work_done(h);
    // 詳情請參考 https://github.com/libuv/libuv/blob/v1.24.0/src/unix/loop.c#L88
    h->async_cb(h);
  }
}

調用線程池的 h->async_cb 後會回到線程池的 uv__work_done 函數:

void uv__work_done(uv_async_t* handle) {
  struct uv__work* w;
  uv_loop_t* loop;
  QUEUE* q;
  QUEUE wq;
  int err;

  loop = container_of(handle, uv_loop_t, wq_async);
  uv_mutex_lock(&loop->wq_mutex);
  // 清空已完成的 loop->wq 隊列
  QUEUE_MOVE(&loop->wq, &wq);
  uv_mutex_unlock(&loop->wq_mutex);

  while (!QUEUE_EMPTY(&wq)) {
    q = QUEUE_HEAD(&wq);
    QUEUE_REMOVE(q);

    w = container_of(q, struct uv__work, wq);
    // 如果在回調前調用了 uv_cancel 取消請求,則即使請求已經執行完,依舊算出錯
    err = (w->work == uv__cancelled) ? UV_ECANCELED : 0;
    w->done(w, err);
  }
}

最後通過 w->done(w, err) 回調 uv__fs_done,並由 uv__fs_done 回調 JS 函數:

static void uv__fs_done(struct uv__work* w, int status) {
  uv_fs_t* req;

  req = container_of(w, uv_fs_t, work_req);
  uv__req_unregister(req->loop, req);

  // 如果取消了則拋出異常
  if (status == UV_ECANCELED) {
    assert(req->result == 0);
    req->result = UV_ECANCELED;
  }

  // 回調 JS
  req->cb(req);
}

以上就是 libuv 是線程池從創建到執行多線程請求的過程。

fs.access 調用過程分析

再回到文章開頭提到的代碼,我們來分析它的調用過程。

const fs = require('fs')
const cb = function (err) {
  console.log(`Is myfile exists: ${!err}`)
}
fs.access('myfile', cb)

假設線程池大小爲 2 ,下面描述了執行 fs.access 時 3 個線程的狀態(略過了 Node.js 啓動和 JavaScript 和 Native 函數調用過程),時間軸從上到下:

空白代表處於阻塞狀態,-代表線程尚未啓動

uv loop thread worker thread 1 worker thread 2
[fs.access]fs.access - -
JavaScript 通過 v8 調用 Native 函數 - -
uv_fs_access - -
uv__work_submit - -
init_threads worker worker
uv_sem_wait uv_sem_post uv_sem_post
uv_cond_wait uv_cond_wait
uv_cond_signal
uv__io_poll access
uv__io_poll
uv__io_poll uv_async_send
uv__io_poll uv_cond_wait
uv__io_poll
uv__async_io
uv__work_done
uv__fs_done
Native 通過 v8 回調 JavaScript 函數
cb
console.log(`Is myfile exists: ${exists}`)

可以看到調用過程如下:

  1. 通過 Node.js 啓動時對 JavaScript 函數與 Native 函數的綁定,fs.access 最終會進入到 Native 函數中,而 Native 函數會調用 libuv 的 uv_fs_access 函數來判斷文件是否可以訪問。(這裏略過 JavaScript 如何通過 v8 調用 Native 函數)
  2. uv_fs_access 在 uv loop 線程向線程池提交了一個多線程請求。
  3. 由於線程池是惰性的,在執行請求前,先進行了初始化線程池的操作。
  4. 線程池初始化完成後喚醒了 worker thread 1 去執行請求,同時 uv loop 線程不斷的輪詢是否完成了請求。
  5. worker thread 1 同步的調用 access 函數判斷目標文件是否可讀。
  6. access 函數完成後, worker thread 1 通過 uv_async_send 同步 uv loop 線程請求已完成,同時自身進入阻塞狀態,等待新的請求將其喚醒。
  7. uv loop 線程發現請求執行完成後通過一系列回調回到 uv__fs_done
  8. uv__fs_done 回調 JavaScript 函數打印日誌。(這裏略過 uv__fs_done 是如何通過 v8 回調到 JavaScript)

整個過程由於沒有新的請求進來, worker thread 2 始終處於阻塞狀態。

結束語

通過對 fs.access 的調用過程分析,我們瞭解了 libuv 是如何通過線程池進行異步調用的。另外也可以看到針對不同的平臺,libuv 對 uv__io_poll 的實現是不同的,後面我們將介紹 uv__io_poll 實現異步 I/O 的方式。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章