說(shuō)簡(jiǎn)單也簡(jiǎn)單,不就是一個(gè)用戶請(qǐng)求嗎?服務(wù)器根據(jù)請(qǐng)求從數(shù)據(jù)庫(kù)中撈出這篇文章,然后通過(guò)網(wǎng)絡(luò)發(fā)回去。
說(shuō)復(fù)雜也復(fù)雜,服務(wù)器是如何并行處理成千上萬(wàn)個(gè)用戶請(qǐng)求呢?這里面涉及到哪些技術(shù)呢?
這篇文章就來(lái)為你解答這個(gè)問(wèn)題。
多進(jìn)程
歷史上最早出現(xiàn)也是最簡(jiǎn)單的一種并行處理多個(gè)請(qǐng)求的方法就是利用多進(jìn)程。
比如在Linux世界中,我們可以使用fork、exec等系統(tǒng)調(diào)用創(chuàng)建多個(gè)進(jìn)程,我們可以在父進(jìn)程中接收用戶的連接請(qǐng)求,然后創(chuàng)建子進(jìn)程去處理用戶請(qǐng)求,就像這樣:
這種方法的優(yōu)點(diǎn)就在于:
- 編程簡(jiǎn)單,非常容易理解
- 由于各個(gè)進(jìn)程的地址空間是相互隔離的,因此一個(gè)進(jìn)程崩潰后并不會(huì)影響其它進(jìn)程
- 充分利用多核資源
多進(jìn)程并行處理的優(yōu)點(diǎn)很明顯,但是缺點(diǎn)同樣明顯:
- 各個(gè)進(jìn)程地址空間相互隔離,這一優(yōu)點(diǎn)也會(huì)變成缺點(diǎn),那就是進(jìn)程間要想通信就會(huì)變得比較困難,你需要借助進(jìn)程間通信(IPC,interprocess communications)機(jī)制,想一想你現(xiàn)在知道哪些進(jìn)程間通信機(jī)制,然后讓你用代碼實(shí)現(xiàn)呢?顯然,進(jìn)程間通信編程相對(duì)復(fù)雜,而且性能也是一大問(wèn)題
- 我們知道創(chuàng)建進(jìn)程開銷是比線程要大的,頻繁的創(chuàng)建銷毀進(jìn)程無(wú)疑會(huì)加重系統(tǒng)負(fù)擔(dān)。
幸好,除了進(jìn)程,我們還有線程。
多線程
不是創(chuàng)建進(jìn)程開銷大嗎?不是進(jìn)程間通信困難嗎?這些對(duì)于線程來(lái)說(shuō)統(tǒng)統(tǒng)不是問(wèn)題。
什么?你還不了解線程,趕緊看看這篇《看完這篇還不懂線程與線程池你來(lái)打我》,這里詳細(xì)講解了線程這個(gè)概念是怎么來(lái)的。
由于線程共享進(jìn)程地址空間,因此線程間通信天然不需要借助任何通信機(jī)制,直接讀取內(nèi)存就好了。
線程創(chuàng)建銷毀的開銷也變小了,要知道線程就像寄居蟹一樣,房子(地址空間)都是進(jìn)程的,自己只是一個(gè)租客,因此非常的輕量級(jí),創(chuàng)建銷毀的開銷也非常小。
我們可以為每個(gè)請(qǐng)求創(chuàng)建一個(gè)線程,即使一個(gè)線程因執(zhí)行I/O操作——比如讀取數(shù)據(jù)庫(kù)等——被阻塞暫停運(yùn)行也不會(huì)影響到其它線程,就像這樣:
但線程就是完美的、包治百病的嗎,顯然,計(jì)算機(jī)世界從來(lái)沒(méi)有那么簡(jiǎn)單。
由于線程共享進(jìn)程地址空間,這在為線程間通信帶來(lái)便利的同時(shí)也帶來(lái)了無(wú)盡的麻煩。
正是由于線程間共享地址空間,因此一個(gè)線程崩潰會(huì)導(dǎo)致整個(gè)進(jìn)程崩潰退出,同時(shí)線程間通信簡(jiǎn)直太簡(jiǎn)單了,簡(jiǎn)單到線程間通信只需要直接讀取內(nèi)存就可以了,也簡(jiǎn)單到出現(xiàn)問(wèn)題也極其容易,死鎖、線程間的同步互斥、等等,這些極容易產(chǎn)生bug,無(wú)數(shù)程序員寶貴的時(shí)間就有相當(dāng)一部分用來(lái)解決多線程帶來(lái)的無(wú)盡問(wèn)題。
雖然線程也有缺點(diǎn),但是相比多進(jìn)程來(lái)說(shuō),線程更有優(yōu)勢(shì),但想單純的利用多線程就能解決高并發(fā)問(wèn)題也是不切實(shí)際的。
因?yàn)殡m然線程創(chuàng)建開銷相比進(jìn)程小,但依然也是有開銷的,對(duì)于動(dòng)輒數(shù)萬(wàn)數(shù)十萬(wàn)的鏈接的高并發(fā)服務(wù)器來(lái)說(shuō),創(chuàng)建數(shù)萬(wàn)個(gè)線程會(huì)有性能問(wèn)題,這包括內(nèi)存占用、線程間切換,也就是調(diào)度的開銷。
因此,我們需要進(jìn)一步思考。
Event Loop:事件驅(qū)動(dòng)
到目前為止,我們提到“并行”二字就會(huì)想到進(jìn)程、線程。但是,并行編程只能依賴這兩項(xiàng)技術(shù)嗎,并不是這樣的。
還有另一項(xiàng)并行技術(shù)廣泛應(yīng)用在GUI編程以及服務(wù)器編程中,這就是近幾年非常流行的事件驅(qū)動(dòng)編程,event-based concurrency。
大家不要覺(jué)得這是一項(xiàng)很難懂的技術(shù),實(shí)際上事件驅(qū)動(dòng)編程原理上非常簡(jiǎn)單。
這一技術(shù)需要兩種原料:
- event
- 處理event的函數(shù),這一函數(shù)通常被稱為event handler
剩下的就簡(jiǎn)單了:
你只需要安靜的等待event到來(lái)就好,當(dāng)event到來(lái)之后,檢查一下event的類型,并根據(jù)該類型找到對(duì)應(yīng)的event處理函數(shù),也就是event handler,然后直接調(diào)用該event handler就好了。
That's it !
以上就是事件驅(qū)動(dòng)編程的全部?jī)?nèi)容,是不是很簡(jiǎn)單!
從上面的討論可以看到,我們需要不斷的接收event然后處理event,因此我們需要一個(gè)循環(huán)(用while或者for循環(huán)都可以),這個(gè)循環(huán)被稱為Event loop。
使用偽代碼表示就是這樣:
- while(true) {
- event = getEvent();
- handler(event);
- }
Event loop中要做的事情其實(shí)是非常簡(jiǎn)單的,只需要等待event的帶來(lái),然后調(diào)用相應(yīng)的event處理函數(shù)即可。
注意,這段代碼只需要運(yùn)行在一個(gè)線程或者進(jìn)程中,只需要這一個(gè)event loop就可以同時(shí)處理多個(gè)用戶請(qǐng)求。
有的同學(xué)可以依然不明白為什么這樣一個(gè)event loop可以同時(shí)處理多個(gè)請(qǐng)求呢?
原因很簡(jiǎn)單,對(duì)于web服務(wù)器來(lái)說(shuō),處理一個(gè)用戶請(qǐng)求時(shí)大部分時(shí)間其實(shí)都用在了I/O操作上,像數(shù)據(jù)庫(kù)讀寫、文件讀寫、網(wǎng)絡(luò)讀寫等。當(dāng)一個(gè)請(qǐng)求到來(lái),簡(jiǎn)單處理之后可能就需要查詢數(shù)據(jù)庫(kù)等I/O操作,我們知道I/O是非常慢的,當(dāng)發(fā)起I/O后我們大可以不用等待該I/O操作完成就可以繼續(xù)處理接下來(lái)的用戶請(qǐng)求。
現(xiàn)在你應(yīng)該明白了吧,雖然上一個(gè)用戶請(qǐng)求還沒(méi)有處理完我們其實(shí)就可以處理下一個(gè)用戶請(qǐng)求了,這也是并行,這種并行就可以用事件驅(qū)動(dòng)編程來(lái)處理。
這就好比餐廳服務(wù)員一樣,一個(gè)服務(wù)員不可能一直等上一個(gè)顧客下單、上菜、吃飯、買單之后才接待下一個(gè)顧客,服務(wù)員是怎么做的呢?當(dāng)一個(gè)顧客下完單后直接處理下一個(gè)顧客,當(dāng)顧客吃完飯后會(huì)自己回來(lái)買單結(jié)賬的。
看到了吧,同樣是一個(gè)服務(wù)員也可以同時(shí)處理多個(gè)顧客,這個(gè)服務(wù)員就相當(dāng)于這里的Event loop,即使這個(gè)event loop只運(yùn)行在一個(gè)線程(進(jìn)程)中也可以同時(shí)處理多個(gè)用戶請(qǐng)求。
相信你已經(jīng)對(duì)事件驅(qū)動(dòng)編程有一個(gè)清晰的認(rèn)知了,那么接下來(lái)的問(wèn)題就是事件驅(qū)動(dòng)、事件驅(qū)動(dòng),那么這個(gè)事件也就是event該怎么獲取呢?
事件來(lái)源:IO多路復(fù)用
在《終于明白了,一文徹底理解I/O多路復(fù)用》這篇文章中我們知道,在Linux/Unix世界中一切皆文件,而我們的程序都是通過(guò)文件描述符來(lái)進(jìn)行I/O操作的,當(dāng)然對(duì)于socket也不例外,那我們?cè)撊绾瓮瑫r(shí)處理多個(gè)文件描述符呢?
IO多路復(fù)用技術(shù)正是用來(lái)解決這一問(wèn)題的,通過(guò)IO多路復(fù)用技術(shù),我們一次可以監(jiān)控多個(gè)文件描述,當(dāng)某個(gè)文件(socket)可讀或者可寫的時(shí)候我們就能得到通知啦。
這樣IO多路復(fù)用技術(shù)就成了event loop的原材料供應(yīng)商,源源不斷的給我們提供各種event,這樣關(guān)于event來(lái)源的問(wèn)題就解決了。
當(dāng)然關(guān)于IO多路復(fù)用技術(shù)的詳細(xì)講解請(qǐng)參見《終于明白了,一文徹底理解I/O多路復(fù)用》。
至此,關(guān)于利用事件驅(qū)動(dòng)來(lái)實(shí)現(xiàn)并發(fā)編程的所有問(wèn)題都解決了嗎?event的來(lái)源問(wèn)題解決了,當(dāng)?shù)玫絜vent后調(diào)用相應(yīng)的handler,看上去大功告成了。
想一想還有沒(méi)有其它問(wèn)題?
問(wèn)題:阻塞式IO
現(xiàn)在,我們可以使用一個(gè)線程(進(jìn)程)就能基于事件驅(qū)動(dòng)進(jìn)行并行編程,再也沒(méi)有了多線程中讓人惱火的各種鎖、同步互斥、死鎖等問(wèn)題了。
但是,計(jì)算機(jī)科學(xué)中從來(lái)沒(méi)有出現(xiàn)過(guò)一種能解決所有問(wèn)題的技術(shù),現(xiàn)在沒(méi)有,在可預(yù)期的將來(lái)也不會(huì)有。
那上述方法有什么問(wèn)題嗎?
不要忘了,我們event loop是運(yùn)行在一個(gè)線程(進(jìn)程),這雖然解決了多線程問(wèn)題,但是如果在處理某個(gè)event時(shí)需要進(jìn)行IO操作會(huì)怎么樣呢?
在《讀取文件時(shí),程序經(jīng)歷了什么》一文中,我們講解了最常用的文件讀取在底層是如何實(shí)現(xiàn)的,程序員最常用的這種IO方式被稱為阻塞式IO,也就是說(shuō),當(dāng)我們進(jìn)行IO操作,比如讀取文件時(shí),如果文件沒(méi)有讀取完成,那么我們的程序(線程)會(huì)被阻塞而暫停執(zhí)行,這在多線程中不是問(wèn)題,因?yàn)椴僮飨到y(tǒng)還可以調(diào)度其它線程。
但是在單線程的event loop中是有問(wèn)題的,原因就在于當(dāng)我們?cè)趀vent loop中執(zhí)行阻塞式IO操作時(shí)整個(gè)線程(event loop)會(huì)被暫停運(yùn)行,這時(shí)操作系統(tǒng)將沒(méi)有其它線程可以調(diào)度,因?yàn)橄到y(tǒng)中只有一個(gè)event loop在處理用戶請(qǐng)求,這樣當(dāng)event loop線程被阻塞暫停運(yùn)行時(shí)所有用戶請(qǐng)求都沒(méi)有辦法被處理,你能想象當(dāng)服務(wù)器在處理其它用戶請(qǐng)求讀取數(shù)據(jù)庫(kù)導(dǎo)致你的請(qǐng)求被暫停嗎?
因此,在基于事件驅(qū)動(dòng)編程時(shí)有一條注意事項(xiàng),那就是不允許發(fā)起阻塞式IO。
有的同學(xué)可能會(huì)問(wèn),如果不能發(fā)起阻塞式IO的話,那么該怎樣進(jìn)行IO操作呢?
有阻塞式IO,就有非阻塞式IO。
非阻塞IO
為克服阻塞式IO所帶來(lái)的問(wèn)題,現(xiàn)代操作系統(tǒng)開始提供一種新的發(fā)起IO請(qǐng)求的方法,這種方法就是異步IO,對(duì)應(yīng)的,阻塞式IO就是同步IO,關(guān)于同步和異步這兩個(gè)概念可以參考《從小白到高手,你需要理解同步與異步》。
異步IO時(shí),假設(shè)調(diào)用aio_read函數(shù)(具體的異步IO API請(qǐng)參考具體的操作系統(tǒng)平臺(tái)),也就是異步讀取,當(dāng)我們調(diào)用該函數(shù)后可以立即返回,并繼續(xù)其它事情,雖然此時(shí)該文件可能還沒(méi)有被讀取,這樣就不會(huì)阻塞調(diào)用線程了。此外,操作系統(tǒng)還會(huì)提供其它方法供調(diào)用線程來(lái)檢測(cè)IO操作是否完成。
就這樣,在操作系統(tǒng)的幫助下IO的阻塞調(diào)用問(wèn)題也解決了。
基于事件編程的難點(diǎn)
雖然有異步IO來(lái)解決event loop可能被阻塞的問(wèn)題,但是基于事件編程依然是困難的。
首先,我們提到,event loop是運(yùn)行在一個(gè)線程中的,顯然一個(gè)線程是沒(méi)有辦法充分利用多核資源的,有的同學(xué)可能會(huì)說(shuō)那就創(chuàng)建多個(gè)event loop實(shí)例不就可以了,這樣就有多個(gè)event loop線程了,但是這樣一來(lái)多線程問(wèn)題又會(huì)出現(xiàn)。
另一點(diǎn)在于編程方面,在《從小白到高手,你需要理解同步與異步》這篇文章中我們講到過(guò),異步編程需要結(jié)合回調(diào)函數(shù)(關(guān)于回調(diào)函數(shù)請(qǐng)才參考《程序員應(yīng)如何徹底理解回調(diào)函數(shù)》),這種編程方式需要把處理邏輯分為兩部分,一部分調(diào)用方自己處理,另一部分在回調(diào)函數(shù)中處理,這一編程方式的改變加重了程序員在理解上的負(fù)擔(dān),基于事件編程的項(xiàng)目后期會(huì)很難擴(kuò)展以及維護(hù)。
那么有沒(méi)有更好的方法呢?
要找到更好的方法,我們需要解決問(wèn)題的本質(zhì),那么這個(gè)本質(zhì)問(wèn)題是什么呢?
更好的方法
為什么我們要使用異步這種難以理解的方式編程呢?
是因?yàn)樽枞骄幊屉m然容易理解但會(huì)導(dǎo)致線程被阻塞而暫停運(yùn)行。
那么聰明的你一定會(huì)問(wèn)了,有沒(méi)有一種方法既能結(jié)合同步IO的簡(jiǎn)單理解又不會(huì)因同步調(diào)用導(dǎo)致線程被阻塞呢?
答案是肯定的,這就是用戶態(tài)線程,user level thread,也就是大名鼎鼎的協(xié)程,關(guān)于協(xié)程值得單獨(dú)拿出一篇文章來(lái)講解,就在下一篇。
雖然基于事件編程有這樣那樣的缺點(diǎn),但是在當(dāng)今的高性能高并發(fā)服務(wù)器上基于事件編程方式依然非常流行,但已經(jīng)不是純粹的基于單一線程的事件驅(qū)動(dòng)了,而是event loop + multi thread + user level thread。
關(guān)于這一組合,同樣值得拿出一篇文章來(lái)講解,我們將在后續(xù)文章中詳細(xì)討論。
總結(jié)
高并發(fā)技術(shù)從最開始的多進(jìn)程一路演進(jìn)到當(dāng)前的事件驅(qū)動(dòng),計(jì)算機(jī)技術(shù)就像生物一樣也在不斷演變進(jìn)化,但不管怎樣,了解歷史才能更深刻的理解當(dāng)下。希望這篇文章能對(duì)大家理解高并發(fā)服務(wù)器有所幫助。