初探五種服務器網絡編程模型

五種服務器網絡編程模型

首先來看看 Linux 上可以使用的 I/O 模型,下圖是基本 Linux I/O 模型的簡單矩陣,瞭解這些 I/O 相關知識能有助於理解後面的網絡模型。
這裏寫圖片描述

1.同步阻塞迭代模型

首先介紹同步阻塞迭代模型,它的核心代碼如下:

bind(srvfd, ...);  
listen(srvfd, ...);  
for(;;){  
    clifd = accept(srvfd, ...); //開始接受客戶端來的連接  
    read(clifd,buf, ...);       //從客戶端讀取數據,一直阻塞,直到讀取到數據才返回
    dosomthingonbuf(buf);    
    write(clifd, buf, ...)          //發送數據到客戶端  
}  

作爲最簡單直接的一種 IO模型,它存在以下弊端:

1、如果沒有客戶端的連接請求,進程會阻塞在 accept() 裏,然後被 CPU 掛起。
2、在與客戶端建立好連接後,通過 read() 從客戶端接受數據,而客戶端合適發送數據過來是不可控的。如果客戶端遲遲不發生數據過來,緩衝區沒有數據可讀,那麼程序同樣會**阻塞**在 read() 中,此時,如果另外的客戶端來嘗試連接時,程序都不會響應。
3、同樣的道理,write() 也會使得程序出現阻塞(例如:客戶端接收數據異常緩慢,服務器端發生數據速度也會很慢,導致寫緩衝區滿,write() 則一直阻塞知道將數據成功寫入緩衝區則返回)。

2.多進程併發模型

再來介紹一種可以同時接受多個連接請求的模型,它在同步阻塞迭代模型的基礎上,引入了多進程。
核心代碼:

bind(srvfd, ...);  //綁定套接字的本地地址
listen(srvfd, ...);  //監聽
for(;;){  
    clifd = accept(srvfd, ...); //父進程開始接受來自客戶端連接  
    ret = fork();  //申請進程
    switch (ret)  
    {  
      case -1 :  
        do_err_handler();  
        break;  
      case 0  :   // 返回 0 則爲子進程  
        client_handler(clifd);  
        break;  
      default :   // 返回正整數則爲父進程,返回值爲子進程 ID
        close(clifd);  
        continue;   
    }  
}
// 子進程處理邏輯
void client_handler(clifd){  
    read(clifd, buf, ...);       //從客戶端讀取數據  
    dosomthingonbuf(buf);    
    write(clifd, buf, ...)          //發送數據到客戶端  
}  

在單個進程中,也同樣存在第一個模型的問題,一個進程同時只能處理一個請求,該方法通過“主進程負責監聽,而多個子進程負責處理”的方式來獲得併發,此模式屬於fork and execute模式,性能也非常低下。

3、多線程併發模型

在多進程併發模型中,每 accept() 到一個客戶端連接 fork() 一個進程,雖然Linux中引入了寫實拷貝機制(Copy On Write,子進程繼承了父進程的頁面,它們在創建後是共享同一片內存的,直到發生了寫才爲子進程分配獨立的內存空間),大大降低了 fork() 一個子進程的消耗,但若客戶端連接較大,則系統依然將不堪負重。通過多線程(以及線程池)的併發模型,可以在一定程度上改善這一問題。

在服務端的線程模型實現方式一般有三種:

1、按需生成(thread per request,來一個連接生成一個線程,與第二種模型類似)
2、線程池(預先生成很多線程)
3、Leader Follower(LF)

爲簡單起見,以第一種爲例,其核心代碼如下:

// 線程回調函數,新創建的線程用來處理請求  
void *thread_callback(void *args) {
    int clifd = *(int *)args;
    client_handler(clifd);  
}  

//
void client_handler(clifd) {  
    read(clifd, buf, ...);  // 從客戶端讀取數據  
    dosomthingonbuf(buf);
    write(clifd, buf, ...);  // 發送數據到客戶端 
}  

srvfd = socket(...);
bind(srvfd, ...);
listen(srvfd, ...);
for(;;){  
    clifd = accept();  // worker線程接收客戶端連接
    pthread_create(..., thread_callback, &clifd);  
}

服務端分爲master線程和worker線程,master線程負責accept()連接,而worker線程負責處理業務邏輯和流的讀取等(這主進程和子進程的工作模式一樣)。因此,即使在worker線程阻塞的情況下,也只是阻塞在一個線程範圍內,對master進程繼續接受新的客戶端連接不會有影響。

第二種實現方式,通過線程池的引入可以避免頻繁的創建、銷燬線程的過程,能在很大程序上提升性能。

但不管如何實現,多線程模型先天具有如下缺點:

  • 穩定性相對較差。一個線程的崩潰會導致整個進程崩潰。(這個問題可以使用多進程+多線程解決)
  • 臨界資源的訪問控制。在加大程序複雜性的同時,鎖機制的引入會是嚴重降低程序的性能。性能上可能會出現“辛辛苦苦好幾年,一夜回到解放前”的情況。

4.I/O多路複用模型之 select/poll

多進程模型和多線程模型每個進程/線程同一時間內只能處理一路I/O,在服務器併發數較高的情況下,過多的進程/線程會使得服務器性能下降。而通過多路I/O複用,能使得一個進程/線程同時處理多路I/O,提升服務器吞吐量。

在 Linux 支持 epoll 模型之前,都使用 select/poll 模型來實現 I/O 的多路複用。

以 select 爲例,其核心代碼如下,這裏只討論監聽符準備好讀時的情況:

int select(int n, fd_set *readset, fd_set *writeset, fd_set *excepser, const struct timeval *timeout);

FD_ZERO(fd_set *fd_set);
FD_CLR(int fd, fd_set *fdset);
FD_SET(int fd, fd_set *fdset);
FD_ISSET(int fd, fd_set *fdset);  //  比特位爲1則表示描述符活躍

listenfd = socket(...);
bind(listenfd, ...);  
listen(listenfd, ...);  
FD_ZERO(&allset);  //清空 allset
FD_SET(listenfd, &allset);  //  將監聽描述符加入 allset,當有連接請求時活躍。fd_set 類似數組,相當於將 fd_set[listenfd] 從 0 變爲 1
for(;;){  
    /* 不斷調用,每次調用會將保存了所有 fd 的 fd_set 複製到內核空間中,通過內核輪訓 fd_set 來檢測是否有事件發生。這裏我們將 timeout 設爲 NULL select()會一直阻塞,直到有事件發生 */
    rset = allset;
    select(listenfd+1, &rset, NULL, NULL, NULL); 
    //這個for循環(輪詢)用來處理可讀的文件描述符
    for(;;){
    //有新的客戶端連接來時, listenfd 變爲可讀狀態
        fd = cliarray[i];  
        if (fd == listenfd && FD_ISSET(fd, &rset)) {   
            clifd = accept();  
            cliarray[] = clifd;       //保存新的連接套接字
            FD_SET(clifd, &allset);   //將已連接描述符加入監聽數組中,
        }      
        //  其他監聽描述符可讀時
        if (FD_ISSET(fd , &rset))  
            dosomething();
        //  沒有可讀時 break;
    }  
}  

select() 實現的IO多路複用同樣存在以下缺點:

  • 1、單個進程能夠監視的文件描述符的數量存在最大限制,通常是1024,當然可以更改數量,但由於 select()是採用輪詢的方式掃描文件描述符,文件描述符數量越多,性能越差;(在linux內核頭文件中,有這樣的定義:#define __FD_SETSIZE 1024)
  • 2、內核/用戶空間內存拷貝問題,select()需要複製傳遞大量的句柄數據結構,產生巨大的開銷;
  • 3、select()返回的是含有所有句柄的 fd_set(類似數組),應用程序需要通過遍歷來確認才能知道哪些描述符是準備好的。
  • 4、select的觸發方式是水平觸發,應用程序如果沒有完成對一個已經就緒的文件描述符進行 I/O 操作,那麼之後每次 select() 後,該文件描述符總是就緒的。

相比 select 模型使用類似數組的 fd_set 來保存描述符,poll 使用鏈表保存描述符,因此沒有了監視文件數量的限制,但其他三個缺點依然存在。

拿select模型爲例,假設我們的服務器需要支持100萬的併發連接,則在 __FD_SETSIZE 爲 1024 的情況下,則我們至少需要開闢1k個進程才能實現100萬的併發連接。除了進程間上下文切換的時間消耗外,從內核/用戶空間大量的無腦內存數據拷貝、內核數組輪詢等,是系統難以承受的。

5.I/O 多路複用模型之 epoll

由於epoll的實現機制與select/poll機制完全不同,上面所說的 select的缺點在epoll上不復存在。

設想一下如下場景:有100萬個客戶端同時與一個服務器進程保持着TCP連接。而每一時刻,通常只有幾百上千個TCP連接是活躍的(事實上大部分場景都是這種情況)。如何實現這樣的高併發?

在 select/poll 時代,服務器進程不斷調用 select() 把這100萬個連接告訴操作系統(從用戶內存空間複製句柄數據結構到內核內存空間),讓操作系統內核去輪詢數組裏的這些套接字上是否有事件發生,輪詢完後,再將套接字相關數據複製到用戶空間,讓服務器應用程序處理就緒的描述符,這一過程資源消耗較大,因此,select/poll 一般只能處理幾千的併發連接。

epoll 的設計和實現與select完全不同。epoll 通過在 Linux 內核內存空間中申請一個簡易的文件系統(文件系統一般用什麼數據結構實現?B+Tree)。把 select/poll 一步完成的工作劃分爲三步:

  • 調用epoll_create()產生epoll句柄
  • 調用epoll_ctl()向epoll對象註冊事件
  • 調用epoll_wait()返回發生的事件的連接

如此一來,要實現上面說是的場景,只需要在進程啓動時建立一個epoll對象,然後在需要的時候向這個epoll對象中添加或者刪除套接字描述符。

下面來看看Linux內核具體的epoll機制實現思路。

當某一進程調用 epoll_create()時,Linux內核會相應地創建一個eventpoll 結構體,這個結構體中有兩個成員與 epoll 的使用方式密切相關。eventpoll 結構體如下所示:

struct eventpoll{  
    ....  
   //紅黑樹的根節點,這顆樹中存儲着所有添加到epoll中的需要監控的事件  
   struct rb_root  rbr;  

   //雙鏈表中則存放着發生的事件,其中包含了對應的描述符
   struct list_head rdlist;  
   ....  
};  

每一個 epoll 對象都有一個獨立的 eventpoll 結構體,用於存放通過 epoll_ctl 方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重複添加的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是log(n),其中n爲樹的高度)。

而所有添加到 epoll 中的事件都會與設備(網卡)驅動程序建立回調關係,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫 ep_poll_callback,它會將發生的事件添加到 rdlist 雙鏈表中。內核不像 select/poll 樣需要輪訓所有的描述符來檢測有哪些描述符是就緒的,基於事件的回調使得 epoll 性能更高。

對於每一個事件,都對應一個 epitem 結構體,如下所示:

struct epitem{  
    struct rb_node  rbn;//紅黑樹節點  
    struct list_head    rdllink;//雙向鏈表節點  
    struct epoll_filefd  ffd;  //事件句柄信息  
    struct eventpoll *ep;    //指向其所屬的eventpoll對象  
    struct epoll_event event; //期待發生的事件類型  
}  

epoll_wait() 中檢查是否有事件發生時,只需要檢查 eventpoll 對象中的 rdlist 雙鏈表中是否有 epitem 元素即可,這就是基於事件回調的好處。如果rdlist不爲空,則把其中的 epitem 複製到用戶內存空間,同時將事件數量返回給用戶。

這裏寫圖片描述
epoll數據結構示意圖

從上面的講解可知:通過紅黑樹和雙鏈表數據結構,並結合回調機制,造就了 epoll 的高效。

講解完了epoll的機理,我們便能很容易掌握epoll的用法了。一句話描述就是:三步曲。

  • epoll_create()系統調用。此調用返回一個句柄,之後所有的使用都依靠這個句柄來標識。
  • epoll_ctl()系統調用。通過此調用向epoll對象中添加、刪除、修改感興趣的事件,返回0標識成功,返回-1表示失敗。
  • epoll_wait()系統調用。通過此調用收集在epoll監控中已經發生的事件。

最後,附上一個 epoll 編程的框架(來自網絡):



   for( ; ; )
      {
          nfds = epoll_wait(epfd,events,20,500);
          for(i=0;i<nfds;++i)
          {
              if(events[i].data.fd==listenfd) //有新的連接
              {
                  connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept這個連接
                  ev.data.fd=connfd;
                 ev.events=EPOLLIN|EPOLLET;
                 epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //將新的fd添加到epoll的監聽隊列中
             }
             else if( events[i].events&EPOLLIN ) //接收到數據,讀socket
             {
                 n = read(sockfd, line, MAXLINE)) < 0    //讀
                 ev.data.ptr = md;     //md爲自定義類型,添加數據
                 ev.events=EPOLLOUT|EPOLLET;
                 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改標識符,等待下一個循環時發送數據,異步處理的精髓
             }
             else if(events[i].events&EPOLLOUT) //有數據待發送,寫socket
             {
                 struct myepoll_data* md = (myepoll_data*)events[i].data.ptr;    //取數據
                 sockfd = md->fd;
                 send( sockfd, md->ptr, strlen((char*)md->ptr), 0 );        //發送數據
                 ev.data.fd=sockfd;
                 ev.events=EPOLLIN|EPOLLET;
                 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改標識符,等待下一個循環時接收數據
             }
             else
             {
                 //其他的處理
             }
         }
     }
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章