select 編程模型就是:一個連接來了,就必須遍歷所有已經註冊的文件描述符,來找到那個需要處理信息的文件描述符,如果已經註冊了幾萬個文件描述符,那會因爲遍歷這些已經註冊的文件描述符,導致cpu爆炸。
通過改進「IO多路複用」模型,進一步的優化,發明了一個叫做epoll的方法。
從常用的IO操作談起,比如read和write,通常IO操作都是阻塞I/O的,也就是說當調用read時,如果沒有數據收到,那麼線程或者進程就會被掛起,直到收到數據。
- 線程是有內存開銷的,1個線程可能需要512K(或2M)存放棧,那麼1000個線程就要512M(或2G)內存。
- 線程的切換,或者說上下文切換是有CPU開銷的,當大量時間花在上下文切換的時候,分配給真正的操作的CPU就要少很多。
那麼,我們就要引入非阻塞I/O的概念,非阻塞IO很簡單,通過fcntl(POSIX)或ioctl(Unix)設爲非阻塞模式,這時,當你調用read時,如果有數據收到,就返回數據,如果沒有數據收到,就立刻返回一個錯誤,如EWOULDBLOCK。這樣是不會阻塞線程了,但是你還是要不斷的輪詢來讀取或寫入。
於是,我們需要引入IO多路複用的概念。多路複用是指使用一個線程來檢查多個文件描述符(Socket)的就緒狀態,比如調用select和poll函數,傳入多個文件描述符,如果有一個文件描述符就緒,則返回,否則阻塞直到超時。得到就緒狀態後進行真正的操作可以在同一個線程裏執行,也可以啓動線程執行(比如使用線程池)。
這樣在處理1000個連接時,只需要1個線程監控就緒狀態,對就緒的每個連接開一個線程處理就可以了,這樣需要的線程數大大減少,減少了內存開銷和上下文切換的CPU開銷。
使用select函數的方式如下圖所示:select()函數
#include<sys/select.h>
#include<sys/time.h>
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset,
const struct timeval *timeout);
//返回:若有就緒描述符則爲其數目,若超時則返回0,出錯返回-1
參數介紹:
maxfd指定被監聽的文件描述符的總數,通常被設置爲select監聽的所有文件描述符中的最大值加1;
- 三個參數readset,writeset,exceptset指定我們要讓內核測試讀、寫和異常條件的描述符。
select使用描述符集,通常是一個整數數組,其中每個整數中的每一位對應一個描述符。舉例來說,假設使用32位整數,那麼該數組的第一個元素對應於描述符0~31,第二個元素對應於描述符32~63,以此類推。所有這些實現細節都與應用程序無關,它們隱藏在名爲fd_set的數據類型和以下四個宏中:
void FD_ZERO(fd_set *fdset); //將fd_set變量的所有位初始化爲0 void FD_SET(int fd, fd_set *fdset); //在參數fdset指向的變量中註冊文件描述符fd的信息 void FD_CLR(int fd, fd_set *fdset); //從參數fdset指向的變量中清除文件描述符fd的信息 int FD_ISSET(int fd, fd_set *fdset); //若參數fdset指向的變量中包含文件描述符fd的信息,則返回真
我們分配一個fd_set數據類型的描述符集,並用這些宏設置或測試該集合中的每一位,也可以用C語言中的賦值語句把它賦值成另外一個描述符集。
注意:前面所討論的每個描述符佔用整數數組中的一位的方法僅僅是select函數的可能實現之一。
readfds、writefds、exceptfds分別指向可讀、可寫和異常等對應的文件描述符集合,select調用返回時,內核將修改它們來通知應用程序哪些文件描述符已經就緒,即select返回的時候結果是在這三個參數裏面的,在調用select之前我們把監視描述符設置爲1,當返回的時候未就緒的會變成0,而就緒的就爲1(除了就緒的,其他的1都變成0,也就是還爲1的那些是就緒的)。它們都是fd_set結構指針類型,fd_set結構體僅包含一個整形數組,該數組的每個元素的每一位標記一個文件描述符。
timeout用來設置select函數的超時時間,告知內核等待所指定描述符中的任何一個就緒可花多長時間,它是一個timeval結構類型的指針
若給timeout的兩個參數都傳0,則select立即返回;若給timeout傳遞NULL,則select一直阻塞,直到某個文件描述符就緒。有三種可能:
- 永遠等待下去:僅在有一個描述符準備好I/O時才返回,將其設爲空指針
- 等待一段固定時間:在有一個描述符準備好I/O時返回,但是不超過由該參數所指向的timeval結構中指定的秒數和微秒數。
- 根本不等待:檢查描述符後立即返回,這就是輪詢。爲此,該參數必須指向一個timeval結構,但是其中的值必須設置爲0
系統調用介紹:select成功時返回就緒(可讀、可寫和異常)文件描述符的總數。
select返回套接字的“就緒”條件
- 滿足下列四個條件之一的任何一個時,一個套接字準備好讀:
- 該套接字接收緩衝區中的數據字節數大於等於套接字接收緩衝區低水位標記的當前大小。對於這樣的套接字執行讀操作不會阻塞並將返回一個大於0的值(也就是返回準備好讀入的數據)。我們使用SO_RECVLOWAT套接字選項設置套接字的低水位標記。對於TCP和UDP套接字而言,其默認值爲1
- 該連接的讀半部關閉(也就是接收了FIN的TCP連接)。對這樣的套接字的讀操作將不阻塞並返回0(也就是返回EOF)
- 該套接字是一個監聽套接字且已完成的連接數不爲0。對這樣的套接字accept通常不會阻塞
- 其上有一個套接字錯誤待處理。對這樣的套接字的讀操作將不阻塞並返回-1(也就是返回一個錯誤),同時把errno設置爲確切的錯誤條件。這些待處理錯誤也可以通過SO_ERROR套接字選項調用getsockopt獲取並清除。
- 下列四個條件的任何一個滿足時,一個套接字準備好寫:
- 該套接字發送緩衝區中的可用字節數大於等於套接字發送緩衝區低水位標記的當前大小,並且或該套接字已連接,或者該套接字不需要連接(如UDP套接字)。這意味着如果我們把這樣的套接字設置成非阻塞的,寫操作將不阻塞並返回一個正值(如由傳輸層接收的字節數)。我們使用SO_SNDLOWAT套接字選項來設置該套接字的低水位標記。對於TCP和UDP而言,默認值爲2048
- 該連接的寫半部關閉。對這樣的套接字的寫操作將產生SIGPIPE信號
- 使用非阻塞式connect套接字已建立連接,或者connect已經已失敗告終
- 其上有一個套接字錯誤待處理。對這樣的套接字的寫操作將不阻塞並返回-1(也就是返回一個錯誤),同時把errno設置爲確切的錯誤條件。這些待處理錯誤也可以通過SO_ERROR套接字選項調用getsockopt獲取並清除。
- 如果一個套接字存在帶外數據或者仍處於帶外標記,那麼它有異常條件待處理。
- 注意:當某個套接字上發生錯誤時,它將由select標記爲既可讀又可寫
- 接收低水位標記和發送低水位標記的目的在於:允許應用進程控制在select可讀或可寫條件之前有多少數據可讀或有多大空間可用於寫。
- 任何UDP套接字只要其發送低水位標記小於等於發送緩衝區大小(默認應該總是這種關係)就總是可寫的,這是因爲UDP套接字不需要連接。
調用select之前一定需要先將fd_set初始化,然後設置自己關心的描述符,當返回的時候調用FD_ISSET()
來查看哪些是就緒的。
select的缺點:
- 單個進程能夠監視的文件描述符的數量存在最大限制,通常是1024,當然可以更改數量,但由於select採用輪詢的方式掃描文件描述符,文件描述符數量越多,性能越差;(在linux內核頭文件中,有這樣的定義:#define __FD_SETSIZE 1024)
- 內核 / 用戶空間內存拷貝問題,select需要複製大量的句柄數據結構,產生巨大的開銷;
- select返回的是含有整個句柄的數組,應用程序需要遍歷整個數組才能發現哪些句柄發生了事件;
- select的觸發方式是水平觸發,應用程序如果沒有完成對一個已經就緒的文件描述符進行IO操作,那麼之後每次select調用還是會將這些文件描述符通知進程。
select本質上是通過設置或者檢查存放fd標誌位的數據結構來進行下一步處理
。這樣所帶來的缺點是:具體數目可以cat /proc/sys/fs/file-max察看
。32位機默認是1024個。64位機默認是2048.如果能給套接字註冊某個回調函數,當他們活躍時,自動完成相關操作,那就避免了輪詢
,這正是epoll與kqueue做的。3、需要維護一個用來存放大量fd的數據結構,這樣會使得用戶空間和內核空間在傳遞該結構時複製開銷大。
epoll()函數
select和poll函數是當關心的描述符如果有就緒事件發生,返回之後它們是不清楚哪個描述符發生了什麼事的,必須去一個一個輪詢,那麼當描述符數量較多的時候效率很明顯會下降,而epoll函數是通過爲每個描述符註冊一個callback回調函數,當描述符有就緒事件發生的時候,就會直接調用callback函數。不過當描述符較多的時候並且很多描述符都會激活的情況epoll的效率不一定比select/poll會高,select/poll適合那種描述符就緒狀態變化頻率較少的場景
另外epoll使用mmap用戶空間映射到進程虛擬地址空間,加速了從內核空間到用戶空間的消息傳遞。
epoll還區分是邊緣觸發(Edge Triggered)和水平觸發(Level Triggered)。對於前者,只有在狀態變化的時候纔得到通知,即使緩衝區內還有未處理的數據也是得不到通知的。而後者是隻要緩衝區有數據,就會一直有通知。
- epoll是Linux特有的I/O複用函數。它在實現和使用上與select、poll有很大的差異。
- 首先,epoll使用一組函數來完成任務,而不是單個函數。
- 其次,epoll把用戶關心的文件描述符上的事件放在內核裏的一個事件表中,從而無須像select和poll那樣每次調用都要重複傳入文件描述符集或事件集。
- 但epoll需要使用一個額外的文件描述符,來唯一標識內核中的這個事件表
epoll文件描述符使用如下方式創建:
size參數完全不起作用,只是給內核一個提示,告訴它事件表需要多大。該函數返回的文件描述符將用作其他所有epoll系統調用的第一個參數,以指定要訪問的內核事件表。成功時返回epoll文件描述符,失敗時返回-1。#include<sys/epoll.h> int epoll_create(int size);//創建保存文件描述符的空間,即epoll例程
下面的函數用來操作epoll的內核事件表:
參數epfd是用於註冊監視對象的epoll例程的文件描述符,fd參數是要操作的文件描述符,op參數則指定操作類型。操作類型有以下三類:#include<sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //返回:若成功返回0,失敗返回-1,並置errno
- EPOLL_CTL_ADD, 將文件描述符註冊到epoll例程
- EPOLL_CTL_MOD, 修改fd上的註冊事件
- EPOLL_CTL_DEL, 從epoll例程中刪除文件描述符
event指定監視對象關注的事件,它是epoll_event結構指針類型,epoll_event的定義如下:
strcut epoll_event{ __uint32_t events; //epoll事件 epoll_data_t data; //用戶數據 };
- 其中,events成員描述事件類型。epoll支持的事件類型同poll基本相同。表示epoll事件類型的宏在poll對應的宏前加上"E",比如epoll的數據可讀事件是EPOLLIN。events成員可以保存的常量及所指的事件類型:EPOLLIN:需要讀取數據的情況;EPOLLOUT:輸出緩衝爲空,可以立即發送數據的情況;EPOLLPRI:收到OOB數據的情況;EPOLLRDHUP:斷開連接或半關閉的情況,在邊緣觸發方式下很有用;EPOLLERR:發生錯誤的情況;EPOLLET:以邊緣觸發的方式得到事件通知;EPOLLONESHOT:發生一次事件後,相應文件描述符不再收到事件通知
- epoll有兩個額外的事件類型——EPOLLET和EPOLLONESHOT。它們對於epoll的高效運作非常關鍵。
data成員用於存儲用戶數據,是一個聯合體:
其中4個成員用得最多的是fd,它指定事件所從屬的目標文件描述符。typedef union epoll_data{ void *ptr; int fd; uint32_t u32; uint64_t u64; }epoll_data_t;
epoll系列系統調用的主要接口是epoll_wait函數,它在一段超時時間內等待一組文件描述符上的事件,其原型如下:
#include<sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); //返回:若成功返回就緒的文件描述符個數,失敗時返回-1,並置errnoo
- maxevents參數指定最多監聽多少個事件,它必須大於0
event_wait函數如果檢測到事件,就將所有就緒事件從內核事件表(由epfd參數指定)中複製到它的第二個參數events指向的數組中。這個數組只用於輸出epoll_wait檢測到的就緒事件,而不像select和poll的數組參數那樣既用於傳入用戶註冊的事件,又用於輸出內核檢測到的就緒事件。這就極大地提高了應用程序索引就緒文件描述符的效率。
下面代碼給出 poll和epoll在使用上的差別:
//如何索引poll返回的就緒文件描述符 int ret = poll(fds, MAX_EVENT_NUMBER, -1); //必須遍歷所有已註冊文件描述符並找到其中的就緒者 for(int i = 0; i < MAX_EVENT_NUMBER; ++i){ if(fds[i].revents & POLLIN) //判斷第 i 個文件描述符是否就緒 { int sockfd = fds[i].fd; //處理sockfd } } //如何索引epoll返回的文件描述符 int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1); //僅遍歷就緒的ret個文件描述符 for(int i = 0; i < ret; ++i){ int sockfd = events[i].data.fd; //sockfd肯定就緒,直接處理 }
- LT和ET模式
- LT(Level Trigger,電平/條件觸發)模式:是默認工作模式,在這種模式下的epoll相當於一個效率較高的poll。當epoll_wait檢測到其上有事件發生並將此事件通知應用程序後,應用程序可以不立即處理該事件。這樣,當應用程序下一次調用epoll_wait時,epoll_wait還會再次嚮應用程序通告此事件。
- ET(Edge Trigger,邊沿/邊緣觸發)模式。對於ET工作模式下的文件描述符,當epoll_wait檢測到其上有事件發生並將此事件通知應用程序後,應用程序必須立即處理該事件,因爲後續的epoll_wait調用將不再向應用程序通知這一事件。
- ET模式在很大程度上降低了同一個epoll事件被重複觸發的次數。因此效率要比LT模式高。
- 每個使用ET模式的文件描述符都應該是非阻塞的。如果文件描述符是阻塞的,那麼讀或寫操作將會因爲沒有後續的時間而一直處於阻塞狀態(飢渴狀態)
- EPOLLONESHOT事件
- 即使使用ET模式,一個socket上的某個事件還是可能被觸發多次。這在併發程序中引起一個問題。比如一個線程(或進程)在讀取完某個socket上的數據後開始處理這些數據,而在數據的處理過程中該socket上又有新數據可讀(EPOLLIN再次被觸發),此時另外一個線程被喚醒來讀取這些新的數據。於是出現了兩個線程同時操作一個socket的場面。這當然不是我們期望的。我們期望的是一個socket連接在任一時刻都只被一個線程處理。
- 對於註冊了EPOLLONESHOT事件的文件描述符,操作系統最多觸發其上註冊的一個可讀、可寫或異常事件,且只觸發一次,除非我們使用epoll_ctl函數重置該文件描述符上的EPOLLONESHOT事件。這樣,當一個線程在處理某個socket時,其他線程時不可能有機會操作該socket的。但反過來思考,註冊了EPOLLONESHOT事件的socket一旦被某個線程處理完畢,該線程就應該立即重置這個socket上的EPOLLONESHOT事件,以確保這個socket下一次可讀時,其EPOLLIN事件能被觸發,進而讓其他工作線程有機會繼續處理這個socket.
Linux內核具體的epoll機制實現思路。
當某一進程調用epoll_create方法時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。eventpoll結構體如下所示:
struct eventpoll{
....
/*紅黑樹的根節點,這顆樹中存儲着所有添加到epoll中的需要監控的事件*/
struct rb_root rbr;
/*雙鏈表中則存放着將要通過epoll_wait返回給用戶的滿足條件的事件*/
struct list_head rdlist;
....
};
每一個epoll對象都有一個獨立的eventpoll結構體,用於存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重複添加的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是lgn,其中n爲樹的高度)。
而所有添加到epoll中的事件都會與設備(網卡)驅動程序建立回調關係,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。
在epoll中,對於每一個事件,都會建立一個epitem結構體,如下所示:
struct epitem{
struct rb_node rbn;//紅黑樹節點
struct list_head rdllink;//雙向鏈表節點
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所屬的eventpoll對象
struct epoll_event event; //期待發生的事件類型
}
當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不爲空,則把發生的事件複製到用戶態,同時將事件數量返回給用戶。
epoll數據結構示意圖
從上面的講解可知:通過紅黑樹和雙鏈表數據結構,並結合回調機制,造就了epoll的高效。
OK,講解完了Epoll的機理,我們便能很容易掌握epoll的用法了。一句話描述就是:三步曲。
第一步:epoll_create()系統調用。此調用返回一個句柄,之後所有的使用都依靠這個句柄來標識。
第二步:epoll_ctl()系統調用。通過此調用向epoll對象中添加、刪除、修改感興趣的事件,返回0標識成功,返回-1表示失敗。
第三部:epoll_wait()系統調用。通過此調用收集收集在epoll監控中已經發生的事件。
shutdown函數
(1)close把描述符的引用計數減一,僅在該計數變爲0時才關閉套接字。使用shutdown可以不管引用計數就激發TCP的正常連接終止
(2)close終止讀和寫兩個方向的數據傳送。而shutdown卻可以單方向的關閉套接字的一半。比如當我們給服務器的數據發送關閉,我們可以直接關閉寫這一半,即使服務器這時候還是有數據要發給我們的話我們仍然可以讀連接。既然TCP連接是全雙工的,有時候我們需要告知對端我們已經完成了數據發送,即使對端有數據要發送給我們,此時可使用shutdonwn函數
#include <sys/socket.h>
int shutdown(int sockfd, int howto);
成功時返回0,出錯時返回-1
參數howto的值:
SHUT_RD 關閉連接的讀一半,套接字不再有數據可接收,而且套接字接收緩衝區中的現有數據都被丟棄。進程不能再對這樣的套接字調用任何讀函數。對一個TCP套接字這樣調用shutdown函數後,由該套接字接收的來自對端的任何數據都被確認,然後悄然丟棄。
SHUT_WR 關閉連接的寫一半,對於TCP套接字,這稱爲半關閉。當前留在套接字發送緩衝區中的數據將被髮送掉,後跟TCP的正常連接終止序列。
SHUT_RDWR 連接的讀半部和寫半部都關閉
三組I/O複用函數的比較:
epoll適用於連接數量多,但活動連接少(因爲若活動連接數多,會頻繁調用回調函數)。
select原理概述
調用select時,會發生以下事情:
- 從用戶空間拷貝fd_set到內核空間;
- 註冊回調函數__pollwait;
- 遍歷所有fd,對全部指定設備做一次poll(這裏的poll是一個文件操作,它有兩個參數,一個是文件fd本身,一個是當設備尚未就緒時調用的回調函數__pollwait,這個函數把設備自己特有的等待隊列傳給內核,讓內核把當前的進程掛載到其中);
- 當設備就緒時,設備就會喚醒在自己特有等待隊列中的【所有】節點,於是當前進程就獲取到了完成的信號。poll文件操作返回的是一組標準的掩碼,其中的各個位指示當前的不同的就緒狀態(全0爲沒有任何事件觸發),根據mask可對fd_set賦值;
- 如果所有設備返回的掩碼都沒有顯示任何的事件觸發,就去掉回調函數的函數指針,進入有限時的睡眠狀態,再恢復和不斷做poll,再作有限時的睡眠,直到其中一個設備有事件觸發爲止。
- 只要有事件觸發,系統調用返回,將fd_set從內核空間拷貝到用戶空間,回到用戶態,用戶就可以對相關的fd作進一步的讀或者寫操作了。
epoll原理概述
調用epoll_create時,做了以下事情:
- 內核幫我們在epoll文件系統裏建了個file結點;
- 在內核cache裏建了個紅黑樹用於存儲以後epoll_ctl傳來的socket;
- 建立一個list鏈表,用於存儲準備就緒的事件。
調用epoll_ctl時,做了以下事情:
- 把socket放到epoll文件系統裏file對象對應的紅黑樹上;
- 給內核中斷處理程序註冊一個回調函數,告訴內核,如果這個句柄的中斷到了,就把它放到準備就緒list鏈表裏。
調用epoll_wait時,做了以下事情:
觀察list鏈表裏有沒有數據。有數據就返回,沒有數據就sleep,等到timeout時間到後即使鏈表沒數據也返回。而且,通常情況下即使我們要監控百萬計的句柄,大多一次也只返回很少量的準備就緒句柄而已,所以,epoll_wait僅需要從內核態copy少量的句柄到用戶態而已。
總結如下:
一顆紅黑樹,一張準備就緒句柄鏈表,少量的內核cache,解決了大併發下的socket處理問題。
執行epoll_create時,創建了紅黑樹和就緒鏈表;
執行epoll_ctl時,如果增加socket句柄,則檢查在紅黑樹中是否存在,存在立即返回,不存在則添加到樹幹上,然後向內核註冊回調函數,用於當中斷事件來臨時向準備就緒鏈表中插入數據;
執行epoll_wait時立刻返回準備就緒鏈表裏的數據即可。
兩種模式的區別:
LT模式下,只要一個句柄上的事件一次沒有處理完,會在以後調用epoll_wait時重複返回這個句柄,而ET模式僅在第一次返回。
兩種模式的實現:
當一個socket句柄上有事件時,內核會把該句柄插入上面所說的準備就緒list鏈表,這時我們調用epoll_wait,會把準備就緒的socket拷貝到用戶態內存,然後清空準備就緒list鏈表,最後,epoll_wait檢查這些socket,如果是LT模式,並且這些socket上確實有未處理的事件時,又把該句柄放回到剛剛清空的準備就緒鏈表。所以,LT模式的句柄,只要它上面還有事件,epoll_wait每次都會返回。
對比
select缺點:
- 最大併發數限制:使用32個整數的32位,即32*32=1024來標識fd,雖然可修改,但是有以下第二點的瓶頸;
- 效率低:每次都會線性掃描整個fd_set,集合越大速度越慢;
- 內核/用戶空間內存拷貝問題。
epoll的提升:
- 本身沒有最大併發連接的限制,僅受系統中進程能打開的最大文件數目限制;
- 效率提升:只有活躍的socket纔會主動的去調用callback函數;
- 省去不必要的內存拷貝:epoll通過內核與用戶空間mmap同一塊內存實現。
當然,以上的優缺點僅僅是特定場景下的情況:高併發,且任一時間只有少數socket是活躍的。
如果在併發量低,socket都比較活躍的情況下,select就不見得比epoll慢了(就像我們常常說快排比插入排序快,但是在特定情況下這並不成立)。