0.概述node
經過本篇文章將瞭解到如下內容:linux
1.複用技術和I/O複用面試
複用技術(multiplexing)並非新技術而是一種設計思想,在通訊和硬件設計中存在頻分複用、時分複用、波分複用、碼分複用等,在平常生活中複用的場景也很是多,所以不要被專業術語所迷惑。從本質上來講,複用就是爲了解決有限資源和過多使用者的不平衡問題,且此技術的理論基礎是資源的可釋放性。編程
舉個實際生活的例子:api
不可釋放場景:ICU病房的呼吸機做爲有限資源,病人一旦佔用且在未脫離危險以前是沒法放棄佔用的,所以不可能幾個狀況同樣的病人輪流使用。數組
可釋放場景:對於一些其餘資源好比醫護人員就能夠實現對多個病人的同時監護,理論上不存在一個病人佔用醫護人員資源不釋放的場景。安全
I/O的含義:在計算機領域常說的IO包括磁盤IO和網絡IO,咱們所說的IO複用主要是指網絡IO,在Linux中一切皆文件,所以網絡IO也常常用文件描述符FD來表示。網絡
複用的含義:那麼這些文件描述符FD要複用什麼呢?在網絡場景中複用的就是任務處理線程,因此簡單理解就是多個IO共用1個線程。數據結構
IO複用的可行性:IO請求的基本操做包括read和write,因爲網絡交互的本質性,必然存在等待,換言之就是整個網絡鏈接中FD的讀寫是交替出現的,時而可讀可寫,時而空閒,因此IO複用是可用實現的。併發
綜上認爲,IO複用技術就是協調多個可釋放資源的FD交替共享任務處理線程完成通訊任務,實現多個fd對應1個任務處理線程。
現實生活中IO複用就像一隻邊牧管理幾百只綿羊同樣:
高效IO複用機制要知足:協調者消耗最少的系統資源、最小化FD的等待時間、最大化FD的數量、任務處理線程最少的空閒、多快好省完成任務等。
在網絡併發量很是小的原始時期,即便per req per process地處理網絡請求也能夠知足要求,可是隨着網絡併發量的提升,原始方式必將阻礙進步,因此就刺激了IO複用機制的實現和推廣。
2.Linux中IO複用工具
在Linux中前後出現了select、poll、epoll等,FreeBSD的kqueue也是很是優秀的IO複用工具,kqueue的原理和epoll很相似,本文以Linux環境爲例,而且不討論過多select和poll的實現機制和細節。
select大約是2000年初出現的,其對外的接口定義:
/* According to POSIX.1-2001 */ #include <sys/select.h> /* According to earlier standards */ #include <sys/time.h> #include <sys/types.h> #include <unistd.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); void FD_CLR(int fd, fd_set *set); int FD_ISSET(int fd, fd_set *set); void FD_SET(int fd, fd_set *set); void FD_ZERO(fd_set *set);
做爲第一個IO複用系統調用,select使用一個宏定義函數按照bitmap原理填充fd,默認大小是1024個,所以對於fd的數值大於1024均可能出現問題,看下官方預警:
Macro: int FD_SETSIZE The value of this macro is the maximum number of file descriptors that a fd_set object can hold information about. On systems with a fixed maximum number, FD_SETSIZE is at least that number. On some systems, including GNU, there is no absolute limit on the number of descriptors open, but this macro still has a constant value which controls the number of bits in an fd_set; if you get a file descriptor with a value as high as FD_SETSIZE, you cannot put that descriptor into an fd_set.
也就是說當fd的數值大於1024時在將不可控,官方不建議超過1024,可是咱們也沒法控制fd的絕對數值大小,以前針對這個問題作過一些調研,結論是系統對於fd的分配有本身的策略,會大機率分配到1024之內,對此我並無充分理解,只是說起一下這個坑。
存在的問題:
綜上可知,select以樸素的方式實現了IO複用,將併發量提升的最大K級,可是對於完成這個任務的代價和靈活性都有待提升。不管怎麼樣select做爲先驅對IO複用有巨大的推進,而且指明瞭後續的優化方向,不要無知地指責select。
epoll最初在2.5.44內核版本出現,後續在2.6.x版本中對代碼進行了優化使其更加簡潔,前後面對外界的質疑在後續增長了一些設置來解決隱藏的問題,因此epoll也已經有十幾年的歷史了。在《Unix網絡編程》第三版(2003年)尚未介紹epoll,由於那個時代epoll尚未出現,書中只介紹了select和poll,epoll對select中存在的問題都逐一解決,簡單來講epoll的優點包括:
綜上可知,epoll出現以後大大提升了併發量對於C10K問題輕鬆應對,即便後續出現了真正的異步IO,也並無(暫時沒有)撼動epoll的江湖地位,主要是由於epoll能夠解決數萬數十萬的併發量,已經能夠解決如今大部分的場景了,異步IO當然優異,可是編程難度比epoll更大,權衡之下epoll仍然富有生命力。
3.epoll的基本實現
//用戶數據載體 typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; //fd裝載入內核的載體 struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; //三板斧api int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
可能上面的描述有些抽象,不過其實很好理解,舉個現實中的例子:
經過man epoll能夠看到官方的demo:
#define MAX_EVENTS 10 struct epoll_event ev, events[MAX_EVENTS]; int listen_sock, conn_sock, nfds, epollfd; /* Set up listening socket, 'listen_sock' (socket(), bind(), listen()) */ epollfd = epoll_create(10); if(epollfd == -1) { perror("epoll_create"); exit(EXIT_FAILURE); } ev.events = EPOLLIN; ev.data.fd = listen_sock; if(epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) { perror("epoll_ctl: listen_sock"); exit(EXIT_FAILURE); } for(;;) { nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1); if (nfds == -1) { perror("epoll_pwait"); exit(EXIT_FAILURE); } for (n = 0; n < nfds; ++n) { if (events[n].data.fd == listen_sock) { //主監聽socket有新鏈接 conn_sock = accept(listen_sock, (struct sockaddr *) &local, &addrlen); if (conn_sock == -1) { perror("accept"); exit(EXIT_FAILURE); } setnonblocking(conn_sock); ev.events = EPOLLIN | EPOLLET; ev.data.fd = conn_sock; if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) { perror("epoll_ctl: conn_sock"); exit(EXIT_FAILURE); } } else { //已創建鏈接的可讀寫句柄 do_use_fd(events[n].data.fd); } } }
4.epoll的底層實現
epoll底層實現最重要的兩個數據結構:epitem和eventpoll。
能夠簡單的認爲epitem是和每一個用戶態監控IO的fd對應的,eventpoll是用戶態建立的管理全部被監控fd的結構,詳細的定義以下:
#ifndef _LINUX_RBTREE_H #define _LINUX_RBTREE_H #include <linux/kernel.h> #include <linux/stddef.h> #include <linux/rcupdate.h> struct rb_node { unsigned long __rb_parent_color; struct rb_node *rb_right; struct rb_node *rb_left; } __attribute__((aligned(sizeof(long))));
/* The alignment might seem pointless, but allegedly CRIS needs it */ struct rb_root { struct rb_node *rb_node; };
struct epitem { struct rb_node rbn; struct list_head rdllink; struct epitem *next; struct epoll_filefd ffd; int nwait; struct list_head pwqlist; struct eventpoll *ep; struct list_head fllink; struct epoll_event event; }; struct eventpoll { spin_lock_t lock; struct mutex mtx; wait_queue_head_t wq; wait_queue_head_t poll_wait; struct list_head rdllist; //就緒鏈表 struct rb_root rbr; //紅黑樹根節點 struct epitem *ovflist; };
epoll_create會建立一個類型爲struct eventpoll的對象,並返回一個與之對應文件描述符,以後應用程序在用戶態使用epoll的時候都將依靠這個文件描述符,而在epoll內部也是經過該文件描述符進一步獲取到eventpoll類型對象,再進行對應的操做,完成了用戶態和內核態的貫穿。
epoll_ctl底層主要調用epoll_insert實現操做:
如圖展現了紅黑樹、雙鏈表、epitem之間的關係:
注:rbr表示rb_root,rbn表示rb_node 上文給出了其在內核中的定義
常見錯誤觀點:epoll_wait返回時,對於就緒的事件,epoll使用的是共享內存的方式,即用戶態和內核態都指向了就緒鏈表,因此就避免了內存拷貝消耗
網上抄來抄去的觀點
關於epoll_wait使用共享內存的方式來加速用戶態和內核態的數據交互,避免內存拷貝的觀點,並無獲得2.6內核版本代碼的證明,而且關於此次拷貝的實現是這樣的:
revents = ep_item_poll(epi, &pt);//獲取就緒事件 if (revents) { if (__put_user(revents, &uevent->events) || __put_user(epi->event.data, &uevent->data)) { list_add(&epi->rdllink, head);//處理失敗則從新加入鏈表 ep_pm_stay_awake(epi); return eventcnt ? eventcnt : -EFAULT; } eventcnt++; uevent++; if (epi->event.events & EPOLLONESHOT) epi->event.events &= EP_PRIVATE_BITS;//EPOLLONESHOT標記的處理 else if (!(epi->event.events & EPOLLET)) { list_add_tail(&epi->rdllink, &ep->rdllist);//LT模式處理 ep_pm_stay_awake(epi); } }
5.ET模式和LT模式
默認採用LT模式,LT支持阻塞和非阻塞套,ET模式只支持非阻塞套接字,其效率要高於LT模式,而且LT模式更加安全。LT和ET模式下均可以經過epoll_wait方法來獲取事件,LT模式下將事件拷貝給用戶程序以後,若是沒有被處理或者未處理完,那麼在下次調用時還會反饋給用戶程序,能夠認爲數據不會丟失會反覆提醒;ET模式下若是沒有被處理或者未處理完,那麼下次將再也不通知到用戶程序,所以避免了反覆被提醒,卻增強了對用戶程序讀寫的要求;
上面的簡單理解在網上隨便找一篇都會講到,可是LT和ET真正使用起來,仍是存在必定難度的。
LT對於read操做比較簡單,有read事件就讀,讀多讀少都沒有問題,可是write就不那麼容易了,通常來講socket在空閒狀態時發送緩衝區必定是不滿的,假如fd一直在監控中,那麼會一直通知寫事件,不勝其煩。因此必須保證沒有數據要發送的時候,要把fd的寫事件監控從epoll列表中刪除,須要的時候再加入回去,如此反覆。
天下沒有免費的午飯,老是無代價地提醒是不可能的,對應write的過分提醒,須要使用者隨用隨加,不然將一直被提醒可寫事件。
fd可讀則返回可讀事件,若開發者沒有把全部數據讀取完畢,epoll不會再次通知read事件,也就是說若是沒有所有讀取全部數據,那麼致使epoll不會再通知該socket的read事件,事實上一直讀完很容易作到。若發送緩衝區未滿,epoll通知write事件,直到開發者填滿發送緩衝區,epoll纔會在下次發送緩衝區由滿變成未滿時通知write事件。ET模式下只有socket的狀態發生變化時纔會通知,也就是讀取緩衝區由無數據到有數據時通知read事件,發送緩衝區由滿變成未滿通知write事件。
使用Linux epoll模型的LT水平觸發模式,當socket可寫時,會不停的觸發socket可寫的事件,如何處理?
騰訊面試題
這道題目對LT和ET考察比較深刻,驗證了前文說的LT模式write問題。
普通作法:
當須要向socket寫數據時,將該socket加入到epoll等待可寫事件。接收到socket可寫事件後,調用write()或send()發送數據,當數據所有寫完後, 將socket描述符移出epoll列表,這種作法須要反覆添加和刪除。
改進作法:
向socket寫數據時直接調用send()發送,當send()返回錯誤碼EAGAIN,纔將socket加入到epoll,等待可寫事件後再發送數據,所有數據發送完畢,再移出epoll模型,改進的作法至關於認爲socket在大部分時候是可寫的,不能寫了再讓epoll幫忙監控。上面兩種作法是對LT模式下write事件頻繁通知的修復,本質上ET模式就能夠直接搞定,並不須要用戶層程序的補丁操做。
若是某個socket源源不斷地收到很是多的數據,在試圖讀取完全部數據的過程當中,有可能會形成其餘的socket得不處處理,從而形成飢餓問題。
解決辦法:爲每一個已經準備好的描述符維護一個隊列,這樣程序就能夠知道哪些描述符已經準備好了可是並無被讀取完,而後程序定時或定量的讀取,若是讀完則移除,直到隊列爲空,這樣就保證了每一個fd都被讀到而且不會丟失數據。
流程如圖:
A線程讀完某socket上數據後開始處理這些數據,此時該socket上又有新數據可讀,B線程被喚醒讀新的數據,形成2個線程同時操做一個socket的局面 ,EPOLLONESHOT保證一個socket鏈接在任一時刻只被一個線程處理。
經過前面的對比能夠看到LT模式比較安全而且代碼編寫也更清晰,可是ET模式屬於高速模式,在處理大高併發場景使用得當效果更好,具體選擇什麼根據本身實際須要和團隊代碼能力來選擇,若是併發很高且團隊水平較高能夠選擇ET模式,不然建議LT模式。
6.epoll的驚羣問題
在2.6.18內核中accept的驚羣問題已經被解決了,可是在epoll中仍然存在驚羣問題,表現起來就是當多個進程/線程調用epoll_wait時會阻塞等待,當內核觸發可讀寫事件,全部進程/線程都會進行響應,可是實際上只有一個進程/線程真實處理這些事件。
在epoll官方沒有正式修復這個問題以前,Nginx做爲知名使用者採用全局鎖來限制每次可監聽fd的進程數量,每次只有1個可監聽的進程,後來在Linux 3.9內核中增長了SO_REUSEPORT選項實現了內核級的負載均衡,Nginx1.9.1版本支持了reuseport這個新特性,從而解決驚羣問題。
EPOLLEXCLUSIVE是在2016年Linux 4.5內核新添加的一個 epoll 的標識,Ngnix 在 1.11.3 以後添加了NGX_EXCLUSIVE_EVENT選項對該特性進行支持。EPOLLEXCLUSIVE標識會保證一個事件發生時候只有一個線程會被喚醒,以免多偵聽下的驚羣問題。
7.巨人的肩膀
http://harlon.org/2018/04/11/networksocket5/
https://devarea.com/linux-io-multiplexing-select-vs-poll-vs-epoll/#.XfmWG6qFOUl
https://jvns.ca/blog/2017/06/03/async-io-on-linux--select--poll--and-epoll/