一步步分析 Node.js 的異步I/O機制

它的優秀之處並不是原創,它的原創之處並不是優秀。編程

《深刻淺出Node》數組

本文章節以下圖所示,閱讀時間大約爲10分鐘~15分鐘,圖少字多,建議仔細閱讀。多線程

-w201

背景

在計算機資源中,I/OCPU計算在硬件支持上是能夠並行進行的。因此,同步編程中的I/O引發的阻塞致使後續任務(多是CPU計算,也多是其餘I/O)的等待會形成資源的沒必要要浪費架構

說白了明明就是硬件支持,可是軟件上不支持,就是浪費。因此要作的是盡最大可能不讓阻塞形成不必的等待併發

問題引入

假設咱們如今拿到一組任務,其中既有I/O又有CPU計算,同時假設咱們的計算機是多核的但計算機資源有限的,爲了減小上述的資源浪費狀況你會怎麼作?負載均衡

-w400

第一種方案:多線程。

經過建立多個線程來分別執行CPU計算和I/O,這樣CPU計算不會被I/O阻塞了。異步

它有以下的缺點:socket

  • 硬件上:建立線程和線程上下文切換有時間開銷。
  • 軟件上:多線程編程模型的死鎖、狀態同步等問題讓開發者頭疼。

第二種方案:單線程 + 異步I/O

首先它能夠規避上述方案的缺點。函數

經過事件驅動的方式,當單線程執行CPU計算,I/O經過異步來進行調用和返回結果。這樣也能使I/O不阻塞CPU計算。oop

可是它也有缺點:

  • 單線程無法利用多核CPU的優勢。(一個線程確定無法運行在多個CPU上)
  • 線程一崩,整個程序就崩潰了。(多線程這個問題的影響很小)
  • 非阻塞I/O經過輪詢實現的,輪詢會消耗額外的CPU資源。

問題分解

咱們將上述描述的問題進行分解,梳理思路:

  • T1:減小I/O阻塞CPU計算的時間。
  • T2:不要帶來鎖、狀態同步等問題。
  • T3:能利用多核CPU的優勢。
  • T4:不要帶來更多的額外消耗。

解決問題

Node經過異步調用+維護I/O線程池+事件循環機制來減小或避免I/O阻塞CPU計算的時間。後面我逐步解釋上述三者:

異步調用

一圖以蔽之。

-w400

這裏咱們要把異步調用處理過程抽象到操做系統層面,咱們可知:異步調用是當應用程序發起I/O調用的時候,將調用信號發給操做系統,這時應用程序繼續往下執行,直到操做系統完成任務以後,將數據返回,應用程序經過回調獲取返回數據並在程序中執行相應的回調函數。

維護I/O線程池

咱們將上述的操做系統進行剖析,其實內部是由Node維護了一個I/O線程池

當JavaScript線程(JavaScript是單線程的我就不解釋了吧)執行過程當中遇到了I/O任務的地方,會進行異步調用,封裝參數和請求對象並將其放入線程池等待隊列中等待執行。

當線程池有空餘線程的時候,會讓空餘線程執行該I/O任務,執行完成以後,歸還所佔用的線程,同時咱們拿到了I/O任務的執行結果

此時異步I/O進行的流程以下圖所示:

-w400

IOCP是輸入輸出完成端口(Input/Output Completion Port,IOCP), 是支持多個同時發生的異步I/O操做的應用程序編程接口,是一個Windows內核對象。

事件循環機制

異步任務完成了,那JavaScript線程是怎麼知道的呢?

最暴力也是最直接的方式就是讓CPU去輪詢,即建立一個無限循環一直去檢查I/O的完成狀態。因此如今爲了解決**問題T1(減小I/O阻塞CPU計算的時間。)而致使了問題T4(不要帶來更多的額外消耗。)**的產生,由於CPU會花費額外的資源去處理狀態判斷和沒必要要的「空轉」。

這裏咱們可抽象地理解爲CPU去輪詢線程池中的各線程的狀態。

因此咱們要經過優化問題T4來儘量地減小消耗。

一個著名的優化思路就是設定一個不可能達到的理想狀況,而後設計具體方法來無限逼近理想目標。這裏咱們要優化問題T4使其趨近於問題T4不存在。

剛剛說了一直去檢查I/O的狀態是性能最低的方案(這叫read方案)。除此以外還有以下幾種方案:

  • 輪詢文件描述符上的事件狀態(select方案)。可是因爲它採用的是1024長度的數組來存儲狀態,因此最多檢查1024個文件描述符,這裏產生了限制性。

文件描述符是一個簡單的整數,用以標明每個被進程所打開的文件和socket。不要以爲1024很大了,在海量請求面前,真的是很小的數字。

  • 基於上述採用鏈表存儲狀態(poll方案)。可是在文件描述符較多的時候性能低下。
  • 在進入輪詢的時候若是沒有檢查到I/O事件的完成,則輪詢進行休眠,直到事件發生將它喚醒(epoll方案)。這是Linux下效率最高的I/O事件通知機制,不會形成CPU的浪費,畢竟輪詢線程(其實就是JavaScript線程)已經休眠了。

下面咱們經過描述生產者/消費者模型來梳理基於epoll的整個方案:

線程池中各線程中I/O事件的完成是事件的生產者

JavaScript線程中的事件的回調函數則是事件的消費者

Step1: Node的輪詢機制在輪詢I/O事件完成隊列時,發現爲空(即沒有任何線程完成I/O),則Node的輪詢機制進入休眠。

Step2: I/O線程池中有部分線程完成了,發送信號(操做系統完成)喚醒Node的輪詢機制,從I/O事件完成隊列裏取出各完成的I/O對象,並執行相應的回調函數。

Step3: 若是在某次輪詢時發現I/O事件完成隊列爲空,則又進入休眠直到再次被喚醒。

上述的Node的輪詢機制則爲事件循環即Event Loop,而I/O事件完成隊列也爲咱們常說的事件觀察者

關於這部分的更多內容可細讀《深刻淺出Node》第三章的3.3.2~3.3.5節。

通過事件循環,咱們能夠得出整個異步I/O的過程了。如圖所示:

-w600

結論:Node經過異步調用+維護I/O線程池+事件循環機制解決了T1問題(即減小I/O阻塞CPU計算的時間),同時也將T4問題(即不要帶來更多的額外消耗)的影響降至最低,因爲JavaScript執行部分始終是單線程的,因此也不存在須要鎖機制和各狀態同步,T2問題(即不要帶來鎖、狀態同步等問題)也不存在了。

因此這裏咱們能夠得知,雖然JavaScript是單線程的,可是Node是多線程的,由於要維護一個I/O線程池啊。

這裏咱們只講了異步I/O的狀況,固然還有非I/O的異步任務,好比setTimeout。若是你看懂了上述的事件循環,其實你就能夠理解爲setTimeout就是往定時器觀察者(這裏不是I/O觀察者哦,觀察者有多個)隊列中插入一個事件而已,每次循環的時候判斷是否到期,到期就執行。

值得注意的是:定時器觀察者是一棵紅黑樹。

好了,最後咱們就要開始解決文章開頭提到的T3問題了:

如何利用多核CPU的優勢?

這裏其實要解決的是單進程單核對於多核使用不足的問題。

廢話很少說,Node用的是多進程架構,並採用Master-Worker的模式。,理想狀態下每一個進程都分配到一個專屬的CPU。

主進程負責調度,工做進程作具體的工做。進程間經過IPC(進程間通訊)傳遞數據。

可是咱們要注意的是,建立工做進程(即子進程)的代價昂貴,須要至少30ms的啓動時間和10MB的內存空間。因此必定要在開發的時候審慎對待。

搞清楚咱們的目的:多進程是爲了利用多核CPU,而不是爲了解決併發

IPC可傳遞句柄,這讓咱們能夠實現多個進程監聽同個端口,可實現負載均衡。具體參考《深刻淺出Node》第九章。

總結

Node經過異步調用+維護I/O線程池+基於epoll的事件循環機制來實現的異步I/O,並經過Master-Worker的多進程架構來充分利用多核CPU

之後在面對這樣的言論你能夠說他們是錯的了:

  • Node是單線程的。
  • Node適用於I/O密集型,而不適用於CPU密集型。
  • Node寫的東西太容易掛了。

你能夠給他們解釋清楚,而後說:

WechatIMG25934
相關文章
相關標籤/搜索