Node.js到底是什麼

接觸前端也有一段時間了,逐漸開始接觸Node.js,剛剛接觸Node.js的時候一直都以爲Node.js就是JavaScript,當對Node.js有一定的瞭解之後,其實並不然兩者之間有關係,其中的關係又不是必然的,對Node.js進行的一些瞭解,對其進行一些概述,本篇文章並沒有對Node.jsAPI進行講解,而是能夠更加的明白Node.js是什麼。

到底什麼是Node.js

先看一下Node.js官網中是如何形容Node.js的,打開官網看到的第一句話就是Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.(Node.js是一個JavaScript運行時建立在Chrome的V8 JavaScript引擎。)在上面這段話中最重要的一點就是運行時

到底什麼是運行時呢?其實在筆者看來運行時就是程序在運行時所需要的組件,可以將其想象成爲是一種編程語言的運行環境。然而這個運行環境包含了代碼運行時所需要的解釋器和底層操作系統的支持等。

文章開頭也說過Node.jsJavaScript之間有關係,但是其關係也不是必然,到這裏大概也就有點眉目了。對於任何語言來說,其中最終要的就是其解釋器如何去處理這些編程語言。Node.js的底層是使用C++實現的,然而語法則是遵循ECMAScript規範,其實完全可以把其實現換乘一種新的編程語言,更換語言的同時也就意味着其解釋器發生了翻天覆地的變化。

Node.js爲什麼要選擇JavaScript

到了這裏可能有些疑問,編程語言和解釋器有關係,那麼爲什麼要選擇了JavaScript然而不是其他的語言呢?Node.js作者(Ryan Dahl)說,在創造Node.js的時,其目的是爲了實現高性能的Web服務器,其看重的並不是JavaScript這門語言。但是他需要的事一種編程語言來實現其想法,這種編程語言不能帶有任何的IO功能,並且需要良好的支持事件機制。說到這裏感覺就是在說JavaScript這門語言(感覺就是天命之選,O(∩_∩)O哈哈~)。首先JavaScript完全滿足上述的兩個條件,然而就順其自然的JavaScript就成了Node.js的主導者。

Runtime

上面一直提到的就是RuntimeRuntime是什麼?運行時刻是指一個程序在運行(cc或者在被執行)的狀態。也就是說,當你打開一個程序使它在電腦上運行的時候,那個程序就是處於運行時刻。在一些編程語言中,把某些可以重用的程序或者實例打包或者重建成爲運行庫。這些實例可以在它們運行的時候被鏈接或者被任何程序調用(節選自百度百科)。

其實對於開發者來說根本就不用去考慮其背後到底是怎樣實現的,我們站在開發的角度來想一想,對於某一種語言的Runtime表示開發者可以在Runtime上運行某種語言所編寫的代碼,如果把這個概念擴大一下說的話,Chorome也是一個JavaScript運行時依賴於背後的JavaScript引擎來運行JavaScript代碼而已。

其對應的Runtime可以對其編程語言進行一些拓展,比如在Node.js中的fs、Buffer就是其對ECMAScript的拓展,Runtime並不包含整個ECMAScript中的全部特性。反過來講,就算一個特性沒有體現在標準裏,而大多數的運行時都支持它,也可以變成實際上的規範。通過上述所說我們可以理解到對於任何語言來講我們無需對其底層的實現,所有的東西都依賴於其運行時的實現而已,運行時環境對其支持情況才能表現出其語言的特性。

同樣的一段代碼可能在瀏覽器端可以順利執行,但是放到Node.js中不一定可以順利執行,反之也是一樣的,這樣的就足可以說明上述問題了。

Node.js內部機制

Node.js中有幾個很重要的關鍵詞單線程,非阻塞異步IO,在筆者剛剛接觸Node.js的時候,這幾個詞經常聽到,有些懵懵懂懂不是太能理解。爲了更好的瞭解其內部機制那麼針對這些東西進行說明。

回調函數

爲什麼要說回調函數呢?對Node.js模塊有一定了解的話Node.js中模塊都是依賴於回調函數的,那麼什麼是回調函數呢?

回調函數就是一個通過函數指針調用的函數。如果你把函數的指針(地址)作爲參數傳遞給另一個函數,當這個指針被用來調用其所指向的函數時,我們就說這是回調函數。回調函數不是由該函數的實現方直接調用,而是在特定的事件或條件發生時由另外的一方調用的,用於對該事件或條件進行響應。(節選自百度百科)。

上面說了一堆套話,其實回調函數就是講一個函數作爲參數傳給另一個函數作爲參數,並且該函數可以被執行。回調方法和主線程處於同一個線程,假設主線程發起了一個底層的系統調用,操作系統會執行這個系統調用,當這個系統調用完成之後則會再回到主進程去執行後續的方法。

Node.js中在操作過程中可能會有一個比較耗時的IO操作,當IO操作有了返回結果之後纔會繼續向下執行,其中在進行IO操作時就造成了代碼的阻塞,在Node.js最初設計的時候已經考慮到了這一點,所以提出了異步函數回調函數的方式,也能實現高併發的處理。對於前端來講Ajax就是一個異步回調函數,當發起請求時如果有後續代碼會先向下繼續執行,而不會等待期請求結果。

回調函數機制:

  1. 定義一個回調函數;
  2. 提供函數實現的一方在初始化的時候,將回調函數的函數指針註冊給調用者;
  3. 當特定的事件或條件發生的時候,調用者使用函數指針調用回調函數對事件進行處理。
同步/異步

有關於同步/異步也搜索了一些文獻,但是都是簡簡單單概括一下,沒有細緻的說明。所謂同步和異步其描述的事進程和線程的調用方式。因爲Node.js的單線程,因此同個時間只能處理同個任務,所有任務都需要排隊,前一個任務執行完,才能繼續執行下一個任務,但是,如果前一個任務的執行時間很長,比如文件的讀取操作或網絡請求,後一個任務就不得不等着,拿文件的讀取操作來說,當用戶向後臺讀取大量的文件時,不得不等到所有數據都讀取完畢才能進行下一步操作,後續程序只能在那裏乾等着,很有可能造成響應超時。因此,Node.js在設計的時候,就已經考慮到這個問題,主線程可以完全不用等待文件的讀取完畢,可以先掛起處於等待中的任務,先運行排在後面的任務,等到文件的讀取有了結果後,再回過頭執行掛起的任務,因此,任務就可以分爲同步任務和異步任務。

  1. 同步任務:同步任務是指在主線程上排隊執行的任務,只有前一個任務執行完畢,才能繼續執行下一個任務,當我們打開網站時,網站的渲染過程,比如元素的渲染,其實就是一個同步任務
  2. 異步任務:異步任務是指不進入主線程,而進入任務隊列的任務,只有任務隊列通知主線程,某個異步任務可以執行了,該任務纔會進入主線程,當我們打開網站時,像圖片的加載,音樂的加載,其實就是一個異步任務

上述所說同步調用指的是進程/線程發起調用後,一直等待調用結果返回後纔會繼續向下執行,但是對於Node.js來說雖然也是這樣,但是並不代表的CPU在這段時間內也會一直等待,操作系統多半會切換到另一個進程/線程上等調用返回結果後在切回原有進程/線程。然而異步則恰恰相反,當發起異步調用時,進程/線程會繼續向下執行,當調用返回結果後通過某種技術手段通知其調用者已經有其結果。

我們一直都在說的一句話就是JavaScript是一門異步語言,但是對於ECMAScript而言並沒有對異步有明確的規範,其實是其解釋器(Node.js或瀏覽器)的runtime的其他線程來實現的,這些並不是JavaScript這門語言本身的功能。

對於異步請參考:淺析JavaScript異步

阻塞/非阻塞

筆者在沒有了解阻塞/非阻塞之前一直以爲同步/異步與阻塞/非阻塞之間是沒有區別的,然而現實就是這麼的打臉,阻塞/非阻塞和同步/異步完全就是兩組概念,他們之間沒有任何的必然關係。很多人大概和我一樣同步=阻塞異步=非阻塞,這種概念是完全不對的。

在瞭解阻塞與非阻塞之前首先要了解一下什麼是IO操作,IO操作其實是內存與外部設備之間複製數據的過程。

在阻塞的情況,是會一直等待直到write完全部的數據再返回。這點行爲上與讀操作有所不同,究其原因主要是讀數據的時候,通常剛開始我們並不知道要讀的數據的長度,而是在數據的頭部設置了一個長度,在讀完指定長度的頭部後,才知道整個要讀的數據長度。如果一開始就貿然設置一個要讀的數據長度,然後像阻塞的write那樣去等讀完,則很可能會造成死循環;而對於write,由於需要寫的長度是已知的,所以可以一直再寫,直到寫完。不過問題是write是可能被打斷造成write一次只write一部分數據,所以write的過程還是需要考慮循環write, 只不過多數情況下一次write調用就可能成功。

非阻塞寫的情況,是採用可以寫多少就寫多少的策略。與讀不一樣的地方在於,有多少讀多少是由網絡發送端是否有數據傳輸到本地內核緩存爲準。但是對於可以寫多少是由本地的網絡堵塞情況爲標準的,在網絡阻塞嚴重的時候,網絡層沒有足夠的內存來進行寫操作,這時候就會出現寫不成功的情況,阻塞情況下會儘可能(有可能被中斷)等待到數據全部發送完畢, 對於非阻塞的情況就是一次寫多少算多少,沒有中斷的情況下也還是會出現write到一部分的情況。

其實用一句話來說講的話,同步調用會造成進程的IO阻塞,而異步不會造成調用進程的IO阻塞。

單線程與多線程

Node.js並沒有提供多進程的支持,這代表在程序中所編寫的代碼只能運行在當前進程中,用於運行代碼的事件也是單線程進行的。開發者無法在一個獨立進程中增加新的線程嗎,但是可以派生出多個進程來達到必行完成任務。

進程

進程是指在操作系統中正在運行的一個應用程序

線程

線程是指進程內獨立執行某個任務的一個單元。線程自己基本上不擁有系統資源,只擁有一點在運行中必不可少的資源(如程序計數器,一組寄存器和棧)。

對於Node.js,如果說JavaScript的函數式編程方式使得其異步編程的思想對程序員展現得更自然,那麼它背後的功臣Libuv,則爲異步編程的實現提供了可能。

1141038-20180307094514784-1056185595.png

上圖中從左往右分爲兩部分,一部分是與Network I/O相關的請求,而另外一部分是由File I/O, DNS Ops以及User code組成的請求。

從圖中可以看出,對於Network I/O和以File I/O爲代表的另一類請求,異步處理的底層支撐機制是完全不一樣的。 對於Network I/O相關的請求, 根據OS平臺不同,分別使用Linux上的epollOSXBSDOS上的kqueueSunOS上的event ports以及Windows上的IOCP機制。 而對於File I/O爲代表的請求,則使用thread pool。利用thread pool的方式實現異步請求處理,在各類OS上都能獲得很好的支持。Libuv團隊爲什麼要選擇thread pool的機制。基本上原因不外乎編碼和維護複雜度太高、可支持的API太少且質量堪憂、技術支持較弱,而用thread pool則很好地避開了這些問題。

Node.js的異步調用時由Libuv來支持的,以readFile爲例的話,讀取文件的系統調用是由Libuv來完成的,Node.js只負責調用Libuv所提供的接口就可以了,等結果返回後在執行對應的回調方法。

並行與併發

自從Node.js出現後,JavaScript開始涉及後端領域,因爲其出色的併發模型,被很多企業用來處理高併發請求。

與併發被同時提及到的還有並行,那麼並行與併發有有什麼區別?並行指在同一時間點同時執行,併發是指在同一時間片段同時執行,上面已將解釋進程與線程,此時就可理解,進程之間相互獨立,可實現並行,但線程不可以,多線程只能併發執行,實際還是順執行,只是在同一時間片段,假似同時執行,CPU可以按時間切片執行,單核CPU同一個時刻只支持一個線程執行任務,多線程併發事實上就是多個線程排隊申請調用CPUCPU處理任務速度非常快,所以看上去多個線程任務說併發處理。

併發指的是一個CPU在不同線程來回跳,然後你會看到兩個線程搶奪CPU資源所以兩個線程輸出執行的順序不固定。

Node.js中的併發任務處理:

  1. 每個Node.js進程只有一個主線程在執行程序代碼,形成一個執行棧。
  2. 主線程之外,還維護了一個"事件隊列"。當用戶的網絡請求或者其它的異步操作到來時,Node都會把它放到事件棧之中,此時並不會立即執行它,代碼也不會被阻塞,繼續往下走,直到主線程代碼執行完畢。
  3. 主線程代碼執行完畢完成後,然後通過事件循環,也就是事件循環機制,開始到事件棧的開頭取出第一個事件,從線程池中分配一個線程去執行這個事件。

接下來繼續取出第二個事件,再從線程池中分配一個線程去執行,然後第三個,第四個。主線程不斷的檢查事件隊列中是否有未執行的事件,直到事件隊列中所有事件都執行完了。

此後每當有新的事件加入到事件隊列中,都會通知主線程按順序取出交EventLoop處理。當有事件執行完畢後,會通知主線程,主線程執行回調,線程歸還給線程池。

我們所看到的Node.js單線程只是一個JavaScript主線程,本質上的異步操作還是由線程池完成的,Node.js將所有的阻塞操作都交給了內部的線程池去實現,本身只負責不斷的往返調度,並沒有進行真正的I/O操作,從而實現異步非阻塞I/O,這便是Node.js單線程和事件驅動的精髓之處了。

總結

讀完本篇文章應該對Node.js有了一個簡單的認識,其中提到的EventLoop在本文章並沒有進行解釋,有時間會對其進一步說明。Node.js完成了它提供高度可伸縮服務器的目標。它使用了Google的一個非常快速的JavaScript引擎,即V8引擎。它使用一個事件驅動設計來保持代碼最小且易於閱讀。所有這些因素促成了Node.js的理想目標,即編寫一個高度可伸縮的解決方案變得比較容易,其Node.js對於高併發的處理也有很好的支持,總之Node.js的強大之處還有很多仍然需要慢慢摸索。

文章中概念較多,大家可以作理解,最後感謝大家用這麼長時間來閱讀這篇文章,文章中如果有什麼差錯請在評論處提出,我會盡快做出改正。

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