網路I/O模型

Koma_Wong發表於2018-08-25

網路I/O模型

在描述這塊內容的諸多書籍中,很多都只說籠統的概念,我們將問題具體化,暫時只考慮伺服器端的網路I/O情形。我們假定目前的情形是伺服器已經在監聽使用者請求,建立連線後伺服器呼叫read()函式等待讀取使用者傳送過來的資料流,之後將接收到的資料列印出來。

所以伺服器端簡單是這樣的流程:建立連線 -> 監聽請求 -> 等待使用者資料 -> 列印資料。我們總結網路通訊中的等待:

  • 建立連線時等待對方的ACK包(TCP)。

  • 等待客戶端請求(HTTP)。

  • 輸入等待:伺服器使用者資料到達核心緩衝區(read函式等待)。

  • 輸出等待:使用者端等待緩衝區有足夠空間可以輸入(write函式等待)。

另外為了能夠解釋清楚網路I/O模型,還需要了解一些基礎。對伺服器而言,列印出使用者輸入的字串(printf函式)和從網路中獲取資料(read函式)需要單獨來看。伺服器首先accept使用者連線請求後首先呼叫read函式等待資料,這裡的read函式是系統呼叫,執行於核心態,使用的也是核心地址空間,並且從網路中取得的資料需要先寫入到核心緩衝區。當read系統呼叫獲取到資料後將這些資料再複製到使用者地址空間的使用者緩衝區中,之後返回到使用者態執行printf函式列印字串。我們需要明確兩點:

  • read執行在核心態且資料流先讀入核心緩衝區;printf執行於使用者態,列印的資料會先從核心緩衝區複製到程式的使用者緩衝區,之後列印出來。

  • printf函式一定是在read函式已經準備好資料之後才能執行,但read函式作為I/O操作通常需要等待而觸發阻塞。呼叫read函式的是伺服器程式,一旦被read呼叫阻塞,整個伺服器在獲取到使用者資料前都不能接受任何其他使用者的請求(單程式/執行緒)。

有了上面的基礎,我們就可以介紹下面四種網路I/O模型。

阻塞式

  • 阻塞表示一旦呼叫I/O函式必須等整個I/O完成才返回。正如上面提到的那種情形,當伺服器呼叫了read函式之後,如果不是立即接收到資料,伺服器程式會被阻塞,之後一直在等待使用者資料到達,使用者資料到達後首先會寫進核心緩衝區,之後核心緩衝區資料複製到使用者程式(伺服器程式)緩衝區。完成了上述所有的工作後,才會把執行許可權返回給使用者(從核心態 -> 使用者態)。

  • 很顯然,阻塞式I/O的效率實在太低,如果使用者輸入資料遲遲不到的話,整個伺服器就會一直被阻塞(單程式/執行緒)。為了不影響伺服器接收其他程式的連線,我們可以考慮多程式模型,這樣當伺服器建立連線後為連線的使用者建立新執行緒,新執行緒即使是使用阻塞式I/O也僅僅是這一個執行緒被阻塞,不會影響伺服器等待接收新的連線。

  • 多執行緒模型下,主執行緒等待使用者請求,使用者有請求到達時建立新執行緒。新執行緒負責具體的工作,即使是因為呼叫了read函式被阻塞也不會影響伺服器。我們還可以進一步優化建立連線池和執行緒池以減小頻繁呼叫I/O介面的開銷。但新問題隨之產生,每個新執行緒或者程式(加入使用對程式模型)都會佔用大量系統資源,除此之外過多的執行緒和程式在排程方面開銷也會大很對,所以這種模型並不適合大併發量。

非阻塞I/O

  • 阻塞和非阻塞最大的區別在於呼叫I/O系統呼叫後,是等整個I/O過程完成再把操作許可權返回給使用者還是會立即返回。

  • 可以使用以下語句將控制程式碼fd設定為非阻塞I/O:fcntl(fd, F_SETFL, O_NONBLOCK);

  • 非阻塞I/O在呼叫後會立即返回,使用者程式對返回的返回值判斷以區分是否完成了I/O。如果返回大於0表示完成了資料讀取,返回值即讀取的位元組數;返回0表示連線已經正常斷開;返回-1表示錯誤,接下來使用者程式會不停地詢問kernel是否準備完畢。

  • 非阻塞I/O雖然不再會完全阻塞使用者程式,但實際上由於使用者程式需要不停地詢問kernel是否準備完資料,所以整體效率依舊非常低,不適合做併發。

I/O多路複用(事件驅動模型)

前面已經論述了多程式、多程式模型會因為開銷巨大和排程困難而導致並不能承受高併發量。但不適用這種模型的話,無論是阻塞還是非阻塞方式都會導致整個伺服器停滯。

所以對於大併發量,我們需要一種代理模型可以幫助我們集中去管理所有的socket連線,一旦某個socket資料到達了就執行其對應的使用者程式,I/O多路複用就是這麼一種模型。Linux下I/O多路複用的系統呼叫有select,poll和epoll,但從本質上來講他們都是同步I/O範疇。

  1. select

    • 相關介面:

      int select (int maxfd, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);

      FD_ZERO(int fd, fd_set* fds) //清空集合

      FD_SET(int fd, fd_set* fds) //將給定的描述符加入集合

      FD_ISSET(int fd, fd_set* fds) //將給定的描述符從檔案中刪除

      FD_CLR(int fd, fd_set* fds) //判斷指定描述符是否在集合中

    • 引數: maxfd:當前最大檔案描述符的值+1(≠ MAX_CONN)。

      readfds:指向讀檔案佇列集合(fd_set)的指標。

      writefds:同上,指向讀集合的指標。

      writefds:同上,指向錯誤集合的指標。

      timeout:指向timeval結構指標,用於設定超時。

    • 其他:

      判斷和操作物件為set_fd集合,集合大小為單個程式可開啟的最大檔案數1024或2048(可重新編譯核心修改但不建議)。

  2. poll

    • 相關介面: int poll(struct pollfd *fds, unsigned int nfds, int timeout);

    • 結構體定義: struct pollfd{ int fd; // 檔案描述符 short events; // 等到的事件 short revents; // 實際發生的事件 }

    • 引數: fds:指向pollfd結構體陣列的指標。

      nfds:pollfd陣列當前已被使用的最大下標。

      timeout:等待毫秒數。

    • 其他:

      判斷和操作物件是元素為pollfd型別的陣列,陣列大小自己設定,即為最大連線數。

  3. epoll

    • 相關介面: int epoll_create(int size); // 建立epoll控制程式碼 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);

    • 結構體定義: struct epoll_event{ __uint32_t events; epoll_data_t data; }; typedef union epoll_data{ void *ptr; int fd; __uint32_t u32; __uint64_t u64; }epoll_data_t;

    • 引數:

      size:用來告訴核心要監聽的數目。

      epfd:epoll函式的返回值。

      op:表示動作(EPOLL_CTL_ADD/EPOLL_CTL_FD/EPOLL_CTL_DEL)。

      fd:需要監聽的fd。

      events:指向epoll_event的指標,該結構記錄監聽的事件。

      maxevents:告訴核心events的大小。

      timeout:超時時間(ms為單位,0表示立即返回,-1將不確定)。

  4. select、poll和epoll區別

    • 操作方式及效率:

      select是遍歷,需要遍歷fd_set每一個位元位(= MAX_CONN),O(n);poll是遍歷,但只遍歷到pollfd陣列當前已使用的最大下標(≠ MAX_CONN),O(n);epoll是回撥,O(1)。

    • 最大連線數:

      select為1024/2048(一個程式開啟的檔案數是有限制的);poll無上限;epoll無上限。

    • fd拷貝:

      select每次都需要把fd集合從使用者態拷貝到核心態;poll每次都需要把fd集合從使用者態拷貝到核心態;epoll呼叫epoll_ctl時拷貝進核心並放到事件表中,但使用者程式和核心通過mmap對映共享同一塊儲存,避免了fd從核心賦值到使用者空間。

    • 其他:

      select每次核心僅僅是通知有訊息到了需要處理,具體是哪一個需要遍歷所有的描述符才能找到。epoll不僅通知有I/O到來還可通過callback函式具體定位到活躍的socket,實現偽AIO。

非同步I/O模型

  • 上面三種I/O方式均屬於同步I/O。

  • 從阻塞式I/O到非阻塞I/O,我們已經做到了呼叫I/O請求後立即返回,但不停輪詢的操作效率又很低,如果能夠既像非阻塞I/O能夠立即返回又能不一直輪詢的話會更符合我們的預期。

  • 之所以使用者程式會不停輪詢就是因為在資料準備完畢後核心不會回撥使用者程式,只能通過使用者程式一次又一次輪詢來查詢I/O結果。如果核心能夠在完成I/O後通過訊息告知使用者程式來處理已經得到的資料自然是最好的,非同步I/O就是這麼回事。

  • 非同步I/O就是當使用者程式發起I/O請求後立即返回,直到核心傳送一個訊號,告知程式I/O已完成,在整個過程中,都沒有程式被阻塞。看上去非同步I/O和非阻塞I/O的區別在於:判斷資料是否準備完畢的任務從使用者程式本身被委託給核心來完成。這裡所謂的非同步只是作業系統提供的一直機制罷了。

相關文章