高併發之網絡IO模型

你好,我是坤哥

今天我們聊一下高併發下的網絡 IO 模型

高併發即我們所說的 C10K(一個 server 服務 1w 個 client),C10M,寫出高併發的程序相信是每個後端程序員的追求,高併發架構其實有一些很通用的架構設計,如無鎖化,緩存等,今天我們主要研究下高併發下的網絡 IO 模型設計,我們知道不管是 Nginx,還是 Redis,Kafka,RocketMQ 等中間件,都能輕鬆支持非常高的 QPS,其實它們背後的網絡 IO 模型設計理念都是一致的,所以瞭解這一塊對我們瞭解設計出高併發的網絡 IO 框架具體重要意義,本文將會從以下幾個方面來循序漸近地向大家介紹如何設計出一個高併發的網絡 IO 框架

  • 傳統網絡 IO 模型的缺陷
  • 針對傳統網絡 IO 模型缺陷的改進
  • 多線程/多進程
  • 阻塞改爲非阻塞
  • IO 多路複用
  • Reactor 的幾種模型介紹

傳統網絡 IO 模型的缺陷

我們首先來看下傳統網絡 IO 模型有哪些缺陷,主要看它們的阻塞點有哪些。我們用一張圖來看下客戶端和服務端的基於 TCP 的通信流程

服務端的僞代碼如下

listenSocket = socket(); //調用socket系統調用創建一個主動套接字
bind(listenSocket);  //綁定地址和端口
listen(listenSocket); //將默認的主動套接字轉換爲服務器使用的被動套接字,也就是監聽套接字
while (1) { //循環監聽是否有客戶端連接請求到來
   connSocket = accept(listenSocket); //接受客戶端連接
   recv(connsocket); //從客戶端讀取數據,只能同時處理一個客戶端
   send(connsocket); //給客戶端返回數據,只能同時處理一個客戶端
}

可以看到,主要的通信流程如下

  1. server 創建監聽 socket 後,執行 bind() 綁定 IP 和端口,然後調用 listen() 監聽,代表 server 已經準備好接收請求了,listen 的主要作用其實是初始化半連接和全連接隊列大小
  2. server 準備好後,client 也創建 socket ,然後執行 connect 向 server 發起連接請求,這一步會被阻塞,需要等待三次握手完成,第一次握手完成,服務端會創建 socket(這個 socket 是連接 socket,注意不要和第一步的監聽 socket 搞混了),將其放入半連接隊列中,第三次握手完成,系統會把 socket 從半連接隊列摘下放入全連接隊列中,然後 accept 會將其從全連接隊列中摘下,之後此 socket 就可以與客戶端 socket 正常通信了,默認情況下如果全連接隊列裏沒有 socket,則 accept 會阻塞等待三次握手完成

經過三次握手後 client 和 server 就可以基於 socket 進行正常的進程通信了(即調用 write 發送寫請求,調用 read 執行讀請求),但需要注意的是 read,write 也很可能會被阻塞,需要滿足一定的條件讀寫纔會成功返回,在 LInux 中一切皆文件,socket 也不例外,每個打開的文件都有讀寫緩衝區,如下圖所示

對文件執行 read(),write() 的具體流程如下

  1. 當執行 read() 時,會從內核讀緩衝區中讀取數據,如果緩衝區中沒有數據,則會阻塞等待,等數據到達後,會通過 DMA 拷貝將數據拷貝到內核讀緩衝區中,然後會喚醒用戶線程將數據從內核讀緩衝區拷貝到應用緩衝區中
  2. 當執行 write() 時,會將數據從應用緩衝區拷貝到內核寫緩衝區,然後再通過 DMA 拷貝將數據從寫緩衝區發送到設備上傳輸出去,如果寫緩衝區滿,則 write 會阻塞等待寫緩衝區可寫

經過以上分析,我們可以看到傳統的 socket 通信會阻塞在 connect,accept,read/write 這幾個操作上,這樣的話如果 server 是單進程/線程的話,只要 server 阻塞,就不能再接收其他 client 的處理了,由此可知傳統的 socket 無法支持 C10K

針對傳統網絡 IO 模型缺陷的改進

接下來我們來看看針對傳統 IO 模型缺陷的改進,主要有兩種

  1. 多進程/線程模型
  2. IO 多路程複用

多進程/線程模型

如果 server 是單進程,阻塞顯然會導致 server 無法再處理其他 client 請求了,那我們試試把 server 改成多進程的?只要父進程 accept 了 socket ,就 fork 一個子進程,把這個 socket 交給子進程處理,這樣就算子進程阻塞了,也不影響父進程繼續監聽和其他子進程處理連接

程序僞代碼如下

while(1) {
  connfd = accept(listenfd);  // 阻塞建立連接
  // fork 創建一個新進程
  if (fork() == 0) {
    // accept 後子進程開始工作
    doWork(connfd);
  }
}
void doWork(connfd) {
  int n = read(connfd, buf);  // 阻塞讀數據
  doSomeThing(buf);  // 利用讀到的數據做些什麼
  close(connfd);     // 關閉連接,循環等待下一個連接
}

通過這種方式確實解決了單進程 server 阻塞無法處理其他 client 請求的問題,但衆所周知 fork 創建子進程是非常耗時的,包括頁表的複製,進程切換時頁表的切換等都非常耗時,每來一個請求就創建一個進程顯然是無法接受的

爲了節省進程創建的開銷,於是有人提出把多進程改成多線程,創建線程(使用 pthread_create)的開銷確實小了很多,但同樣的,線程與進程一樣,都需要佔用堆棧等資源,而且碰到阻塞,喚醒等都涉及到用戶態,內核態的切換,這些都極大地消耗了性能

由此可知採用多進程/線程的方式並不可取

畫外音: 在 Linux 下進程和線程都是用統一的 task_struct 表示,區別不大,所以下文描述不管是進程還是線程區別都不大

阻塞改爲非阻塞

既然多進程/多線程的方式並不可取,那能否將進程的阻塞操作(connect,accept,read/write)改爲非阻塞呢,這樣只要調用這些操作,如果相應的事件未準備好,就立馬返回 EWOULDBLOCK 或 EAGAIN 錯誤,此時進程就不會被阻塞了,使用 fcntl 可以可以將 socket 設置爲非阻塞,以 read 爲例僞代碼如下

connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
// 此時 connfd 變爲非阻塞,如果數據未就緒,read 會立即返回
int n = read(connfd, buffer) != SUCCESS; 

read 的非阻塞操作流程圖如下

非阻塞read
非阻塞read

這樣的話調用 read 就不會阻塞等待而會馬上返回了,也就實現了非阻塞的效果,不過需要注意的,我們這裏說的非阻塞並非嚴格意義上的非阻塞,這裏的非阻塞只是針對網卡數據拷貝到內核緩衝區這一段,如果數據就緒後,再執行 read 此時依然是阻塞的,此時用戶進程會佔用 CPU 去把數據從內核緩衝區拷貝到用戶緩衝區中,可以看到這種模式是同步非阻塞的,這裏我們簡單解釋下阻塞/非阻塞,同步/非同步的概念

  • 阻塞/非阻塞指的是在數據從網卡拷貝到內核緩衝區期間,進程能不能動,如果能動,就是非阻塞的,不能動就是阻塞的

  • 同步/非同步指的是數據就緒後是否需要用戶進程親自調用 read 來搬運數據(將數據從內核空間拷貝到用戶空間),如果需要,則是同步,如果不需要則是非同步(即異步),異步 I/O 示意圖如下:

    異步 IO
    異步 IO

異步 IO 執行流程如下:進程發起 I/O 請求後,讓內核在整個操作處理完後再通知進程,這整個操作包括網卡拷貝數據到內核緩衝區,將數據從內核緩衝區拷貝到用戶緩衝區這兩個階段,內核在處理數據期間(從無數據到拷貝完成),應用進程是可以繼續執行其他邏輯的,異步編程需要操作系統支持,目前只有 windows 完美支持,Linux 暫不支持。可以看出異步 I/O 纔是真正意義上的非阻塞操作,因爲數據從內核緩衝區拷貝到用戶緩衝區這一步不需要用戶進程來操作,而是由內核代勞了

我們以一個案例來總結下阻塞/非阻塞,同步/異步:當你去餐館點餐時,如果在廚師做菜期間,你啥也不能幹,那就是阻塞,如果在此期間你可以玩手機,喝喝茶,能動,那就是非阻塞,如果廚師做好了菜,你需要親自去拿,那就是同步,如果廚師做好了,菜由服務員直接送到你的餐桌,那就是非同步(異步)

現在回過頭來看將阻塞轉成非阻塞是否滿足了我們的需求呢?看起來進程確實可以動了,但進程需要不斷地以輪詢數據的形式調用 accept,read/write 這此操作來詢問內核數據是否就緒了,這些都是系統調用,對性能的消耗很大,而且會持續佔用 CPU,導致 CPU 負載很高,遠不如等數據就緒好了再通知進程去取更高效。這就好比,廚師做菜期間,你不斷地去問菜做好了沒有,顯然沒有意義,更高效的方式無疑是等廚師菜做好了主動通知你去取

IO 多路複用

經過前面的分析我們可以得出兩個結論

  1. 使用多進程/多線程 IO 模型是不可行的,這一步可以優化爲單線程
  2. 應該等數據就緒好了之後再通知用戶進程去讀取數據,而不是做毫無意義的輪詢,注意這裏的數據就緒不光是指前文所述的 read 的數據已就緒,而是泛指 accept,read/write 這三個事件的數據都已就緒

於是 IO 多路複用模型誕生了,它是指用一個進程來監聽 listen socket(監聽 socket) 的連接建立事件,connect socket(已連接 socket) 的讀寫事件(讀寫),一旦數據就緒,內核就會喚醒用戶進程去處理這些 socket 的相應的事件

IO多路複用
IO多路複用

這裏簡單介紹一下 fd(文件描述符),以便大家更好地瞭解之後 IO 多路複用中出現的 fd 集合等概念

文件系統簡介

我們知道在 Linux 中無論是文件,socket,還是管道,設備等,一切皆文件,Linux 抽象出了一個 VFS(virtual file system) 層,屏蔽了所有的具體的文件,VFS 提供了統一的接口給上層調用,這樣應用層只與 VFS 打交道,極大地方便了用戶的開發,仔細對比你會發現,這和 Java 中的面向接口編程很類似

通過 open(),socket() 創建文件後,都有一個 fd(文件描述符) 與之對應,對於每一個進程,都有有一個文件描述符列表(File Discriptor Table) 來記錄其打開的文件,這個列表的每一項都指向其背後的具體文件,而每一項對應的數組下標就是 fd,對於用戶而言,可以認爲 fd 代表其背後指向的文件

fd 的值從 0 開始,其中 0,1,2 是固定的,分別指向標準輸入(指向鍵盤),標準輸出/標準錯誤(指向顯示器),之後每打開一個文件,fd 都會從 3 開始遞增,但需要注意的是 fd 並不一定都是遞增的,如果關閉了文件,之前的 fd 是可以被回收利用的

IO 多路複用其實也就是用一個進程來監聽多個 fd 的數據事件,用戶可以把自己感興趣的 fd 及對應感興趣的事件(accept,read/write)傳給內核,然後內核就會檢測 fd ,一旦某個 socket 有事件了,內核可以喚醒用戶進程來處理

那麼怎樣才能知道某個 fd 是否有事件呢,一種很容易想到的做法是搞個輪詢,每次調用一下 read(fd),讓內核告知是否數據已就緒,但是這樣的話如果有 n 個感興趣的 fd 就會有 n 次 read 系統調用,開銷很大,顯然不可接受

所以使用 IO 多路複用監聽 fd 的事件可行,但必須解決以下三個涉及到性能瓶頸的點

  1. 如何高效將用戶感興趣的 fd 和事件傳給內核
  2. 某個 socket 數據就緒後,內核如何高效通知用戶進程進行處理
  3. 用戶進程如何高效處理事件

前面兩步的處理目前有 select,poll,epoll 三種 IO 多路事件模型,我們一起來看看,看完你就會知道爲啥 epoll 的性能是如此高效了

select

我們先來看下 select 函數的定義

返回:若有就緒描述符則爲其數目,若超時則爲 0,若出錯則爲-1
int select(int maxfd, 
                     fd_set *readset, 
                     fd_set *writeset, 
                     fd_set *exceptset, 
                     const struct timeval *timeout)
;

maxfd 是待測試的描述符基數,爲待測試的最大描述符加 1,readset,writeset,exceptset 分別爲讀描述符集合,寫描述符集合,異常描述符集合,這三個分別通知內核,在哪些描述描述符上檢測數據可以讀,可寫,有異常事件發生,timeout 可以設置阻塞時間,如果爲 null 代表一直阻塞

這裏需要說明一下,爲啥 maxfd 爲待測試的描述符加 1 呢,主要是因爲數組的下標是從 0 開始的,假設進程新建了一個 listenfd,它的 fd 爲 3,那麼代表它有 4 個 感興趣的 fd(每個進程有固定的 fd = 0,1,2 這三個描述符),由此可知 maxfd = 3 + 1 = 4

接下來我們來看看讀,寫,異常集合是怎麼回事,如何設置針對 fd 的感興趣事件呢,其實事件集合是採用了一種位結構(bitset)的方式,比如現在假設我們對標準輸入(fd = 0),listenfd(fd = 3)的讀事件感興趣,那麼就可以在 readset 對應的位上置 1

畫外音: 使用 FD_SET 可將相應位置置1,如 FD_SET(listenfd, &readset)

如下

即 readset 爲 {1, 0,0, 1},在調用 select 後會將 readset 傳給內核,如果內核發現 listenfd 有連接已就緒的事件,則內核也會在將相應位置置 1(其他無就緒事件的 fd 對應的位置爲 0)然後會回傳給用戶線程,此時的 readset 如下,即 {1,0,0,0}

於是進程就可以根據 readset 相應位置是否是 1(用 FD_ISSET(i, &read_set) 來判斷)來判斷讀事件是否就緒了

需要注意的是由於 accept 的 socket 會越來越多,maxfd 和事件 set 都需要及時調整(比如新 accept 一個已連接的 socket,maxfd 可能會變,另外也需要將其加入到讀寫描述符集合中以讓內核監聽其讀寫事件)

可以看到 select 將感興趣的事件集合存在一個數組裏,然後一次性將數組拷貝給了內核,這樣 n 次系統調用就轉化爲了一次,然後用戶進程會阻塞在 select 系統調用上,由內核不斷循環遍歷,如果遍歷後發現某些 socket 有事件(accept 或 read/write 準備好了),就會喚醒進程,並且會把數據已就緒的 socket 數量傳給用戶進程,動圖如下

select 圖解,圖片來自《低併發編程》
select 圖解,圖片來自《低併發編程》

select 的僞代碼如下

int listen_fd,conn_fd; //監聽套接字和已連接套接字的變量
listen_fd = socket() //創建套接字
bind(listen_fd)   //綁定套接字
listen(listen_fd) //在套接字上進行監聽,將套接字轉爲監聽套接字

fd_set master_rset;  //被監聽的描述符集合,關注描述符上的讀事件

int max_fd = listen_fd

//初始化 master_rset 數組,使用 FD_ZERO 宏設置每個元素爲 0 
FD_ZERO(&master_rset);
//使用 FD_SET 宏設置 master_rset 數組中位置爲 listen_fd 的文件描述符爲 1,表示需要監聽該文件描述符
FD_SET(listen_fd,&master_rset);

fd_set working_set;

while(1) {

   // 每次都要將 master_set copy 給 working_set,因爲 select 返回後 working_set 會被內核修改
     working_set = master_set
   /**
    * 調用 select 函數,檢測 master_rset 數組保存的文件描述符是否已有讀事件就緒,
    * 返回就緒的文件描述符個數,我們只關心讀事件,所以其它參數都設置爲 null 了
    */

   nready = select(max_fd+1, &working_set, NULLNULLNULL);

   // 依次檢查已連接套接字的文件描述符
   for (i = 0; i < max_fd && nready > 0; i++) {
      // 說明 fd = i 的事件已就緒
      if (FD_ISSET(i, &working_set)) {
          nready -= 1;
          //調用 FD_ISSET 宏,在 working_set 數組中檢測 listen_fd 對應的文件描述符是否就緒
         if (FD_ISSET(listen_fd, &working_set)) {
             //如果 listen_fd 已經就緒,表明已有客戶端連接;調用 accept 函數建立連接
             conn_fd = accept();
             //設置 master_rset 數組中 conn_fd 對應位置的文件描述符爲 1,表示需要監聽該文件描述符
             FD_SET(conn_fd, &master_rset);
             if (conn_fd > max_fd) {
                max_fd = conn_fd;
             }
         } else {
            //有數據可讀,進行讀數據處理
           read(i, xxx)
        }
      }
    }
}

看起來 select 很好,但在生產上用處依然不多,主要是因爲 select 有以下劣勢:

  1. 每次調用 select,都需要把 fdset 從用戶態拷貝到內核態,在高併發下是個巨大的性能開銷(可優化爲不拷貝)
  2. 調用 select 阻塞後,用戶進程雖然沒有輪詢,但在內核還是通過遍歷的方式來檢查 fd 的就緒狀態(可通過異步 IO 喚醒的方式)
  3. select 只返回已就緒 fd 的數量,用戶線程還得再遍歷所有的 fd 查看哪些 fd 已準備好了事件(可優化爲直接返回給用戶進程數據已就緒的 fd 列表)

正在由於 1,2 兩個缺點,所以 select 限制了 maxfd 的大小爲 1024,表示只能監聽 1024 個 fd 的事件,這離 C10k 顯然還是有距離的

poll

poll 的機制其實和 select 一樣,唯一比較大的區別其實是把 1024 這個限制給放開了,雖然通過放開限制可以使內核監聽上萬 socket,但由於以上說的兩點劣勢,它的性能依然不高,所以生產上也不怎麼使用

epoll

接下來我們再來介紹下生產上用得最多的 epoll,epoll 其實和 select,poll 這兩個系統調用不一樣,它本來其實是個內核的數據結構,這個數據結構允許進程監聽多個 socket 的 事件,一般我們通過 epoll_create 來創建這個實例

然後我們再調用 epoll_ctl 把 感興趣的 fd 加入到 epoll 實例中的 interest_list,然後再調用 epoll_wait 即可將控制權交給內核,這樣內核就會檢測此 interest_list,如果發現 socket 已就緒就會把已就緒的 fd 加入到一個 ready_list(簡稱 rdlist) 中,然後再喚醒用戶進程,之後用戶進程只要遍歷此 rdlist 即可

爲了方便快速對 fd 進行增刪改查,必須設計好 interest list 的數據結構,經綜合考慮,內核使用了紅黑樹,而 rdlist 則採用了鏈表的形式,這樣一旦在紅黑樹上發現了就緒的 socket ,就會把它放到 rdlist 中

epoll 的僞代碼如下

int sock_fd,conn_fd; //監聽套接字和已連接套接字的變量
sock_fd = socket() //創建套接字
bind(sock_fd)   //綁定套接字
listen(sock_fd) //在套接字上進行監聽,將套接字轉爲監聽套接字

epfd = epoll_create(); //創建epoll實例,
//創建epoll_event結構體數組,保存套接字對應文件描述符和監聽事件類型    
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE);

//創建epoll_event變量
struct epoll_event ee
//監聽讀事件
ee.events = EPOLLIN;

//監聽的文件描述符是剛創建的監聽套接字
ee.data.fd = sock_fd;

//將監聽套接字加入到監聽列表中    
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ee); 

while (1) {
   //等待返回已經就緒的描述符 
   n = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); 
   //遍歷所有就緒的描述符     
   for (int i = 0; i < n; i++) {
       //如果是監聽套接字描述符就緒,表明有一個新客戶端連接到來 
       if (ep_events[i].data.fd == sock_fd) { 
          conn_fd = accept(sock_fd); //調用accept()建立連接
          ee.events = EPOLLIN;  
          ee.data.fd = conn_fd;
          //添加對新創建的已連接套接字描述符的監聽,監聽後續在已連接套接字上的讀事件      
          epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ee); 

       } else { //如果是已連接套接字描述符就緒,則可以讀數據
           ...//讀取數據並處理
           read(ep_events[i].data.fd, ..)
       }
   }
}

epoll 的動圖如下

epoll 圖解,圖片來自《低併發編程》
epoll 圖解,圖片來自《低併發編程》

可以看到 epoll 很好地解決了 select 的痛點

  1. 「每次調用 select 都把 fd 集合拷貝給內核」優化爲「只有第一次調用 epoll_ctl 添加感興趣的 fd 到內核的 epoll 實例中,之後只要調用 epoll_wait 即可,數據集合不再需要拷貝」

  2. 「用戶進程調用 select 阻塞後,內核會通過遍歷的方式來同步檢查 fd 的就緒狀態」優化爲「內核使用異步事件通知」

  3. 「select 僅返回已就緒 fd 的數量,用戶線程還得再遍歷一下所有的 fd 來挨個檢查哪個 fd 的事件已就緒了」優化爲「內核直接返回已就緒的 fd 集合」

除了以上針對 select 痛點進行的改進之外,epoll 還引入了一種邊緣觸發(edge trigger,ET)的模式,這種模式也會讓 epoll 在高併發下的表現更加優秀,而 select/poll 則只有水平觸發模式(level trigger,LT),首先我們來了解一下什麼是水平觸發和邊緣觸發

  • 水平觸發:只要讀緩衝區可讀(或可寫緩衝區可寫),就會一直觸發可讀(或可寫)信號

  • 邊緣觸發:當套接字的緩衝狀態發生變化時纔會觸發讀寫信號。 對於讀緩衝,有新到達的數據被添加到讀緩衝時才觸發

對於水平觸發而言,只要緩衝區裏還有數據,內核就會不停地觸發讀事件,也就意味着如果收到了大量的數據而應用程序每次只會讀取一小部分數據時就會不停地從內核態切換到用戶態,浪費大量的內核資源,而對於邊緣觸發而言,只有在套接字的緩衝狀態發生變化(即新收到數據或剛好發出數據)時纔會觸發讀寫信號,也就意味着內核只通知喚醒用戶進程一次,這在高併發下無疑是更佳選擇,當然了也正是由於邊緣觸發模式下內核只會觸發一次的原因,read 要儘可能地將數據全部讀走(一般是在一個循環裏不斷地 read ,直到沒有數據),否則一旦沒有新的數據進來,緩衝區中剩餘的數據就無法讀取了

既然 epoll 這麼好,那麼它的性能到底比 select,poll 強多少呢,關於這一點,我們最好做對其進行做下壓測,我們來看下 libevent 框架對 select,poll,Epoll,Kqueue(可以認爲是 mac 下的 epoll)的壓測數據

640
640

可以看到,在 100 活躍連接(所謂活躍連接就是讀寫比較頻繁),每個連接發生 1000 次讀寫操作的情況下,隨着句柄數量的增加,epoll 和 Kqueue 的響應時間幾乎不變,而 select 和 poll 的響應時間則是急遽增加,所以 epoll 非常適合應對大量網絡連接,少量活躍連接的情況

不過需要注意一下這裏的限制條件:epoll 在應對大量網絡連接時,只有在活躍連接數較少的情況下性能才表現優異,如果圖中 15000 的網絡連接都是活躍連接,那麼 epoll 和 select 的表現是差不多的,甚至有可能 epoll 還不如 select,爲什麼會這樣呢?

  1. select/poll 的開銷主要是因爲無論就緒的 fd 有多少,都要遍歷一遍全部的 fd 來找到就緒的 fd 再處理,如果活躍連接數很少,那麼很多時間都浪費在遍歷上了,但如有很多活躍連接,那遍歷的開銷就可忽略不計

  2. 爲什麼活躍連接多,epoll 表現反而不佳呢,其實主要是因爲在喚醒過程中 epoll 實現較爲複雜,比如爲了保證就緒隊列的寫入安全,使用了自旋鎖,如下

    static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
    {
       int pwake = 0;
       unsigned long flags;
       struct epitem *epi = ep_item_from_wait(wait);
       struct eventpoll *ep = epi->ep;
       int ewake = 0;

       /* 獲得自旋鎖 ep->lock來保護就緒隊列
        * 自旋鎖ep->lock在 epoll_wait() 調用的 ep_poll() 裏被釋放
        * /
       spin_lock_irqsave(&ep->lock, flags);

       /* If this file is already in the ready list we exit soon */

       /* 在這裏將就緒事件添加到 rdllist */
       if (!ep_is_linked(&epi->rdllink)) {
           list_add_tail(&epi->rdllink, &ep->rdllist);
           ep_pm_stay_awake_rcu(epi);
       }
       ...
    }

    這樣的話如果活躍連接多的話,鎖的開銷就比較大了

IO 多路複用+非阻塞

那麼當 IO 多路程複用檢查到數據就緒後(select(),poll(),epoll_wait() 返回後),該怎麼處理呢,有人說直接 accept,read/write 不就完了,話是沒錯,但之前我們也說了,這些操作其實默認是阻塞的,我們需要將其改成非阻塞,爲什麼呢,數據不是已經就緒了嗎,說明 accept,read/write 這些操作可以正常獲取數據啊?

其實數據就緒只是說在調用 select(),poll(),epoll_wait() 返回時的數據是就緒的,但當你再去調用 accept,read/write 時可能就會變成未就緒了,舉個例子:當某個 socket 接收緩衝區有新數據分節到達,然後 select 報告這個 socket 描述符可讀,但隨後,協議棧檢查到這個新分節有錯誤,然後丟棄這個分節,這時候調用 read 則無數據可讀,這樣的話就會產生一個嚴重的後果:由於線程阻塞在了 read 上,便再也不能調用 select 來監聽 socket 事件了,所以 IO 多路程複用一定要和非阻塞配合使用,也就是說要把 listenfd 和 connectfd 設置爲非阻塞纔行

Reactor 模式

經過以上介紹相信大家對 IO 多路複用的原理有了比較深刻的理解,我們知道 IO 多路程複用是用一個進程來管理多個 socket 的, 那麼是否還有優化的空間呢,我們以 select 爲例來拆解一下 IO 多路複用的流程

主要流程如下:調用 select 來監聽連接,讀寫事件,收到事件後判斷是否是監聽 socket 上的事件,是的話調用 accept(),否則判斷是否是已連接 socket 上的讀寫事件,是的話調用 read(),write()

單進程/線程 Reactor

目前這樣的寫法沒有問題,不過所有的邏輯都藕合在一起,可擴展性不是很好,我們可以將相近的功能劃分到同一個模塊(以類的形式)中如下

我們將其分成三個模塊,Reactor, Acceptor,Handler,主要工作流程如下

  1. Reactor 對象首先調用 select 來監聽 socket 事件,收到事件後會通過 dispatch 分發
  2. 如果是連接建立事件,則由 Acceptor 處理,Acceptor 通過調用 accept 接收連接,並且會創建一個 Handler 來處理後續的讀寫等事件
  3. 如果不是連接建立事件,則 Reactor 會調用連接對應的 Handler 進行響應,handler 會完成 read,業務處理,write 的完整業務流程

以上這些操作其實和之前的 IO 多路複用一樣,所有的的都是由一個進程進行操作的,這裏多了一個新名詞 Reactor,它指的是對事件的響應,如果來了一個事件就把相應的事件 dispatch 給對應的 acceptor 或 handler 對象來處理,由於整個操作都在一個進程裏處理,我們把這種模式稱爲單 Reactor 模型,單 Reactor 要求對事件的響應要快,比如對數據業務的處理要快,像 Redis 就很適合,因爲它的數據都是基於內存操作的(當然像 bigKey 這種異常場景除外)

單 Reactor 多線程模型

如果在單進程 Reactor 模型中,業務處理耗時較長,那麼線程就會被阻塞,就無法再處理其它事件了,可能會造成嚴重的性能問題,而且單進程 Reactor 還有一個劣勢,那就是無法充分複用多核的優勢,於是人們又提出了 單 Reactor 多線程模型,即把業務處理這一塊放到一個線程池中處理

通過這種方式主進程的壓力得到了釋放,也充分複用了多核優勢來提升併發度

但依然有如下兩個瓶頸點

  1. 子線程處理好數據後需要將其傳給 handler 進行發送處理,這涉及到共享數據的互斥和保護機制
  2. 主進程承擔的所有事件的監聽和響應,瞬時的高併發可能成爲性能瓶頸

多 Reactor 多進程/線程模型

爲以解決單 Reactor 多線程模型存在的兩個問題,人們又提出了多 Reactor 多進程/線程模型模塊,示意圖如下

工作原理如下

  1. 主進程主要負責 accept 連接,接收後會將其傳給 subReactor,subReactor 將其連接加入連接隊列中來監控其事件
  2. 一旦子進程中有新的事件被監聽到了,則 subReactor 會將其交給 Handler 進入處理

使用這種方式由於數據的 read,業務處理,write 都在一個線程中處理,所以避免了數據的同步加鎖操作,父子進程職責很明確,父進程負責 accept,子進程則負責完成後續業務處理

以上介紹的只是標準的 Reactor 模型,但實際上生產上應用的 Reactor 不一定完全遵照這些標準,可能會有一些變化,比如 Nginx 的 Reactor 雖然也是多 Reactor 多進程模型,但它是一種變體:每個子進程都監聽了同一個端口,內核接收到連接已建立的事件後會通過負載均衡的方式將其轉給其中一個子進程,然後子進程會將其加入到連接隊列中監控其事件,監控到事件後也不會轉交給其他線程而是自己處理

總結

隨着互聯網的發展,server 面對的連接越來越多,傳統的網絡 IO 模型由於在 connect,accept,read/write 這三步中會有阻塞操作,顯然無法滿足我們 C10K 要求,於是人們提出了多進程/線程模型,每接收一個連接分配一個進程/線程來負責後續的交互,但創建進程/線程本身需要創建堆棧,複製頁表等資源,而且進程/線程的上下文切換涉及到用戶態,內核態的切換,會造成嚴重的性能瓶頸,那麼把阻塞操作改成非阻塞呢,這樣做進程/線程確實是不會被阻塞了,但也意味着 CPU 會做大量的無用功,承擔不必要的高負載

綜合考慮人們提出了 IO 多路複用這種先進的理念,即一個線程管理監聽多個 socket 的事件,當然了其實將 socket 設置成非阻塞的,然後讓一個線程不斷地去輪詢也是能達到一個線程監聽多個 socket 的目的,但這樣做需要對每個 socket 調用一個 read 系統調用,所以 IO 多路複用還有另一層更重要的意義:將多個系統調用轉成一個系統調用再交給內核去監聽事件。

IO 多路複用主要有三個模型,select,poll,epoll,每一個都是對前者的改進:

  1. select 是每次調用時都要把 fd 集合拷貝給內核,而且內核是通過不斷遍歷這種同步的方式來檢查 fd 是否有事件就緒,並且一旦檢測到有事件後也只是返回有就緒事件的 fd 的個數,用戶線程也需要遍歷 fd 集合來查看 fd 是否已就緒,select 限制了 fd 的集合只能有 1024 個
  2. poll 和 select 的原理差不多,只不過是放開了 1024 個 fd 的限制,fd 的個數可以任意設置,這樣也就讓支持 C10k 成爲了可能,但由於它的實現機制與 select 類似,所以也存在和 select 一樣的性能瓶頸
  3. 爲了徹底解決 select,poll 的性能瓶頸,epoll 出現了,它把需要監聽的 fd 傳給內核 epoll 實例,epoll 以紅黑樹的形式來管理這些 fd,這樣每次 epoll 只需調用 epoll_wait 即可將控制權轉給內核來監聽 fd 的事件,避免了無意義的 fd 集合的拷貝,同時由於紅黑樹的高效,一旦某個 socket 來事件了,可以迅速從紅黑樹中查找到相應的 socket,然後再喚醒用戶進程,此過程是異步的,比起 select 的同步喚醒又是一大進步,此時喚醒用戶進程後,內核會把數據已就緒的 fd 放到一個就緒列表裏傳給用戶進程,用戶進程只需要遍歷此就緒列表即可,比起 select 需要全部遍歷也是一大進步,除此之外 epoll 引入了邊緣觸發讓其在高併發下的表現也更加優異,正是由於 epoll 對 select,poll 的這些改進,也讓它成爲了 IO 多路複用絕對的王者

IO 多路複用是指用一個進程/線程去監聽多個 socket 的事件,也即意味着一旦事件就緒了進程需要快速地處理這些事件(不然其他 socket 事件的處理的會阻塞),這種對 Redis 非常合適,因爲它是基於內存操作的,處理非常快,但對於其它的開源框架,只有一個進程/線程顯然是不太滿足業務需要的,比如業務處理,可能是 CPU 密集型的,如果只一個進程/線程的話,可能會阻塞在業務處理上,於是人們基於 IO 多路複用又提出了 Reactor 模型,Reactor 即對事件的反應,然後派發事件給相應的處理器,Reator 模型有多個變種,如單 Reactor,單 Reactor 多線程,多 Reacor 多進程/線程模型,這三種模型各有各的優勢,主要是爲了充分利用多線程/多核來提升性能或是爲了避免瞬時的高併發讓主線程崩潰

由此可見,網絡 IO 模型經歷了傳統的網絡 IO 模型 ---> IO 多路程複用(select,poll,epoll) --> Reactor 模型這三個階段,主要是爲了滿足日益增長的 C10k 甚至 C100K 等超高連接數的要求。

最後:歡迎大家關注我的公號「碼海」,一起交流,共同進步!

巨人的肩膀

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