linux網絡編程IO模型

   構建現代的服務器應用程序需要以某種方法同時接收數百、數千甚至數萬個事件,無論它們是內部請求還是網絡連接,都要有效地處理它們的操作。

     有許多解決方案,但事件驅動也被廣泛應用到網絡編程中。並大規模部署在高連接數高吞吐量的服務器程序中,如 http 服務器程序、ftp 服務器程序等。相比於傳統的網絡編程方式,事件驅動能夠極大的降低資源佔用,增大服務接待能力,並提高網絡傳輸效率。

       這些事件驅動模型中, libevent 庫和 libev庫能夠大大提高性能和事件處理能力。在本文中,我們要討論在 UNIX/Linux 應用程序中使用和部署這些解決方案所用的基本結構和方法。libev 和 libevent 都可以在高性能應用程序中使用。

      在討論libev 和 libevent之前,我們看看I/O模型演進變化歷史

1、阻塞網絡接口:處理單個客戶端

      我們 第一次接觸到的網絡編程一般都是從 listen()send()recv()等接口開始的。使用這些接口可以很方便的構建服務器 /客戶機的模型。

       阻塞I/O模型圖:在調用recv()函數時,發生在內核中等待數據和複製數據的過程。


     當調用recv()函數時,系統首先查是否有準備好的數據。如果數據沒有準備好,那麼系統就處於等待狀態。當數據準備好後,將數據從系統緩衝區複製到用戶空間,然後該函數返回。在套接應用程序中,當調用recv()函數時,未必用戶空間就已經存在數據,那麼此時recv()函數就會處於等待狀態。

       我們注意到,大部分的 socket 接口都是阻塞型的。所謂阻塞型接口是指系統調用(一般是 IO 接口)不返回調用結果並讓當前線程一直阻塞,只有當該系統調用獲得結果或者超時出錯時才返回。

     實際上,除非特別指定,幾乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。這給網絡編程帶來了一個很大的問題,如在調用 send() 的同時,線程將被阻塞,在此期間,線程將無法執行任何運算或響應任何的網絡請求。這給多客戶機、多業務邏輯的網絡編程帶來了挑戰。這時,很多程序員可能會選擇多線程的方式來解決這個問題。

  使用阻塞模式的套接字,開發網絡程序比較簡單,容易實現。當希望能夠立即發送和接收數據,且處理的套接字數量比較少的情況下,即一個一個處理客戶端,服務器沒什麼壓力,使用阻塞模式來開發網絡程序比較合適。

      阻塞模式給網絡編程帶來了一個很大的問題,如在調用 send()的同時,線程將被阻塞,在此期間,線程將無法執行任何運算或響應任何的網絡請求。如果很多客戶端同時訪問服務器,服務器就不能同時處理這些請求。這時,我們可能會選擇多線程的方式來解決這個問題。

2、多線程/進程處理多個客戶端

         應對多客戶機的網絡應用,最簡單的解決方式是在服務器端使用多線程(或多進程)。多線程(或多進程)的目的是讓每個連接都擁有獨立的線程(或進程),這樣任何一個連接的阻塞都不會影響其他的連接。

       具體使用多進程還是多線程,並沒有一個特定的模式。傳統意義上,進程的開銷要遠遠大於線程,所以,如果需要同時爲較多的客戶機提供服務,則不推薦使用多進程;如果單個服務執行體需要消耗較多的 CPU 資源,譬如需要進行大規模或長時間的數據運算或文件訪問,則進程較爲安全。通常,使用 pthread_create () 創建新線程,fork() 創建新進程。即:

     (1)    a new Connection 進來,用 fork() 產生一個 Process 處理。 
     (2)   a new Connection 進來,用 pthread_create() 產生一個 Thread 處理。 

      多線程/進程服務器同時爲多個客戶機提供應答服務。模型如下:

     

       

    主線程持續等待客戶端的連接請求,如果有連接,則創建新線程,並在新線程中提供爲前例同樣的問答服務。

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <string.h>
  4. #include <unistd.h>
  5. #include <sys/socket.h>
  6. #include <netinet/in.h>
  7. #include <arpa/inet.h>
  8. void do_service(int conn);
  9. void err_log(string err, int sockfd) {
  10. perror("binding"); close(sockfd); exit(-1);
  11. }
  12. int main(int argc, char *argv[])
  13. {
  14. unsigned short port = 8000;
  15. int sockfd;
  16. sockfd = socket(AF_INET, SOCK_STREAM, 0);// 創建通信端點:套接字
  17. if(sockfd < 0) {
  18. perror("socket");
  19. exit(-1);
  20. }
  21. struct sockaddr_in my_addr;
  22. bzero(&my_addr, sizeof(my_addr));
  23. my_addr.sin_family = AF_INET;
  24. my_addr.sin_port = htons(port);
  25. my_addr.sin_addr.s_addr = htonl(INADDR_ANY);
  26. int err_log = bind(sockfd, (struct sockaddr*)&my_addr, sizeof(my_addr));
  27. if( err_log != 0) err_log("binding");
  28. err_log = listen(sockfd, 10);
  29. if(err_log != 0) err_log("listen");
  30. struct sockaddr_in peeraddr; //傳出參數
  31. socklen_t peerlen = sizeof(peeraddr); //傳入傳出參數,必須有初始值
  32. int conn; // 已連接套接字(變爲主動套接字,即可以主動connect)
  33. pid_t pid;
  34. while (1) {
  35. if ((conn = accept(sockfd, (struct sockaddr *)&peeraddr, &peerlen)) < 0) //3次握手完成的序列
  36. err_log("accept error");
  37. printf("recv connect ip=%s port=%d/n", inet_ntoa(peeraddr.sin_addr),ntohs(peeraddr.sin_port));
  38. pid = fork();
  39. if (pid == -1)
  40. err_log("fork error");
  41. if (pid == 0) {// 子進程
  42. close(listenfd);
  43. do_service(conn);
  44. exit(EXIT_SUCCESS);
  45. }
  46. else
  47. close(conn); //父進程
  48. }
  49. return 0;
  50. }
  51. void do_service(int conn) {
  52. char recvbuf[1024];
  53. while (1) {
  54. memset(recvbuf, 0, sizeof(recvbuf));
  55. int ret = read(conn, recvbuf, sizeof(recvbuf));
  56. if (ret == 0) { //客戶端關閉了
  57. printf("client close/n");
  58. break;
  59. }
  60. else if (ret == -1)
  61. ERR_EXIT("read error");
  62. fputs(recvbuf, stdout);
  63. write(conn, recvbuf, ret);
  64. }
  65. }


   很多初學者可能不明白爲何一個 socket 可以 accept 多次。實際上,socket 的設計者可能特意爲多客戶機的情況留下了伏筆,讓 accept() 能夠返回一個新的 socket。下面是 accept 接口的原型:

int accept(int s, struct sockaddr *addr, socklen_t *addrlen);

     輸入參數 s 是從 socket(),bind() 和 listen() 中沿用下來的 socket 句柄值。執行完 bind() 和 listen() 後,操作系統已經開始在指定的端口處監聽所有的連接請求,如果有請求,則將該連接請求加入請求隊列。調用 accept() 接口正是從 socket s 的請求隊列抽取第一個連接信息,創建一個與 s 同類的新的 socket 返回句柄。新的 socket 句柄即是後續 read() 和 recv() 的輸入參數。如果請求隊列當前沒有請求,則 accept() 將進入阻塞狀態直到有請求進入隊列。

     上述多線程的服務器模型似乎完美的解決了爲多個客戶機提供問答服務的要求,但其實並不盡然。如果要同時響應成百上千路的連接請求,則無論多線程還是多進程都會嚴重佔據系統資源,降低系統對外界響應效率,而線程與進程本身也更容易進入假死狀態。

      因此其缺點:

     1)用 fork() 的問題在於每一個 Connection 進來時的成本太高,如果同時接入的併發連接數太多容易進程數量很多,進程之間的切換開銷會很大,同時對於老的內核(Linux)會產生雪崩效應。 

      2)用 Multi-thread 的問題在於 Thread-safe 與 Deadlock 問題難以解決,另外有 Memory-leak 的問題要處理,這個問題對於很多程序員來說無異於惡夢,尤其是對於連續服務器的服務器程序更是不可以接受。 如果才用 Event-based 的方式在於實做上不好寫,尤其是要注意到事件產生時必須 Nonblocking,於是會需要實做 Buffering 的問題,而 Multi-thread 所會遇到的 Memory-leak 問題在這邊會更嚴重。而在多 CPU 的系統上沒有辦法使用到所有的 CPU resource。 

       由此可能會考慮使用“線程池”或“連接池”。“線程池”旨在減少創建和銷燬線程的頻率,其維持一定合理數量的線程,並讓空閒的線程重新承擔新的執行任務。“連接池”維持連接的緩存池,儘量重用已有的連接、減少創建和關閉連接的頻率。這兩種技術都可以很好的降低系統開銷,都被廣泛應用很多大型系統,如apache,mysql數據庫等。

      但是,“線程池”和“連接池”技術也只是在一定程度上緩解了頻繁調用 IO 接口帶來的資源佔用。而且,所謂“池”始終有其上限,當請求大大超過上限時,“池”構成的系統對外界的響應並不比沒有池的時候效果好多少。所以使用“池”必須考慮其面臨的響應規模,並根據響應規模調整“池”的大小。

      對應上例中的所面臨的可能同時出現的上千甚至上萬次的客戶端請求,“線程池”或“連接池”或許可以緩解部分壓力,但是不能解決所有問題。因爲多線程/進程導致過多的佔用內存或 CPU等系統資源


3、非阻塞的服務器模型

        以上面臨的很多問題,一定程度是 IO 接口的阻塞特性導致的。多線程是一個解決方案,還一個方案就是使用非阻塞的接口。

非阻塞的接口相比於阻塞型接口的顯著差異在於,在被調用之後立即返回。使用如下的函數可以將某句柄 fd 設爲非阻塞狀態。

我們可以使用 fcntl(fd, F_SETFL, flag | O_NONBLOCK); 將套接字標誌變成非阻塞:

fcntl( fd, F_SETFL, O_NONBLOCK );

下面將給出只用一個線程,但能夠同時從多個連接中檢測數據是否送達,並且接受數據。

使用非阻塞的接收數據模型:
       

      在非阻塞狀態下,recv() 接口在被調用後立即返回,返回值代表了不同的含義。

   調用recv,如果設備暫時沒有數據可讀就返回-1,同時置errno爲EWOULDBLOCK(或者EAGAIN,這兩個宏定義的值相同),表示本來應該阻塞在這裏(would block,虛擬語氣),事實上並沒有阻塞而是直接返回錯誤,調用者應該試着再讀一次(again)。這種行爲方式稱爲輪詢(Poll),調用者只是查詢一下,而不是阻塞在這裏死等

如在本例中,

  • recv() 返回值大於 0,表示接受數據完畢,返回值即是接受到的字節數;
  • recv() 返回 0,表示連接已經正常斷開;
  • recv() 返回 -1,且 errno 等於 EAGAIN,表示 recv 操作還沒執行完成;
  • recv() 返回 -1,且 errno 不等於 EAGAIN,表示 recv 操作遇到系統錯誤 errno。

這樣可以同時監視多個設備

while(1){

  非阻塞read(設備1);

  if(設備1有數據到達)

      處理數據;

  非阻塞read(設備2);

  if(設備2有數據到達)

     處理數據;

   ..............................

}

如果read(設備1)是阻塞的,那麼只要設備1沒有數據到達就會一直阻塞在設備1的read調用上,即使設備2有數據到達也不能處理,使用非阻塞I/O就可以避免設備2得不到及時處理。

類似一個快遞的例子:這裏使用忙輪詢的方法:每隔1微妙(while(1)幾乎不間斷)到A樓一層(內核緩衝區)去看快遞來了沒有。如果沒來,立即返回。而快遞來了,就放在A樓一層,等你去取。

非阻塞I/O有一個缺點,如果所有設備都一直沒有數據到達,調用者需要反覆查詢做無用功,如果阻塞在那裏,操作系統可以調度別的進程執行,就不會做無用功了,在實際應用中非阻塞I/O模型比較少用。

       可以看到服務器線程可以通過循環調用 recv() 接口,可以在單個線程內實現對所有連接的數據接收工作。

       但是上述模型絕不被推薦。因爲,循環調用 recv() 將大幅度推高 CPU 佔用率;此外,在這個方案中,recv() 更多的是起到檢測“操作是否完成”的作用,實際操作系統提供了更爲高效的檢測“操作是否完成“作用的接口,例如 select()。

4、IO複用事件驅動服務器模型

     簡介:主要是select和epoll;對一個IO端口,兩次調用,兩次返回,比阻塞IO並沒有什麼優越性;關鍵是能實現同時對多個IO端口進行監聽;

      I/O複用模型會用到select、poll、epoll函數,這幾個函數也會使進程阻塞,但是和阻塞I/O所不同的的,這兩個函數可以同時阻塞多個I/O操作。而且可以同時對多個讀操作,多個寫操作的I/O函數進行檢測,直到有數據可讀或可寫時,才真正調用I/O操作函數

      我們先詳解select:

        SELECT函數進行IO複用服務器模型的原理是:當一個客戶端連接上服務器時,服務器就將其連接的fd加入fd_set集合,等到這個連接準備好讀或寫的時候,就通知程序進行IO操作,與客戶端進行數據通信。

        大部分 Unix/Linux 都支持 select 函數,該函數用於探測多個文件句柄的狀態變化。

4.1 select 接口的原型:

  1. FD_ZERO(int fd, fd_set* fds)
  2. FD_SET(int fd, fd_set* fds)
  3. FD_ISSET(int fd, fd_set* fds)
  4. FD_CLR(int fd, fd_set* fds)
  5. int select(
  6. int maxfdp, //Winsock中此參數無意義
  7. fd_set* readfds, //進行可讀檢測的Socket
  8. fd_set* writefds, //進行可寫檢測的Socket
  9. fd_set* exceptfds, //進行異常檢測的Socket
  10. const struct timeval* timeout //非阻塞模式中設置最大等待時間
  11. )

參數列表:
int maxfdp :是一個整數值,意思是“最大fd加1(max fd plus 1). 在三個描述符集(readfds, writefds, exceptfds)中找出最高描述符  

編號值,然後加 1也可將maxfdp設置爲 FD_SETSIZE,這是一個< sys/types.h >中的常數,它說明了最大的描述符數(經常是    256或1024) 。但是對大多數應用程序而言,此值太大了。確實,大多數應用程序只應用 3 ~ 1 0個描述符。如果將第三個參數設置爲最高描述符編號值加 1,內核就只需在此範圍內尋找打開的位,而不必在數百位的大範圍內搜索。

fd_set *readfds: 是指向fd_set結構的指針,這個集合中應該包括文件描述符,我們是要監視這些文件描述符的讀變化的,即我們關

心是否可以從這些文件中讀取數據了,如果這個集合中有一個文件可讀,select就會返回一個大於0的值,表示有文件可讀,如果沒有可讀的文件,則根據timeout參數再判斷是否超時,若超出timeout的時間,select返回0,若發生錯誤返回負值。可以傳入NULL值,表示不關心任何文件的讀變化。 

fd_set *writefds: 是指向fd_set結構的指針,這個集合中應該包括文件描述符,我們是要監視這些文件描述符的寫變化的,即我們關

心是否可以向這些文件中寫入數據了,如果這個集合中有一個文件可寫,select就會返回一個大於0的值,表示有文件可寫,如果沒有可寫的文件,則根據timeout參數再判斷是否超時,若超出timeout的時間,select返回0,若發生錯誤返回負值。可以傳入NULL值,表示不關心任何文件的寫變化。 

fd_set *errorfds:  同上面兩個參數的意圖,用來監視文件錯誤異常。 

      readfds , writefds,*errorfds每個描述符集存放在一個fd_set 數據類型中.如圖:

      

struct timeval* timeout :是select的超時時間,這個參數至關重要,它可以使select處於三種狀態:
第一,若將NULL以形參傳入,即不傳入時間結構,就是將select置於阻塞狀態,一定等到監視文件描述符集合中某個文件描述符發生變化爲止;
第二,若將時間值設爲0秒0毫秒,就變成一個純粹的非阻塞函數,不管文件描述符是否有變化,都立刻返回繼續執行,文件無變化返回0,有變化返回一個正值;
第三,timeout的值大於0,這就是等待的超時時間,即 select在timeout時間內阻塞,超時時間之內有事件到來就返回了,否則在超時後不管怎樣一定返回,返回值同上述。

4.2 使用select庫的步驟是

(1)創建所關注的事件的描述符集合(fd_set),對於一個描述符,可以關注其上面的讀(read)、寫(write)、異常(exception)事件,所以通常,要創建三個fd_set, 一個用來收集關注讀事件的描述符,一個用來收集關注寫事件的描述符,另外一個用來收集關注異常事件的描述符集合。
(2)調用select(),等待事件發生。這裏需要注意的一點是,select的阻塞與是否設置非阻塞I/O是沒有關係的。
(3)輪詢所有fd_set中的每一個fd ,檢查是否有相應的事件發生,如果有,就進行處理。
  1. /* 可讀、可寫、異常三種文件描述符集的申明和初始化。*/
  2. fd_set readfds, writefds, exceptionfds;
  3. FD_ZERO(&readfds);
  4. FD_ZERO(&writefds);
  5. FD_ZERO(&exceptionfds);
  6. int max_fd;
  7. /* socket配置和監聽。*/
  8. sock = socket(...);
  9. bind(sock, ...);
  10. listen(sock, ...);
  11. /* 對socket描述符上發生關心的事件進行註冊。*/
  12. FD_SET(&readfds, sock);
  13. max_fd = sock;
  14. while(1) {
  15. int i;
  16. fd_set r,w,e;
  17. /* 爲了重複使用readfds 、writefds、exceptionfds,將它們拷貝到臨時變量內。*/
  18. memcpy(&r, &readfds, sizeof(fd_set));
  19. memcpy(&w, &writefds, sizeof(fd_set));
  20. memcpy(&e, &exceptionfds, sizeof(fd_set));
  21. /* 利用臨時變量調用select()阻塞等待,timeout=null表示等待時間爲永遠等待直到發生事件。*/
  22. select(max_fd + 1, &r, &w, &e, NULL);
  23. /* 測試是否有客戶端發起連接請求,如果有則接受並把新建的描述符加入監控。*/
  24. if(FD_ISSET(&r, sock)){
  25. new_sock = accept(sock, ...);
  26. FD_SET(&readfds, new_sock);
  27. FD_SET(&writefds, new_sock);
  28. max_fd = MAX(max_fd, new_sock);
  29. }
  30. /* 對其它描述符發生的事件進行適當處理。描述符依次遞增,最大值各系統有所不同(比如在作者系統上最大爲1024),
  31. 在linux可以用命令ulimit -a查看(用ulimit命令也對該值進行修改)。
  32. 在freebsd下,用sysctl -a | grep kern.maxfilesperproc來查詢和修改。*/
  33. for(i= sock+1; i <max_fd+1; ++i) {
  34. if(FD_ISSET(&r, i))
  35. doReadAction(i);
  36. if(FD_ISSET(&w, i))
  37. doWriteAction(i);
  38. }
  39. }

   

4.3 和select模型緊密結合的四個宏

FD_ZERO(int fd, fd_set* fds)  //清除其所有位
FD_SET(int fd, fd_set* fds)  //在某 fd_set 中標記一個fd的對應位爲1
FD_ISSET(int fd, fd_set* fds) // 測試該集中的一個給定位是否仍舊設置
FD_CLR(int fd, fd_set* fds)  //刪除對應位

      這裏,fd_set 類型可以簡單的理解爲按 bit 位標記句柄的隊列,例如要在某 fd_set 中標記一個值爲 16 的句柄,則該 fd_set 的第 16 個 bit 位被標記爲 1。具體的置位、驗證可使用 FD_SET、FD_ISSET 等宏實現。

        

      例如,編寫下列代碼: 

  1. fd_setreadset,writeset;
  2. FD_ZERO(&readset);
  3. FD_ZERO(&writeset);
  4. FD_SET(0,&readset);
  5. FD_SET(3,&readset);
  6. FD_SET(1,&writeset);
  7. FD_SET(2,&writeset);
  8. select(4,&readset,&writeset,NULL,NULL);
然後,下圖顯示了這兩個描述符集的情況:

       

因爲描述符編號從0開始,所以要在最大描述符編號值上加1。第一個參數實際上是要檢查的描述符數(從描述符0開始)。

4.4  select有三個可能的返回值

(1)返回值-1表示出錯。這是可能發生的,例如在所指定的描述符都沒有準備好時捕捉到一個信號。
(2)返回值0表示沒有描述符準備好。若指定的描述符都沒有準備好,而且指定的時間已經超過,則發生這種情況。
(3)返回一個正值說明了已經準備好的描述符數,在這種情況下,三個描述符集中仍舊打開的位是對應於已準備好的描述符位。


4.5 使用select()的接收數據模型圖:     

     下面將重新模擬上例中從多個客戶端接收數據的模型。

     使用select()的接收數據模型
     

       上述模型只是描述了使用 select() 接口同時從多個客戶端接收數據的過程;由於 select() 接口可以同時對多個句柄進行讀狀態、寫狀態和錯誤狀態的探測,所以可以很容易構建爲多個客戶端提供獨立問答服務的服務器系統。

      使用select()接口的基於事件驅動的服務器模型

      


    這裏需要指出的是,客戶端的一個 connect() 操作,將在服務器端激發一個“可讀事件”,所以 select() 也能探測來自客戶端的 connect() 行爲。

       上述模型中,最關鍵的地方是如何動態維護 select() 的三個參數 readfds、writefds 和 exceptfds。作爲輸入參數,readfds 應該標記所有的需要探測的“可讀事件”的句柄,其中永遠包括那個探測 connect() 的那個“母”句柄;同時,writefds 和 exceptfds 應該標記所有需要探測的“可寫事件”和“錯誤事件”的句柄 ( 使用 FD_SET() 標記 )。

       作爲輸出參數,readfds、writefds 和 exceptfds 中的保存了 select() 捕捉到的所有事件的句柄值。程序員需要檢查的所有的標記位 ( 使用 FD_ISSET() 檢查 ),以確定到底哪些句柄發生了事件。

         上述模型主要模擬的是“一問一答”的服務流程,所以,如果 select() 發現某句柄捕捉到了“可讀事件”,服務器程序應及時做 recv() 操作,並根據接收到的數據準備好待發送數據,並將對應的句柄值加入 writefds,準備下一次的“可寫事件”的 select() 探測。同樣,如果 select() 發現某句柄捕捉到“可寫事件”,則程序應及時做 send() 操作,並準備好下一次的“可讀事件”探測準備。下圖描述的是上述模型中的一個執行週期。

        一個執行週期

       

      這種模型的特徵在於每一個執行週期都會探測一次或一組事件,一個特定的事件會觸發某個特定的響應。我們可以將這種模型歸類爲“事件驅動模型”。

4.6 select的優缺點

      相比其他模型,使用 select() 的事件驅動模型只用單線程(進程)執行,佔用資源少,不消耗太多 CPU,同時能夠爲多客戶端提供服務。如果試圖建立一個簡單的事件驅動的服務器程序,這個模型有一定的參考價值。但這個模型依舊有着很多問題。

      select的缺點:

   (1)單個進程能夠監視的文件描述符的數量存在最大限制

   (2)select需要複製大量的句柄數據結構,產生巨大的開銷 

    (3)select返回的是含有整個句柄的列表,應用程序需要消耗大量時間去輪詢各個句柄才能發現哪些句柄發生了事件

    (4)select的觸發方式是水平觸發,應用程序如果沒有完成對一個已經就緒的文件描述符進行IO操作,那麼之後每次select調用還是會將這些文件描述符通知進程。相對應方式的是邊緣觸發。

       (6)   該模型將事件探測和事件響應夾雜在一起,一旦事件響應的執行體龐大,則對整個模型是災難性的。如下例,龐大的執行體 1 的將直接導致響應事件 2 的執行體遲遲得不到執行,並在很大程度上降低了事件探測的及時性。

      龐大的執行體對使用select()的事件驅動模型的影響
      


       很多操作系統提供了更爲高效的接口,如 linux 提供了 epoll,BSD 提供了 kqueue,Solaris 提供了 /dev/poll …。如果需要實現更高效的服務器程序,類似 epoll 這樣的接口更被推薦。


4.7 poll事件模型

poll庫是在linux2.1.23中引入的,windows平臺不支持poll. poll與select的基本方式相同,都是先創建一個關注事件的描述符的集合,然後再去等待這些事件發生,然後再輪詢描述符集合,檢查有沒有事件發生,如果有,就進行處理。因此,poll有着與select相似的處理流程:
(1)創建描述符集合,設置關注的事件
(2)調用poll(),等待事件發生。下面是poll的原型:
        int poll(struct pollfd *fds, nfds_t nfds, int timeout);
        類似select,poll也可以設置等待時間,效果與select一樣。
(3)輪詢描述符集合,檢查事件,處理事件。
  在這裏要說明的是,poll與select的主要區別在與,select需要爲讀、寫、異常事件分別創建一個描述符集合,最後輪詢的時候,需要分別輪詢這三個集合。而poll只需要一個集合,在每個描述符對應的結構上分別設置讀、寫、異常事件,最後輪詢的時候,可以同時檢查三種事件。

4.7 epoll事件模型

epoll是和上面的poll和select不同的一個事件驅動庫,它是在linux 2.5.44中引入的,它屬於poll的一個變種。
poll和select庫,它們的最大的問題就在於效率。它們的處理方式都是創建一個事件列表,然後把這個列表發給內核,返回的時候,再去輪詢檢查這個列表,這樣在描述符比較多的應用中,效率就顯得比較低下了。
 epoll是一種比較好的做法,它把描述符列表交給內核,一旦有事件發生,內核把發生事件的描述符列表通知給進程,這樣就避免了輪詢整個描述符列表。下面對epoll的使用進行說明:
(1).創建一個epoll描述符,調用epoll_create()來完成,epoll_create()有一個整型的參數size,用來告訴內核,要創建一個有size個描述符的事件列表(集合)
int epoll_create(int size)
(2).給描述符設置所關注的事件,並把它添加到內核的事件列表中去,這裏需要調用epoll_ctl()來完成。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
這裏op參數有三種,分別代表三種操作:
a. EPOLL_CTL_ADD, 把要關注的描述符和對其關注的事件的結構,添加到內核的事件列表中去
b. EPOLL_CTL_DEL,把先前添加的描述符和對其關注的事件的結構,從內核的事件列表中去除
c. EPOLL_CTL_MOD,修改先前添加到內核的事件列表中的描述符的關注的事件
(3). 等待內核通知事件發生,得到發生事件的描述符的結構列表,該過程由epoll_wait()完成。得到事件列表後,就可以進行事件處理了。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
在使用epoll的時候,有一個需要特別注意的地方,那就是epoll觸發事件的文件有兩種方式:
(1)Edge Triggered(ET),在這種情況下,事件是由數據到達邊界觸發的。所以要在處理讀、寫的時候,要不斷的調用read/write,直到它們返回EAGAIN,然後再去epoll_wait(),等待下次事件的發生。這種方式適用要遵從下面的原則:
      a. 使用非阻塞的I/O;b.直到read/write返回EAGAIN時,纔去等待下一次事件的發生。
(2)Level Triggered(LT), 在這種情況下,epoll和poll類似,但處理速度上可能比poll快。在這種情況下,只要有數據沒有讀、寫完,調用epoll_wait()的時候,就會有事件被觸發。
         
  1. /* 新建並初始化文件描述符集。*/
  2. struct epoll_event ev;
  3. struct epoll_event events[MAX_EVENTS];
  4. /* 創建epoll句柄。*/
  5. int epfd = epoll_create(MAX_EVENTS);
  6. /* socket配置和監聽。*/
  7. sock = socket(...);
  8. bind(sock, ...);
  9. listen(sock, ...);
  10. /* 對socket描述符上發生關心的事件進行註冊。*/
  11. ev.events = EPOLLIN;
  12. ev.data.fd = sock;
  13. epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);
  14. while(1) {
  15. int i;
  16. /*調用epoll_wait()阻塞等待,等待時間爲永遠等待直到發生事件。*/
  17. int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
  18. for(i=0; i <n; ++i) {
  19. /* 測試是否有客戶端發起連接請求,如果有則接受並把新建的描述符加入監控。*/
  20. if(events.data.fd == sock) {
  21. if(events.events & POLLIN){
  22. new_sock = accept(sock, ...);
  23. ev.events = EPOLLIN | POLLOUT;
  24. ev.data.fd = new_sock;
  25. epoll_ctl(epfd, EPOLL_CTL_ADD, new_sock, &ev);
  26. }
  27. }else{
  28. /* 對其它描述符發生的事件進行適當處理。*/
  29. if(events.events & POLLIN)
  30. doReadAction(i);
  31. if(events.events & POLLOUT)
  32. doWriteAction(i);
  33. }
  34. }
  35. }

       epoll支持水平觸發和邊緣觸發,理論上來說邊緣觸發性能更高,但是使用更加複雜,因爲任何意外的丟失事件都會造成請求處理錯誤。Nginx就使用了epoll的邊緣觸發模型。
      這裏提一下水平觸發和邊緣觸發就緒通知的區別:

     這兩個詞來源於計算機硬件設計。它們的區別是隻要句柄滿足某種狀態,水平觸發就會發出通知;而只有當句柄狀態改變時,邊緣觸發纔會發出通知。例如一個socket經過長時間等待後接收到一段100k的數據,兩種觸發方式都會向程序發出就緒通知。假設程序從這個socket中讀取了50k數據,並再次調用監聽函數,水平觸發依然會發出就緒通知,而邊緣觸發會因爲socket“有數據可讀”這個狀態沒有發生變化而不發出通知且陷入長時間的等待。
因此在使用邊緣觸發的 api 時,要注意每次都要讀到 socket返回 EWOULDBLOCK爲止


       遺憾的是不同的操作系統特供的 epoll 接口有很大差異,所以使用類似於 epoll 的接口實現具有較好跨平臺能力的服務器會比較困難。

      幸運的是,有很多高效的事件驅動庫可以屏蔽上述的困難,常見的事件驅動庫有 libevent 庫,還有作爲 libevent 替代者的 libev 庫。這些庫會根據操作系統的特點選擇最合適的事件探測接口,並且加入了信號 (signal) 等技術以支持異步響應,這使得這些庫成爲構建事件驅動模型的不二選擇。下章將介紹如何使用 libev 庫替換 select 或 epoll 接口,實現高效穩定的服務器模型。

     
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章