【計算機內功心法】七:高併發高效能伺服器是如何實現的

碼農的荒島求生發表於2021-01-31

當在讀這篇文章的時候,你想過沒有,伺服器是怎麼把這篇文章傳送給你的呢?

說簡單也簡單,不就是一個使用者請求嗎?伺服器根據請求從資料庫中撈出這篇文章,然後通過網路發回去。

說複雜也複雜,伺服器是如何並行處理成千上萬個使用者請求呢?這裡面涉及到哪些技術呢?

這篇文章就來為你解答這個問題。

多程式

歷史上最早出現也是最簡單的一種並處處理多個請求的方法就是利用多程式

比如在Linux世界中,我們可以使用fork、exec等方法建立多個程式,我們可以在父程式中接收使用者的連結請求,然後建立子程式去處理使用者請求,就像這樣:

1604622551783
1604622551783

這種方法的優點就在於:

  1. 程式設計簡單,非常容易理解
  2. 由於各個程式的地址空間是相互隔離的,因此一個程式崩潰後並不會影響其它程式
  3. 充分利用多核資源

多程式並行處理的優點和明顯,但是缺點同樣明顯:

  1. 各個程式地址空間相互隔離,這一優點也會變成缺點,那就是程式間要想通訊就會變得比較困難,你需要藉助程式間通訊(IPC,interprocess communications)機制,想一想你現在知道哪些程式間通訊機制,然後讓你用程式碼實現呢?顯然,程式間通訊程式設計相對複雜,而且效能也是一大問題
  2. 我們知道建立程式開銷是比執行緒要大的,頻繁的建立銷燬程式無疑會加重系統負擔。

幸好,除了程式,我們還有執行緒。

多執行緒

不是建立程式開銷大嗎?不是程式間通訊困難嗎?這些對於執行緒來說統統不是問題。

什麼?你還不瞭解執行緒,趕緊看看這篇《看完這篇還不懂高併發中的執行緒與執行緒池你來打我(內含20張圖)》,這裡詳細講解了執行緒這個概念是怎麼來的。

由於執行緒共享程式地址空間,因此執行緒間通訊天然不需要藉助任何通訊機制,直接讀取記憶體就好了。

執行緒建立銷燬的開銷也變小了,要知道執行緒就像寄居蟹一樣,房子(地址空間)都是程式的,自己只是一個租客,因此非常的輕量級,建立銷燬的開銷也非常小。

110018455_gettyimages-548995833
110018455_gettyimages-548995833

我們可以為每個請求建立一個執行緒,即使一個執行緒因執行I/O操作——比如讀取資料庫等——被阻塞暫停執行也不會影響到其它執行緒,就像這樣:

1604623078665
1604623078665

但執行緒就是完美的、包治百病的嗎,顯然,計算機世界從來沒有那麼簡單。

由於執行緒共享程式地址空間,這在為執行緒間通訊帶來便利的同時也帶來了無盡的麻煩。

正是由於執行緒間共享地址空間,因此一個執行緒崩潰會導致整個程式崩潰退出,同時執行緒間通訊簡直太簡單了,簡單到執行緒間通訊只需要直接讀取記憶體就可以了,也簡單到出現問題也極其容易,死鎖、執行緒間的同步互斥、等等,這些極容易產生bug,無數程式設計師寶貴的時間就有相當一部分用來解決多執行緒帶來的無盡問題。

雖然執行緒也有缺點,但是相比多程式來說,執行緒更有優勢,但想單純的利用多執行緒就能解決高併發問題也是不切實際的。

因為雖然執行緒建立開銷相比程式小,但依然也是有開銷的,對於動輒數萬數十萬的連結的高併發伺服器來說,建立數萬個執行緒會有效能問題,這包括記憶體佔用、執行緒間切換,也就是排程的開銷。

因此,我們需要進一步思考。

Event Loop:事件驅動

到目前為止,我們提到“並行”二字就會想到程式、執行緒。但是,並行程式設計只能依賴這兩項技術嗎,並不是這樣的。

還有另一項並行技術廣泛應用在GUI程式設計以及伺服器程式設計中,這就是近幾年非常流行的事件驅動程式設計,event-based concurrency。

大家不要覺得這是一項很難懂的技術,實際上事件驅動程式設計原理上非常簡單。

這一技術需要兩種原料:

  1. event
  2. 處理event的函式,這一函式通常被稱為event handler

剩下的就簡單了:

你只需要安靜的等待event到來就好,當event到來之後,檢查一下event的型別,並根據該型別找到對應的event處理函式,也就是event handler,然後直接呼叫該event handler就好了。

1604625624789
1604625624789

That's it !

以上就是事件驅動程式設計的全部內容,是不是很簡單!

從上面的討論可以看到,我們需要不斷的接收event然後處理event,因此我們需要一個迴圈(用while或者for迴圈都可以),這個迴圈被稱為Event loop。

使用虛擬碼表示就是這樣:

while(true) {
    event = getEvent();
    handler(event);
}

Event loop中要做的事情其實是非常簡單的,只需要等待event的帶來,然後呼叫相應的event處理函式即可。

注意,這段程式碼只需要執行在一個執行緒或者程式中,只需要這一個event loop就可以同時處理多個使用者請求。

有的同學可以依然不明白為什麼這樣一個event loop可以同時處理多個請求呢?

原因很簡單,對於web伺服器來說,處理一個使用者請求時大部分時間其實都用在了I/O操作上,像資料庫讀寫、檔案讀寫、網路讀寫等。當一個請求到來,簡單處理之後可能就需要查詢資料庫等I/O操作,我們知道I/O是非常慢的,當發起I/O後我們大可以不用等待該I/O操作完成就可以繼續處理接下來的使用者請求

1604638005526
1604638005526

現在你應該明白了吧,雖然上一個使用者請求還沒有處理完我們其實就可以處理下一個使用者請求了,這就是並行,這種並行就可以用事件驅動程式設計來處理。

這就好比餐廳服務員一樣,一個服務員不可能一直等這上一個顧客下單、上菜、吃飯、買單之後才接待下一個顧客,服務員是怎麼做的呢?當一個顧客下完單後直接處理下一個顧客,當顧客吃完飯後會自己回來買單結賬的。

看到了吧,同樣是一個服務員也可以同時處理多個顧客,這個服務員就相當於這裡的Event loop,即使這個event loop只執行在一個執行緒(程式)中也可以同時處理多個使用者請求。

相信你已經對事件驅動程式設計有一個清晰的認知了,那麼接下來的問題就是事件驅動、事件驅動,那麼這個事件也就是event該怎麼獲取呢?

事件來源:IO多路複用

從《終於明白了,一文徹底理解I/O多路複用》這篇文章中我們知道,在Linux/Unix世界中一切皆檔案,而我們的程式都是通過檔案描述符來進行I/O操作的,當然對於socket也不例外,那我們該如何同時處理多個檔案描述符呢?

IO多路複用技術正是用來解決這一問題的,通過IO多路複用技術,我們一次可以監控多個檔案描述,當某個檔案(socket)可讀或者可寫的時候我們就能得到通知啦。

這樣IO多路複用技術就成了event loop的發動機,源源不斷的給我們提供各種event,這樣關於event來源就解決了。

1604626409311
1604626409311

當然關於IO多路複用技術的詳細講解請參見《終於明白了,一文徹底理解I/O多路複用》。

至此,關於利用事件驅動來實現併發程式設計的所有問題都解決了嗎?event的來源問題解決了,當得到event後呼叫相應的handler,看上去大功告成了。
想一想還有沒有其它問題?

問題:阻塞式IO

現在,我們可以使用一個執行緒(程式)就能基於事件驅動進行並行程式設計,再也沒有了多執行緒中讓人惱火的各種鎖、同步互斥、死鎖等問題了。
但是,電腦科學中從來沒有出現過一種能解決所有問題的技術,現在沒有,在可預期的將來也不會有。

那上述方法有什麼問題嗎?

不要忘了,我們event loop是執行在一個執行緒(程式),這雖然解決了多執行緒問題,但是如果在處理某個event時需要進行IO操作會怎麼樣呢?

在《讀取檔案時,程式經歷了什麼》一文中,我們講解了最常用的檔案讀取在底層是如何實現的,程式設計師最常用的這種IO方式被稱為阻塞式IO,也就是說,當我們進行IO操作,比如讀取檔案時,如果檔案沒有讀取完成,那麼我們的程式(執行緒)會被阻塞而暫停執行,這在多執行緒中不是問題,因為作業系統還可以排程其它執行緒。

但是在單執行緒的event loop中是有問題的,原因就在於當我們在event loop中執行阻塞式IO操作時整個執行緒(event loop)會被暫停執行,這時作業系統將沒有其它執行緒可以呼叫,因為系統中只有一個event loop在處理使用者請求,這樣當event loop執行緒被阻塞暫停執行時所有使用者請求都沒有辦法被處理,你能想象當伺服器在處理其它使用者請求讀取資料庫導致你的請求被暫停嗎?

1604637422090
1604637422090

因此,在基於事件驅動程式設計時有一條注意事項,那就是不允許發起阻塞式IO

有的同學可能會問,如果不能發起阻塞式IO的話,那麼該怎樣進行IO操作呢?
有阻塞式IO,就有非阻塞式IO。

非阻塞IO

為克服阻塞式IO所帶來的問題,現代作業系統開始提供一種新的發起IO請求的方法,這種方法就是非同步IO,對於的,阻塞式IO就是同步IO,關於同步和非同步這兩個概念參考《從小白到高手,你需要理解同步與非同步》。

非同步IO時,假設呼叫aio_read函式(具體的非同步IO API請參考具體的作業系統平臺),也就是非同步讀取,當我們呼叫該函式後可以立即返回,並繼續其它事情,雖然此時該檔案可能還沒有被讀取,這樣就不會阻塞呼叫執行緒了。此外,作業系統還會提供其它方法供呼叫執行緒來檢測IO操作是否完成。

就這樣,在作業系統的幫助下IO的阻塞呼叫問題也解決了。

基於事件程式設計的難點

雖然有非同步IO來解決event loop可能被阻塞的問題,但是基於事件程式設計依然是困難的。

首先,我們提到,event loop是執行在一個執行緒中的,顯然一個執行緒是沒有辦法充分利用多核資源的,有的同學可能會說那就建立多個event loop例項不就可以了,這樣就有多個event loop執行緒了,但是這樣一來多執行緒問題又會出現。

另一點在於程式設計方面,在《從小白到高手,你需要理解同步與非同步》這篇文章中我們講到過,非同步程式設計需要結合回撥函式,這種程式設計方式需要把處理邏輯分為兩部分,一部分呼叫方自己處理,另一部分在回撥函式中處理,這一程式設計方式的改變加重了程式設計師在理解上的負擔,基於事件程式設計的專案後期會很難擴充套件以及維護。

那麼有沒有更好的方法呢?

要找到更好的方法,我們需要解決問題的本質,那麼這個本質問題是什麼呢?

更好的方法

為什麼我們要使用非同步這種難以理解的方式程式設計呢?

是因為阻塞式程式設計雖然容易理解但會導致執行緒被阻塞而暫停執行。

那麼聰明的你一定會問了,有沒有一種方法既能結合同步IO的簡單理解又不會因同步呼叫導致執行緒被阻塞呢?

雖然基於事件程式設計有這樣那樣的缺點,但是在當今的高效能高併發伺服器上基於事件程式設計方式依然非常流行,但已經不是純粹的基於單一執行緒的事件驅動了,而是event loop + multi thread + user level thread。
關於這一組合,同樣值得拿出一篇文章來講解,我們將在後續文章中詳細討論。

總結

高併發技術從最開始的多程式一路演進到當前的事件驅動,計算機技術就像生物一樣也在不斷演變進化,但不管怎樣,瞭解歷史才能更深刻的理解當下。希望這篇文章能對大家理解高併發伺服器有所幫助。

相關文章