當在讀這篇文章的時候,你有沒有想過,服務器是怎么把這篇文章發送給你的呢?
說簡單也簡單,不就是一個用戶請求嗎?服務器根據請求從資料庫中撈出這篇文章,然后通過網路發回去,
說復雜也復雜,服務器是如何并行處理成千上萬個用戶請求呢?這里面涉及到哪些技術呢?
這篇文章就來為你解答這個問題,
多行程
歷史上最早出現也是最簡單的一種并行處理多個請求的方法就是利用多行程,
比如在Linux世界中,我們可以使用fork、exec等系統呼叫創建多個行程,我們可以在父行程中接收用戶的連接請求,然后創建子行程去處理用戶請求,就像這樣:
這種方法的優點就在于:
編程簡單,非常容易理解
由于各個行程的地址空間是相互隔離的,因此一個行程崩潰后并不會影響其它行程
充分利用多核資源
多行程并行處理的優點很明顯,但是缺點同樣明顯:
各個行程地址空間相互隔離,這一優點也會變成缺點,那就是行程間要想通信就會變得比較困難,你需要借助行程間通信(IPC,interprocess communications)機制,想一想你現在知道哪些行程間通信機制,然后讓你用代碼實作呢?顯然,行程間通信編程相對復雜,而且性能也是一大問題
我們知道創建行程開銷是比執行緒要大的,頻繁的創建銷毀行程無疑會加重系統負擔,
幸好,除了行程,我們還有執行緒,
多執行緒
不是創建行程開銷大嗎?不是行程間通信困難嗎?這些對于執行緒來說統統不是問題,
什么?你還不了解執行緒,趕緊看看這篇《看完這篇還不懂執行緒與執行緒池你來打我》,這里詳細講解了執行緒這個概念是怎么來的,
由于執行緒共享行程地址空間,因此執行緒間通信天然不需要借助任何通信機制,直接讀取記憶體就好了,
執行緒創建銷毀的開銷也變小了,要知道執行緒就像寄居蟹一樣,房子(地址空間)都是行程的,自己只是一個租客,因此非常的輕量級,創建銷毀的開銷也非常小,
我們可以為每個請求創建一個執行緒,即使一個執行緒因執行I/O操作——比如讀取資料庫等——被阻塞暫停運行也不會影響到其它執行緒,就像這樣:
但執行緒就是完美的、包治百病的嗎,顯然,計算機世界從來沒有那么簡單,
由于執行緒共享行程地址空間,這在為執行緒間通信帶來便利的同時也帶來了無盡的麻煩,
正是由于執行緒間共享地址空間,因此一個執行緒崩潰會導致整個行程崩潰退出,同時執行緒間通信簡直太簡單了,簡單到執行緒間通信只需要直接讀取記憶體就可以了,也簡單到出現問題也極其容易,死鎖、執行緒間的同步互斥、等等,這些極容易產生bug,無數程式員寶貴的時間就有相當一部分用來解決多執行緒帶來的無盡問題,
雖然執行緒也有缺點,但是相比多行程來說,執行緒更有優勢,但想單純的利用多執行緒就能解決高并發問題也是不切實際的,
因為雖然執行緒創建開銷相比行程小,但依然也是有開銷的,對于動輒數萬數十萬的鏈接的高并發服務器來說,創建數萬個執行緒會有性能問題,這包括記憶體占用、執行緒間切換,也就是調度的開銷,
因此,我們需要進一步思考,
Event Loop:事件驅動
到目前為止,我們提到“并行”二字就會想到行程、執行緒,但是,并行編程只能依賴這兩項技術嗎,并不是這樣的,
還有另一項并行技術廣泛應用在GUI編程以及服務器編程中,這就是近幾年非常流行的事件驅動編程,event-based concurrency,
大家不要覺得這是一項很難懂的技術,實際上事件驅動編程原理上非常簡單,
這一技術需要兩種原料:
event
處理event的函式,這一函式通常被稱為event handler
剩下的就簡單了:
你只需要安靜的等待event到來就好,當event到來之后,檢查一下event的型別,并根據該型別找到對應的event處理函式,也就是event handler,然后直接呼叫該event handler就好了,
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操作完成就可以繼續處理接下來的用戶請求,
現在你應該明白了吧,雖然上一個用戶請求還沒有處理完我們其實就可以處理下一個用戶請求了,這也是并行,這種并行就可以用事件驅動編程來處理,
這就好比餐廳服務員一樣,一個服務員不可能一直等上一個顧客下單、上菜、吃飯、買單之后才接待下一個顧客,服務員是怎么做的呢?當一個顧客下完單后直接處理下一個顧客,當顧客吃完飯后會自己回來買單結賬的,
看到了吧,同樣是一個服務員也可以同時處理多個顧客,這個服務員就相當于這里的Event loop,即使這個event loop只運行在一個執行緒(行程)中也可以同時處理多個用戶請求,
相信你已經對事件驅動編程有一個清晰的認知了,那么接下來的問題就是事件驅動、事件驅動,那么這個事件也就是event該怎么獲取呢?
事件來源:IO多路復用
在《終于明白了,一文徹底理解I/O多路復用》這篇文章中我們知道,在Linux/Unix世界中一切皆檔案,而我們的程式都是通過檔案描述符來進行I/O操作的,當然對于socket也不例外,那我們該如何同時處理多個檔案描述符呢?
IO多路復用技術正是用來解決這一問題的,通過IO多路復用技術,我們一次可以監控多個檔案描述,當某個檔案(socket)可讀或者可寫的時候我們就能得到通知啦,
這樣IO多路復用技術就成了event loop的原材料供應商,源源不斷的給我們提供各種event,這樣關于event來源的問題就解決了,
當然關于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執行緒被阻塞暫停運行時所有用戶請求都沒有辦法被處理,你能想象當服務器在處理其它用戶請求讀取資料庫導致你的請求被暫停嗎?
因此,在基于事件驅動編程時有一條注意事項,那就是不允許發起阻塞式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的簡單理解又不會因同步呼叫導致執行緒被阻塞呢?
答案是肯定的,這就是用戶態執行緒,user level thread,也就是大名鼎鼎的協程,關于協程值得單獨拿出一篇文章來講解,就在下一篇,
雖然基于事件編程有這樣那樣的缺點,但是在當今的高性能高并發服務器上基于事件編程方式依然非常流行,但已經不是純粹的基于單一執行緒的事件驅動了,而是event loop + multi thread + user level thread,
關于這一組合,同樣值得拿出一篇文章來講解,我們將在后續文章中詳細討論,
總結
高并發技術從最開始的多行程一路演進到當前的事件驅動,計算機技術就像生物一樣也在不斷演變進化,但不管怎樣,了解歷史才能更深刻的理解當下,希望這篇文章能對大家理解高并發服務器有所幫助,
碼農的荒島求生
往期精選
看完這篇還不懂執行緒與執行緒池你來打我
讀取檔案時,程式經歷了什么?
終于明白了,一文徹底理解I/O多路復用
從小白到高手,你需要理解同步與異步
程式員應如何徹底理解回呼函式
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/242330.html
標籤:AI
