前言
Coding 應當是一生的事業,而不僅僅是 30 歲的青春飯
本文已收錄 Github https://github.com/ponkans/F2E,歡迎 Star,持續更新
位元組跳動面試官問:Node.js 多程式模型,以及多程式監聽同一埠的底層原理是如何實現滴?
好朋友被位元組跳動面試官這道題吊打了, 週末怪怪加班,寫下這篇深入探究 Node.js 多程式架構的底層實現~ 純乾貨,分享給大家!!!
很多小夥伴對一些基礎,特別是底層不是很瞭解,順帶也可以好好補一下底層原理的基礎哈 ~
每篇文章都希望你能收穫到東西,這篇由淺入深講 Node.js 多程式模型(後面會有些底層,小夥伴們做好心理準備哦~),希望看完能夠有這些收穫:
- 徹底搞懂程式、執行緒、協程之間的關係
- 徹底搞懂 Node.js 的多程式模型
- 如何用有限的計算機資源,搭建更高效能的服務端
作業系統的程式與執行緒
之前的《吊打面試官》系列 Node.js 雙十一秒殺系統中提了一下 Node 的多程式模型,本文將詳細的講解 Node 程式的各個細節
程式和執行緒,可以說是老僧長談的話題了。
只要是從事計算機相關的小夥伴,提起這個大都思如泉湧,多執行緒~高併發~ 但各種零散的概念和認知或許難以匯成一個成體系的知識結構。我們先來羅列一下這兩個概念簡潔的官方解釋。
- 程式:處於執行期的程式碼,正在執行的程式,它不僅包括目的碼,還有資料、資源、狀態和虛擬的計算機。
- 執行緒:在一個程式裡的一個執行路線就叫做執行緒(thread)。更準確的定義是:執行緒是“一個程式內部的控制序列”。
看到上面兩個定義,很多小夥伴小眉頭可能會皺一下,啥@#¥%玩意。。怪怪給小夥伴們準備了下圖幫助理解哈~。
感受程式
程式其實遍佈在我們電腦的每個角落,剛剛被對面團滅的英雄聯盟,瀏覽器上正在播放的小電影等等,都是一個個執行中的程式。
程式其實是處於執行期的程式和相關資源的總稱,裡面包含了要執行的程式碼段,需要用到的檔案,埠,硬體資源,很常見的一種說法是程式是資源分配的最小單位,這句話更直白的說就是,要執行某個可執行的程式碼段會需要某些資源,當這個程式碼段執行起來的時候,這些資源也必須被分配給他。
那我們總結下就是:執行中的程式碼+他佔有的資源 = 程式。
感受執行緒
講完程式後,有些小夥伴可能懵了。
程式=執行的程式碼段+資源,那我們的執行緒存在的意義在哪?為什麼不直接讓程式去執行。
上面我們提到了,程式是資源分配的最小單位,意味著程式和資源是1:1,與之對應的一句話就是,執行緒是排程的最小單位,程式和執行緒是一個1:n的關係。
舉個不完全恰當的栗子:我們把一家商場比做一臺計算機,裡面一個一個的店家就是程式,他們是商場資源的最小單位了,他們既有對應的資源,也在進行著商業活動,就如同一個有資源和在執行中的程式。
每個商鋪裡面的店員就是一個個執行緒,他們在自己的資源裡各司其職,有人拉客,有人站臺,有人把風。這些人才是真正排程的最小單位。
我們試想,如果資源的分配和排程是 1:1 的關係,那意味著一個商店裡在活動的人同一時間只能有一個,當你在拉客的時候,其他人不可以在店裡,你在站臺的時候,其他人也只能在一邊候著,但其實你們都是用的同一家店鋪的資源。
這顯然不 OK,所以程式同理,在程式中使用多執行緒就是讓共享同一批資源的操作一起進行。
這樣可以極大的減少程式資源切換的開銷。當我們在進行多個操作的時候,他們相互之間在切換時自然是越輕量越好。
就像玩手機的時候,你在刷微博,這時你忽然又想玩遊戲,當你在這兩個操作之間切換的時候,自然是越輕越好,你無需把手關機再重啟,然後再開啟遊戲吧,不然這手機也太弱雞了吧~~
程式與執行緒切換的代價
既然說到了程式的切換,那我們可以細探一下程式切換的開銷。一個程式會獨佔一批資源,比如使用暫存器,記憶體,檔案等。
當切換的時候,首先會儲存現場,將一系列執行的中間結果儲存起來,存放在記憶體中的程式的程式碼和資料,它的棧、通用目的暫存器的內容、程式計數器、環境變數以及開啟的檔案描述符的集合,這個狀態叫做上下文。
然後在他恢復回來的時候又需要將上述資源切換回去。顯而易見,切換的時候需要儲存的資源越少,系統效能就會越好,執行緒存在的意義就在於此。執行緒有自己的上下文,包括唯一的整數執行緒 ID,棧、棧指標、程式計數器、通用目的暫存器和條件碼。
可以理解為執行緒上下文是程式上下文的子集。
執行緒之下,協程
程式的編寫總是追求最極致的效能優化,執行緒的出現讓共享同一批資源的程式在切換時更輕量,那有沒有比執行緒還要輕的呢?
協程的出現讓這個變成了可能,執行緒和程式是作業系統的支援帶來的優化,而協程本質上是一種應用層面的優化了。
這就如同執行緒和程式是天生的遊戲奇才,超神玩家,協程是這位奇才覺得自己超神不夠還想超鬼,是自己又做了後天努力。
協程可以理解為特殊的函式,這個函式可以在某個地方掛起,並且可以重新在掛起處外繼續執行,簡單來說,一個執行緒內可以由多個這樣的特殊函式在執行,但是有一點必須明確的是,一個執行緒的多個協程的執行是序列的。
(圈重點啦)如果是多核 CPU,多個程式或一個程式內的多個執行緒是可以並行執行的,但是一個執行緒內協程卻絕對是序列的,無論 CPU 有多少個核。
畢竟協程雖然是一個特殊的函式,但仍然是一個函式。一個執行緒內可以執行多個函式,但這些函式都是序列執行的。當一個協程執行時,其它協程必須掛起。
協程一般來自語言的支援,如 Python,下面隨意貼一段協程的 py 程式碼。裡面做的事情也很簡單,yield 是 python 當中的語法。
當函式執行到 yield 關鍵字時,會暫停在那一行(並非阻塞,只是應用層面的暫停),等到主執行緒呼叫 send 方法傳送了資料,協程才會接到資料繼續執行,個人感覺跟回撥比較像。(Python yield 這個語法比較老舊,新語法使用 async/await)
下面是執行結果。
Linux 之執行緒程式
Linux 的設計總讓人有種化繁為簡的感覺,除了大家熟悉的一切皆檔案,他對程式執行緒的設計也是類似的感覺,嚴格來說在 Linux 上並沒有執行緒的概念,此話怎麼說?
因為無論是程式還是執行緒,都要有存在的證明,你說你在世界上存在,你怎麼證明呢?
程式的存在證明就是程式控制塊,Linux 每一個程式都有其對應的控制塊,裡面包含了程式 id,需要的硬體資源,執行的程式碼段等等。執行緒亦如是,在 windows 中有明確的執行緒控制塊,由作業系統來做執行緒排程。
Linux 視執行緒和程式是一樣的,都用程式控制塊進行管控,但這並不等於 Linux 不支援執行緒,只是不同作業系統對概念的抽象不同,Linux 提供 pthread 庫來 fork 微程式,多個微程式可以共享資源,和執行緒本質上並無區別,只是沒有提供專門的執行緒管控,有興趣的同學可以詳細瞭解下。
哦豁,是不是感覺怪怪有點東西了?彆著急,接續往下看↓~
多 CPU,影分身~
要成為 nb 的業界大手,你要會哪些技能?
面試扛千億併發,入職調按鈕樣式,哈哈哈。
這裡有個概念是併發,與之爛兄爛弟的概念就是並行,讓人意亂神迷傻傻分不清。
現在我們經常會聽到各種名詞,什麼多核機器,多 cpu 什麼的。多個 cpu 意味著什麼呢?
首先要搞清楚 cpu 到底是幹嘛的。cpu 的作用用兩個字來講就是:計算。
我們的各種花裡胡哨的程式碼,最終編譯完真正執行的時候也無非這兩個字:計算。上面提到了程式一定是在執行的程式碼,那程式碼的執行必然就是在 CPU 上。
我們有幾個 cpu 意味著我們可以有幾個程式同時在計算,這就是並行,就如同小時候會想有鳴人的影分身,就可以讓他們一個來寫數學,一個來寫語文,一個來寫英語。
與多核對應的就是苦逼的單核今計算機了,就像沒有影分身的我,這個時候也有多個作業要做,咋整?半個小時寫語文,半個小時寫數學,再半個小時寫語文,再來半小時寫數學。。(強行時間片輪轉了)這是語文數學英語也都同時寫了,但實際上只有我苦逼的一個人,這就是分時併發,但非並行。
總結下就是並行一定併發,併發未必並行。
關於 cpu 排程程式的策略,cpu 執行程式碼的細節,如果有興趣可以留言,後續有時間可以安排,這裡就不展開了
Node 之執行緒
Node 單執行緒
學習 Node 的第一天就看到過 Node 是個單程式單執行緒模型,他執行緒安全。嗯確實是執行緒安全。。但在後端同學看來就如同一個單身狗在說我是不會迷失在愛情裡的,廢話因為你本來就沒有。。
如我們上面所講,單執行緒再怎麼秀,也只能在一個 cpu 上花裡胡哨,對於我們要對標全棧的 Node 必然是不能接受。
Node 多程式模型
既然一個 Node 程式只能有一個執行緒,那想通過單程式多執行緒的姿勢來壓榨 cpu(類似於 Java)應該是黃了,但 Node 支援多程式模型。
Node 提供了 child_process 模組,通過 child_process.fork()函式來進行程式的複製。
如下圖,master 呼叫 child_process.fork 程式,被 fork 出的程式為 worker。
child_process 模組給予 Node 建立子程式的能力,父程式與子程式之間是一種 master/worker 的工作模式。
這種模式在分散式系統中隨處可見,但高手總是能撒豆成兵,Node 在單機上對父子程式採用了這種管理模式,這種模式很像經典的 reactor 模式(只是 reactor 是主執行緒),利用父程式來做主程式,並且將任務 dispatch 到 worker 程式。
通常會阻塞的操作分發給 worker 來執行(查 db,讀檔案,程式耗時的計算等等),master 上儘量編寫非阻塞的程式碼。
Node 多程式通訊
既然提到了主從程式,那避免不了的一個問題就是他們之間的通訊。
程式通訊的姿勢很多,例如基於 socket,基於管道,基於 mmap 記憶體對映等等,這裡我們主要討論Node 的通訊,這裡和大家先簡單的講解兩個概念:檔案描述符、管道。
檔案描述符是作業系統用來做檔案管理的一個概念,如上圖所示,每個程式會有一個自己的檔案描述符表,裡面包含了檔案描述符標誌和檔案指標,每個程式自己的表都是從 0 開始,然後由檔案指標來指向同一個系統級的開啟檔案表,開啟檔案表裡面會記錄檔案偏移量(這個檔案被讀寫到了哪個位置)、inode 指標。
再由 inode 指標來指向系統級的 inode 表,inode 表就是真正維護作業系統檔案本身的一個實體了,裡面包含了檔案型別,大小,create time 等等~
其實系統中的檔案描述符不一定是指向一個磁碟檔案,也可以能是指向一個網路的 socket 這種,站在Linux的角度上來說,作業系統把一切都抽象為檔案,網路資料,磁碟資料等等,都是用檔案描述符來做維護。
講了檔案描述符,我們可以大致感知到程式要讀東西,一定需要一個媒介,那我們父子程式之間的通訊也一定需要一個介質來通訊。
接下來我們丟擲管道的概念,如同其名字,管道一定是用來連通兩個東西的,就像家裡的水管,一個入口,一個出口。
我們來分析一下兩個程式是如何建立起來通訊的。
之前提到了程式會有自己的檔案描述符表,我們在 fork 程式的時候父程式也會把自己的檔案描述符拷貝給子程式。我們來看一段比較拙劣的 C 程式碼。(還記得大學剛開始學 C 時,指標帶給你的困擾嘛)
我們分析一下上面程式碼,小夥伴們不必在意 C 的語法哈~,只需關注管道的建立過程
我們一開始呼叫 pipe(fd),傳人的是一個 size 是 2 的空陣列,如果建立成功,這個陣列的 fd[0]就是讀所用的檔案描述符,fd[1]就是寫所用的檔案描述符。
這個時候,我們在當前程式呼叫 vfork(),create 出一個子程式,父子程式都持有這個 fd[]。
如果我們判斷是子程式,就關閉他的讀檔案描述符,如果是父程式,就關閉他的寫檔案描述符。
這時,如下圖所示,我們會實現一個單向通訊,作業系統呼叫 pipe(建立管道)的時候,會新建一片記憶體空間,這片記憶體專用與兩個程式通訊,這應證了我們上面所說的,系統會把很多東西抽象成檔案,比如這裡就是把那一片共用記憶體抽象了起來,之後子程式通過 fd[1],往那片記憶體區域寫入資料,父程式通過 fd[0]來讀,這裡就實現了一個單工通訊。
或許上面講的有點晦澀,我們來舉一個不完全恰當的栗子,你住長江頭,妹子住長江尾,河流就像你們之間的管道,你想跟她之間有所交流咋整?只需寫一封信,順著江流流下去(write),她在那邊接收就行(read)。你們之間就是一個單向的管道通訊。
但單向肯定是不行的,如何實現一個雙工通訊呢,很簡單,用兩個管道就 OK 了。
如果上面的解釋還沒看懂,請結合下面的圖,再去理解一下,或者加群@接水怪,為你提供一對一私人服務!!!
接下來我們回到最初的起點,Node 之間的程式如何通訊,其實也不過如此。Node 自己抽象了一個 libuv 的概念,根據不同作業系統有不同的底層實現,我們上面講到的雙工管道通訊就是其中一種。
極致的優化 — Node 控制程式碼傳遞
要真正理解服務端為何能承受高併發,理解當前服務架構的核心,需要從網路到作業系統的每一個細節進行理解。
上面聊了一系列比較晦澀的裝逼話題,接下來我們聊點相對實際的。我們寫出來服務端是為了什麼?
目的自然是讓別人來呼叫,想想我們平時呼叫服務的方式,最簡單的就是我們的 http,用瀏覽器發起小電影請求,小電影服務端接收到並返回結果,然後開始一個個不眠的夜晚。
我們的請求本質就是去訪問小電影伺服器,伺服器對應的埠收到了請求然後做相應處理並且返回結果。看小電影最不能接受的就是卡頓,比如說看建黨偉業的時候,在下因為在聽xxx宣言的時候卡住了捶胸頓足了好久,hhhh~~。
那服務端如何能不卡?上面我們的多程式如何用起來?
上圖是一種可以實現的架構,由 master 監聽預設的 80 埠,使用者的請求都打在 80 上,其他子程式監聽一個別的埠,當父程式收到後往子程式監聽的埠寫資料,子程式來做處理。
這裡看似可以實現,實則浪費了太多檔案描述符,上面講到了每個程式都有檔案描述符表,而每個 socket 的讀寫也是基於檔案描述符,作業系統的檔案描述符是有限的,這樣的設計顯然不夠優雅,擴充性不強。
這個時候有小夥伴會問,為什麼不直接讓每個程式都去監聽 80,幹嘛還要轉一次。這個思路很 OK。
But,最終會發現直接的監聽最後只會有一個程式搶佔埠成功,其他程式會丟擲埠被佔用的異常。為了解決這個問題,Node 用了另外一種架構模式。如下圖。
一開始依然是 master 程式監聽 80,當收到使用者請求之後,master 並不是直接把這些資料扔給 worker,而是在 80 埠接收到資料後,生成對應的 socket,再把該 socket 對應的檔案描述符通過管道傳給 worker,一個 socket 意味著服務端和客戶端的一個資料通道,也就意味著 master 把跟客戶端的資料通道傳給了 worker。
如下圖,在之後 master 停止監聽 80port,因為已經把檔案描述符給了 worker,之後 worker 直接監聽這個套接字即可。
於是就有了下面那種模式,多個 worker 直接監聽同一個 port。
這個時候小夥伴們可能很疑惑,為啥這個時候不會埠衝突??
這裡的關鍵在於兩個點。
第一個是,Node 對每個埠監聽設定了SO_REUSEADRR,標示可以允許這個埠被多個程式監聽。
第二個點是,用這個的前提是每個監聽這個埠的程式,監聽的檔案描述符要相同。
之前講檔案描述符的時候提到過,檔案描述符表是每個程式私有的,相互之間不可見,那對這個埠他們也會有各自的檔案描述符,這樣就無法利用 SO_REUSEADRR 的特性。
那為什麼通過 master 傳給 worker 就可以了呢?
因為 master 在與 worker 通訊的時候,每個子程式收到的檔案描述符都是一樣的(通過 master 傳入,不理解的參見上面雙工通訊的講解),這個時候就是所有子程式監聽相同的 socket 檔案描述符,就可以實現多個程式監聽同一個埠的目標啦~。
總結
本文已收錄 Github https://github.com/ponkans/F2E,歡迎 Star,持續更新?
Node 利用 master/worker 模式來利用多核資源,利用 SO_REUSEADRR 與控制程式碼(檔案描述符)傳遞來使多個程式同時監聽同一個埠,提高吞吐量。
對程式、執行緒、cpu 有認知是最基本的,這樣寫專案才能對自己的每一行程式碼瞭然於心。
本文僅算是入門貼,真正的 Node 核心有待大家一一深入學習,如果對某一塊有特別的興趣可以在下面留言,直接加群來討論,怪怪我等你!~
近期會針對 Node.js 寫一個系列,同系列傳送門:
喜歡的小夥伴加個關注,點個贊哦,感恩??
聯絡我 / 公眾號
微信搜尋【接水怪】或掃描下面二維碼回覆”加群“,我會拉你進技術交流群。講真的,在這個群,哪怕您不說話,光看聊天記錄也是一種成長。(阿里技術專家、敖丙作者、Java3y、蘑菇街資深前端、螞蟻金服安全專家、各路大牛都在)。
接水怪也會定期原創,定期跟小夥伴進行經驗交流或幫忙看簡歷。加關注,不迷路,有機會一起跑個步? ↓↓↓