概念說明
用戶空間與內核空間
現在操作系統都是采用虛擬存儲器,那么對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內 部定義的等待隊列),這也能節省不少的開銷。
感謝閱讀,希望能幫助到大家,謝謝大家對本站的支持!