注意:本文是本人的學習總結,可能存在理解上的錯誤,請帶着懷疑眼光看待,如果有不准確的地方歡迎指出,疑義相與析。為了敘述完整性,前面有一些前置知識,可以根據目錄直接看后面的詳解部分。
前置知識
用戶態與內核態
如果不了解,參考我之前的博文:用戶態與內核態
上下文切換
如果不了解,參考我之前的博文:上下文切換
文件描述符
Linux中,一切皆是文件,也就是意味着任何一個進程以及線程也都有一個唯一標識,這個標識稱之為為文件描述符(file descriptor),舉一些例子,例如建立Socket連接,會產生一個文件,文件的描述符標記為fd1,socket監聽也會產生一個文件,也會產生文件描述符fd2,文件描述符在形式上是一個非負整數,是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。
具體來說,文件又可分為:普通文件、目錄文件、鏈接文件和設備文件。文件描述符(file descriptor)是內核為了高效管理已被打開的文件所創建的索引,其是一個非負整數(通常是小整數),用於指代被打開的文件,所有執行I/O操作的系統調用都通過文件描述符。程序剛剛啟動的時候,0是標准輸入,1是標准輸出,2是標准錯誤。如果此時去打開一個新的文件,它的文件描述符會是3。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫往往會圍繞着文件描述符展開。但是文件描述符這一概念往往只適用於UNIX、Linux這樣的操作系統。
打開文件表
每一個文件描述符會與一個打開文件相對應,同時,不同的文件描述符也會指向同一個文件。相同的文件可以被不同的進程打開也可以在同一個進程中被多次打開。系統為每一個進程維護了一個文件描述符表,該表的值都是從0開始的,所以在不同的進程中你會看到相同的文件描述符,這種情況下相同文件描述符有可能指向同一個文件,也有可能指向不同的文件。具體情況要具體分析,要理解具體其概況如何,需要查看由內核維護的3個數據結構。
進程級的文件描述符表
系統級的打開文件描述符表
文件系統的i-node表
進程級的描述符表的每一條目記錄了單個文件描述符的相關信息。
控制文件描述符操作的一組標志。(目前,此類標志僅定義了一個,即close-on-exec標志)
對打開文件句柄的引用
內核對所有打開的文件的文件維護有一個系統級的描述符表格(open file description table)。有時,也稱之為打開文件表(open file table),並將表格中各條目稱為打開文件句柄(open file handle)。一個打開文件句柄存儲了與一個打開文件相關的全部信息,如下所示:
當前文件偏移量(調用read()和write()時更新,或使用lseek()直接修改)
打開文件時所使用的狀態標識(即,open()的flags參數)
文件訪問模式(如調用open()時所設置的只讀模式、只寫模式或讀寫模式)
與信號驅動相關的設置
對該文件i-node對象的引用
文件類型(例如:常規文件、套接字或FIFO)和訪問權限
一個指針,指向該文件所持有的鎖列表
文件的各種屬性,包括文件大小以及與不同類型操作相關的時間戳
文件描述符與打開文件表
注意
-
由於進程級文件描述符表的存在,不同的進程中會出現相同的文件描述符,它們可能指向同一個文件,也可能指向不同的文件**
-
兩個不同的文件描述符,若指向同一個打開文件句柄,將共享同一文件偏移量。因此,如果通過其中一個文件描述符來修改文件偏移量(由調用read()、write()或lseek()所致),那么從另一個描述符中也會觀察到變化,無論這兩個文件描述符是否屬於不同進程,還是同一個進程,情況都是如此。
-
要獲取和修改打開的文件標志(例如:O_APPEND、O_NONBLOCK和O_ASYNC),可執行fcntl()的F_GETFL和F_SETFL操作,其對作用域的約束與上一條頗為類似。
-
文件描述符標志(即,close-on-exec)為進程和文件描述符所私有。對這一標志的修改將不會影響同一進程或不同進程中的其他文件描述符
緩存 I/O
緩存 I/O 又被稱作標准 I/O,大多數文件系統的默認 I/O 操作都是緩存 I/O。在 Linux 的緩存 I/O 機制中,操作系統會將 I/O 的數據緩存在文件系統的頁緩存( page cache )中,也就是說,數據會先被拷貝到操作系統內核的緩沖區中,然后才會從操作系統內核的緩沖區拷貝到應用程序的地址空間。
**Page cache **也叫頁緩沖或文件緩沖,是由好幾個磁盤塊構成,大小通常為4k,在64位系統上為8k,構成的幾個磁盤塊在物理磁盤上不一定連續,文件的組織單位為一頁, 也就是一個page cache大小,文件讀取是由外存上不連續的幾個磁盤塊,到buffer cache(塊緩存),然后組成page cache,然后供給應用程序。
Page cache在linux讀寫文件時,它用於緩存文件的邏輯內容,從而加快對磁盤上映像和數據的訪問。具體說是加速對文件內容的訪問,buffer cache緩存文件的具體內容——物理磁盤上的磁盤塊,這是加速對磁盤的訪問。
緩存 I/O 的缺點:
數據在傳輸過程中需要在應用程序地址空間和內核進行多次數據拷貝操作,這些數據拷貝操作所帶來的 CPU 以及內存開銷是非常大的。
阻塞與IO
Linux下,與阻塞以及IO相關的概念有四個:同步、異步、阻塞、非阻塞
關於這四個概念,先舉一個通俗的例子,后面再深入解釋:
假如我們需要燒一壺水,一直在旁邊盯着看水開沒,這叫同步阻塞調用。
假如我們需要燒一壺水,不是在旁邊盯,而是時不時去看看水開沒,這叫同步非阻塞調用。
假如我們需要燒一壺水,這個水壺有警報,不用在旁邊盯,水開警報響起通知我,這叫異步調用
如果深入到多路復用Linux內核,異步目前還沒有統一的方案,目前所有的討論均在同步上進行。
阻塞IO
在linux中,默認情況下所有的socket都是blocking IO,一個典型的讀操作流程大概是這樣:
當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:准備數據(對於網絡IO來說,很多時候數據在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的數據到來)。這個過程需要等待,也就是說數據被拷貝到操作系統內核的緩沖區中是需要一個過程的。而在用戶進程這邊,整個進程會被阻塞(當然,是進程自己選擇的阻塞)。當kernel一直等到數據准備好了,它就會將數據從kernel中拷貝到用戶內存,然后kernel返回結果,用戶進程才解除block的狀態,重新運行起來。
所以,blocking IO的特點就是在IO執行的兩個階段都被block了。
非阻塞 IO
linux下,可以通過設置socket使其變為非阻塞 I/O(nonblocking IO)。當對一個non-blocking socket執行讀操作時,流程是這個樣子:
當用戶進程發出read操作時,如果kernel中的數據還沒有准備好,那么它並不會block用戶進程,而是立刻返回一個error(-1)。從用戶進程角度講 ,它發起一個read操作后,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個error時,它就知道數據還沒有准備好,於是它可以再次發送read操作。一旦kernel中的數據准備好了,並且又再次收到了用戶進程的system call,那么它馬上就將數據拷貝到了用戶內存,然后返回。
所以,nonblocking IO的特點是用戶進程需要不斷的主動詢問kernel數據好了沒有。
IO 多路復用
多路復用示意圖:
IO 多路復用(multiplexing)就是我們說的select,poll,epoll,首先講解Selelct
詳解Select
如果有很多個請求(Select限制請求上限為1024)連接到服務器,Select(poll與其類似)會對所有請求遍歷,記錄下有數據到達的請求IO,然后返回記錄信息,客戶端讀取,當請求數量很多但是有數據傳輸的請求很少時,有很多不必要的空讀取,這是Select的瓶頸。
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
函數參數介紹
(1)第一個參數maxfdp1指定待測試的描述字個數,它的值是待測試的最大描述字加1(因此把該參數命名為maxfdp1),描述字0、1、2...maxfdp1-1均將被測試。
因為文件描述符是從0開始的。
(2)中間的三個參數readset、writeset和exceptset指定我們要讓內核測試讀、寫和異常條件的描述字。如果對某一個的條件不感興趣,就可以把它設為空指針。struct fd_set可以理解為一個集合,這個集合中存放的是文件描述符,可通過以下四個宏進行設置:
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); // 檢查集合中指定的文件描述符是否可以讀寫
(3)timeout告知內核等待所指定描述字中的任何一個就緒可花多少時間。其timeval結構用於指定這段時間的秒數和微秒數。
struct timeval{
long tv_sec; //seconds
long tv_usec; //microseconds
};
這個參數有三種可能:
(1)永遠等待下去:僅在有一個描述字准備好I/O時才返回。為此,把該參數設置為空指針NULL。
(2)等待一段固定時間:在有一個描述字准備好I/O時返回,但是不超過由該參數所指向的timeval結構中指定的秒數和微秒數。
(3)根本不等待:檢查描述字后立即返回,這稱為輪詢。為此,該參數必須指向一個timeval結構,而且其中的定時器值必須為0。
select原理圖
詳解Poll
poll的機制與select類似,與select在本質上沒有多大差別,管理多個描述符也是進行輪詢,根據描述符的狀態進行處理,但是poll沒有最大文件描述符數量的限制。poll和select同樣存在一個缺點就是,包含大量文件描述符的數組被整體復制於用戶態和內核的地址空間之間,而不論這些文件描述符是否就緒,它的開銷隨着文件描述符數量的增加而線性增大。
Poll調用函數
# include <poll.h>
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
pollfd結構體定義如下:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 實際發生了的事件 */
} ;
每一個pollfd結構體指定了一個被監視的文件描述符,可以傳遞多個結構體,指示poll()監視多個文件描述符。每個結構體的events域是監視該文件描述符的事件掩碼,由用戶來設置這個域。revents域是文件描述符的操作結果事件掩碼,內核在調用返回時設置這個域。events域中請求的任何事件都可能在revents域中返回。合法的事件如下:
POLLIN 有數據可讀。
POLLRDNORM 有普通數據可讀。
POLLRDBAND 有優先數據可讀。
POLLPRI 有緊迫數據可讀。
POLLOUT 寫數據不會導致阻塞。
POLLWRNORM 寫普通數據不會導致阻塞。
POLLWRBAND 寫優先數據不會導致阻塞。
POLLMSGSIGPOLL 消息可用。
此外,revents域中還可能返回下列事件:
POLLER 指定的文件描述符發生錯誤。
POLLHUP 指定的文件描述符掛起事件。
POLLNVAL 指定的文件描述符非法。
這些事件在events域中無意義,因為它們在合適的時候總是會從revents中返回。
Poll與Select的區別
使用poll()和select()不一樣,不需要顯式地請求異常情況報告。
POLLIN | POLLPRI等價於select()的讀事件,POLLOUT |POLLWRBAND等價於select()的寫事件。POLLIN等價於POLLRDNORM |POLLRDBAND,而POLLOUT則等價於POLLWRNORM。例如,要同時監視一個文件描述符是否可讀和可寫,我們可以設置 events為POLLIN |POLLOUT。在poll返回時,我們可以檢查revents中的標志,對應於文件描述符請求的events結構體。如果POLLIN事件被設置,則文件描述符可以被讀取而不阻塞。如果POLLOUT被設置,則文件描述符可以寫入而不導致阻塞。這些標志並不是互斥的:它們可能被同時設置,表示這個文件描述符的讀取和寫入操作都會正常返回而不阻塞。
timeout參數指定等待的毫秒數,無論I/O是否准備好,poll都會返回。timeout指定為負數值表示無限超時,使poll()一直掛起直到一個指定事件發生;timeout為0指示poll調用立即返回並列出准備好I/O的文件描述符,但並不等待其它的事件。這種情況下,poll()就像它的名字那樣,一旦選舉出來,立即返回。
返回值和錯誤代碼
成功時,poll()返回結構體中revents域不為0的文件描述符個數;如果在超時前沒有任何事件發生,poll()返回0;失敗時,poll()返回-1,並設置errno為下列值之一:
EBADF 一個或多個結構體中指定的文件描述符無效。
EFAULTfds 指針指向的地址超出進程的地址空間。
EINTR 請求的事件之前產生一個信號,調用可以重新發起。
EINVALnfds 參數超出PLIMIT_NOFILE值。
ENOMEM 可用內存不足,無法完成請求。
詳解Epoll
epoll是在2.6內核中提出的,是之前的select和poll的增強版本。相對於select和poll來說,epoll更加靈活,沒有描述符限制。epoll使用一個文件描述符管理多個描述符,將用戶關系的文件描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的copy只需一次。
Epoll多路復用場景
上圖流程解釋:
JAVA默認會調用Epoll作為多路復用選擇器,可以設置參數調整
-Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.EPollSelectorProvider
當JAVA端調用Selector后,創建一個套接字Socket,描述符是fd4,綁定頓口bind開始監聽,這時候在epoll里面會調用單個函數(后面會詳細解釋),首先調用epoll_create(int size)創建紅黑樹空間fd6,這個空間是需要被監聽時間的數目,然后執行epoll_ctl,將空間fd6與Socket描述符fd4關聯起來,當有連接有數據時,記錄IO的編號,並將這個編號存儲在一個鏈表中,當epoll調用epoll_wait時,將這個記錄有事件IO的鏈表返回,之后調用程序只需要根據記錄的信息讀寫產生事件的IO即可。
Epoll函數解釋
epoll操作過程需要三個接口,分別如下:
#include <sys/epoll.h>
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);
(1) int epoll_create(int size);
創建一個epoll的句柄,size用來告訴內核這個監聽的數目一共有多大。這個參數不同於select()中的第一個參數,給出最大監聽的fd+1的值。需要注意的是,當創建好epoll句柄后,它就是會占用一個fd值,在linux下如果查看/proc/進程id/fd/,是能夠看到這個fd的,所以在使用完epoll后,必須調用close()關閉,否則可能導致fd被耗盡。
(2)int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注冊函數,它不同與select()是在監聽事件時告訴內核要監聽什么類型的事件,而是在這里先注冊要監聽的事件類型。第一個參數是epoll_create()的返回值,第二個參數表示動作,用三個宏來表示:
EPOLL_CTL_ADD:注冊新的fd到epfd中;
EPOLL_CTL_MOD:修改已經注冊的fd的監聽事件;
EPOLL_CTL_DEL:從epfd中刪除一個fd;
第三個參數是需要監聽的fd,第四個參數是告訴內核需要監聽什么事,struct epoll_event結構如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events可以是以下幾個宏的集合:
EPOLLIN :表示對應的文件描述符可以讀(包括對端SOCKET正常關閉);
EPOLLOUT:表示對應的文件描述符可以寫;
EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這里應該表示有帶外數據到來);
EPOLLERR:表示對應的文件描述符發生錯誤;
EPOLLHUP:表示對應的文件描述符被掛斷;
EPOLLET: 將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。
EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之后,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里
(3) int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的產生,類似於select()調用。參數events用來從內核得到事件的集合,maxevents告之內核這個events有多大,這個maxevents的值不能大於創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。
工作模式
epoll對文件描述符的操作有兩種模式:LT(level trigger)和ET(edge trigger)。LT模式是默認模式,LT模式與ET模式的區別如下:
LT模式:當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序可以不立即處理該事件。下次調用epoll_wait時,會再次響應應用程序並通知此事件。
ET模式:當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序必須立即處理該事件。如果不處理,下次調用epoll_wait時,不會再次響應應用程序並通知此事件。
ET模式在很大程度上減少了epoll事件被重復觸發的次數,因此效率要比LT模式高。epoll工作在ET模式的時候,必須使用非阻塞套接口,以避免由於一個文件句柄的阻塞讀/阻塞寫操作把處理多個文件描述符的任務餓死。
三種多路復用器區別總結
Select
流程:
(1)使用copy_from_user從用戶空間拷貝fd_set到內核空間
(2)注冊回調函數__pollwait
(3)遍歷所有fd,調用其對應的poll方法(對於socket,這個poll方法是sock_poll,sock_poll根據情況會調用到tcp_poll,udp_poll或者datagram_poll)
(4)以tcp_poll為例,其核心實現就是__pollwait,也就是上面注冊的回調函數。
(5)__pollwait的主要工作就是把current(當前進程)掛到設備的等待隊列中,不同的設備有不同的等待隊列,對於tcp_poll來說,其等待隊列是sk->sk_sleep(注意把進程掛到等待隊列中並不代表進程已經睡眠了)。在設備收到一條消息(網絡設備)或填寫完文件數據(磁盤設備)后,會喚醒設備等待隊列上睡眠的進程,這時current便被喚醒了。
(6)poll方法返回時會返回一個描述讀寫操作是否就緒的mask掩碼,根據這個mask掩碼給fd_set賦值。
(7)如果遍歷完所有的fd,還沒有返回一個可讀寫的mask掩碼,則會調用schedule_timeout是調用select的進程(也就是current)進入睡眠。當設備驅動發生自身資源可讀寫后,會喚醒其等待隊列上睡眠的進程。如果超過一定的超時時間(schedule_timeout指定),還是沒人喚醒,則調用select的進程會重新被喚醒獲得CPU,進而重新遍歷fd,判斷有沒有就緒的fd。
(8)把fd_set從內核空間拷貝到用戶空間。
select的幾大缺點:
(1)每次調用select,都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大
(2)同時每次調用select都需要在內核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大
(3)select支持的文件描述符數量太小了,默認是1024
Poll
poll的實現和select非常相似,只是描述fd集合的方式不同,poll使用pollfd結構而不是select的fd_set結構,沒有1024限制,其他的都差不多。
關於select和poll的實現分析,可以參考CSDN上幾篇博文:
Epoll
epoll既然是對select和poll的改進,就應該能避免上述的三個缺點。那epoll都是怎么解決的呢?在此之前,我們先看一下epoll和select和poll的調用接口上的不同,select和poll都只提供了一個函數——select或者poll函數。而epoll提供了三個函數,epoll_create,epoll_ctl和epoll_wait,epoll_create是創建一個epoll句柄;epoll_ctl是注冊要監聽的事件類型;epoll_wait則是等待事件的產生。
對於第一個缺點,epoll的解決方案在epoll_ctl函數中。每次注冊新的事件到epoll句柄中時(在epoll_ctl中指定EPOLL_CTL_ADD),會把所有的fd拷貝進內核,而不是在epoll_wait的時候重復拷貝。epoll保證了每個fd在整個過程中只會拷貝一次。
對於第二個缺點,epoll的解決方案不像select或poll一樣每次都把current輪流加入fd對應的設備等待隊列中,而只在epoll_ctl時把current掛一遍(這一遍必不可少)並為每個fd指定一個回調函數,當設備就緒,喚醒等待隊列上的等待者時,就會調用這個回調函數,而這個回調函數會把就緒的fd加入一個就緒鏈表)。epoll_wait的工作實際上就是在這個就緒鏈表中查看有沒有就緒的fd(利用schedule_timeout()實現睡一會,判斷一會的效果,和select實現中的第7步是類似的)。
對於第三個缺點,epoll沒有這個限制,它所支持的FD上限是最大可以打開文件的數目,這個數字一般遠大於2048,舉個例子,在1GB內存的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統內存關系很大。
總結
(1)select,poll實現需要自己不斷輪詢所有fd集合,直到設備就緒,期間可能要睡眠和喚醒多次交替。而epoll其實也需要調用epoll_wait不斷輪詢就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設備就緒時,調用回調函數,把就緒fd放入就緒鏈表中,並喚醒在epoll_wait中進入睡眠的進程。雖然都要睡眠和交替,但是select和poll在“醒着”的時候要遍歷整個fd集合,而epoll在“醒着”的時候只要判斷一下就緒鏈表是否為空就行了,這節省了大量的CPU時間。這就是回調機制帶來的性能提升。
(2)select,poll每次調用都要把fd集合從用戶態往內核態拷貝一次,並且要把current往設備等待隊列中掛一次,而epoll只要一次拷貝,而且把current往等待隊列上掛也只掛一次(在epoll_wait的開始,注意這里的等待隊列並不是設備等待隊列,只是一個epoll內部定義的等待隊列)。這也能節省不少的開銷。
(3)無論是那種多路復用器,知道IO狀態后都需要自己去讀取,所以都是同步而非異步