作業系統的核心是核心(kernel),它獨立於普通的應用程式,可以存取受保護的記憶體空間,也有存取底層硬體裝置的所有許可權。為了保證核心的安全,現在的作業系統一般都強制使用者程序不能直接操作核心。
由於我們使用者所有的應用都是執行在作業系統之上的,所以一旦作業系統不能穩定執行,那就完了。因此為了保證作業系統的穩定性,Linux 區分了核心空間和使用者空間。
可以這樣理解,核心空間執行作業系統程式和驅動程式,而使用者空間則執行應用程式。Linux 以這種方式隔離了作業系統程式和應用程式,避免了應用程式影響到作業系統自身的穩定性。
當程序執行在核心空間時就處於核心態,而程序執行在使用者空間時則處於使用者態。
對於以前的 DOS 作業系統來說,是沒有核心空間、使用者空間以及核心態、使用者態這些概念的。可以認為所有的程式碼都是執行在核心態的,因而使用者編寫的應用程式程式碼可以很容易的讓作業系統崩潰掉。
其實所有的系統資源管理都是在核心空間中完成的,比如讀寫磁碟檔案、分配回收記憶體、從網路介面讀寫資料等等。我們的應用程式是無法直接進行這樣的操作的。但是我們可以通過核心提供的介面來完成這樣的任務。
比如應用程式要讀取磁碟上的一個檔案,它可以向核心發起一個「系統呼叫」,以此來告訴核心「我要讀取磁碟上的某某檔案」。
系統呼叫就是作業系統向用戶提供服務的介面。
IO 是輸入(Input)和輸出(Output)的首字母縮寫,直觀意思是計算機輸入和輸出,它描述的是計算機的資料流動的過程,因此 IO 的第一大特徵是有資料的流動。另外,對於一次 IO 操作,它究竟是輸入還是輸出,是針對不同的主體而言的,不同的主體有不同的描述。
例如,甲乙兩人交談,甲將大腦中的想法通過聲帶震動,繼而通過聲波傳入乙的耳朵,乙通過耳膜的震動再由神經將訊號解析到大腦,就這個資料流動的過程,對甲而言是輸出,對乙而言則是輸入。
因此,理解 IO 一定要弄清楚所要研究的本體。下面,我們從三個層面來理解IO。
此時,IO 是計算機和外設之間的資料流動過程,本體是一個有使用意義的可執行的電腦,它是計算機執行的完全必要部分。姑且認為這個完全必要部分是臺式電腦的主機,裡面有 CPU、記憶體、主機板、電源等裝置,因為有了這些,一臺有使用意義的電腦即可執行。有了主機,並不能方便的為人所服務,因此得有外設。
外設是電腦的外圍裝置,如顯示器、鍵盤、滑鼠等,它們是完成人機互動的輔助工具。
外設包含兩種重要裝置(但不限於此):輸入裝置和輸出裝置。
從計算機架構上來講,任何涉及到計算機核心(CPU 和記憶體)與其他裝置間的資料流動的過程就是 IO。本體就是計算機核心(CPU 和 記憶體)。
例如從硬碟上讀取資料到記憶體,是一次輸入;將記憶體中的資料寫入到硬碟就產生了輸出。在計算機的世界裡,這就是 IO 的本質。
此時,IO 的主體是其應用程式的執行態,即程序。特別強調的是我們的應用程式其實並不存在實質的 IO 過程,真正的 IO 過程是作業系統的事情,這裡把應用程式的 IO 操作分為兩種動作:IO 呼叫和 IO 執行。
因此,更準確些來說,此時所說的 IO 是應用程式對作業系統 IO 功能的一次觸發,即 IO 呼叫。IO 呼叫的目的是:
這裡,外部資料指非程序空間資料,如從檔案中讀取的資料。
以一個程序的輸入型別的 IO 呼叫為例,它將完成或引起如下工作內容:
快取 IO 又被稱作標準 IO,大多數檔案系統的預設 IO 操作都是快取 IO。
在 Linux 的快取 IO 機制中:
從一個裝置中讀資料到記憶體或者從記憶體寫資料到這個裝置,而這個裝置就叫 IO 裝置:
根據 IO 裝置不同,IO 分為「磁碟 IO」和「網路 IO」:
阻塞和非阻塞強調的是程序對於作業系統 IO 是否處於就緒狀態的處理方式。上面已經說過,應用程式的 IO 實際是分為兩個步驟:IO 呼叫和 IO 執行。
作業系統的 IO 情況決定了程序 IO 呼叫是否能夠得到立即響應。如程序發起了讀取資料的 IO 呼叫,作業系統需要將外部資料拷貝到程序緩衝區,在有資料拷貝到程序緩衝區前,程序緩衝區處於不可讀狀態,我們稱之為作業系統 IO 未就緒。
程序的 IO 呼叫是否能得到立即執行是需要作業系統 IO 處於就緒狀態的,對於讀取資料的操作:
我們以 Socket 為例,在 Linux 中,預設情況下所有 Socket 都是阻塞模式的。當用戶程序或執行緒呼叫系統函數read()
,核心開始準備資料(從網路接收資料),核心準備資料完成後,資料從核心拷貝到使用者空間的應用程式緩衝區,資料拷貝完成後,請求才返回。從發起 Read 請求到最終完成核心到應用程式的拷貝,整個過程都是阻塞的:
如果當前程序或執行緒一直等待直到其就緒,該種 IO 方式就稱為阻塞 IO
如果使用者程序或執行緒執行緒在發起 Read 請求後立即返回,不用等待核心準備資料的過程,而是可以做其他事情,這種方式就稱為非阻塞 IO:
對於非阻塞 IO,我們程式設計時需要經常去輪詢就緒狀態。即如果 Read 請求沒讀取到資料,使用者程序或執行緒會不斷輪詢發起 Read 請求,直到資料到達(核心準備好資料)後才停止輪詢
同步和非同步描述的是針對當前執行程序或執行緒而言,發起 IO 呼叫後,當前程序或執行緒是否掛起等待作業系統的 IO 執行完成。
我們說一個 IO 執行是同步執行的,意思是程式發起 IO 呼叫後,當前執行緒或程序需要等待作業系統完成 IO 執行工作並告知程序或執行緒已經完成,程序或執行緒才能繼續往下執行其他既定指令。
如果說一個 IO 執行是非同步的,意思是該動作是由當前程序或執行緒請求發起,且當前程序或執行緒不必等待作業系統 IO 的執行完畢,可直接繼續往下執行其他既定指令。作業系統完成 IO 後,當前程序或執行緒會得到作業系統的通知。
以一個讀取資料的 IO 操作而言,在作業系統將外部資料寫入程序緩衝區這個期間,程序或執行緒掛起等待作業系統 IO 執行完成的話,這種 IO 執行策略就為同步,如果程序或執行緒並不掛起而是繼續工作,這種 IO 執行策略便為非同步。
同步和非同步這個概念是針對於程式程序,而阻塞與非阻塞是針對系統處理 IO 操作的過程。
伺服器端:
socket()
函數,建立網路協定為 IPv4、以及傳輸協定為 TCP 的 Socketbind()
函數,給這個 Socket 繫結一個 IP 地址和埠listen()
函數進行監聽accept()
函數,來從核心獲取使用者端的連線,如果沒有使用者端連線,則會阻塞等待使用者端連線的到來使用者端:
socket()
函數,建立網路協定為 IPv4,以及傳輸協定為 TCP 的 Socketconnect()
函數發起連線,然後萬眾期待的 TCP 三次握手就開始了連線建立後,使用者端和伺服器端就開始相互傳輸資料了,雙方都可以通過read()
和write()
函數來讀寫資料。至此, TCP 協定的 Socket 程式的呼叫過程就結束了,整個過程如下圖:
TCP Socket 呼叫流程是最簡單、最基本的,它基本只能一對一通訊,因為使用的是同步阻塞的方式,當伺服器端在還沒處理完一個使用者端的網路 I/O 時,或者讀寫操作發生阻塞時,其他使用者端是無法與伺服器端連線的。
可如果我們伺服器只能服務一個客戶,那這樣就太浪費資源了,於是我們要改進這個網路 I/O 模型,以支援更多的使用者端。
基於最原始的阻塞網路 I/O, 如果伺服器要支援多個使用者端,其中比較傳統的方式,就是使用多程序模型,也就是為每個使用者端分配一個程序來處理請求。
伺服器的主程序負責監聽客戶的連線,一旦與使用者端連線完成,accept()
函數就會返回一個「已連線 Socket」,這時就通過 fork()
函數建立一個子程序,實際上就把父程序所有相關的東西都複製一份,包括檔案描述符、記憶體地址空間、程式計數器、執行的程式碼等。這兩個程序剛複製完的時候,幾乎一摸一樣,不過,會根據返回值來區分是父程序還是子程序,如果返回值是 0,則是子程序;如果返回值是其他的整數,就是父程序。
正因為子程序會複製父程序的檔案描述符,於是就可以直接使用「已連線 Socket 」和使用者端通訊了,可以發現:
下面這張圖描述了從連線請求到連線建立,父程序建立子程序為客戶服務的過程:
這種用多個程序來應付多個使用者端的方式,在應對 100 個使用者端還是可行的,但是當用戶端數量高達一萬時,肯定扛不住的,因為每產生一個程序,必會佔據一定的系統資源,而且程序間上下文切換的「包袱」是很重的,效能會大打折扣。
既然程序間上下文切換的「包袱」很重,那我們就搞個比較輕量級的模型來應對多使用者的請求——多執行緒模型。
執行緒是執行在程序中的一個「邏輯流」,單程序中可以執行多個執行緒,同進程裡的執行緒可以共用程序的部分資源的,比如檔案描述符列表、程序空間、程式碼、全域性資料、堆、共用庫等,這些共用資源在上下文切換時是不需要切換的,而只需要切換執行緒的私有資料、暫存器等不共用的資料,因此同一個程序下的執行緒上下文切換的開銷要比程序小得多。
當伺服器與使用者端 TCP Socket 完成連線後,通過pthread_create()
函數建立執行緒,然後將「已連線 Socket」的檔案描述符傳遞給執行緒函數,接著線上程裡和使用者端進行通訊,從而達到並行處理的目的。
如果每來一個連線就建立一個執行緒,執行緒執行完後,作業系統還得銷燬執行緒,雖說執行緒切換的上寫文開銷不大,但是如果頻繁建立和銷燬執行緒,系統開銷也是不小的。那麼,我們可以使用執行緒池的方式來避免執行緒的頻繁建立和銷燬,所謂的執行緒池,就是提前建立若干個執行緒,這樣當由新連線建立時,將這個「已連線的 Socket」放入到一個工作佇列中,然後執行緒池裡的執行緒負責從工作佇列中取出「已連線 Socket」程序處理。
上面基於多程序或者多執行緒的模型,其實還是有問題的。新到來一個 TCP 連線,就需要分配一個程序或者執行緒,那麼如果要達到 C10K,意味著要一臺機器維護 1 萬個連線,相當於要維護 1 萬個程序 / 執行緒,作業系統就算死扛也是扛不住的。
既然為每個請求分配一個程序 / 執行緒的方式不合適,那有沒有可能只使用一個程序來維護多個 Socket 呢?答案是有的,那就是 I/O 多路複用技術。
一個程序雖然任一時刻只能處理一個請求,但是處理每個請求的事件時,耗時控制在 1 毫秒以內,這樣 1 秒內就可以處理上千個請求,把時間拉長來看,多個請求複用了一個程序,這就是多路複用。這種思想很類似一個 CPU 並行多個程序,所以也叫做時分多路複用。
我們所熟知的 select、poll、epoll,就是核心提供給使用者的多路複用系統呼叫的介面,當用戶呼叫這些介面時,程序就可以通過一個系統呼叫函數從核心中獲取多個事件,從而實現了 IO 多路複用。
下面對這三個多路複用介面做了簡單介紹。
select 實現多路複用的方式是,將已連線的 Socket 都放到一個檔案描述符集合,然後呼叫 select 函數將檔案描述符集合拷貝到核心中,讓核心來檢查是否有網路事件產生。檢查的方式很粗暴,就是通過遍歷檔案描述符集合的方式,當檢查到有事件產生後,將此 Socket 標記為可讀或可寫, 接著再把整個檔案描述符集合拷貝回用戶態裡,然後使用者態還需要再通過遍歷的方法找到可讀或可寫的 Socket,然後再對其處理。
所以,對於 select 這種方式,需要進行兩次「遍歷」檔案描述符集合的操作,一次是在核心態裡,一個次是在使用者態裡 ,而且還會發生兩次「拷貝」檔案描述符集合,先從使用者空間傳入到核心空間,由核心修改後,再傳出到使用者空間中。
且 select 使用固定長度的 BitsMap,表示檔案描述符集合,而且所支援的檔案描述符的個數是有限制的,在 Linux 系統中,由核心中的 FD_SETSIZE 限制, 預設最大值為 1024,只能監聽 0~1023 的檔案描述符。
poll 不再用 BitsMap 來儲存所關注的檔案描述符,取而代之用動態陣列,以連結串列形式來組織,突破了 select 的檔案描述符個數限制,當然還會受到系統檔案描述符限制。
但是 poll 和 select 並沒有太大的本質區別,都是使用「線性結構」儲存程序關注的 Socket 集合,因此都需要遍歷檔案描述符集合來找到可讀或可寫的 Socket,時間複雜度為 O(n),而且也需要在使用者態與核心態之間拷貝檔案描述符集合,這種方式隨著並行數上來,效能的損耗會呈指數級增長。
epoll 通過兩個方面,很好解決了 select/poll 的問題。
epoll_ctl()
函數加入核心中的紅黑樹裡,紅黑樹是個高效的資料結構,增刪查一般時間複雜度是 O(logn),通過對這棵黑紅樹進行操作,這樣就不需要像 select/poll 每次操作時都傳入整個 socket 集合,只需要傳入一個待檢測的 socket,減少了核心和使用者空間大量的資料拷貝和記憶體分配。epoll_wait()
函數時,只會返回有事件發生的檔案描述符的個數,不需要像 select/poll 那樣輪詢掃描整個 socket 集合,大大提高了檢測的效率。從下圖你可以看到 epoll 相關的介面作用:
epoll 的方式使得即使監聽的 Socket 數量變得很多的時候,效率也不會大幅度降低,而監聽的檔案描述符的上限就為系統定義的程序開啟的最大檔案描述符個數。