Linux 下的五種 IO 模型

decaywood's Blog發表於2016-07-28

概念說明

使用者空間與核心空間

現在作業系統都是採用虛擬儲存器,那麼對32位作業系統而言,它的定址空間(虛擬儲存空間)為4G(2的32次方)。作業系統的核心是核心,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體裝置的所有許可權。為了保證使用者程式不能直接操作核心(kernel),保證核心的安全,作業系統將虛擬空間劃分為兩部分,一部分為核心空間,一部分為使用者空間。針對linux作業系統而言,將最高的1G位元組(從虛擬地址0xC0000000到0xFFFFFFFF),供核心使用,稱為核心空間,而將較低的3G位元組(從虛擬地址0×00000000到0xBFFFFFFF),供各個程式使用,稱為使用者空間。

程式切換

為了控制程式的執行,核心必須有能力掛起正在CPU上執行的程式,並恢復以前掛起的某個程式的執行。這種行為被稱為程式切換。因此可以說,任何程式都是在作業系統核心的支援下執行的,是與核心緊密相關的。

從一個程式的執行轉到另一個程式上執行,這個過程中經過下面這些變化:

  • 儲存處理機上下文,包括程式計數器和其他暫存器。
  • 更新PCB資訊。
  • 把程式的PCB移入相應的佇列,如就緒、在某事件阻塞等佇列。 選擇另一個程式執行,並更新其PCB。
  • 更新記憶體管理的資料結構。
  • 恢復處理機上下文。

程式的阻塞

正在執行的程式,由於期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新資料尚未到達或無新工作做等,則由系統自動執行阻塞原語(Block),使自己由執行狀態變為阻塞狀態。可見,程式的阻塞是程式自身的一種主動行為,也因此只有處於執行態的程式(獲得CPU),才可能將其轉為阻塞狀態。當程式進入阻塞狀態,是不佔用CPU資源的。

檔案描述符

檔案描述符(File descriptor)是電腦科學中的一個術語,是一個用於表述指向檔案的引用的抽象化概念。

檔案描述符在形式上是一個非負整數。實際上,它是一個索引值,指向核心為每一個程式所維護的該程式開啟檔案的記錄表。當程式開啟一個現有檔案或者建立一個新檔案時,核心向程式返回一個檔案描述符。在程式設計中,一些涉及底層的程式編寫往往會圍繞著檔案描述符展開。但是檔案描述符這一概念往往只適用於UNIX、Linux這樣的作業系統。

快取 IO

快取 IO 又被稱作標準 IO,大多數檔案系統的預設 IO 操作都是快取 IO。在 Linux 的快取 IO 機制中,作業系統會將 IO 的資料快取在檔案系統的頁快取( page cache )中,也就是說,資料會先被拷貝到作業系統核心的緩衝區中,然後才會從作業系統核心的緩衝區拷貝到應用程式的地址空間。

快取 IO 的缺點:

資料在傳輸過程中需要在應用程式地址空間和核心進行多次資料拷貝操作,這些資料拷貝操作所帶來的 CPU 以及記憶體開銷是非常大的。

同步與非同步 & 阻塞與非阻塞

在進行網路程式設計時,我們常常見到同步(Sync)/非同步(Async),阻塞(Block)/非阻塞(Unblock)四種呼叫方式,先理解一些概念性的東西。

1.同步與非同步

同步與非同步同步和非同步關注的是訊息通訊機制 (synchronous communication/ asynchronous communication)所謂同步,就是在發出一個呼叫時,在沒有得到結果之前,該呼叫就不返回。但是一旦呼叫返回,就得到返回值了。換句話說,就是由呼叫者主動等待這個呼叫的結果。

而非同步則是相反,呼叫在發出之後,這個呼叫就直接返回了,所以沒有返回結果。換句話說,當一個非同步過程呼叫發出後,呼叫者不會立刻得到結果。而是在呼叫發出後,被呼叫者通過狀態、通知來通知呼叫者,或通過回撥函式處理這個呼叫。

典型的非同步程式設計模型比如Node.js。

2016.4.17更新:

POSIX對這兩個術語的定義:

  • 同步I/O操作:導致請求程式阻塞,直到I/O操作完成
  • 非同步I/O操作:不導致請求程式阻塞

2. 阻塞與非阻塞

阻塞和非阻塞關注的是程式在等待呼叫結果(訊息,返回值)時的狀態。

阻塞呼叫是指呼叫結果返回之前,當前執行緒會被掛起。呼叫執行緒只有在得到結果之後才會返回。非阻塞呼叫指在不能立刻得到結果之前,該呼叫不會阻塞當前執行緒。

關於阻塞/非阻塞 & 同步/非同步更加形象的比喻

老張愛喝茶,廢話不說,煮開水。 出場人物:老張,水壺兩把(普通水壺,簡稱水壺;會響的水壺,簡稱響水壺)。

1. 老張把水壺放到火上,立等水開。(同步阻塞) 老張覺得自己有點傻

2. 老張把水壺放到火上,去客廳看電視,時不時去廚房看看水開沒有。(同步非阻塞) 老張還是覺得自己有點傻,於是變高階了,買了把會響笛的那種水壺。水開之後,能大聲發出嘀~~~~的噪音。

3. 老張把響水壺放到火上,立等水開。(非同步阻塞) 老張覺得這樣傻等意義不大

4. 老張把響水壺放到火上,去客廳看電視,水壺響之前不再去看它了,響了再去拿壺。(非同步非阻塞) 老張覺得自己聰明瞭。

所謂同步非同步,只是對於水壺而言。普通水壺,同步;響水壺,非同步。雖然都能幹活,但響水壺可以在自己完工之後,提示老張水開了。這是普通水壺所不能及的。同步只能讓呼叫者去輪詢自己(情況2中),造成老張效率的低下。

所謂阻塞非阻塞,僅僅對於老張而言。立等的老張,阻塞;看視的老張,非阻塞。情況1和情況3中老張就是阻塞的,媳婦喊他都不知道。雖然3中響水壺是非同步的,可對於立等的老張沒有太大的意義。所以一般非同步是配合非阻塞使用的,這樣才能發揮非同步的效用。

Linux下的五種IO模型

  • 阻塞IO(blocking IO)
  • 非阻塞IO (nonblocking IO)
  • IO複用(select 和poll) (IO multiplexing)
  • 訊號驅動IO (signal driven IO (SIGIO))
  • 非同步IO (asynchronous IO (the POSIX aio_functions))

前四種都是同步,只有最後一種才是非同步IO。

阻塞IO模型

在這個模型中,應用程式(application)為了執行這個read操作,會呼叫相應的一個system call,將系統控制權交給kernel,然後就進行等待(這其實就是被阻塞了)。kernel開始執行這個system call,執行完畢後會嚮應用程式返回響應,應用程式得到響應後,就不再阻塞,並進行後面的工作。

非阻塞IO

在linux下,應用程式可以通過設定檔案描述符的屬性O_NONBLOCK,IO操作可以立即返回,但是並不保證IO操作成功。也就是說,當應用程式設定了O_NONBLOCK之後,執行write操作,呼叫相應的system call,這個system call會從核心中立即返回。但是在這個返回的時間點,資料可能還沒有被真正的寫入到指定的地方。也就是說,kernel只是很快的返回了這個 system call(只有立馬返回,應用程式才不會被這個IO操作blocking),但是這個system call具體要執行的事情(寫資料)可能並沒有完成。而對於應用程式,雖然這個IO操作很快就返回了,但是它並不知道這個IO操作是否真的成功了,為了知道IO操作是否成功,一般有兩種策略:一是需要應用程式主動地迴圈地去問kernel(這種方法就是同步非阻塞IO);二是採用IO通知機制,比如:IO多路複用(這種方法屬於非同步阻塞IO)或訊號驅動IO(這種方法屬於非同步非阻塞IO)。

IO多路複用(非同步阻塞IO)

和之前一樣,應用程式要執行read操作,因此呼叫一個system call,這個system call被傳遞給了kernel。但在應用程式這邊,它呼叫system call之後,並不等待kernel的返回結果而是立即返回,雖然立即返回的呼叫函式是一個非同步的方式,但應用程式會被像select()、poll和epoll等具有複用多個檔案描述符的函式阻塞住,一直等到這個system call有結果返回了,再通知應用程式。也就是說,“在這種模型中,IO函式是非阻塞的,使用阻塞 select、poll、epoll系統呼叫來確定一個 或多個IO 描述符何時能操作。”所以,從IO操作的實際效果來看,非同步阻塞IO和第一種同步阻塞IO是一樣的,應用程式都是一直等到IO操作成功之後(資料已經被寫入或者讀取),才開始進行下面的工作。不同點在於非同步阻塞IO用一個select函式可以為多個描述符提供通知,提高了併發性。舉個例子:假如有一萬個併發的read請求,但是網路上仍然沒有資料,此時這一萬個read會同時各自阻塞,現在用select、poll、epoll這樣的函式來專門負責阻塞同時監聽這一萬個請求的狀態,一旦有資料到達了就負責通知,這樣就將之前一萬個的各自為戰的等待與阻塞轉為一個專門的函式來負責與管理。與此同時,非同步阻塞IO和第二種同步非阻塞IO的區別在於:同步非阻塞IO是需要應用程式主動地迴圈去詢問是否有運算元據可操作,而非同步阻塞IO是通過像select和poll等這樣的IO多路複用函式來同時檢測多個事件控制程式碼來告知應用程式是否可以有資料操作。

訊號驅動IO (signal driven IO (SIGIO))

應用程式提交read請求的system call,然後,kernel開始處理相應的IO操作,而同時,應用程式並不等kernel返回響應,就會開始執行其他的處理操作(應用程式沒有被IO操作所阻塞)。當kernel執行完畢,返回read的響應,就會產生一個訊號或執行一個基於執行緒的回撥函式來完成這次 IO 處理過程。

從理論上說,阻塞IO、IO複用和訊號驅動的IO都是同步IO模型。因為在這三種模型中,IO的讀寫操作都是在IO事件發生之後由應用程式來完成。而POSIX規範所定義的非同步IO模型則不同。對非同步IO而言,使用者可以直接對IO執行讀寫操作,這些操作告訴核心使用者讀寫緩衝區的位置,以及IO操作完成後核心通知應用程式的方式。非同步IO讀寫操作總是立即返回,而不論IO是否阻塞的,因為真主的讀寫操作已經由核心接管。也就是說,同步IO模型要求使用者程式碼自行執行IO操作(將資料從核心緩衝區讀入使用者緩衝區,或將資料從使用者緩衝區寫入核心緩衝區),而非同步IO機制則是由核心來執行IO操作(資料在核心緩衝區和使用者緩衝區之間的移動是由核心在後臺完成的)。你可以這樣認為,同步IO嚮應用程式通知的是IO就緒事件,而非同步IO嚮應用程式通知的是IO完成事件。linux環境下,aio.h標頭檔案中定義的函式提供了對非同步IO的支援。

非同步IO (asynchronous IO (the POSIX aio_functions))

非同步IO與上面的非同步概念是一樣的, 當一個非同步過程呼叫發出後,呼叫者不能立刻得到結果,實際處理這個呼叫的函式在完成後,通過狀態、通知和回撥來通知呼叫者的輸入輸出操作。非同步IO的工作機制是:告知核心啟動某個操作,並讓核心在整個操作完成後通知我們,這種模型與訊號驅動的IO區別在於,訊號驅動IO是由核心通知我們何時可以啟動一個IO操作,這個IO操作由使用者自定義的訊號函式來實現,而非同步IO模型是由核心告知我們IO操作何時完成。為了實現非同步IO,專門定義了一套以aio開頭的API,如:aio_read.

小結:前四種模型–阻塞IO、非阻塞IO、多路複用IO和訊號驅動IO都屬於同步模式,因為其中真正的IO操作(函式)都將會阻塞程式,只有非同步IO模型真正實現了IO操作的非同步性。

IO複用

為了解釋這個名詞,首先來理解下複用這個概念,複用也就是共用的意思,這樣理解還是有些抽象,為此,我們們來理解下複用在通訊領域的使用,在通訊領域中為了充分利用網路連線的物理介質,往往在同一條網路鏈路上採用分時多工或分頻多工的技術使其在同一鏈路上傳輸多路訊號,到這裡我們就基本上理解了複用的含義,即公用某個“介質”來儘可能多的做同一類(性質)的事,那IO複用的“介質”是什麼呢?為此我們首先來看看伺服器程式設計的模型,客戶端發來的請求服務端會產生一個程式來對其進行服務,每當來一個客戶請求就產生一個程式來服務,然而程式不可能無限制的產生,因此為了解決大量客戶端訪問的問題,引入了IO複用技術,即:一個程式可以同時對多個客戶請求進行服務。也就是說IO複用的“介質”是程式(準確的說複用的是select和poll,因為程式也是靠呼叫select和poll來實現的),複用一個程式(select和poll)來對多個IO進行服務,雖然客戶端發來的IO是併發的但是IO所需的讀寫資料多數情況下是沒有準備好的,因此就可以利用一個函式(select和poll)來監聽IO所需的這些資料的狀態,一旦IO有資料可以進行讀寫了,程式就來對這樣的IO進行服務。

理解完IO複用後,我們在來看下實現IO複用中的三個API(select、poll和epoll)的區別和聯絡,select,poll,epoll都是IO多路複用的機制,IO多路複用就是通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知應用程式進行相應的讀寫操作。但select,poll,epoll本質上都是同步IO,因為他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而非同步IO則無需自己負責進行讀寫,非同步IO的實現會負責把資料從核心拷貝到使用者空間。三者的原型如下所示:

  • int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

select

select的第一個引數nfds為fdset集合中最大描述符值加1,fdset是一個位陣列,其大小限制為__FD_SETSIZE(1024),位陣列的每一位代表其對應的描述符是否需要被檢查。第二三四參數列示需要關注讀、寫、錯誤事件的檔案描述符位陣列,這些引數既是輸入引數也是輸出引數,可能會被核心修改用於標示哪些描述符上發生了關注的事件,所以每次呼叫select前都需要重新初始化fdset。timeout引數為超時時間,該結構會被核心修改,其值為超時剩餘的時間。

select的呼叫步驟如下:

  • 使用copy_from_user從使用者空間拷貝fdset到核心空間
  • 註冊回撥函式__pollwait
  • 遍歷所有fd,呼叫其對應的poll方法(對於socket,這個poll方法是sock_poll,sock_poll根據情況會呼叫到tcp_poll,udp_poll或者datagram_poll)
  • 以tcp_poll為例,其核心實現就是__pollwait,也就是上面註冊的回撥函式。
  • __pollwait的主要工作就是把current(當前程式)掛到裝置的等待佇列中,不同的裝置有不同的等待佇列,對於tcp_poll 來說,其等待佇列是sk->sk_sleep(注意把程式掛到等待佇列中並不代表程式已經睡眠了)。在裝置收到一條訊息(網路裝置)或填寫完檔案數 據(磁碟裝置)後,會喚醒裝置等待佇列上睡眠的程式,這時current便被喚醒了。
  • poll方法返回時會返回一個描述讀寫操作是否就緒的mask掩碼,根據這個mask掩碼給fd_set賦值。
  • 如果遍歷完所有的fd,還沒有返回一個可讀寫的mask掩碼,則會呼叫schedule_timeout是呼叫select的程式(也就是 current)進入睡眠。當裝置驅動發生自身資源可讀寫後,會喚醒其等待佇列上睡眠的程式。如果超過一定的超時時間(schedule_timeout 指定),還是沒人喚醒,則呼叫select的程式會重新被喚醒獲得CPU,進而重新遍歷fd,判斷有沒有就緒的fd。
  • 把fd_set從核心空間拷貝到使用者空間。

總結下select的幾大缺點:

(1)每次呼叫select,都需要把fd集合從使用者態拷貝到核心態,這個開銷在fd很多時會很大 (2)同時每次呼叫select都需要在核心遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大 (3)select支援的檔案描述符數量太小了,預設是1024

poll

poll與select不同,通過一個pollfd陣列向核心傳遞需要關注的事件,故沒有描述符個數的限制,pollfd中的events欄位和revents分別用於標示關注的事件和發生的事件,故pollfd陣列只需要被初始化一次。

poll的實現機制與select類似,其對應核心中的sys_poll,只不過poll向核心傳遞pollfd陣列,然後對pollfd中的每個描述符進行poll,相比處理fdset來說,poll效率更高。poll返回後,需要對pollfd中的每個元素檢查其revents值,來得指事件是否發生。

epoll

直到Linux2.6才出現了由核心直接支援的實現方法,那就是epoll,被公認為Linux2.6下效能最好的多路IO就緒通知方法。epoll可以同時支援水平觸發和邊緣觸發(Edge Triggered,只告訴程式哪些檔案描述符剛剛變為就緒狀態,它只說一遍,如果我們沒有采取行動,那麼它將不會再次告知,這種方式稱為邊緣觸發),理論上邊緣觸發的效能要更高一些,但是程式碼實現相當複雜。epoll同樣只告知那些就緒的檔案描述符,而且當我們呼叫epoll_wait()獲得就緒檔案描述符時,返回的不是實際的描述符,而是一個代表就緒描述符數量的值,你只需要去epoll指定的一個陣列中依次取得相應數量的檔案描述符即可,這裡也使用了記憶體對映(mmap)技術,這樣便徹底省掉了這些檔案描述符在系統呼叫時複製的開銷。另一個本質的改進在於epoll採用基於事件的就緒通知方式。在select/poll中,程式只有在呼叫一定的方法後,核心才對所有監視的檔案描述符進行掃描,而epoll事先通過epoll_ctl()來註冊一個檔案描述符,一旦基於某個檔案描述符就緒時,核心會採用類似callback的回撥機制,迅速啟用這個檔案描述符,當程式呼叫epoll_wait()時便得到通知。

epoll既然是對select和poll的改進,就應該能避免上述的三個缺點。那epoll都是怎麼解決的呢?在此之前,我們先看一下epoll 和select和poll的呼叫介面上的不同,select和poll都只提供了一個函式——select或者poll函式。而epoll提供了三個函 數,epoll_create,epoll_ctl和epoll_wait,epoll_create是建立一個epoll控制程式碼;epoll_ctl是注 冊要監聽的事件型別;epoll_wait則是等待事件的產生。

對於第一個缺點,epoll的解決方案在epoll_ctl函式中。每次註冊新的事件到epoll控制程式碼中時(在epoll_ctl中指定 EPOLL_CTL_ADD),會把所有的fd拷貝進核心,而不是在epoll_wait的時候重複拷貝。epoll保證了每個fd在整個過程中只會拷貝一次。

對於第二個缺點,epoll的解決方案不像select或poll一樣每次都把current輪流加入fd對應的裝置等待佇列中,而只在 epoll_ctl時把current掛一遍(這一遍必不可少)併為每個fd指定一個回撥函式,當裝置就緒,喚醒等待佇列上的等待者時,就會呼叫這個回撥 函式,而這個回撥函式會把就緒的fd加入一個就緒連結串列)。epoll_wait的工作實際上就是在這個就緒連結串列中檢視有沒有就緒的fd(利用 schedule_timeout()實現睡一會,判斷一會的效果,和select實現中的第7步是類似的)。

對於第三個缺點,epoll沒有這個限制,它所支援的FD上限是最大可以開啟檔案的數目,這個數字一般遠大於2048,舉個例子, 在1GB記憶體的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統記憶體關係很大。

總結

(1)select,poll實現需要自己不斷輪詢所有fd集合,直到裝置就緒,期間可能要睡眠和喚醒多次交替。而epoll其實也需要呼叫 epoll_wait不斷輪詢就緒連結串列,期間也可能多次睡眠和喚醒交替,但是它是裝置就緒時,呼叫回撥函式,把就緒fd放入就緒連結串列中,並喚醒在 epoll_wait中進入睡眠的程式。雖然都要睡眠和交替,但是select和poll在“醒著”的時候要遍歷整個fd集合,而epoll在“醒著”的 時候只要判斷一下就緒連結串列是否為空就行了,這節省了大量的CPU時間,這就是回撥機制帶來的效能提升。

(2)select,poll每次呼叫都要把fd集合從使用者態往核心態拷貝一次,並且要把current往裝置等待佇列中掛一次,而epoll只要 一次拷貝,而且把current往等待佇列上掛也只掛一次(在epoll_wait的開始,注意這裡的等待佇列並不是裝置等待佇列,只是一個epoll內 部定義的等待佇列),這也能節省不少的開銷。

相關文章