從BIO到epoll(硬核講解)

老樣子,我先放幾個問題,你自我檢測一下,看看自己掌握多少,再去看我的講解。

  1. 計算機怎麼能接收網絡信息
  2. SocketException: Too many open files 是什麼
  3. 同步非阻塞的缺點是什麼
  4. 僅僅只是非阻塞,是否存在什麼問題
  5. 什麼時候會涉及用戶態與內核態的切換
  6. 共享空間在網絡IO的作用
  7. 什麼是中斷
  8. 異步是如何實現的
  9. 那 Linux 可以實現異步嗎

很多 Java 程序員還停留在只知道 BIO 的知識面,有些基礎的應該都知道,除了 BIO,還有 NIO、AIO。
學習這些底層的 I/O 原理是有必要的,因爲這是提高性能的很關鍵的點。一個服務器的併發量能夠不斷地突發曾經的瓶頸,這也依靠着 I/O 的發展。

學習忌浮躁

概念區分

這是我額外添加的一小節,防止有些同學還分不清楚概念:
同步與非同步、阻塞與非阻塞

  • 同步是指,一個進程(或者線程)在讀取 I/O 數據時,必須要自己去調用方法查看是否數據已經準備好;
    非同步則表示,進程(或線程)自身可以先不用去理會 I/O 操作,可以讓數據準備好之後,操作系統來通知它,然後再去執行讀取數據。
  • 阻塞是指,一個進程(或者線程)在讀取 I/O 數據時,期間是不能夠做其他事的,執行的代碼必須停在讀取數據的地方,直到讀取到數據;
    非阻塞是指,在讀取 I/O 數據時,如果數據還不存在,代碼仍然可以向後執行。

洗衣機例子區分概念

爲了防止你們聽了之後更混淆,我舉一個例子:
假設你去用一臺洗衣機洗衣服:

  • 同步:你必須自己去看洗衣機洗完了沒有
  • 異步:洗衣機洗完了自己會告訴你
  • 阻塞:洗衣機洗衣服你不能做別的事
  • 非阻塞:洗衣機沒洗完你也可以做別的事
    ——————————————————————————————————————————————
  • 同步阻塞:你必須一直盯着洗衣機,直到它洗完了
  • 同步非阻塞:你可以在洗衣機洗衣服的時候去做別的事,但是(由於同步)洗衣機自己不會告訴你它洗完了,所以得你自己老是跑回來看一下,沒事跑回來再看一下,看看它有沒有洗完。
  • 異步阻塞:洗衣機洗完了會自己通知你,但是你在這個期間不能幹別的事。(看起來這個操作有毛病)
  • 異步非阻塞:洗衣機洗碗了會通知你

代碼層面區分概念

我再從代碼層面幫助區分
1、同步阻塞:

前面的代碼
input.read(); // 代碼會阻塞在這裏,直到數據準備好
// 不過確保了後面的代碼一定是在讀到數據以後執行的
後面的代碼 // 這裏就會很長時間得不到執行

2、同步非阻塞

前面的代碼
input.read() // 有數據則讀到,沒數據則讀不到,方法都會返回,不會阻塞
後面的代碼 // 不管有沒有讀到數據,都會往後執行

這樣會有個問題,如果讀不到數據,那麼接下來的操作可能就會出錯
所以可以再修改

前面的代碼
int i = input.read(); // 讀不讀的到都往後執行
if(i != -1) {
    讀到數據做的事
}
else {
    沒讀到數據做的事
}

3、異步阻塞

前面的代碼
input.read(); // 代碼會阻塞在這裏,直到數據準備好
後面的代碼 // 這裏就會很長時間得不到執行

爲什麼和 同步阻塞看起來一樣???
因爲代碼不該這麼寫:

操作系統通知:調用方法 function
void function() {
    // 因爲操作系統告訴你數據有了,這時候能直接讀到
    // 就不會浪費時間阻塞
    input.read();
}

4、異步非阻塞也應該用上面的寫法:
等操作系統通知,再讀取。

操作系統通知:調用方法 function
void function() {
    // 因爲操作系統告訴你數據有了,這時候能直接讀到
    // 就不會讀出空的數據
    input.read();
}

IO 的本質

這裏着重講網絡 I/O

發送方

首先,網絡是部分什麼客戶端服務端的,它只關係發送方和接收方。
(只不過一般我們的發送方都是客戶端)

首先發送方,比如我們的瀏覽器,在按了一個回車之後,其實是我們的瀏覽器應用程序在操作。
那麼這個應用程序是怎麼發送消息的呢。

實際上,應用程序本身不能發送,而是要通過操作系統。
應用程序通過系統內庫找到操作系統提供的函數,然後調用操作系統提供的方法,去發送消息。

所以,消息內容就從應用程序流動到操作系統,然後操作系統可以控制我們的 IO 設備,比如這裏的網卡,將消息發送出去。
io發送

接收方

那麼接收方又是怎麼做的?
比如我們最常見的應用服務器,Java 服務器

首先同樣的,要有操作系統,然後操作系統上運行着 JVM。
數據要發給我們的 Java 服務器,那麼最終就要存儲到我們的運行時數據區,堆空間裏面。
io
但是,這裏有個小問題,這個數據時直接進入到這個堆中的嗎?

有點基礎的同學都知道肯定不是的,在操作系統中有一個網絡處理模塊,不管是 TCP 還是 UDP,它們的數據都會存在對應的緩衝區裏。
所以,當我們的客戶端與服務端建立連接時,服務端操作系統就會開闢出一個緩衝區,用來存放這個連接所傳輸的數據。

但是,數據傳過來了,要怎麼知道是給服務器上哪個應用程序的呢?
這時候就需要端口派上用場,服務端應用程序指定的哪個端口,就從專門的緩衝區去讀取數據。

所以,這個時候操作系統的麻煩就會很少,而且也很安全。
因爲操作系統不會去幹擾應用程序,而是把數據存起來,讓應用程序自己去讀取。
io
但是,這同樣產生了問題,就是應用程序應該什麼時候去讀?
這時不確定的,
如果去早了,就讀不到數據,浪費時間
如果去晚了,那麼就會造成服務端的響應延時。

所以纔會衍生出各種各樣的網絡 I/O 模型。

同步阻塞I/O(BIO)

最先出現的就是 BIO,因爲它的實現最爲簡單:
應用程序只管去讀取,讀不到的時候,在那裏等着,等到數據有了,就讀取回去。

  • 首先,計算機有內核,它會接收很多很多客戶端的連接,所有的連接都會先到達內核。
  • 早些時候,內核會有很多文件描述符 fd,一個連接就有一個 fd,只有一個 read 可以讀一個文件描述符 fd。
  • 每讀一個 fd,就會用到一個進程,在 java 中就叫一個線程。
  • 因爲 socket 在這個時期是 blocking(阻塞)的,也就是客戶請求沒到的時候,線程是阻塞的。

客戶端請求一多,由於之前的線程執行到那段代碼,就在那阻塞着,啥也不幹,動也動不了,所以要處理更多的請求,就要拋出更多的線程。
CPU 又同一時刻只能處理一條數據,這個時候可能一個線程數據還沒到,另一個線程數據到了,這時候 CPU 就會並不是時時刻刻都能處理到這些數據,就會造成計算機資源的嚴重浪費。而
且線程數量一多,切換線程也是會有成本的,尤其線程漲到好幾千甚至更多,可能計算機就只忙着線程切換,都沒什麼時間用來執行代碼。

這就是早期的 BIO 時期,因爲 socket 是阻塞的,計算機資源是很難被利用起來的。
BIO

同步非阻塞I/O(NIO)

然後,隨着時間的推進,技術的發展與演變,內核當中的 socket 有了非阻塞了(socket nonblock),也就是說,進程(線程)可以不用這麼傻傻地在那裏等着浪費時間,如果沒有數據,完全可以去做其他的事。

這樣,我們就可以不用再向以前那樣,去創建很多很多的線程才能夠讀取所有連接的數據。
不阻塞了就可以用一個線程,少弄那麼多沒用的,就不會有什麼線程開銷。
然後線程裏面寫一個死循環,while(true) 一直在那裏 read 遍歷所有的文件描述符。比如先 read fd8,沒有,在 read fd9,再沒有,再 read fd8,有了,然後 read fd9。

但是這個時候,我們要知道,雖然非阻塞了,這是同步的還是異步的。你看,這是線程先去操作系統,把文件描述符取出來,再去遍歷處理。所以,這是同步、非阻塞時期。你看,是不是不阻塞了,但是取出來,遍歷這種事,還是得自己做,就是同步非阻塞了,就叫 NIO 了。
NIO

但是,問題還有沒有?

  1. 比如說,現在有 1W 個文件描述符(fd),也就是說,這個線程要輪詢 1W 次。你看啊,查詢一次文件描述符,就要系統調用,然後用戶態內核態切換,一大堆事在那換來換去。
  2. 雖然有了 1W 個請求,但是這 1W 個文件描述符有哪個要讀,哪個不要讀,誰都不知道,可能只有少數的幾個是有 read 的需求的,那麼輪詢一次,就要遍歷 1W 個 fd,可見效率有多低。

所以,這種單線程輪詢在面對大併發的時候,性能又會開始下降。
所以接下來就要解決這個頻繁的系統調用,但是這個用戶能實現嗎,肯定是實現不了的,所以內核得向前發展。

select 系統調用

爲了解決用戶頻繁地爲每一個連接去頻繁系統調用,內核裏發展出了一個 系統調用 select

這個時候,用戶空間只要調用 select 系統調用,假設這個時候又有 1W 個文件描述符來了,
用戶只要調用一次 select,那麼在內核態,直接輪詢 1W 個 fd,然後一次性返回給用戶,
這樣就大大減少了系統調用。這是用戶只需 read 那少量的有數據來的 fd。

這樣就是原來調 一萬次,現在只要 調一次,操作系統直接返回。
於是就發展成了 多路複用
NIO

數據拷貝問題

select 有了,但是還有問題你發現了沒有,這裏面還是涉及到用戶態和內核態的問題。

這個問題就是系統調用的數據拷貝:
用戶進程放着 1W 個文件描述符,每次系統調用,就要傳參,數據就要拷貝,所以大量的文件描述符就會成爲累贅。

所以爲了解決這個問題,Linux 用到的方法是:
開闢一個共享內存空間!!!

曾經你的內核在內存有自己的空間,用戶在內存又有自己的空間,雖然可能是在同一塊物理內存上,但是虛擬內存是劃分開來的,用戶肯定是不能訪問內核的空間
所以系統調用就要 傳遞參數,就要傳這批數據,數據就要 拷貝

於是,爲了解決這個問題,內核和用戶就劃出一塊空間,兩邊都能訪問。
用戶只要把數據寫到這裏面,然後就不用再拷貝數據給內核,只要告訴它在哪個位置,然後 CPU 一轉,馬上就能找到那個位置,然後就能直接讀數據了,就不用再拷貝這個數據了。
這就是所謂的共享空間,這個系統調用叫做 mmap

所以這個時候,這 1W 個 fd 不用用戶進程自己去維護這個數據,而是直接地就往這個共享空間往裏面一丟,丟到這個紅黑樹裏邊。
這個時候就沒有傳參,就沒有拷貝的過程。
這時候內核就可以直接看到這些個文件描述符,由我們內核直接去管理文件描述符,
誰的數據到達了,誰的緩衝區有了,然後把到達的放到鏈表裏。
這樣,上層用戶進程是不是隻要去鏈表裏直接取出,直接讀取就可以了。
mmap

輪詢的效率問題

epoll 不止解決了這一個問題。

事實上,之前 select 多路複用,將 1W 次輪詢放到了內核空間。
雖然說用戶進程不輪詢了,但是內核還在那逛次逛次在那輪詢,依然效率存在問題。
我們知道,這個輪詢遍歷它的時間複雜度是 O(n) 級別的,所以在這個請求量一大,還是非常耗費計算機資源的。

epoll 是怎麼做的呢,就是化主動爲被動。

我們都知道,計算機有個東西叫 CPU,程序裏有個東西叫內核(kernel),用戶空間比如有幾個程序,除了這些,還有網卡等 I/O 設備,如果只有一顆 CPU,如何保證計算機又能跑用戶程序,又能讀寫網絡數據,又能鍵盤打字,移動鼠標等等。我們用戶寫程序的時候肯定不會寫,比如過了幾百毫秒,然後發一條指令,說我休息了,你先去忙別人吧,肯定不會,那 CPU 是如何實現的。

很簡單,實際上是用到了 中斷。CPU 裏面就有晶振時間中斷,比如每秒震 10 下,那就是把一秒鐘切成 10 個時間片,每個時間片處理不同的任務。
但是中斷了之後幹嘛,CPU 怎麼知道要接下來做什麼事,中斷有了之後,有中斷號,在內核裏面會維護一段代碼,叫回調(callback),每次中斷的時候,程序會在裏面根據自己的中斷號埋一個 callback,這樣就能保證 CPU 能不斷執行各種事情。
然後我們的網卡要傳數據的時候,因爲每次那麼010101010就來那麼一點,不能說每來一點就中斷一次,那這樣只要隨便傳點數據,CPU 就要砰砰砰砰斷個不停,那就沒完了。
爲了優美點,內存空間就還有個區域,叫 DMA,直接內存訪問,也就是網卡接受到點了數據,就放進去,有一點就放進去。
也就是把數據放進去這事不用 CPU 來處理了,直接自己就能放進去。
放好了之後,就會敲中斷,比如網卡中斷號就是 88,那麼網卡就會往這個內核里加一個 88 中斷號的 callback,這時候 CPU 就會用這個 callback 函數找到這個 DMA,內核就可以從裏面讀數據了。
讀完東西之後,就會有對應的一個事件去通知這個進程,然後進程就會去對這個數據做相應的處理。

那這些有什麼意義?
客戶端到達的時候會有一箇中斷,以及產生一個 callback 事件,之後內核間接知道了網絡數據包到達。
所以我們就可以明白,計算機不一定要主動,完全可以被動地接收事件。
callback

epoll 完整過程

首先我們用戶進程調用 epoll_create 得到一個文件描述符比如 5,然後就會在內核裏面開闢一塊空間,用這個文件描述符 fd5 來代表這個空間,就是上文說的紅黑樹。
然後調用系統調用,epoll_ctl(5,6) 在 5 裏面添加一個 6,用來監聽事件,監聽 accept。
然後註冊監聽事件之後,就開始瘋狂的調 epoll_wait,對這個 fd5 這個區域去 wait,實際上這個時候 CPU 在忙別的事情。
這時候如果這個客戶端來了,想要建立一個連接,那麼這時候,操作系統就把 fd6 扔到另外一個區域裏,就是那個鏈表,扔過來之後,那麼這個 wait 就會有返回值,事實上用戶就是在等這個這個鏈表。
有返回之後,用戶就會根據返回調用 accept(fd6),比如將這個客戶端描述成 fd9,然後 accept 之後就可以等客戶端發數據了。
但是由於數據什麼時候到來是未知的,所以,會繼續把這個 fd9 客戶端註冊進 fd5,也就是調用 epoll_ctl(5,9),對這個客戶端,肯定是等待 read 這個事件,等待數據到達。

假設又有一個客戶端到達,那肯定是觸發 fd6,假設之前的那個客戶端有了數據,那肯定會觸發 fd9,所以 fd6 和 fd9 會被都丟到右邊那個鏈表裏,這時候,用戶進程就會一次收到兩個。
你可以回想一下當初用 java 寫 NIO 的時候,是不是有個 select() 方法,返回一個集合,你要去遍歷裏面的 key,去做處理,是不是有 read 的,有 accept 的。
epoll

真正的異步

有些人可能要說上面的 epoll 就是用的異步了。
不過實際上,epoll 並不是真正的異步,Linux 是 沒有 實現完全異步的。
目前是 Windows 實現了異步,但是由於 Windows 不開源,我們也不可能去用一個 Windows 去做服務器。

但是 Linux 的 epoll 已經基本上實現異步了,只是最後在 wait 返回了之後,用戶進程要主動去 read,在 read 的時候,是一個 同步 的過程。
不過由於 read 的時間非常短,相比之前 wait 的異步操作,已經能提高到非常高的一個效率了。

所以 redis 能支持這麼高的併發,epoll 的功勞不可末。
還有 Nginx,爲什麼能叫高性能 web 服務器,爲啥能做負載均衡,能抗下這麼高的併發,底層都是 epoll。

epoll 流程圖:
epoll

作者的話

實際上,對於我們 Java 程序員來說,網絡的知識是很重要的,因爲我們大多數的任務是做服務端的開發,這就不可避免的涉及到網絡、併發等的知識。

但是,由於很多程序員對於網絡的底層知識並不瞭解,很可能是大學因爲找不到它的實際作用因而沒有學得很仔細;
也可能是因爲是培訓出身,並沒有系統的學習過計算機底層的知識,所以對這些知識不夠了解。

但是,不論如何,如果現在還不夠理解,那一定需要對這些知識去進行一個學習和彌補。
一方面是因爲目前的技術發展,對一個程序員的技能要求也是越來越高;
另一方面,也是因爲程序員的入門門檻逐步變低,程序員的競爭壓力也會越來越大。

所以,請無論如何保持一份學習的動力。
共勉。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章