目錄
伺服器端為了處理使用者端的連線和請求的資料,寫了如下程式碼。
- listenfd = socket(); // 開啟一個網路通訊埠
- bind(listenfd); // 繫結
- listen(listenfd); // 監聽
- while(1) {
- connfd = accept(listenfd); // 阻塞建立連線
- int n = read(connfd, buf); // 阻塞讀資料
- doSomeThing(buf); // 利用讀到的資料做些什麼
- close(connfd); // 關閉連線,迴圈等待下一個連線
- }
這段程式碼會執行得磕磕絆絆,就像這樣。
可以看到,伺服器端的執行緒阻塞在了兩個地方,一個是 accept 函數,一個是 read 函數。
如果再把 read 函數的細節展開,我們會發現其阻塞在了兩個階段。
這就是傳統的阻塞 IO。
整體流程如下圖。
所以,如果這個連線的使用者端一直不發資料,那麼伺服器端執行緒將會一直阻塞在 read 函數上不返回,也無法接受其他使用者端連線。
這肯定是不行的。
為了解決上面的問題,其關鍵在於改造這個 read 函數。
有一種聰明的辦法是,每次都建立一個新的程序或執行緒,去呼叫 read 函數,並做業務處理。
- while(1) {
- connfd = accept(listenfd); // 阻塞建立連線
- pthread_create(doWork); // 建立一個新的執行緒
- }
- void doWork() {
- int n = read(connfd, buf); // 阻塞讀資料
- doSomeThing(buf); // 利用讀到的資料做些什麼
- close(connfd); // 關閉連線,迴圈等待下一個連線
- }
這樣,當給一個使用者端建立好連線後,就可以立刻等待新的使用者端連線,而不用阻塞在原使用者端的 read 請求上。
不過,這不叫非阻塞 IO,只不過用了多執行緒的手段使得主執行緒沒有卡在 read 函數上不往下走罷了。作業系統為我們提供的 read 函數仍然是阻塞的。
所以真正的非阻塞 IO,不能是通過我們使用者層的小把戲,而是要懇請作業系統為我們提供一個非阻塞的 read 函數。
這個 read 函數的效果是,如果沒有資料到達時(到達網路卡並拷貝到了核心緩衝區),立刻返回一個錯誤值(-1),而不是阻塞地等待。
作業系統提供了這樣的功能,只需要在呼叫 read 前,將檔案描述符設定為非阻塞即可。
- fcntl(connfd, F_SETFL, O_NONBLOCK);
- int n = read(connfd, buffer) != SUCCESS);
這樣,就需要使用者執行緒迴圈呼叫 read,直到返回值不為 -1,再開始處理業務。
這裡我們注意到一個細節。
非阻塞的 read,指的是在資料到達前,即資料還未到達網路卡,或者到達網路卡但還沒有拷貝到核心緩衝區之前,這個階段是非阻塞的。
當資料已到達核心緩衝區,此時呼叫 read 函數仍然是阻塞的,需要等待資料從核心緩衝區拷貝到使用者緩衝區,才能返回。
整體流程如下圖
也就是說這不是真正意義上的非阻塞IO。
IO 多路複用
為每個使用者端建立一個執行緒,伺服器端的執行緒資源很容易被耗光。
當然還有個聰明的辦法,我們可以每 accept 一個使用者端連線後,將這個檔案描述符(connfd)放到一個陣列裡。
fdlist.add(connfd);
然後弄一個新的執行緒去不斷遍歷這個陣列,呼叫每一個元素的非阻塞 read 方法。
- while(1) {
- for(fd <-- fdlist) {
- if(read(fd) != -1) {
- doSomeThing();
- }
- }
- }
這樣,我們就成功用一個執行緒處理了多個使用者端連線。
你是不是覺得這有些多路複用的意思?
但這和我們用多執行緒去將阻塞 IO 改造成看起來是非阻塞 IO 一樣,這種遍歷方式也只是我們使用者自己想出的小把戲,每次遍歷遇到 read 返回 -1 時仍然是一次浪費資源的系統呼叫。
所以,還是得懇請作業系統老大,提供給我們一個有這樣效果的函數,我們將一批檔案描述符通過一次系統呼叫傳給核心,由核心層去遍歷(而不是在使用者態呼叫,再陷入到核心態中去遍歷),才能真正解決這個問題。
select 是作業系統提供的系統呼叫函數,通過它,我們可以把一個檔案描述符的陣列發給作業系統, 讓作業系統去遍歷,確定哪個檔案描述符可以讀寫, 然後告訴我們去處理:
select系統呼叫的函數定義如下。
- int select(
- int nfds,
- fd_set *readfds,
- fd_set *writefds,
- fd_set *exceptfds,
- struct timeval *timeout);
- // nfds:監控的檔案描述符集裡最大檔案描述符加1
- // readfds:監控有讀資料到達檔案描述符集合,傳入傳出引數
- // writefds:監控寫資料到達檔案描述符集合,傳入傳出引數
- // exceptfds:監控異常發生達檔案描述符集合, 傳入傳出引數
- // timeout:定時阻塞監控時間,3種情況
- // 1.NULL,永遠等下去
- // 2.設定timeval,等待固定時間
- // 3.設定timeval裡時間均為0,檢查描述字後立即返回,輪詢
伺服器端程式碼,這樣來寫。
首先一個執行緒不斷接受使用者端連線,並把 socket 檔案描述符放到一個 list 裡。
- while(1) {
- connfd = accept(listenfd);
- fcntl(connfd, F_SETFL, O_NONBLOCK);
- fdlist.add(connfd);
- }
然後,另一個執行緒不再自己遍歷,而是呼叫 select,將這批檔案描述符 list 交給作業系統去遍歷。
- while(1) {
- // 把一堆檔案描述符 list 傳給 select 函數
- // 有已就緒的檔案描述符就返回,nready 表示有多少個就緒的
- nready = select(list);
- ...
- }
不過,當 select 函數返回後,使用者依然需要遍歷剛剛提交給作業系統的 list。
只不過,作業系統會將準備就緒的檔案描述符做上標識,使用者層將不會再有無意義的系統呼叫開銷。
- while(1) {
- nready = select(list);
- // 使用者層依然要遍歷,只不過少了很多無效的系統呼叫
- for(fd <-- fdlist) {
- if(fd != -1) {
- // 唯讀已就緒的檔案描述符
- read(fd, buf);
- // 總共只有 nready 個已就緒描述符,不用過多遍歷
- if(--nready == 0) break;
- }
- }
- }
可以看出幾個細節:
整個 select 的流程圖如下。
可以看到,這種方式,既做到了一個執行緒處理多個使用者端連線(檔案描述符),又減少了系統呼叫的開銷(多個檔案描述符只有一次 select 的系統呼叫 + n 次就緒狀態的檔案描述符的 read 系統呼叫)。
epoll 是最終的大 boss,它解決了 select 和 poll 的一些問題。
還記得上面說的 select 的三個細節麼?epoll 主要就是針對這三點進行了改進。
使用起來,其內部原理就像如下一般絲滑。
一切的開始,都起源於這個 read 函數是作業系統提供的,而且是阻塞的,我們叫它 阻塞 IO。
為了破這個局,程式設計師在使用者態通過多執行緒來防止主執行緒卡死。
後來作業系統發現這個需求比較大,於是在作業系統層面提供了非阻塞的 read 函數,這樣程式設計師就可以在一個執行緒內完成多個檔案描述符的讀取,這就是 非阻塞 IO。
但多個檔案描述符的讀取就需要遍歷,當高並行場景越來越多時,使用者態遍歷的檔案描述符也越來越多,相當於在 while 迴圈裡進行了越來越多的系統呼叫。
後來作業系統又發現這個場景需求量較大,於是又在作業系統層面提供了這樣的遍歷檔案描述符的機制,這就是 IO 多路複用。
多路複用有三個函數,最開始是 select,然後又發明了 poll 解決了 select 檔案描述符的限制,然後又發明了 epoll 解決 select 的三個不足。
所以,IO 模型的演進,其實就是時代的變化,倒逼著作業系統將更多的功能加到自己的核心而已。
如果你建立了這樣的思維,很容易發現網上的一些錯誤。
比如好多文章說,多路複用之所以效率高,是因為用一個執行緒就可以監控多個檔案描述符。
這顯然是知其然而不知其所以然,多路複用產生的效果,完全可以由使用者態去遍歷檔案描述符並呼叫其非阻塞的 read 函數實現。而多路複用快的原因在於,作業系統提供了這樣的系統呼叫,使得原來的 while 迴圈裡多次系統呼叫,變成了一次系統呼叫 + 核心層遍歷這些檔案描述符。
就好比我們平時寫業務程式碼,把原來 while 迴圈裡調 http 介面進行批次,改成了讓對方提供一個批次新增的 http 介面,然後我們一次 rpc 請求就完成了批次新增。