部分內容來源:JavaGuide?
select/poll/epoll 和 三種IO模型之間的關系是什么?
區分普通IO和IO多路復用
普通IO,即一個線程對應一個連接,因為每個線程只處理一個客戶端 socket,目標明確:線程中直接操作該 socket 的 read() / write(),無需關心其他連接,也就是無需遍歷文件描述符
可以理解成,一個線程只持有一個Socket的文件描述符
而IO多路復用是一個線程持有多個Socket的文件描述符,所以它有一個文件描述符集合,內核要遍歷這個文件描述集合檢查每個Socket中是否有數據
區分select/poll/epoll和三種IO模型
select/poll/epoll 是 如何遍歷文件描述符尋找有數據的 Socket 的方法;
IO 模型是Socket 拿到數據后如何處理的策略
IO多路復用是單線程如何高效管理和尋找多個Socket,而IO模型是如何處理多個Socket的數據
并且IO多路復用屬于同步IO
Reactor是基于select/epoll這些IO多路復用機制實現的,也就是基于同步IO
Proactor是基于異步IO機制實現的
IO多路復用的演進流程
線程池實現資源復用
如果要讓服務器服務多個客戶端,那么最直接的方式就是為每一條連接創建線程
其實創建進程也是可以的,原理是一樣的
進程和線程的區別在于線程比較輕量級些,線程的創建和線程間切換的成本要小些
為了描述簡述,后面都以線程為例
處理完業務邏輯后,隨著連接關閉后線程也同樣要銷毀了,但是這樣不停地創建和銷毀線程,不僅會帶來性能開銷,也會造成浪費資源,而且如果要連接幾萬條連接,創建幾萬個線程去應對也是不現實的
要這么解決這個問題呢?我們可以使用「資源復用」的方式
通過池化思想保存歷史連接
也就是不用再為每個連接創建線程,而是創建一個「線程池」,將連接分配給線程,然后一個線程可以處理多個連接的業務。
線程怎樣才能高效地處理多個連接的業務?
當一個連接對應一個線程時,線程一般采用「read -> 業務處理 -> send」的處理流程
如果當前連接沒有數據可讀,那么線程會阻塞在 read 操作上(socket 默認情況是阻塞 I/O)
不過這種阻塞方式并不影響其他線程。
但是引入了線程池,那么一個線程要處理多個連接的業務,線程在處理某個連接的 read 操作時,如果遇到沒有數據可讀,就會發生阻塞,那么線程就沒辦法繼續處理其他連接的業務。
要解決這一個問題,最簡單的方式就是將 socket 改成非阻塞,然后線程不斷地輪詢調用 read 操作來判斷是否有數據,這種方式雖然該能夠解決阻塞的問題
但是解決的方式比較粗暴
因為輪詢是要消耗 CPU 的,而且隨著一個線程處理的連接越多,輪詢的效率就會越低
為什么Socket要設置為非阻塞?
當socket設為非阻塞時,即使沒有數據可讀,read()也會立即返回一個【無數據的錯誤】
(如 Linux 的EAGAIN
),不會阻塞線程
此時線程可以通過輪詢處理多個連接
線程循環遍歷自己負責的所有socket
描述符,對每個socket
調用read()
- 如果
read()
返回數據,就處理該連接的業務; - 如果
read()
返回 “無數據”,就跳過這個socket
,繼續輪詢下一個
監聽連接
上面的問題在于,線程并不知道當前連接是否有數據可讀,從而需要每次通過 read 去試探
那有沒有辦法在只有當連接上有數據的時候,線程才去發起讀請求呢?
答案是有的,實現這一技術的就是 I/O 多路復用
I/O 多路復用技術會用一個系統調用函數來監聽我們所有關心的連接,也就說可以在一個監控線程里面監控很多的連接
三種IO模型
小區別
IO多路復用屬于網絡監聽模型,例如Redis實現網絡監聽的時候就是使用IO多路復用的,當Redis啟動的時候,它會主動使用IO多路復用網絡監聽模型去監聽
每個 Socket 在內核中都有對應的接收緩沖區和發送緩沖區
read:從該 Socket 對應的接收緩沖區中讀取數據到用戶緩沖區
Reactor 是非阻塞同步網絡模式,而 Proactor 是異步網絡模式
這里先給大家復習下阻塞、非阻塞、同步、異步 I/O 的概念
阻塞I/O
先來看看阻塞 I/O,當用戶程序執行 read,線程會被阻塞
一直等到內核數據準備好,并把數據從內核緩沖區拷貝到應用程序的緩沖區中,當拷貝過程完成,read 才會返回
注意:阻塞等待的是「內核數據準備好」和「數據從內核態拷貝到用戶態」這兩個過程
過程如下圖:
非阻塞 I/O
知道了阻塞 I/O,來看看非阻塞 I/O,非阻塞的 read 請求在數據未準備好的情況下立即返回,可以繼續下執行
此時應用程序不斷輪詢內核直到數據準備好
內核將數據拷貝到應用程序緩沖區,read 才可以獲取到結果
過程如下圖:
注意,這里最后一次 read 調用,獲取數據的過程,是一個同步的過程,是需要等待的過程。這里的同步指的是內核態的數據拷貝到用戶程序的緩存區這個過程
異步IO
如果 socket 設置了 O_NONBLOCK 標志,那么就表示使用的是非阻塞 I/O 的方式訪問
而不做任何設置的話,默認是阻塞 I/O
因此,無論 read 和 send 是阻塞 I/O,還是非阻塞 I/O 都是同步調用
因為在 read 調用時,內核將數據從內核空間拷貝到用戶空間的過程都是需要等待的,也就是說這個過程是同步的,如果內核實現的拷貝效率不高,read 調用就會在這個同步過程中等待比較長的時間
為什么這么說?
同步 的核心是:用戶線程必須親自參與數據拷貝的等待過程
并且用戶 線程必須等待拷貝完成才能繼續執行
同步IO要等待的兩個步驟:
- 「內核數據準備好」
- 「數據從內核空間拷貝到用戶空間」
阻塞IO:等待。你到咖啡店后,發現咖啡還沒做好,于是站在柜臺前一直等
非阻塞IO:輪詢。你到咖啡店后如果沒好,那你就去附近晃悠一會兒,然后再問咖啡時候做好了,直到店員說好了
真正的異步 I/O 是「內核數據準備好」和「數據從內核態拷貝到用戶態」這兩個過程都不用等待
當我們發起 aio_read(異步 I/O)之后,就立即返回,內核自動將數據從內核空間拷貝到用戶空間
這個拷貝過程同樣是異步的,內核自動完成的
和前面的同步操作不一樣,應用程序并不需要主動發起拷貝動作
過程如下圖:
理解IO的簡單例子
舉個你去飯堂吃飯的例子,你好比應用程序,飯堂好比操作系統
阻塞 I/O
你去飯堂吃飯,但是飯堂的菜還沒做好,然后你就一直在那里等啊等,等了好長一段時間終于等到飯堂阿姨把菜端了出來(數據準備的過程),但是你還得繼續等阿姨把菜(內核空間)打到你的飯盒里(用戶空間),經歷完這兩個過程,你才可以離開。
非阻塞 I/O
你去了飯堂,問阿姨菜做好了沒有,阿姨告訴你沒,你就離開了,過幾十分鐘,你又來飯堂問阿姨,阿姨說做好了,于是阿姨幫你把菜打到你的飯盒里,這個過程你是得等待的
異步 I/O
你讓飯堂阿姨將菜做好并把菜打到飯盒里后,把飯盒送到你面前,整個過程你都不需要任何等待
很明顯,異步 I/O 比同步 I/O 性能更好
因為異步 I/O 在「內核數據準備好」和「數據從內核空間拷貝到用戶空間」這兩個過程都不用等待
Proactor 正是采用了異步 I/O 技術,所以被稱為異步網絡模型
簡單總結-IO多路復用的演進流程
面試引導:
線程創建銷毀的開銷
->池化思想
->select,poll存在的問題
->輪詢機制對比事件驅動機制
->epoll,基于紅黑樹和哈希表而不是遍歷文件描述符
->引出IO多路復用的兩種實現,即Reactor和Proactor
池化思想:
如果讓一個服務器能夠服務更多的服務端,最直接的方式就是給每一條連接創建一個線程
但是線程的創建和銷毀是有一定的開銷的,會消耗CPU的時間片輪轉的資源
所以為了達到資源復用就出現了【池化思想】,也就是我們的線程池
不要弄混執行任務的線程池和處理IO的線程池,這兩個東西只是分別處理的東西不同
線程的職責可以根據任務類型劃分
有的線程專注于處理連接的 I/O 操作
有的線程專注于執行業務邏輯任務
IO線程如何高效處理多個連接的業務:
一個連接對應一個線程時線程的處理流程:
「read -> 業務處理 -> send」
線程池的目的是讓一個線程照顧多個連接
但一線程對應多連接時,線程在處理某個連接的 read 操作時,如果沒有數據可讀,則會阻塞
導致線程無法執行其它的IO任務
簡單解決方式:將Socket換成非阻塞,之后線程不斷地輪詢調用 read 操作來判斷是否有數據
但輪詢是要消耗 CPU 的,隨著一個線程處理的連接越多,輪詢的效率就會越低
當socket設為非阻塞時,即使沒有數據可讀,read()也會立即返回一個【無數據的錯誤】,不會阻塞線程
監聽連接:
問題所在:線程并不知道當前連接是否有數據可讀
因此需要每次通過 read 去試探(輪詢)
我們可以引入一種事件驅動機制,等連接來了我們再去執行,這就是epoll
三種IO模型-快速復習
小區別(防止概念混淆):
IO多路復用屬于網絡監聽模型,例如Redis實現網絡監聽的時候就是使用IO多路復用的,當Redis啟動的時候,它會主動使用IO多路復用網絡監聽模型去監聽
每個 Socket 在內核中都有對應的接收緩沖區和發送緩沖區
read:從該 Socket 對應的接收緩沖區中讀取數據到用戶緩沖區
理解三種IO模型:
read 和 send 是阻塞 I/O,還是非阻塞 I/O 都是同步調用
因為在 read 調用時,內核將數據從內核空間拷貝到用戶空間的過程都是需要等待的,也就是說這個過程是同步的,如果內核實現的拷貝效率不高,read 調用就會在這個同步過程中等待比較長的時間
為什么這么說?
同步 的核心是:用戶線程必須親自參與數據拷貝的等待過程
并且用戶 線程必須等待拷貝完成才能繼續執行
同步IO要等待的兩個步驟:
- 「內核數據準備好」
- 「數據從內核空間拷貝到用戶空間」
阻塞IO:等待。你到咖啡店后,發現咖啡還沒做好,于是站在柜臺前一直等
非阻塞IO:輪詢。你到咖啡店后如果沒好,那你就去附近晃悠一會兒,然后再問咖啡時候做好了,直到店員說好了
真正的異步 I/O 是「內核數據準備好」和「數據從內核態拷貝到用戶態」這兩個過程都不用等待
當我們發起 aio_read(異步 I/O)之后,就立即返回
內核自動將數據從內核空間拷貝到用戶空間
這個拷貝過程同樣是異步的,內核自動完成的
和前面的同步操作不一樣,應用程序并不需要主動發起拷貝動作