6.1 高級I/O和進程資源
正如我們在前面章節 中看到的,程序可以同時打開多個文件描述符。這些文件描述符並不一定就是文件,還可以是fifo、pipe或者socket。於是,如何復用這些打開的描 述符就很重要了。例如,考慮一個簡單的郵件閱讀程序,比如pine。它顯然應當允許用戶在讀寫email的同時也能去檢查是否有新郵件。這就意味着在任一 給定時刻都至少能夠接收兩個來源的輸入:一個來源是用戶,另一個是用來檢查新郵件的描述符。處理描述符的復用是個復雜的問題。一種方法是把所有打開的描述 符都標記為非阻塞的(O_NONBLOCK),然后在它們之中循環,直到找到一個可以進行I/O操作的描述符為止。這種方法的問題是程序會一直在循環,如 果長時間內沒有I/O可用,進程就會一直占據CPU。當有多個進程在一組很少的描述符上循環時,你的CPU的負載就會惡化。
另一種方法就 是設置信號處理器去捕獲I/O變為可用的事件,然后就讓進程進入休眠狀態。如果你只打開了少量的描述符,而且並不經常請求I/O的話,這種方法從理論上看 倒是不錯。由於進程已經休眠,就不會再占用CPU,僅當I/O可用時它才恢復執行。然而,這種方法的問題在於信號處理的開銷有點大。比如一個web服務 器,每分鍾收到100個請求,那就幾乎一直都在捕獲信號。每秒鍾捕獲上百個信號的開銷是相當大的,不單是進程,對於內核發送信號的開銷而言也是一樣的。
到 目前為止,我們看到的兩種選擇都有限制,效率也不高,它們需要解決的共同問題就是進程需要知道I/O究竟什么時候能用?然而,這個信息實際上只有內核才能 事先知道,因為是內核在最終處理系統中的所有打開的描述符。例如,當一個進程通過fifo向另一個進程發送數據的時候,發送進程會調用write,這是一 個系統調用,因此會進入內核。在發送方的write系統調用執行完畢之前接收方對此是一無所知的。於是就引出了一個更好的復用文件描述符的方法:由內核來 替進程管理描述符。換句話說,就是把一個打開描述符的鏈表發送給內核,然后等待,直到內核發現某個或多個描述符已經准備好了或者已經超時了為止。
這 就是select()、poll()和kqueue()接口采用的方法。通過這些接口,內核就會管理文件描述符,當I/O可用時就去喚醒進程。這些接口巧 妙地處理了上述問題。進程不必再在打開的文件描述符中循環,也不必再去設置信號了。但進程在使用這些函數的時候還是會產生一點小問題。這是因為I/O操作 是在從這些接口返回之后才去執行的。所以它至少需要兩個系統調用才能完成其操作。例如,你的程序有兩個用於讀的描述符。你對它們使用select,然后等 待它們直至有數據可讀。這就需要進程首先調用select,在select返回之后,就對該描述符調用read。更妙的是,你還可以對所有打開的描述符執 行一個整體的read。一旦其中有某個描述符准備好讀之后,read就會返回,並把數據放在緩沖區中,同時還會給出一個標識,用來指示這個數據是從哪個描 述符讀進來的。
6.2 select
我首先要講的接口是select()。格式如下:
傳給select的第一個參數已經造成了多年的混亂。nfds參數的正確用法是把它設成文件描述符的最大值加1。換句話說,如果你有一組文件描述符 {0,1,8},nfds參數就應當被設置成9,因為你的描述符的最大值為8。有些人錯誤地以為這個參數的意思是文件描述符的總數加1,對於我們的例子而 言就是4。記住,一個文件描述符只是一個整數而已,所以你的程序就需要指出你所想要在其上select的最大的描述符值。
select接 下來會按順序針對所有尚未完成的讀、寫以及異常條件檢查其余的三個參數,readfds、writefds和exceptfds。(詳細信息請參見 man(2) select)。注意,如果readfds、writefds和execptfds中沒有設置描述符,那么傳給select的對應參數應當被設置成 NULL。
readfds、writefds和execptfds參數通過以下4個宏進行設置。
FD_ZERO(&fdset);
FD_ZERO宏用來對指定的描述符集合中的bit進行清零。有一點需要特別注意:只要使用select,就應當調用這個宏;否則select的行為將是不可預知的。
FD_SET(fd, &fdset);
FD_SET宏用於向一組激活的描述符中添加一個描述符。
FD_CLR(fd, &fdset);
FD_CLR宏用於從一組激活的描述符中刪除一個描述符。
FD_ISSET(fd, &fdset);
FD_ISSET宏是在select返回之后使用的,用於測試某個描述符是否已准備好進行I/O操作。
select的最后的參數是一個超時值。如果超時值被設置為NULL,則對select的調用將以不確定的方式被阻塞,直至某個操作已准備好為止。如果你需要一個確定的超時時間,那么超時值就得是一個非空的timeval結構體。timeval結構體如下:
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
如果select調用成功,將返回准備好的描述符的數目。如果select因為超時而返回,則返回值為0。如果有錯誤發生,則返回-1,同時會相應地設置errno。
6.3 poll
我 們在這里對I/O的討論主要是針對BSD的。System V支持一種特殊類型的I/O,即所謂的STREAMS。和socket一樣,STREAMS也具有優先級屬性,這種屬性有時也被成為數據帶。數據帶可用來 給STREAMS中的特定數據設置較高的優先級。BSD最初並不支持這一特性,不過有些人添加了System V仿真功能,可以對某些類型提供支持。由於我們並不關注System V,因此我們只會引用數據帶或數據優先級帶的概念。詳細信息請參見System V STREAMS。
poll函數和select很相似:
和原產於BSD的select不同,poll是由System V Unix創建的,在早期的BSD版本中並不支持它。目前主流BSD系統中都已經支持poll了。
和 select相似,poll也是在一組給定的文件描述符上進行復用。在指定這些描述符的時候,你必須使用一個結構體數組,其中每個結構體代表一個文件描述 符。和select相比,poll的好處就是你可以判斷一些很罕見的條件,而select則無法做到。這些條件是POLLERR、POLLHUP和 POLLNVAL,我們稍后討論。盡管對於選擇select還是poll的問題已經有了相當多的討論,但這在很大程度上還是取決於你的個人愛好。poll 所使用的結構體是pollfd結構體,如下:
int fd; /* which file descriptor to poll */
short events; /* events we are interested in */
short revents; /* events found on return */
};
fd
fd成員用於指定你想要poll的文件描述符。如果你想刪除一個描述符,那就把那個描述符的fd成員設置成-1。通過這種方法,你可以避免對整個數組進行混洗,同時還可以清除revents成員中列出的所有事件。
events, revents
events成員是一個bit掩碼,用於指定針對指定描述符所關心的事件。revents成員也是一個bit掩碼,但它的值是由poll設置的,用於記錄在指定描述符上發生的事件。這些事件的定義如下:
POLLIN事件表明你的程序將選擇該描述符上的可讀數據事件。注意,此處的數據不包括高優先級數據,比如socket上的帶外數據。
POLLPRI事件表明你的程序准備選擇該描述符上的任何高優先級事件。
#define POLLWRNORM POLLOUT
POLLOUT和POLLWRNOMR事件表明你的程序想知道什么時候可以對一個描述符執行寫操作了。在FreeBSD和OpenBSD上這兩個事件是相 同的;你可以在你的系統頭文件(/usr/include/poll.h)中查證這一點。從技術角度來說,它們之間的區別在於POLLWRNOMR僅當數 據優先帶等於0的時候才去檢測是否可以進行寫操作。
POLLRDNORM事件表明你的程序准備選擇該描述符上的常規數據。注意,在某些系統上,這個事件指定的操作和POLLIN完全一樣。但在NetBSD 和FreeBSD上,這個事件和POLLIN並不相同。同樣,請去查看你的系統頭文件(/usr/include/poll.h)。嚴格地 說,POLLRDNORM僅當數據優先帶等於0的時候采取檢測是否可以進行讀操作。
POLLRDBAND事件表明你的程序想知道什么時候能夠以一個非0的數據帶值從該描述符讀數據。
POLLWRBAND事件表明你的程序想知道什么時候能夠以一個非0的數據帶值向該描述符寫數據。
專用於FreeBSD的選項
下 面的選項是專用於FreeBSD的,知道的和使用的人都不是太多。但它們還是值得提一下,因為它們可以提供更多的靈活性。這些都是新的選項,poll並不 保證能夠檢測這些條件,而且它們只能用於UFS文件系統。如果你的程序需要檢測這些類型的事件,那最好使用kqueue接口,我們將在稍后介紹。
如果文件已經被執行,則設置POLLEXTEND事件。
如果有任一文件屬性發生改變,則設置POLLATTIB事件。
如果文件被重命名、刪除或解除鏈接,則設置POLLNLINK事件。
如果文件內容被修改,則設置POLLWRITE事件。
下面的事件並不是pollfd events成員的有效標志,poll也將忽略它們。它們是在pollfd revents中返回的,用於表明發生了某個事件。
POLLERR事件表明有錯誤發生。
POLLHUP表明在對應的STREAMS上發生了掛起事件。POLLHUP和POLLOUT是互斥事件,因為一個發生了掛起的STREAMS就不再是可寫的了。
POLLNVAL表明對poll的請求是無效的。
poll的最后一個參數是超時值。可以通過這個參數告訴poll一個以微秒為單位的超時值。如果把超時值設置為-1,poll就會阻塞,直至所請求的事件發生為止。如果超時值設置為0,則poll將立即返回。
如果對poll的調用成功,則返回一個正整數。這個正整數的值表示有多少個描述符發生了事件。如果超時,poll將返回0。如果有錯誤發生,poll則會返回-1。
6.4 kqueue
到 目前為止,poll和select已經是相當不錯的復用文件描述符的方法了。但為了使用這兩個函數,你需要創建一個描述符的鏈表,然后把它們發送給內核, 在返回的時候又要再次查看這個鏈表。這看上去有點效率低下。一個更好一些的模型是把描述符鏈表交給內核,然后就等待。一旦有某個或多個事件發生,內核就把 一個只包含有發生了事件的描述符的鏈表通知給進程,由此避免了每次函數返回的時候都要去遍歷整個鏈表。盡管對於只打開了幾個描述符的進程而言這點改進算不 得什么,但對於那些打開了幾千個文件描述符的程序來說,這種性能改進就相當顯著了。這就是kqueue誕生背后的主要目的。同時,設計者還希望進程能夠檢 測更多類型的事件,比如文件修改、文件刪除、信號交付或者子進程退出,並提供一個包含了其它任務的靈活的函數調用。處理信號、復用文件描述符、以及等待子 進程等操作都可以封裝到這個單一的kqueue接口中,因為它們都是在等待某個事件的發生。
另一個設計考慮就是如何讓一個進程毫無干擾地 使用多個kqueue實例。如你所見,進程可以設置一個信號處理器,但是,當代碼中的其它部分也想捕獲那個指定信號的時候該怎么辦?或者考慮更壞的情況, 比如一個庫函數對你的程序想要捕獲的信號設置了信號處理器的時候?要想通過調試來找出你的程序為什么沒有執行你所設置的信號處理器可能要花費幾個小時的時 間。不過一般說來,這些情況並不會經常發生。好的程序員應該避免在庫函數中設置信號處理器。對於大型的、復雜的程序來說,這些情況就很難避免了,所以為了 更完美一點,我們應當能夠檢測這些事件,而kqueue就可以。
kqueue API由兩個函數調用和一個輔助設置事件的宏組成。這些函數將在下面進行簡要介紹。
kqueue函數啟動一個新的kqueue。如果調用成功,返回值將是一個用來和新創建的kqueue交互的描述符。每個kqueue都有一個與之關聯的 唯一的描述符。因此,一個程序可以同時打開多個kqueue。kqueue描述符的行為和常規文件描述符類似:它們也可以被復用。
最后一點,這些描述符是不能被fork創建的子進程繼承的。如果子進程是通過rfork調用創建的,那就需要設置RFFDG標志,以免這些描述符被子進程共享。如果kqueue函數失敗,將返回-1,同時相應的設置errno。
kevent函數用於和kqueue的交互。第一個參數是kqueue返回的描述符。changelist參數是一個大小為nchanges的 kevent結構體數組。changelist參數用於注冊或修改事件,並且將在從kqueue讀出事件之前得到處理。
eventlist 參數是一個大小為nevents的kevent結構體數組。kevent通過把事件放在eventlist參數中來向調用進程返回事件。如果需要的 話,eventlist和changelist參數可以指向同一個數組。最后一個參數是kevent所期待的超時時間。如果超時參數被指定為 NULL,kevent將阻塞,直至有事件發生為止。如果超時參數不為NULL,則kevent將阻塞到超時為止。如果超時參數指定的是一個內容為0的結 構體,kevent將立即返回所有當前尚未處理的事件。
kevent的返回值指定了放在eventlist數組中的事件的數目。如果事件 數目超過了eventlist的大小,可以通過后續的kevent調用來獲得它們。在處理事件的過程中發生的錯誤也會在還有空間的前提下被放到 eventlist參數中。帶有錯誤的事件會設置EV_ERROR位,系統錯誤也會被放到data成員中。對於其它的所有錯誤都將返回-1,並相應地設置 errno。
kevent結構體用於和kqueue的通信。FreeBSD上的頭文件位於/usr/include/sys /event.h。在這個文件中有對kevent結構體的聲明,以及其它的一些選項和標志。和select和poll比起來,kqueue還相當的年輕, 所以它一直都在發展和添加新的特性。請查看你的系統頭文件以確定任何新的或者特定於系統的選項。
原始的kevent結構體的聲明如下:
uintptr_t ident;
short filter;
u_short flags;
u_int fflags;
intptr_t data;
void *udata;
};
現在,讓我們來看看各個成員:
ident
ident成員用於存儲kqueue的唯一標識。換句話說,如果你想給一個事件添加一個文件描述符的話,ident成員就應當被設置成目標描述符的值。
filter
filter成員用於指定你希望內核用於ident成員的過濾器。
flags
flags成員將告訴內核應當對該事件完成哪些操作和處理哪些必要的標志。在返回的時候,flags成員可用於保存錯誤條件。
fflags
fflags成員用於指定你想讓內核使用的特定於過濾器的標志。在返回的時候,fflags成員可用於保存特定於過濾器的返回值。
data
data成員用於保存任何特定於過濾器的數據。
udata
udata成員並不由kqueue使用,kqueue會把它的值不加修改地透傳。這個成員可被進程用來發送信息甚至是一個函數給它自己,用於一些依賴於事件檢測的場合。
kqueue 過濾器
下面列出的是kqueue使用的過濾器。某些過濾器會有專用於它的標志。這些標志是在kevent結構體的fflags成員中設置的。
EVFILT_READ過濾器用於檢測什么時候數據可讀。kevent的ident成員應當被設成一個有效的描述符。盡管這個過濾器的行為和select 或這poll很像,但它返回的事件將是特定於所使用的描述符的類型的。
如果描述符引用的打開文件是一個vnode,該事件就表明讀取偏移 量尚未到達文件末尾。data成員保存的是當前距文件末尾的偏移量,這可以是負值。如果描述符引用的是一個pipe或者fifo,那么過濾器將在有實際數 據可讀時返回。data成員保存的是可供讀取的字節數目。EV_EOF bit用於表示是哪個寫入者關閉了連接。(關於使用socket時EVFILT_READ的行為細節請參見kqueue的手冊頁。)
EVFILT_WRITE過濾器用於檢測是否可以對描述符執行寫操作。如果描述符引用的是一個pipe、fifo或者socket,則data成員將存有 寫緩沖區中可用的字節數目。EV_EOF bit表示讀取方已經關閉了連接。這個標志對於打開的文件描述符無效。
EVFILT_AIO用於異步I/O操作,用於檢測和aio_error系統調用相似的條件。
EVFILT_VNODE過濾器用於檢測對文件系統上一個文件的某種改動。把ident成員設置成一個有效的打開文件描述符,用fflags成員指定所關 心的事件。返回時,fflags成員將含有所發生事件的比特掩碼。這些事件如下:
NOTE_DELETE fflag表示進程想知道該文件何時被刪。
NOTE_WRITE fflag表示進程想知道該文件內容何時被改變。
NOTE_EXTEND fflag表示進程想知道該文件何時被擴展。
NOTE_ATTRIB fflag表示進程想知道該文件屬性何時被改變。
NOTE_LINK fflag表示進程想知道該文件的鏈接計數何時被改變。當文件通過link函數調用進行硬鏈接的時候,它的鏈接計數就會改變。(詳情請參見man(2) link。)
NOTE_RENAME fflag表示進程想知道該文件是否被重新命名了。
NOTE_REVOKE fflag表示對文件的訪問被revoke了。詳情請見man(2) revoke。
EVLILT_PROC過濾器被進程用來檢測發生在另外一個進程里的事件。所關心進程的PID存儲在ident成員中,fflags成員則被設成所關心的 事件。返回時,事件將被放在fflags成員中。這些事件由下列事件按比特OR的方式設置:
NOTE_EXIT fflag用於檢測該進程何時退出。
NOTE_FORK fflag用於檢測該進程何時調用fork。
NOTE_EXEC fflag用於檢測該進程何時調用exec函數。
NOTE_TRACK fflag讓kqueue去跟蹤一個跨越fork調用的進程。子進程返回時將設置fflags中的NOTE_CHILD標志,父進程的PID將放在data成員中。
當在跟蹤子進程的過程中有錯誤發生時,就會設置NOTE_TRACKERR fflag。這是一個僅用於返回的fflag。
NOTE_CHILD fflag在子進程內設置。這是一個僅用於返回的fflag。
EVFILT_SIGNAL過濾器用於檢測是否有信號發送給該進程。每當有信號發送時這個過濾器就會檢測到,並把計數值放在data成員中。這包括設置了 SIG_IGN標志的信號。事件將在執行完常規的信號處理過程之后放到kqueue上。注意,這個過濾器將在內部設置EV_CLEAR標志。
EVFILT_TIMER過濾器會給kqueue創建一個定時器,用於記錄消逝的事件。如果需要一個一次性的定時器,可以設置EV_ONESHOT標志。 這個定時器是在ident成員中指定的,data成員用來指定以毫秒為單位的超時時間。返回值放在data成員中。注意,這個過濾器將在內部設置 EV_CLEAR標志。
kqueue操作
kqueue操作由所需的操作和標志以比特OR的方式進行設置。
EV_ADD操作向kqueue添加事件。由於kqueue中不允許出現重復,所以如果你想添加一個已經存在的事件的話,現有事件將被新的添加操作覆蓋。 注意,在添加事件的時候,它們已經被默認激活了,除非你設置了EV_DISABLE標志。
EV_DELETE操作從kqueue中刪除事件。
EV_ENABLE用於激活kqueue中的事件。注意,新添加的事件默認就是激活的。
EV_DISABLE禁止kqueue返回某個事件的信息。注意,kqueue並不會刪除過濾器。
kqueue操作標志
kqueue的操作標志定義如下。它們和上面列出的操作結合使用。它們是通過和所需操作進行比特OR來設置的。
EV_ONESHOT標志用於通知kqueue只返回第一個。
EV_CLEAR標志用於通知kqueue,一旦進程從kqueue中獲取到了該事件就將該事件的狀態復位。
kqueue返回值
僅用於返回的值是放在kevent結構體的flags成員中的。這些值的定義如下:
EV_EOF用於表示文件結束的情況。
EV_ERROR用於表示有錯誤發生了。系統錯誤將被放到data成員中。
6.5 結論
本 章研究了BSD中的描述符復用。作為一個程序員,你可以選擇三個接口:select、poll和kqueue。對於小數量的描述符來說,這三者的性能差不 多,但是當描述符數量很大時,kqueue則是最好的選擇。除此之外,kqueue還可以檢測比I/O事件更為豐富的條件。它可以檢測信號、文件修改以及 子進程相關的事件。在下一章中,我們將針對FreeBSD 5.x中的新特性,研究其它的獲取子進程信息和當前進程統計信息的方法