天天看點

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

本節書摘來自異步社群《linux系統程式設計(第2版)》一書中的第2章,第2.10節,作者:【美】robert love著,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視

應用通常需要在多個檔案描述符上阻塞:在鍵盤輸入(stdin)、程序間通信以及很多檔案之間協調i/o。基于事件驅動的圖形使用者界面(gui)應用可能會和成百上千個事件的主循環競争[5]。

如果不使用線程,而是獨立處理每個檔案描述符,單個程序無法同時在多個檔案描述符上阻塞。隻要這些描述符已經有資料可讀寫,也可以采用多個檔案描述符的方式。但是,要是有個檔案描述符資料還沒有準備好——比如發送了read()調用,但是還沒有任何資料——程序會阻塞,而且無法對其他的檔案描述符提供服務。該程序可能隻是阻塞幾秒鐘,導緻應用效率變低,影響使用者體驗。然而,如果該檔案描述符一直沒有資料,程序就會一直阻塞。因為檔案描述符的i/o總是關聯的(比如管道),很可能一個檔案描述符依賴另一個檔案描述符,在後者可用前,前者一直處于不可用狀态。尤其是對于網絡應用而言,可能同時會打開多個socket,進而引發很多問題。

試想一下如下場景:當标準輸入裝置(stdin)挂起,沒有資料輸出,應用在和程序間通信(ipc)相關的檔案描述符上阻塞。隻有當阻塞的ipc檔案描述符傳回資料後,程序才知道鍵盤輸入挂起——但是如果阻塞的操作一直沒有傳回,又會發生什麼呢?

如前所述,非阻塞i/o 是這種問題的一個解決方案。使用非阻塞i/o,應用可以發送i/o請求,該請求傳回特定錯誤,而不是阻塞。但是,該方案效率不高,主要有兩個原因:首先,程序需要連續随機發送i/o操作,等待某個打開的檔案描述符可以執行i/o操作。這種設計很糟糕。其次,如果程序睡眠則會更高效,睡眠可以釋放cpu資源,使得cpu可以處理其他任務,直到一個或多個檔案描述符可以執行i/o時再喚醒程序。

下面我們一起來探讨i/o多路複用。

i/o多路複用支援應用同時在多個檔案描述符上阻塞,并在其中某個可以讀寫時收到通知。是以,i/o多路複用成為應用的關鍵所在,在設計上遵循以下原則。

1.i/o多路複用:當任何一個檔案描述符i/o就緒時進行通知。

2.都不可用?在有可用的檔案描述符之前一直處于睡眠狀态。

3.喚醒:哪個檔案描述符可用了?

4.處理所有i/o就緒的檔案描述符,沒有阻塞。

5.傳回第1步,重新開始。

linux提供了三種i/o多路複用方案:select、poll和epoll。本章先探讨select和poll,epoll是linux特有的進階解決方案,将在第4章詳細說明。

2.10.1 select()

select()系統調用提供了一種實作同步i/o多路複用的機制:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

在給定的檔案描述符i/o就緒之前并且還沒有超出指定的時間限制,select()調用就會阻塞。

監視的檔案描述符可以分為3類,分别等待不同的事件。對于readfds集中的檔案描述符,監視是否有資料可讀(即某個讀操作是否可以無阻塞完成);對于writefds集中的檔案描述符,監視是否有某個寫操作可以無阻塞完成;對于exceptfds中的檔案描述符,監視是否發生異常,或者出現帶外(out-of-band)資料(這些場景隻适用于socket)。指定的集合可能是null,在這種情況下,select()不會監視該事件。

成功傳回時,每個集合都修改成隻包含相應類型的i/o就緒的檔案描述符。舉個例子,假定readfds集中有兩個檔案描述符7和9。當調用傳回時,如果描述符7還在集合中,它在i/o讀取時不會阻塞。如果描述符9不在集合中,它在讀取時很可能會發生阻塞。(這裡說的是“很可能”是因為在調用完成後,資料可能已經就緒了。在這種場景下,下一次調用select()就會傳回描述符可用。)[6]

第一個參數n,其值等于所有集合中檔案描述符的最大值加1。是以,select()調用負責檢查哪個檔案描述符值最大,将該最大值加1後傳給第一個參數。

參數timeout是指向timeval結構體的指針,定義如下:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

如果該參數不是null,在tv_sec秒tv_usec微秒後。select()調用會傳回,即使沒有一個檔案描述符處于i/o就緒狀态。傳回時,在不同的unix系統中,該結構體是未定義的,是以每次調用必須(和檔案描述符集一起)重新初始化。實際上,目前linux版本會自動修改該參數,把值修改成剩餘的時間。是以,如果逾時設定是5秒,在檔案描述符可用之前已逝去了3秒,那麼在調用傳回時,tv.tv_sec的值就是2。

如果逾時值都是設定成0,調用會立即傳回,調用時報告所有事件都挂起,而不會等待任何後續事件。

不是直接操作檔案描述符集,而是通過輔助宏來管理。通過這種方式,unix系統可以按照所希望的方式來實作。不過,大多數系統把集合實作成位數組。

fd_zero從指定集合中删除所有的檔案描述符。每次調用select()之前,都應該調用該宏。

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

fd_set向指定集中添加一個檔案描述符,而fd_clr則從指定集中删除一個檔案描述符。

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

設計良好的代碼應該都不需要使用fd_clr,極少使用該宏。

fd_isset檢查一個檔案描述符是否在給定集合中。如果在,則傳回非0值,否則傳回0。當select()調用傳回時,會通過fd_isset來檢查檔案描述符是否就緒:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

由于檔案描述符集是靜态建立的,是以檔案描述符數存在上限值,而且存在最大檔案描述符值,這兩個值都是由fd_setsize設定。在linux,該值是1024。我們将在本章稍後一起來看各種不同限制。

傳回值和錯誤碼

select()調用成功時,傳回三個集合中i/o就緒的檔案描述符總數。如果給出了逾時設定,傳回值可能是0。出錯時,傳回-1,并把errno值設定成如下值之一:

ebadf

某個集合中存在非法檔案描述符。

eintr

等待時捕獲了一個信号,可以重新發起調用。

einval

參數n是負數,或者設定的逾時時間值非法。

enomem

沒有足夠的記憶體來完成該請求。

select()示例

我們來看看下面的示例代碼,雖然簡單但對select()用法的說明卻非常實用。在這個例子中,會阻塞等待stdin的輸入,逾時設定是5秒。由于隻監視單個檔案描述符,該示例不算i/o多路複用,但它很清晰地說明了如何使用系統調用:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用
《Linux系統程式設計(第2版)》——2.10 I/O多路複用
《Linux系統程式設計(第2版)》——2.10 I/O多路複用

用select()實作可移植的sleep功能

在各個unix系統中,相比微秒級的sleep功能,對select()的實作更普遍,是以select()調用常常被作為可移植的sleep實作機制:把所有三個集都設定null,逾時值設定為非null。如下:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

linux提供了高精度的sleep機制。在第11章中,我們将詳細說明它。

pselect()

select()系統調用很流行,它最初是在4.2bsd中引入的,但是posix标準在posix 1003.1g-2000和後來的posix 1003.1-2001中定義了自己的pselect()方法:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

pselect()和select()存在三點差別:

pselect()的timeout參數使用了timespec結構體,而不是timeval結構體。timespec結構體使用秒和納秒,而不是秒和毫秒,從理論上講更精确些。但實際上,這兩個結構體在毫秒精度上已經不可靠了。

pselect()調用不會修改timeout參數。是以,在後續調用中,不需要重新初始化該參數。

select()系統調用沒有sigmask參數。當這個參數設定為null時,pselect()的行為和select()相同。

timespec結構體定義如下:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

把pselect()添加到unix工具箱的主要原因是為了增加sigmask參數,該參數是為了解決檔案描述符和信号之間等待而出現競争條件(在第10章将深入讨論信号)。假設信号處理程式設定了全局标志位(大部分都如此),程序每次調用select()之前會檢查該标志位。現在,假定在檢查标志位和調用之間收到信号,應用可能會一直阻塞,永遠都不會響應該信号。pselect()提供了一組可阻塞信号,應用在調用時可以設定這些信号來解決這個問題。阻塞的信号要等到解除阻塞才會處理。一旦pselect()傳回,核心就會恢複老的信号掩碼。

在linux核心2.6.16之前,pselect()還不是系統調用,而是由glibc提供的對select()調用的簡單封裝。該封裝對出現競争的風險最小化,但是并沒有完全消除競争。當真正引入了新的系統調用pselect()之後,才徹底解決了競争問題。

雖然和select()相比,pselect()有一定的改進,但大多數應用還是使用select(),有的是出于習慣,也有的是為了更好的可移植性。

2.10.2 poll()

poll()系統調用是system v的i/o多路複用解決方案。它解決了一些select()的不足,不過select()還是被頻繁使用(還是出于習慣或可移植性的考慮):

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

select()使用了基于檔案描述符的三位掩碼的解決方案,其效率不高;和它不同,poll()使用了由nfds個pollfd結構體構成的數組,fds指針指向該數組。pollfd結構體定義如下:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

每個pollfd結構體指定一個被監視的檔案描述符。可以給poll()傳遞多個pollfd結構體,使它能夠監視多個檔案描述符。每個結構體的events變量是要監視的檔案描述符的事件的位掩碼。使用者可以設定該變量。revents變量是該檔案描述符的結果事件的位掩碼。核心在傳回時會設定revents變量。events變量中請求的所有事件都可能在revents變量中傳回。以下是合法的events值:

pollin

有資料可讀。

pollrdnorm

有普通資料可讀。

pollrdband

有優先資料可讀。

pollpri

有高優先級資料可讀。

pollout

寫操作不會阻塞。

pollwrnorm

寫普通資料不會阻塞。

pollband

寫優先資料不會阻塞。

pollmsg

有sigpoll消息可用。

此外,revents變量可能會傳回如下事件:

poller

給定的檔案描述符出現錯誤。

pollhup

給定的檔案描述符有挂起事件。

pollnval

給定的檔案描述符非法。

對于events變量,這些事件沒有意義,events參數不要傳遞這些變量,它們會在revents變量中傳回。poll()和select()不同,不需要顯式請求異常報告。

pollin | pollpri等價于select()的讀事件,而pollout | pollwrband等價于select()的寫事件。pollin等價于pollrdnorm | pollrdband,而pollout等價于pollwrnorm。

舉個例子,要監視某個檔案描述符是否可讀寫,需要把events設定成pollin | pollout。傳回時,會檢查revents中是否有相應的标志位。如果設定了pollin,檔案描述符可非阻塞讀;如果設定了pollout,檔案描述符可非阻塞寫。标志位并不是互相排斥的:可以同時設定,表示可以在該檔案描述符上讀寫,而且都不會阻塞。

timeout參數指定等待的時間長度,機關是毫秒,不論是否有i/o就緒,poll()調用都會傳回。如果timeout值為負數,表示永遠等待;timeout為0表示poll()調用立即傳回,并給出所有i/o未就緒的檔案描述符清單,不會等待更多事件。在這種情況下,poll()調用如同其名,輪詢一次後立即傳回。

poll()調用成功時,傳回revents變量不為0的所有檔案描述符個數;如果沒有任何事件發生且未逾時,傳回0。失敗時,傳回-1,并相應設定errno值如下:

一個或多個結構體中存在非法檔案描述符。

efault

fds指針指向的位址超出了程序位址空間。

在請求事件發生前收到了一個信号,可以重新發起調用。

nfds參數超出了rlimit_nofile值。

可用記憶體不足,無法完成請求。

poll()示例

我們一起來看一下poll()的示例程式,它同時檢測stdin讀和stdout寫是否會發生阻塞:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

運作後,生成結果如下(和期望一緻):

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

再次運作,這次把一個檔案重定向到标準輸入,可以看到兩個事件:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

如果在實際應用中使用poll(),不需要在每次調用時都重新建構pollfd結構體。該結構體可能會被重複傳遞多次,核心會在必要時把revents清空。

ppoll()

類似于pselect()和select(),linux也為poll()提供了ppoll()。然而,和pselect()不同,ppoll()是linux特有的調用:

《Linux系統程式設計(第2版)》——2.10 I/O多路複用

類似于pselect(),timeout參數指定的逾時時間是秒和納秒,sigmask參數提供了一組等待處理的信号。

2.10.3 poll()和select()的差別

雖然poll()和select()完成相同的工作,但poll()調用在很多方面仍然優于select()調用:

poll()不需要使用者計算最大檔案描述符值加1作為參數傳遞給它。

poll()對于值很大的檔案描述符,效率更高。試想一下,要通過select()監視一個值為900的檔案描述符,核心需要檢查每個集合中的每個位,一直檢查900個位。

select()的檔案描述符集合是靜态的,需要對大小設定進行權衡:如果值很小,會限制select()可監視的最大檔案描述符值;如果值很大,效率會很低。當值很大時,大的位掩碼操作效率不高,尤其是當無法确定集合是否稀疏集合。[7]對于poll(),可以準确建立大小合适的數組。如果隻需要監視一項,則僅傳遞一個結構體。

對于select()調用,傳回時會重新建立檔案描述符集,是以每次調用都必須重新初始化。poll()系統調用會把輸入(events變量)和輸出(revents變量)分離開,支援無需改變數組就可以重新使用。

select()調用的timeout參數在傳回時是未定義的。代碼要支援可移植,需要重新對它初始化。而對于pselect(),不存在這些問題。

不過,select()系統調用也有些優點:

select()可移植性更好,因為有些unix系統不支援poll()。

select()提供了更高的逾時精度:select()支援微秒級,poll()支援毫秒級。ppoll()和pselect()理論上都提供了納秒級的逾時精度,但是實際上,這兩個調用的毫秒級精度都不可靠。

繼續閱讀