IO操作根據裝置型別一般分為記憶體IO,網路IO,和磁碟IO。其中記憶體IO的速度大大快於後兩者,計算機的效能瓶頸一般不在於記憶體IO. 儘管網路IO可通過購買獨享頻寬和高速網絡卡來提升速度,可以使用RAID磁碟陣列來提升磁碟IO的速度,但是由於IO操作都是由系統核心呼叫來完成,而系統呼叫是通過cpu來排程的,而cpu的速度遠遠快於IO操作,導致會浪費cpu的寶貴時間來等待慢速的IO操作。為了讓cpu和慢速的IO裝置更好的協調工作,減少CPU在IO呼叫上的消耗,逐漸發展出各種IO模型。

IO模型

IO步驟

I/O主要為:網路IO(本質是socket檔案讀取)、磁碟IO

每次IO,對於一次IO訪問,資料會先被拷貝到核心的緩衝區中,然後才會從核心的緩衝區拷貝到應用程式的地址空間。需要經歷兩個階段:

  • 第一步:將資料從檔案先載入至核心記憶體空間(緩衝區),等待資料準備完成,時間較長
  • 第二步:將資料從核心緩衝區複製到使用者空間的程序的記憶體中,時間較短

阻塞/非阻塞和同步/非同步

IO模型總是離不開阻塞/非阻塞、同步/非同步這些概念。

  • 阻塞/非阻塞:阻塞和非阻塞是對呼叫方執行緒狀態的描述,如果一次IO過程中,呼叫方執行緒需要阻塞執行緒等待資料的到達,那麼說這次IO是阻塞式IO。
  • 同步/非同步:同步和非同步是對呼叫方獲取資料方式的描述,如果呼叫方主動去查詢並複製資料,那麼稱IO是同步的。如果是作業系統在資料準備完成(複製到使用者快取區)之後告訴呼叫方有資料準備好了,那麼稱IO是非同步的。

IO模型分類

發起系統呼叫的是執行在系統上的某個應用的程序、物件是磁碟上的資料、獲取資料需要通過I/O、整個過程就是應用等待獲取磁碟資料。針對整個過程中應用程序的狀態不同,可以分為:同步阻塞型,同步非阻塞型,同步複用型,訊號驅動型,非同步。

同步阻塞型IO

類比:老李去火車站買票,排隊三天買到一張退票。耗費:在車站吃喝拉撒睡3天,其他事一件沒幹。

同步阻塞IO模型是最簡單的IO模型,使用者執行緒在核心進行IO操作時被阻塞,等到資料讀取完成之後在繼續處理後續邏輯,其步驟如下所示(以read()介面為例):

read(file, tmp_buf, len);
  1. 使用者程式需要讀取資料,呼叫read方法,把讀取資料的指令交給CPU執行。
  2. CPU發出指令給DMA,告訴DMA需要讀取磁碟的哪些資料,然後返回,執行緒進入阻塞狀態
  3. DMA向磁碟控制器發出IO請求,告訴磁碟控制器需要讀取哪些資料,然後返回;
  4. 磁碟控制器收到IO請求之後,把資料讀取到磁碟快取區,當磁碟快取讀取完成之後,中斷DMA;
  5. DMA收到磁碟的中斷訊號,將磁碟快取區的資料讀取到PageCache快取區,然後中斷CPU;
  6. CPU響應DMA中斷訊號,知道資料讀取完成,然後將PageCache快取區中的資料讀取到使用者快取中;
  7. 使用者程式從記憶體中讀取到資料,可以繼續執行後續邏輯。

同步阻塞IO的優缺點

優點:程式簡單,在阻塞等待資料期間程序/執行緒掛起,基本不會佔用CPU資源。

缺點:每個連線需要獨立的程序/執行緒單獨處理,當併發請求量大時為了維護程式,記憶體、執行緒切換開銷較大,這種模型在實際生產中很少使用。

同步非阻塞型IO

類比:老李去火車站買票,隔12小時去火車站問有沒有退票,三天後買到一張票。耗費:往返車站6次,路上6小時,其他時間做了好多事。

非阻塞IO就是當呼叫方發起讀取資料申請時,如果核心資料沒有準備好會即刻告訴呼叫方,不需要呼叫方執行緒阻塞等待。

以recvfrom方法為例,呼叫方呼叫recvfrom讀取資料時,如果該緩衝區沒有資料的話,就會直接返回一個EWOULDBLOCK錯誤,不會讓應用一直等待中。在沒有資料的時候會即刻返回錯誤標識,那也意味著如果應用要讀取資料就需要不斷的呼叫recvfrom請求,直到讀取到它資料要的資料為止。其讀取步驟如下所示:

  1. 呼叫方呼叫recvfrom方法嘗試獲取資料;
  2. 如果recvfrom方法返回EWOULDBLOCK錯誤,執行步驟1;如果revifrom方法發現快取區有資料,那麼執行步驟3;
  3. CPU將PageCache快取區中的資料讀取到使用者快取中;
  4. 使用者程式從記憶體中讀取到資料,可以繼續執行後續邏輯。

種方式在程式設計中對socket設定O_NONBLOCK即可。但此方式僅僅針對網路IO有效,對磁碟IO並沒有作用。因為本地檔案IO預設是阻塞,我們所說的網路IO的阻塞是因為網路IO有無限阻塞的可能,而本地檔案除非是被鎖住,否則是不可能無限阻塞的,因此只有鎖這種情況下,O_NONBLOCK才會有作用。而且,磁碟IO時要麼資料在核心緩衝區中直接可以返回,要麼需要呼叫物理裝置去讀取,這時候程序的其他工作都需要等待。因此,後續的IO複用和訊號驅動IO對檔案IO也是沒有意義的。

IO複用模型

IO複用,也叫多路IO就緒通知。這是一種程序預先告知核心的能力,讓核心發現程序指定的一個或多個IO條件就緒了,就通知程序。使得一個程序能在一連串的事件上等待。IO複用的實現方式目前主要有select、poll和epoll。

select/poll

類比:老李去火車站買票,委託黃牛,然後每隔6小時電話黃牛詢問,黃牛三天內買到票,然後老李去火車站交錢領票。耗費:往返車站2次,路上2小時,黃牛手續費100元,打電話17次

select和poll的原理基本相同:

  1. 註冊待偵聽的fd(這裡的fd建立時最好使用非阻塞)
  2. 每次呼叫都去檢查這些fd的狀態,當有一個或者多個fd就緒的時候返回
  3. 返回結果中包括已就緒和未就緒的fd

相比select,poll解決了單個程序能夠開啟的檔案描述符數量有限制這個問題:select受限於FD_SIZE的限制,如果修改則需要修改這個巨集重新編譯核心;而poll通過一個pollfd陣列向核心傳遞需要關注的事件,避開了檔案描述符數量限制。

此外,select和poll共同具有的一個很大的缺點就是包含大量fd的陣列被整體複製於使用者態和核心態地址空間之間,開銷會隨著fd數量增多而線性增大。

epoll

老李去火車站買票,委託黃牛,黃牛買到後即通知老李去領,然後老李去火車站交錢領票。耗費:往返車站2次,路上2小時,黃牛手續費100元,無需打電話

epoll是poll的一種改進:

  1. 基於事件驅動的方式,避免了每次都要把所有fd都掃描一遍。
  2. epoll_wait只返回就緒的fd。
  3. epoll使用nmap記憶體對映技術避免了記憶體複製的開銷。
  4. epoll的fd數量上限是作業系統的最大檔案控制代碼數目,這個數目一般和記憶體有關,通常遠大於1024。

目前,epoll是Linux2.6下最高效的IO複用方式,也是Nginx、Node的IO實現方式。而在freeBSD下,kqueue是另一種類似於epoll的IO複用方式。

此外,對於IO複用還有一個水平觸發和邊緣觸發的概念:

  • 水平觸發:當就緒的fd未被使用者程序處理後,下一次查詢依舊會返回,這是select和poll的觸發方式。
  • 邊緣觸發:無論就緒的fd是否被處理,下一次不再返回。理論上效能更高,但是實現相當複雜,並且任何意外的丟失事件都會造成請求處理錯誤。epoll預設使用水平觸發,通過相應選項可以使用邊緣觸發。

由於同步非阻塞方式需要不斷主動輪詢,輪詢佔據了很大一部分過程,輪詢會消耗大量的CPU時間,而 “後臺” 可能有多個任務在同時進行,人們就想到了迴圈查詢多個任務的完成狀態,只要有任何一個任務完成,就去處理它。如果輪詢不是程序的使用者態,而是有人幫忙就好了。那麼這就是所謂的 “IO 多路複用”。UNIX/Linux 下的 select、poll、epoll 就是幹這個的(epoll 比 poll、select 效率高,做的事情是一樣的)。

IO多路複用有兩個特別的系統呼叫select、poll、epoll函式。select呼叫是核心級別的,select輪詢相對非阻塞的輪詢的區別在於---前者可以等待多個socket,能實現同時對多個IO埠進行監聽,當其中任何一個socket的資料準好了,就能返回進行可讀,然後程序再進行recvform系統呼叫,將資料由核心拷貝到使用者程序,當然這個過程是阻塞的。select或poll呼叫之後,會阻塞程序,與blocking IO阻塞不同在於,此時的select不是等到socket資料全部到達再處理, 而是有了一部分資料就會呼叫使用者程序來處理。如何知道有一部分資料到達了呢?監視的事情交給了核心,核心負責資料到達的處理。也可以理解為"非阻塞"吧。

I/O複用模型會用到select、poll、epoll函式,這幾個函式也會使程序阻塞,但是和阻塞I/O所不同的的,這兩個函式可以同時阻塞多個I/O操作。而且可以同時對多個讀操作,多個寫操作的I/O函式進行檢測,直到有資料可讀或可寫時(注意不是全部資料可讀或可寫),才真正呼叫I/O操作函式。

對於多路複用,也就是輪詢多個socket。多路複用既然可以處理多個IO,也就帶來了新的問題,多個IO之間的順序變得不確定了,當然也可以針對不同的編號。具體流程,如下圖所示:

訊號驅動模型

類比:老李去火車站買票,給售票員留下電話,有票後,售票員電話通知老李,然後老李去火車站交錢領票。耗費:往返車站2次,路上2小時,免黃牛費100元,無需打電話

訊號驅動IO模型,應用程序告訴核心:當資料報準備好的時候,給我傳送一個訊號,對SIGIO訊號進行捕捉,並且呼叫我的訊號處理函式來獲取資料報。流程如下:

  1. 開啟套接字訊號驅動IO功能;
  2. 系統呼叫sigaction執行訊號處理函式(非阻塞,立刻返回),告訴系統資料就緒式呼叫哪個函式;
  3. 資料就緒,生成sigio訊號,通過訊號回撥通知應用來讀取資料。

此種io方式存在的一個很大的問題:Linux中訊號佇列是有限制的,如果超過這個數字問題就無法讀取資料。

Linux訊號的處理:如果這個程序正在使用者態忙著做別的事(例如在計算兩個矩陣的乘積),那就強行打斷之,呼叫事先註冊的訊號處理函式,這個函式可以決定何時以及如何處理這個非同步任務。由於訊號處理函式是突然闖進來的,因此跟中斷處理程式一樣,有很多事情是不能做的,因此保險起見,一般是把事件 “登記” 一下放進佇列,然後返回該程序原來在做的事。

如果這個程序正在核心態忙著做別的事,例如以同步阻塞方式讀寫磁碟,那就只好把這個通知掛起來了,等到核心態的事情忙完了,快要回到使用者態的時候,再觸發訊號通知。

如果這個程序現在被掛起了,例如無事可做 sleep 了,那就把這個程序喚醒,下次有 CPU 空閒的時候,就會排程到這個程序,觸發訊號通知。

非同步 API 說來輕巧,做來難,這主要是對 API 的實現者而言的。Linux 的非同步 IO(AIO)支援是 2.6.22 才引入的,還有很多系統呼叫不支援非同步 IO。Linux 的非同步 IO 最初是為資料庫設計的,因此通過非同步 IO 的讀寫操作不會被快取或緩衝,這就無法利用作業系統的快取與緩衝機制。

很多人把 Linux 的 O_NONBLOCK 認為是非同步方式,但事實上這是前面講的同步非阻塞方式。需要指出的是,雖然 Linux 上的 IO API 略顯粗糙,但每種程式設計框架都有封裝好的非同步 IO 實現。作業系統少做事,把更多的自由留給使用者,正是 UNIX 的設計哲學,也是 Linux 上程式設計框架百花齊放的一個原因。

從前面 IO 模型的分類中,我們可以看出 AIO 的動機:

  • 同步阻塞模型需要在 IO 操作開始時阻塞應用程式。這意味著不可能同時重疊進行處理和 IO 操作。
  • 同步非阻塞模型允許處理和 IO 操作重疊進行,但是這需要應用程式根據重現的規則來檢查 IO 操作的狀態。
  • 這樣就剩下非同步非阻塞 IO 了,它允許處理和 IO 操作重疊進行,包括 IO 操作完成的通知。

非同步IO

類比:老李去火車站買票,給售票員留下電話,有票後,售票員電話通知老李並快遞送票上門。耗費:往返車站1次,路上1小時,免黃牛費100元,無需打電話

當應用程式呼叫aio_read時,核心一方面去取資料報內容返回,另一方面將程式控制權還給應用程序,應用程序繼續處理其他事情,是一種非阻塞的狀態。

當核心中有資料報就緒時,由核心將資料報拷貝到應用程式中,返回aio_read中定義好的函式處理程式。

很少有Linux系統支援,Windows的IOCP就是該模型。可以看出,阻塞程度:阻塞IO>非阻塞IO>多路轉接IO>訊號驅動IO>非同步IO,效率是由低到高的。

歡迎關注御狐神的微信公眾號

參考文件

IO和零拷貝

非同步IO、epoll、零拷貝

IO概念和五種IO模型

本文最先發布至微信公眾號,版權所有,禁止轉載!