本文摘抄自linux基礎編程
IO概念
Linux的內核將所有外部設備都可以看做一個文件來操作。那么我們對與外部設備的操作都可以看做對文件進行操作。
我們對一個文件的讀寫,都通過調用內核提供的系統調用;內核給我們返回一個filede scriptor(fd,文件描述符)。而對一個socket的讀寫也會有相應的描述符,稱為socketfd(socket描述符)。描述符就是一個數字,指向內核中一個結構體(文件路徑,數據區,等一些屬性)。那么我們的應用程序對文件的讀寫就通過對描述符的讀寫完成。
linux將內存分為內核區,用戶區。linux內核給我們管理所有的硬件資源,應用程序通過調用系統調用和內核交互,達到使用硬件資源的目的。應用程序通過系統調用read發起一個讀操作,這時候內核創建一個文件描述符,并通過驅動程序向硬件發送讀指令,并將讀的的數據放在這個描述符對應結構體的內核緩存區中,然后再把這個數據讀到用戶進程空間中,這樣完成了一次讀操作;
但是大家都知道I/O設備相比cpu的速度是極慢的。linux提供的read系統調用,也是一個阻塞函數。這樣我們的應用進程在發起read系統調用時,就必須阻塞,就進程被掛起而等待文件描述符的讀就緒,那么什么是文件描述符讀就緒,什么是寫就緒?
讀就緒:就是這個文件描述符的接收緩沖區中的數據字節數大于等于套接字接收緩沖區低水位標記的當前大小;
寫就緒:該描述符發送緩沖區的可用空間字節數大于等于描述符發送緩沖區低水位標記的當前大小。(如果是socket fd,說明上一個數據已經發送完成)。
接收低水位標記和發送低水位標記:由應用程序指定,比如應用程序指定接收低水位為64個字節。那么接收緩沖區有64個字節,才算fd讀就緒;
綜上所述,一個基本的IO,它會涉及到兩個系統對象,一個是調用這個IO的進程對象,另一個就是系統內核(kernel)。當一個read操作發生時,它會經歷兩個階段:
1:通過read系統調用想內核發起讀請求。內核向硬件發送讀指令,并等待讀就緒。
2:內核把將要讀取的數據復制到描述符所指向的內核緩存區中。將數據從內核緩存區拷貝到用戶進程空間中。
IO模型
在linux系統下面,根據IO操作的是否被阻塞以及同步異步問題進行分類,可以得到下面五種IO模型:
1、阻塞I/O模型
最流行的I/O模型是阻塞I/O模型,缺省情形下,所有文件操作都是阻塞的。我們以套接口為例來講解此模型。在進程空間中調用recvfrom,其系統調用直到數據報到達且被拷貝到應用進程的緩沖區中或者發生錯誤才返回,期間一直在等待。我們就說進程在從調用recvfrom開始到它返回的整段時間內是被阻塞的。
2、非阻塞I/O模型
進程把一個套接口設置成非阻塞是在通知內核:當所請求的I/O操作不能滿足要求時候,不把本進程投入睡眠,而是返回一個錯誤。也就是說當數據沒有到達時并不等待,而是以一個錯誤返回。
3、I/O復用模型
linux提供select/poll,進程通過將一個或多個fd傳遞給select或poll系統調用,阻塞在select;這樣select/poll可以幫我們偵測許多fd是否就緒。但是select/poll是順序掃描fd是否就緒,而且支持的fd數量有限。
linux還提供了一個epoll系統調用,epoll是基于事件驅動方式,而不是順序掃描,當有fd就緒時,立即回調函數rollback;
4、信號驅動異步I/O模型
首先開啟套接口信號驅動I/O功能, 并通過系統調用sigaction安裝一個信號處理函數(此系統調用立即返回,進程繼續工作,它是非阻塞的)。當數據報準備好被讀時,就為該進程生成一個SIGIO信號。隨即可以在信號處理程序中調用recvfrom來讀數據報,井通知主循環數據已準備好被處理中。也可以通知主循環,讓它來讀數據報。
5、異步I/O模型
告知內核啟動某個操作,并讓內核在整個操作完成后(包括將數據從內核拷貝到用戶自己的緩沖區)通知我們。這種模型與信號驅動模型的主要區別是:信號驅動I/O:由內核通知我們何時可以啟動一個I/O操作;異步I/O模型:由內核通知我們I/O操作何時完成。
非阻塞IO詳解
通過上面,我們知道,所有的IO操作在默認情況下,都是屬于阻塞IO。盡管上圖中所示的反復請求的非阻塞IO的效率底下(需要反復在用戶空間和進程空間切換和判斷,把一個原本屬于IO密集的操作變為IO密集和計算密集的操作),但是在后面IO復用中,需要把IO的操作設置為非阻塞的,此時程序將會阻塞在select和poll系統調用中。把一個IO設置為非阻塞IO有兩種方式:在創建文件描述符時,指定該文件描述符的操作為非阻塞;在創建文件描述符以后,調用fcntl()函數設置相應的文件描述符為非阻塞。
創建描述符時,利用open函數和socket函數的標志設置返回的fd/socket描述符為O_NONBLOCK。
int sd=socket(int domain, int type|O_NONBLOCK, int protocol);
int fd=open(const char *pathname, int flags|O_NONBLOCK);
創建描述符后,通過調用fcntl函數設置描述符的屬性為O_NONBLOCK
#include
#include
int fcntl(int fd, int cmd, ... /* arg */ );
//例子
if (fcntl(fd, F_SETFL, fcntl(sockfd, F_GETFL, 0)|O_NONBLOCK) == -1) {
return -1;
}
return 0;
}
IO復用詳解
在IO編程過程中,當需要處理多個請求的時,可以使用多線程和IO復用的方式進行處理。上面的圖介紹了整個IO復用的過程,它通過把多個IO的阻塞復用到一個select之類的阻塞上,從而使得系統在單線程的情況下同時支持處理多個請求。和多線程/進程比較,I/O多路復用的最大優勢是系統開銷小,系統不需要建立新的進程或者線程,也不必維護這些線程和進程。IO復用常見的應用場景:
客戶程序需要同時處理交互式的輸入和服務器之間的網絡連接。
客戶端需要對多個網絡連接作出反應。
服務器需要同時處理多個處于監聽狀態和多個連接狀態的套接字
服務器需要處理多種網絡協議的套接字。
目前支持I/O復用的系統調用有select、pselect、poll、epoll,下面幾小結分別來學習一下select和epoll的使用。
select
#include
int select(int maxfdps, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
struct timeval{
long tv_sec; //秒
long tv_usec; //微秒
};
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);//清空集合
struct timeval *time結構體告知內核等待所指定描述字中的任何一個就緒可花多少時間。
參數取值:
(1)(struct timeval *)0:永遠等待下去,僅在有一個描述字準備好I/O時才返回。
(2)struct timeval *time:在有一個描述字準備好I/O時返回,但是不超過由該參數所指向的timeval結構中指定的秒數和微秒數。如果超過時間,沒有描述字準備好,那就返回0。如果秒=微秒=0,檢查描述字后立即返回,此時相當于輪詢。
中間的三個參數readset、writeset和exceptset指定我們要讓內核測試讀、寫和異常條件的描述字。如果我們對某一個的條件不感興趣,就可以把它設為空指針。
fd_set可以理解為一個集合,這個集合中存放的是文件描述符,可通過上面的四個宏進行設置(注意,fd_set不是struct fd_set。。。 剛剛開始調試程序就犯錯誤,返回storage size of 'fds' isn't known)。
第一個參數maxfdp1指定待測試的描述字個數,它的值是待測試的最大描述字加1(因此我們把該參數命名為maxfdp1),描述字0、1、2...maxfdp1-1均將被測試。
pselect
#include
int pselect(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, const struct timespec *timeout,? const sigset_t *sigmask);
struct timespec{
time_t tv_sec; //秒
long tv_nsec; //納秒
};
比較select和pselect函數,我們發現在原型上面有兩個不同:
1、pselect使用timespec結構,而不使用timeval結構。t
imespec結構是POSIX的又一個發明。 這兩個結構的區別在于第二個成員:新結構的該成員tv_nsec指定納秒數,而舊結構的該成員tv_usec指定微秒數。
2、pselect函數增加了第六個參數:一個指向信號掩碼的指針。該參數允許程序先禁止遞交某些信號,再測試由這些當前被禁止的信號處理函數設置的全局變量,然后調用pselect,告訴它重新設置信號掩碼。
首先我們看看信號掩碼的概念:在POSIX下,每個進程有一個信號掩碼(signal mask)。簡單地說,信號掩碼是一個“位圖”,其中每一位都對應著一種信號。如果位圖中的某一位為1,就表示在執行當前信號的處理程序期間相應的信號暫時被“屏蔽”,使得在執行的過程中不會嵌套地響應那種信號。為什么對某一信號進行屏蔽呢?我們來看一下對CTRL_C的處理。大家知道,當一個程序正在運行時,在鍵盤上按一下CTRL_C,內核就會向相應的進程發出一個SIGINT 信號,而對這個信號的默認操作就是通過do_exit()結束該進程的運行。但是,有些應用程序可能對CTRL_C有自己的處理,所以就要為SIGINT另行設置一個處理程序,使它指向應用程序中的一個函數,在那個函數中對CTRL_C這個事件作出響應。但是,在實踐中卻發現,兩次CTRL_C事件往往過于密集,有時候剛剛進入第一個信號的處理程序,第二個SIGINT信號就到達了,而第二個信號的默認操作是殺死進程,這樣,第一個信號的處理程序根本沒有執行完。為了避免這種情況的出現,就在執行一個信號處理程序的過程中將該種信號自動屏蔽掉。所謂“屏蔽”,與將信號忽略是不同的,它只是將信號暫時“遮蓋”一下,一旦屏蔽去掉,已到達的信號又繼續得到處理。
在我們開發過程中,如果進程阻塞于select函數,此時該阻塞被信號所打斷,select返回-1,errno=EINTR的錯誤。由于這個原因,我們才有了pselect函數在信號上的優化處理。因此在信號和select都被使用的系統里,pselect有其作用。否則和select沒有任何區別。
poll
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
和select()不一樣,poll()沒有使用低效的三個基于位的文件描述符set,而是采用了一個單獨的結構體pollfd數組,由fds指針指向這個數組的第一個元素,其中nfds表示該數組的大小。每一個pollfd 結構體指定了一個被監視的文件描述符,每個結構體的events域是監視該文件描述符的事件掩碼,由用戶來設置這個域。revents域是文件描述符的操作結果事件掩碼。內核在調用返回時設置這個域**。events域中請求的任何事件都可能在revents域中返回,而部分事件不能出現在events中,詳細如下表。
POLLIN:普通或優先級帶數據可讀
POLLRDNORM:普通數據可讀
POLLRDBAND:優先級帶數據可讀
POLLPRI:高優先級數據可讀
POLLOUT:普通數據可寫
POLLWRNORM:普通數據可寫
POLLWRBAND:優先級帶數據可寫
POLLERR:發生錯誤
POLLHUP:發生掛起
POLLNVAL:描述字不是一個打開的文件
注意:后三個只能作為描述字的返回結果存儲在revents中,而不能作為測試條件用于events中。
最后一個參數timeout是指定poll函數返回前等待多長時間
成功時,**poll()返回結構體中revents域不為0的文件描述符個數;如果在超時前沒有任何事件發生,poll()返回0;失敗時,poll()返回-1。
關鍵代碼如下:
client[0].fd = listenfd;? ? ? ? /*將數組中的第一個元素設置成監聽描述字*/
client[0].events = POLLIN;
while(1)
{
nready = poll(client, maxi+1,INFTIM); //將進程阻塞在poll上
if( client[0].revents & POLLIN/*POLLRDNORM*/ ) /*先測試監聽描述字*/
epoll
在linux的網絡編程中,很長的一段時間都在使用select來做事件觸發。然而select逐漸暴露出了一些缺陷,使得linux不得不在新的內核中尋找出替代方案,那就是epoll。其實,epoll與select原理類似,只不過,epoll作出了一些重大改進,即:
1.支持一個進程打開大數目的socket描述符(FD)select 最不能忍受的是一個進程所打開的FD是有一定限制的,由FD_SETSIZE設置,默認值是2048。對于那些需要支持的上萬連接數目的IM服務器來說顯然太少了。這時候你一是可以選擇修改這個宏然后重新編譯內核,不過資料也同時指出這樣會帶來網絡效率的下降,二是可以選擇多進程的解決方案(傳統的 Apache方案),不過雖然linux上面創建進程的代價比較小,但仍舊是不可忽視的,加上進程間數據同步遠比不上線程間同步的高效,所以也不是一種完美的方案。不過 epoll則沒有這個限制,它所支持的FD上限是最大可以打開文件的數目,這個數字一般遠大于2048,舉個例子,在1GB內存的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統內存關系很大。
2.IO效率不隨FD數目增加而線性下降傳統的select/poll另一個致命弱點就是當你擁有一個很大的socket集合,不過由于網絡延時,任一時間只有部分的socket是"活躍"的,但是select/poll每次調用都會線性掃描全部的集合,導致效率呈現線性下降。但是epoll不存在這個問題,它只會對"活躍"的socket進行操作---這是因為在內核實現中epoll是根據每個fd上面的callback函數實現的。那么,只有"活躍"的socket才會主動的去調用 callback函數,其他idle狀態socket則不會,在這點上,epoll實現了一個"偽"AIO,因為這時候推動力在os內核。
3.使用mmap加速內核與用戶空間的消息傳遞。這點實際上涉及到epoll的具體實現了。無論是select,poll還是epoll都需要內核把FD消息通知給用戶空間,如何避免不必要的內存拷貝就很重要,在這點上,epoll是通過內核于用戶空間mmap同一塊內存實現的。
4.內核微調這一點其實不算epoll的優點了,而是整個linux平臺的優點。也許你可以懷疑linux平臺,但是你無法回避linux平臺賦予你微調內核的能力。
epoll api函數比較簡單,包括
創建一個epoll描述符
添加監聽事件
阻塞等待所監聽的事件發生
關閉epoll描述符,如下:
#include
#include
int epoll_create(int size); //epoll描述符
int close(int fd);//關閉epoll描述符
創建一個epoll的句柄,size用來告訴內核這個監聽的數目一共有多大。需要注意的是,當創建好epoll句柄后,epoll本身就占用一個fd值,所以用完后必須調用close()關閉,以防止fd被耗盡。
#include
#include
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);//添加監聽事件
typedef union epoll_data {
void? ? ? ? *ptr;
int? ? ? ? ? fd;
uint32_t? ? u32;
uint64_t? ? u64;
} epoll_data_t;
struct epoll_event {
uint32_t? ? events;? ? ? /* Epoll events */
epoll_data_t data;? ? ? ? /* User data variable */
};
epoll_ctl為事件注冊函數,第一個參數是epoll_create()的返回值,第二個參數表示動作,用三個宏來表示:
EPOLL_CTL_ADD:注冊新的fd到epfd中;
EPOLL_CTL_MOD:修改已經注冊的fd的監聽事件;
EPOLL_CTL_DEL:從epfd中刪除一個fd;
第三個參數是需要監聽的描述符fd,第四個參數是告訴內核需要監聽什么事件,struct epoll_event結構如上所示,其中events為需要注冊的事件,可以為下面幾個宏的集合:
EPOLLIN:表示對應的文件描述符可以讀(包括對端SOCKET正常關閉);
EPOLLOUT:表示對應的文件描述符可以寫;
EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這里應該表示有帶外數據到來);
EPOLLERR:表示對應的文件描述符發生錯誤;
EPOLLHUP:表示對應的文件描述符被掛斷;
EPOLLET:將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對于水平觸發(Level Triggered)來說的。詳細見下面描述
EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之后,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里。
操作系統對被注冊的文件描述符的事件發送以后有兩種處理方式,分別有LT和ET模式,默認情況下為LT,如果需要設置為ET模式,設置
struct epoll_event.events|EPOLLET。
LT(level triggered)是缺省的工作方式,并且同時支持block和no-block socket.在這種做法中,內核告訴你一個文件描述符是否就緒了,然后你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內核還是會繼續通知你的,所以,這種模式編程出錯誤可能性要小一點,傳統的select/poll都是這種模型的代表。
ET (edge-triggered)是高速工作方式,只支持no-block socket。在這種模式下,當描述符從未就緒變為就緒時,內核通過epoll告訴你。然后它會假設你知道文件描述符已經就緒,并且不會再為那個文件描述符發送更多的就緒通知,直到你做了某些操作導致那個文件描述符不再為就緒狀態了(比如,你在發送,接收或者接收請求,或者發送接收的數據少于一定量時導致了一個EWOULDBLOCK 錯誤)。但是請注意,如果一直不對這個fd作IO操作(從而導致它再次變成未就緒),內核不會發送更多的通知(only once),不過在TCP協議中,ET模式的加速效用仍需要更多的benchmark確認。
struct epoll_event.data為一個epoll_data_t類型的聯合體,詳細結構如上,其中可以保存指針,描述符,32/64位的整數。如果在監聽過程中,該描述符上面有相應的事件發生,系統將會把該字段返回。先看監聽函數吧??赐昃椭?/p>
#include
#include
int epoll_wait(int epfd, struct epoll_event *events,? int maxevents, int timeout);//阻塞等待所監聽的事件發生
阻塞監聽函數,類似于select()調用。參數events用來從內核得到事件的集合,返回的結構也是struct epoll_event,其中event為相應的事件,data為注冊時,設置的值(常見情況,data設置為注冊的描述符,這樣就可以對相應的描述符進行IO操作)。maxevents告之內核這個events有多大,這個maxevents的值不能大于創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。
struct epoll_event ev, *events;
int kdpfd = epoll_create(100);
ev.events = EPOLLIN | EPOLLET; // 注意這個EPOLLET,指定了邊緣觸發
ev.data.fd =listener;
epoll_ctl(kdpfd, EPOLL_CTL_ADD, listener, &ev);
for(;;)
{
nfds = epoll_wait(kdpfd, events, maxevents, -1);
for(n = 0; n < nfds; ++n)
{
if(events[n].data.fd == listener)
{
client = accept(listener, (struct sockaddr *) &local, &addrlen);
if(client < 0){
perror("accept");
continue;
}
setnonblocking(client);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = client;
if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0)
{
fprintf(stderr, "epoll set insertion error: fd=%d0, client);
return -1;
}
}else{
do_use_fd(events[n].data.fd);
}
}
}
最后我們說一下,用來克服select/poll缺點的方法不只有epoll。epoll只是一種linux 的方案,在freeBSD下有kqueue,而dev/poll 是最古老的Solaris的方案,使用難度依次遞增。 kqueue 是freebsd 的寵兒,kqueue 實際上是一個功能相當豐富的kernel 事件隊列,它不僅僅是select/poll 的升級,而且可以處理 signal 、目錄結構變化、進程等多種事件。Kqueue 是邊緣觸發的。
信號驅動異步IO詳解
上面介紹了不管是阻塞/非阻塞或者IO復用,在應用程序層面都需要進行阻塞或者輪詢指定的IO上面是否有相應的事件發生。本節我們將將介紹一個全新的模型:異步模型,異步則意味著不需要用戶層進行阻塞或者輪詢,在特定IO或者事件發生時候,會主動通知應用程序IO就緒,本節介紹的通知機制即信號,我們把這個模型叫著信號驅動的異步I/O。
Unix上有定義了許多信號,源自Berkeley的實現使用的是SIGIO信號來支持套接字和終端設備上的信號驅動IO。在套接字IO中,信號驅動IO模型主要是在UDP套接字上使用,在TCP套接字上幾乎是沒有什么使用的(在TCP上,由于TCP是雙工的,它的信號產生過于平凡,并且信號的出現幾乎沒有告訴我們發生了什么事情。因此對于TCP套接字,SIGIO信號是沒有什么使用的)。
使用信號驅動異步IO主要有下面幾個步驟:
為了讓套接字描述符可以工作于信號驅動I/O模式,應用進程必須完成如下三步設置:
1.注冊SIGIO信號處理程序。(安裝信號處理器)
2.使用fcntl的F_SETOWN命令,設置套接字所有者。
3.使用fcntl的F_SETFL命令,置O_ASYNC和O_NONBLOCK標志,允許套接字信號驅動I/O。
注意,必須保證在設置套接字所有者之前,向系統注冊信號處理程序,否則就有可能在fcntl調用后,信號處理程序注冊前內核向應用交付SIGIO信號,導致應用丟失此信號。
代碼片段如下:
struct sigaction sigio_action;
memset(&sigio_action, 0, sizeof(sigio_action));
sigio_action.sa_flags = 0;
sigio_action.sa_handler = do_sigio;//信號發生時的處理函數
sigaction(SIGIO, &sigio_action, NULL);
fcntl(listenfd1, F_SETOWN, getpid());
int flags;
flags = fcntl(listenfd1, F_GETFL, 0);
flags |= O_ASYNC | O_NONBLOCK;
fcntl(listenfd1, F_SETFL, flags);
對同步異步以及阻塞與非阻塞的理解:
在處理 IO 的時候,阻塞和非阻塞都是同步 IO。只有使用了特殊的 API 才是異步 IO。
阻塞同步是指一直等待結果
非阻塞同步是指立即返回錯誤的結果