I/O複用:select、poll和epoll函數

weixin_45673259 2022-01-07 21:33:01 阅读数:614

select poll epoll

I/O複用場景:

  • 當客戶處理多個描述符(通常是交互式輸入和網絡套接字)時,必須使用I/O複用。
  • 一個客戶同時處理多個套接字。
  • 一個TCP服務器既要處理監聽套接字,又要處理已連接套接字。
  • 一個服務器既要處理TCP,又要處理UDP。
  • 一個服務器要處理多個服務或者多個協議。

Unix下的五種可用I/O模型的基本區別:

  • 阻塞式I/O
  • 非阻塞式I/O
  • I/O複用(select和poll)
  • 信號驅動式I/O(SIGIO)
  • 异步I/O(POSIX的aio_系列函數)

阻塞式I/O模型
在這裏插入圖片描述
非阻塞I/O模型
在這裏插入圖片描述
I/O複用模型
在這裏插入圖片描述
信號驅動式I/O模型
在這裏插入圖片描述
异步I/O模型
在這裏插入圖片描述
各種I/O模型對比
在這裏插入圖片描述
同步I/O和异步I/O對比

  • 同步I/O操作導致請求進程阻塞,直到I/O操作完成;
  • 异步I/O操作不導致請求進程阻塞。

根據上述定義,前四種I/O模型為同步I/O模型,因為真正的I/O操作將阻塞進程。只有异步I/O模型與POSIX定義的异步I/O相匹配。

select函數

該函數允許進程指示內核等待多個事件中的任何一個發生,並只在有一個或多個事件發生或經曆一段指定的時間才返回。

#include<sys/select.h>
#include<sys/time.h>
int select(int maxfdp1,fd_set *readset,fd_set *writeset,
fd_set *exceptset,const struct timeval *timeout);
返回值:若有就緒描述符則為其數目,若超時則為0,若出錯則為-1
struct timeval{

long tv_sec;
long tv_usec;
}
void FD_ZERO(fd_set *fdset);
void FD_SET(int fd,fd_set *fdset);
void FD_CLR(int fd,fd_set *fdset);
int FD_ISSET(int fd,fd_set *fdset);

最後一個參數有三種可能:
(1)永遠等待:盡在有一個描述符准備好I/O時才返回。為此,把參數設為NULL。
(2)等待一段固定時間:在有一個描述符准備好I/O時返回,但是不超過由該參數所指定的時間數。
(3)根本不等待:檢查描述符後立即返回,這稱為輪詢。為此,該參數的定時器值指定的秒和微秒數必須為0。

支持的异常條件:
(1)某個套接字帶外數據的到達。
(2)某個已置為分組模式的偽終端存在可從其主端讀取的控制狀態信息。

頭文件<sys/select.h>中定義的FD_SETSIZE常值是數據類型fd_set中的描述符總數,其值通常是1024。
描述符集內任何與未就緒描述符對應的比特返回時均清0。為此,每次調用select函數時,都得再次把所有描述符集內所關心的比特均置1。

(1)滿足下列四個條件中的任何一個時,一個套接字准備好讀。
a)該套接字接收緩沖區中的數據字節數大於等於套接字接收緩沖區低水比特標記的當前大小。對這樣的套接字執行讀操作不會阻塞並將返回一個大於0的值(也就是返回准備好讀入的數據)。我們可以使用SO_RCVLOWAT套接字選項設置該套接字的低水比特標記。對於TCP和UDP套接字而言,其默認值為1.
b)該連接的讀半部關閉(也就是接收了FIN的TCP連接)。對這樣的套接字的讀操作將不阻塞並返回0 (也就是返回EOF).
c)該套接字是一個監聽套接字且己完成的連接數不為0。對這樣的套接字的accept通常不會阻塞,不過我們將在15.6節講解accept可能阻塞的一種時序條件。
d)其上有一個套接字錯誤待處理。對這樣的套接字的讀操作將不阻塞並返回-1 (也就是返回一個錯誤),同時把errmo設置成確切的錯誤條件。這些待處理錯誤(pending cror)也可以通過指定SO_ERROR套接字選項調用getsockopt獲取並清除。

(2)下列四個條件中的任何一個滿足時,一個套接字准備好寫。
a)該套接字發送緩沖區中的可用空間字節數大於等於套接字發送緩沖區低水比特標記的當前們把這樣的套接字設置成非阻塞(第16章),寫操作將不阻塞並返回一個正值的字節數)。我們可以使用SO_SNDLOWAT套接字選項來設置該套接字的低水比特標記。對UDP套接字而言,其默認值通常為2048.
b)該連接的寫半部關閉。對這樣的套接字的寫操作將產生SIGPIPE信號(5.12節)。
c)使用非阻塞式connect的套接字已建立連接,或者connect已經以失敗告終。
d)其上有一個套接字錯誤待處理。對這樣的套接字的寫操作將不阻塞並返回-1 (也就是返回一個錯誤),同時把errno設置成確切的錯誤條件。這些待處理的錯誤也可以通過指定SO_ERROR套接字選項調用getsockopt獲取並清除。
(3)如果一個套接字存在帶外數據或者仍處於帶外標記,那麼它有异常條件特處理。

當某個套接字發生錯誤時,它將由select標記為即可寫又可讀。

select的缺點:
(1)每次調用select 函數時,都需要把fd集合從用戶態複制到內核態,這個開銷在fd較多時會很大,同時每次調用select函數都需要在內核中遍曆傳遞進來的所有fd,這個開銷在fd較多時也很大。
(2)單個進程能够監視的文件描述符的數量存在最大限制,在Linux上一般為1024,可以通過先修改宏定義然後重新編譯內核來調整這一限制,但這樣非常麻煩而且效率低下。
(3)select函數在每次調用之前都要對傳人的參數進行重新設定,這樣做也比較麻煩。
(4)在Linux上,select函數的實現原理是其底層使用了poll函數。

shutdown函數

終止網絡連接的通常方法是調用close函數。不過close有兩個限制,卻可以使用shutdowm來避免。

(1)close把描述符的引用計數减1,僅在該計數變為0時才關閉套接字。我們已在4.8節討論過這一點。使用shutdown可以不管引用計數就激發TCP的正常連接終止序列(圖2-5中由FIN開

(2) close終止讀和寫兩個方向的數據傳送。既然TCP連接是全雙工的,有時候我們需要告知對端我們已經完成了數據發送,即使對端仍有數據要發送給我們。這就是我們在前一節中遇到的str _cli函數在批量輸入時的情况。圖6- 12展示了這樣的情况下典型的函數調用。

SHUT_ RD 關閉連接的讀這一半——套接字中不再有數據可接收,而且套接字接收緩沖區中的現有數據都被丟弃。進程不能再對這樣的套接字調用任何讀函數。對對TCP套接字這樣調用shutdown的數後,由該套接字接收的來自對端的任何數據都被確認,然後悄然丟弃。

SHUT_WR 關閉連接的寫這一半——對 於TCP套接字,這稱為半關閉(half-close, 見TCPv1的18.5節)。當前留在套接字發送緩沖區中的數據將被發送掉,後跟TCP的正常連接終止序列。我們已經說過,不管套接字描述符的引用計數是否等於0,這樣的寫半部關閉照樣執行。進程不能再對這樣的套接字調用任何寫函數。

SHUT_RDWR 連接的讀半部和寫半部都關閉——這與調用shutdown兩次等效;第一次調用指定SHUT_RD,第二次調用指定SHUT_WR。

close的c’z取决於SO_LINGER套接字選項的值。

pselect函數

#include<sys/select.h>
#include<signal.h>
#include<time.h>
int pselect(int maxfdp1,fd_set *readset,fd_set *writeset,
fd_set *exceptset,const struct timespec*timeout,
const sigset_t *sigmask);
返回值:若有就緒描述符則為其數目,若超時則為0,若出錯則為-1
struct timespec{

time_t tv_sec;
long tv_nsec;
}

pselect函數增加第六個參數:一個指向信號掩碼的指針,該參數允許程序先禁止遞交某些信號,再測試由這些當前被禁止的信號的信號處理函數設置的全局變量,然後調用pselect,告訴它重新設置信號掩碼。

poll函數

#include<poll.h>
int poll(struct pollfd *fdarray,unsigned long nfds,int timeout);
返回值:若有就緒描述符則為其數目,若超時則為0,若出錯則為-1
struct pollfd{

int fd;
short events;
short revents;
};

結構數組的個數由nfds指定
在這裏插入圖片描述

在這裏插入圖片描述

  • 所有正規TCP數據和所有UDP數據都被認為是普通數據。
  • TCP的帶外數據(第24章)被認為是優先級帶數據。
  • 當TCP連接的讀半部關閉時(譬如收到了一個來自對端的FIN),也被認為是普通數據,隨後的讀操作將返回0。
  • TCP連接存在錯誤既可認為是普通數據,也可認為是錯誤(POLLERR)。無論哪種情况,隨後的讀操作將返回-1,並把errno設置成合適的值。這可用於處理諸如接收到RST或發生超時等條件。
  • 在監聽套接字上有新的連接可用既可認為是普通數據,也可認為是優先級數據。大多數實現視之為普通數據。
  • 非阻塞式connect的完成被認為是使相應套接字可寫。
    poll相比於select的優點:
    (1) poll不要求開發者計算最大文件描述符加1的大小;
    (2)與select相比,poll在處理大數量的文件描述符時速度更快;
    (3) poll沒有最大連接數的限制,因為其存儲fd的數組沒有長度限制;
    (4)在調用poll函數時,只需對參數進行一次設置就好了。
    poll的缺點:
    (1)在調用poll函數時,不管有沒有意義,大量fd的數組在用戶態和內核地址空間之間被整體複制。
    (2)與select函數一樣,poll函數返回後,需要遍曆fd集合來獲取就緒的fd,這樣會使性能下降;
    (3)同時連接的大量客戶端在某一時刻可能只有很少的就緒狀態,因此隨著監視的描述符數量的增長,其效率也會線性下降。

epoll

要使用epoll模式,則必須先創建一個epollfd,需要用到epoll_create函數:

#include<sys/epoll.h>
int epoll_create(int size);

參數size從Linux2.6.8以後就不再使用了,但是必須為它設置一個大於0的值。若epoll_create函數調用成功,則返回一個非負值的epollfd,否則返回-1。
有了epollfd之後,我們需要將檢測事件的其他fd綁定到這個epollfd上,或者修改一個板頂上去的fd的事件類型,或者在不需要時將fd從epollfd上解綁,這都可以使用epoll_ctl函數完成:

int epoll_ctl(int epfd, int op, int fd,struct epoll_event* event);

對其中的參數說明如下:
(1)epfd:即上面的epollfd
(2)op:操作類型,取值有EPOLL_CTL_ADD、EPOLL_CTL_MOD和EPOLL_CTL_DEL,分別錶示在epollfd上添加、修改和移除fd,當取值是EPOLL_CTL_DEL時,第四個參數event忽略不計,可以設置為NULL;
(3)fd:即要操作的fd。
(4)event:這是一個epoll_event結構體的地址。epoll_event結構體定義如下:

struct epoll_event
{

uint32_t events;/* 需要檢測的fd事件標志*/
epoll_data_t data;/* 用戶自定義的數據*/
}

epoll_event結構體的data字段類型是epoll_data_t,我們可以利用這個字段設置一個自定義數據,它在本質上是一個Union對象,在64比特操作系統中大小是8字節,定義如下:

typedef union epoll_data
{

void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;

(5)函數返回值:epoll_ctl若調用成功返回0,失敗則返回-1;可以通過errno錯誤碼獲取具體的錯誤原因。
創建epollfd,設置好某個fd需要檢測的事件並將該fd綁定到epollfd上,就可以調用epoll_wait檢測事件了。

int epoll_wait(int epfd, struct epoll_event* events,int maxevents,int timeout);
返回值:成功則返回有事件的fd數量;若返回0,錶示超時,失敗則返回-1

參數event是一個epoll_event結構數組的首地址,它是一個輸出參數,在函數調用成功後,在event中存放的是與就緒事件相關的epoll_event結構體數組;參數maxevents是數組元素的個數;timeout是超時時間,單比特為0毫秒,如果將其設置為0,則epoll_wait會立即返回。

通過對poll與epoll_wait 雨數的介紹可以發現:我們在epol_wait函數調用完成後,通過參數event拿到所有有事件就緒的fd(參數event僅僅是個輸出參數);而poll事件集合參數( poll 函數的第1個參數)在調用前後數量都不會改變,只不過調用通過pollfd結構體的events字段設置待檢測的事件,調用後通過pollfd結構體的revents字段檢測就緒的事件(參數fds既是入參也是出參)。
poll 函數的效率不一定不如epoll_wait 函數,一般在fd數量比較多但就某段時間內就緒事件fd數量較少的情况下,epoll_wail 函數才會體現它的優勢,也就是說socket連接數量較大而活躍的連接較少時,epoll 模型更高效。

與pol模式的事件宏相比,epoll模式新增了一個事件宏EPOLLET.即邊緣觸發模式(Edge Tgger, ET), 我們稱默認的模式為水平觸發模式(Level Tigger LT)。這兩種模式的區別在於:

(1)對於水平觸發模式,一個事件只要有,就會一直觸發;
(2)對於邊緣觸發模式,在一個事件從無到有時才會觸發。

這兩個詞匯來自電學術語,我們可以將fd上有數據的狀態認為是高電平狀態,將沒有數據的狀態認為是低電平狀態,將fd可寫狀態認為是高電平狀態,將fd不可寫狀態認為是低電平狀態。那麼水平模式的觸發條件是處於高電平狀態,而邊緣模式的觸發條件是新來的一次電信號將當前狀態變為高電平狀態。

水平模式的觸發條件:①低電平→高電平;②處於高電平狀態。
邊緣模式的觸發條件:低電平→高電平。

socket可讀事件的水平模式觸發條件:①socket上無數據—>socket 上有數據; ②sock處於有數據狀態。

socket可讀事件的邊緣模式觸發條件:①socket上無數據—>socket 上有數據;②sock又新來一次數據。

socket可寫事件的水平模式觸發條件:①socket可寫—>socket 不可寫;②socket 不可寫一>socket可寫。

socket可寫事件的邊緣模式觸發條件: socket 不可寫一>socket 可寫。

也就是說,對於一個非阻塞socket,如果使用epoll邊緣模式檢測數據是否可讀,觸發可讀事件後,一定要一次性地把socket上的數據收取幹淨。也就是說,一定要循調用recv函數直到recv出錯,錯誤碼是EWOULDBLOCK ( EAGAIN也一樣,此時錶示socket上的本次數據已經讀完);如果使用水平模式,則我們可以根據業務一次性地收固定的字節數,或者到收完為止。

(1)在LT模式下,讀事件觸發後可以按需收取想要的字節數,不用把本次接收的數據收取幹淨(即不用循環到recv或者read 函數返回-1,錯誤碼為EWOULDBLOCK或EAGAIN);在ET模式下,讀事件時必須把數據收取幹淨,因為我們不一定再有機會收取數據了,即使有機會,也可能因為沒有及時處理上次沒讀完的數據,造成客戶端響應延遲。

(2)在LT模式下,不需要寫事件時一定要及時移除,避免不必要地觸發且浪費CPU資源;在ET模式下,寫事件觸發後,如果還需要下一次的寫事件觸發來驅動任務(例如分送上次剩餘的數據),則我們需要繼續注册一次檢測可寫事件。

(3)LT模式和ET模式各有優缺點,無所謂孰優孰劣。使用LT模式時,我們可以自由决定每次收取多少字節(對於普通socket)或何時接收連接(對於監聽socket),但是可能會導致多次觸發;使用ET模式時,我們必須每次都將數據接收完(對於普通socket)或立即調用accept接受連接(對於監聽socket),其優點是觸發次數少。

epoll模型的EPOLLONESHOT選項,如果某個socket注册了該標志,則其監聽的事件(例如EPOLLIN)在觸發一次後再也不會觸發,除非重新注册監聽該事件類型。
在一些特殊的應用場景中,如果涉及多個線程同時處理某個socket 上的事件,則為了避免數據亂序,我們不得不使用複雜的多線程同步機制;但是有了EPOLLONESHOT選項,我們就可以减少線程同步邏輯了。以EPOLLIN事件處理為例,多個線程同時從一個socket 上讀數據,可以使某個線程先處理,在該線程處理完之後再重新給該socket器加讀事件,這樣讀事件再次觸發時,就可以被其他線程繼續處理了。這種做法在本質上記是保證同一個時刻只有一個線程在處理某個socket上的事件。當然,多個線程同時操個二個socket本來就是一.種不好的錶現,我們在實際開發時應該盡量避免。

版权声明:本文为[weixin_45673259]所创,转载请带上原文链接,感谢。 https://gsmany.com/2022/01/202201072133004051.html