在傳統的blockIO中,一個TCP連接的可讀事件與用戶的實際讀取操作是糅合在一起的。用戶想要讀取數據只需要調用read系統調用,之後當前線程會阻塞在這裡直到當前連接的讀緩衝區有數據可讀,此時作業系統會調度讓此線程退出阻塞狀態繼續執行,在用戶態我們就可以實現讀取數據的操作了。
此時的讀取操作都是阻塞的,不過因為用戶直接用一個線程來實現一個連接的讀寫,當前線程的阻塞並不會對其它的連接產生影響,所以阻塞也無所謂。
後來因為C10k問題,也就是如何讓一個作業系統能同時維護10k個連接的問題的提出,傳統的blockIO模型已經不能符合人們此時的需求。因為在傳統的IO模型中,TCP連接的可讀可寫事件以及讀寫操作本身都被實現在READ和WRITE系統調用中,而讀寫事件本身會阻塞當前線程,這也就導致了我們必須要給每一個讀寫操作分配一個單獨的線程。
因為傳統IO需要一個連接對應著一個線程,所以當連接數過多時線程數也很多,現代作業系統在線程過多時運行效率會明顯降低,這主要是因為兩個原因
- 作業系統需要為每一個線程存儲一些meta信息(包括線程的當前上下文狀態等等),當線程數過多時,會對內存造成一定的壓力。
- 作業系統的線程切換操作是十分耗費系統資源的,當線程數過多時,線程切換的頻率大大增加,線程切換次數變多會導致整個系統的吞吐量降低,因而會影響用戶程序的執行效率。
現代作業系統使用多路復用和非阻塞來解決C10K問題,它們的核心在於細化了對連接的管理方式。以前我們管理連接只能使用READ和WRITE,然後無腦開個線程,讓作業系統來幫助我們管理連接的可讀可寫事件。為了解決這些問題,我們開始需要自己管理連接的讀寫事件。
首先我們把連接的操作進行拆分,不再像以前那樣READ和WRITE一把梭,而是把 可讀可寫 和 讀寫操作本身 進行拆分。我們之前說過,我們之所以要給每一個連接創建一個線程,是為了能讓作業系統幫助我們管理每一個連接的讀寫事件。
多路復用
我們把一個連接的可讀可寫事件剝離出來,使用單獨的線程來對其進行管理,這裡的關鍵點在於此線程不僅可以管理一個線程的可讀可寫事件,事實上這個線程中我們可以管理多個連接的可讀可寫事件,這個線程中實現的操作就叫 多路復用 ,多路復用需要作業系統提供相應的syscall才可以使用。
有了多路復用器,連接與線程之間的緊密聯繫被拆開。不再需要太多的線程,我們可以在僅一個線程中就維護著數百萬個連接的讀寫事件,C10k問題被解決。
非阻塞
多路復用解決的是維護大量TCP連接的狀態以及它們的可讀可寫事件的問題,這是我們在連接的可讀可寫事件上進行的優化,接下來我們需要對連接數據的讀寫操作本身進行優化。
一般來說,在從多路復用器得到了一個連接可讀或者可寫的訊息之後,我們就需要對這個連接進行讀寫操作了。因為多路復用器所在的線程可能會阻塞,所以我們一般會把這些連接的讀寫操作放到新的線程中。因為讀寫操作本身也可能導致線程阻塞(例如讀取數據的數量還不滿足要求),所以此時我們仍然需要為每一個連接的讀寫操作開闢新的線程(也可以使用線程池),這在讀寫連接較少的情況下沒什麼問題,但是在有大量連接都需要進行讀寫操作時仍然會產生大量的線程,降低系統吞吐量。
解決辦法是使用非阻塞IO,即一旦當前連接讀緩衝區中的數據已被讀完或當前連接的寫緩衝區中的數據已滿,則READ或WRITE系統調用立即返回,而不是阻塞住當前線程。有了非阻塞IO,我們就可以在一個線程中進行多個連接的讀寫操作而不用擔心某一個連接會導致當前線程阻塞,這樣我們就能降低讀寫操作所需要的線程的數量了。
傳統IO多路復用非阻塞讀寫事件被綁定在讀寫操作上,讀寫操作本身是阻塞的把讀寫事件剝離出讀寫操作本身,單個線程可以管理數百萬個連接的讀寫事件,讀寫操作本身還是阻塞的讀寫操作本身是非阻塞的,可以在少量線程中實現大量連接的讀寫操作
這三種類型的IO模型的使用情況如下圖:
協程
協程是用戶態層面的代碼執行管理單元,可以類比作業系統的線程。
類型線程協程被調度時meta數據的存儲區域內核態內存空間用戶態內存空間切換操作需要調用作業系統內核提供的syscall簡單的現場保存和恢復即可調度機制現代作業系統都是搶占式的,由內核實現依賴當前協程主動讓出(yield)CPU資源
由於多路復用與非阻塞的使用,導致單個連接的狀態管理不再像BlockIO時那樣的簡單,而且因為線程不會阻塞在讀寫操作、尤其是讀操作上,所以此時我們一般使用回調函數的方式來實現讀操作。簡單來說就是在讀取時,如果已經讀取的數據還不滿足需求,程序就暫時把這些數據讀取並保存在用戶態的內存中,待數據讀取滿足要求之後就調用回調函數,通過異步的方式把數據交給相應的處理函數。
異步的問題在於不便於程式設計師的理解,人類更加習慣於同步的操作行為,異步的操作總是會顯得晦澀而又難以理解,這會提升代碼的複雜度。
我們可以使用協程對多路復用和非阻塞進行改造來實現同步的IO操作。首先我們在協程中調用我們自己實現的方法 READ0 ,該方法為阻塞方法,此時該協程被阻塞。在語言內部我們使用多路復用和非阻塞來管理連接和數據,一旦數據滿足了要求,我們再次調度到該協程,此時 READ0 方法返回,在用戶看來整個 READ0 方法就是同步阻塞的,非常易於程式設計師的使用。
此外,由於協程的數據都存儲在用戶態的內存空間且不需要通過syscall即可以調度,所以協程的調度相較於線程是非常輕量級的,事實上在go語言中我們可以開數十萬個協程而不會有性能問題,而同樣的機器上運行數千個線程就已經很吃力了。
協程和線程不存在相互替代的關係,它們都是對一個指定的邏輯流的抽象,它們之間是互補的關係。協程和線程的發展歷史大致如下:
- 早期一台計算機上面只能執行一個程序
- 一台機器上只執行一個程序太浪費CPU資源了,我們可以寫一個控制程序,當某個程序執行IO時就讓出CPU資源交給另一個程序執行,這就是協程的思想
- 在多任務作業系統中,為了避免某個程序一直霸占CPU資源,搶占式的作業系統被發明,由作業系統內核對CPU的資源進行管理和分配(事實上不僅僅是CPU,現代作業系統實現了對計算機所有的硬體資源的高效的管理)
- 多核CPU被發明,我們可以直接在作業系統層面支持多核,面向程式設計師的線程模型無需改變
- 線程的切換需要內核的幫助,比較耗費系統資源。為了避免大量的使用線程,我們可以在單個進程中模擬早期的調度程序的行為,從而實現多個邏輯流的執行,這就是協程
在作業系統層面,線程實現了搶占式多任務處理以及對多核CPU的支持;在用戶層面,協程提供了統一的邏輯流的抽象,並向上提供編程模型。協程和線程之間是互補的關係,它們本質上都只是對一些的狀態的維護。