1. 程式人生 > 其它 >阻塞IO和非阻塞IO

阻塞IO和非阻塞IO

阻塞IO

傳統的阻塞IO

listenfd = socket();   // 開啟一個網路通訊埠
bind(listenfd);        // 繫結
listen(listenfd);      // 監聽
while(1) {
  connfd = accept(listenfd);  // 阻塞建立連線
  int n = read(connfd, buf);  // 阻塞讀資料
  doSomeThing(buf);  // 利用讀到的資料做些什麼
  close(connfd);     // 關閉連線,迴圈等待下一個連線
}

服務端的執行緒阻塞在了兩個地方,一個是 accept 函式,一個是 read 函式。

Read函式的細節,阻塞兩次,第一次是等待檔案描述符就緒(網絡卡->核心緩衝區),第二階段是讀取資料(核心緩衝區->使用者緩衝區)。

整體流程

多執行緒阻塞IO

每次都建立一個新的程序或執行緒,去呼叫 read 函式,並做業務處理。

while(1) {
  connfd = accept(listenfd);  // 阻塞建立連線,建立一個連線後會阻塞等待下一個連線,第二階段的讀取阻塞交給子執行緒處理。
  pthread_create(doWork);  // 建立一個新的執行緒
}
void doWork() {
  int n = read(connfd, buf);  // 阻塞讀資料
  doSomeThing(buf);  // 利用讀到的資料做些什麼
  close(connfd);     // 關閉連線,迴圈等待下一個連線
}

這樣,當給一個客戶端建立好連線後,就可以立刻等待新的客戶端連線,而不用阻塞在原客戶端的read請求上。

但是,這並不叫非阻塞IO,僅僅用了多執行緒的手段使得主執行緒沒有卡在read函式上不往下走。作業系統為我們提供的read函式仍然是阻塞的。

真正的非阻塞IO,不是能通過使用者層的小把戲,而是要懇請作業系統為我們提供一個非阻塞的read函式。
這個非阻塞IO的read函式的效果是如果沒有資料到達時(到達網絡卡並拷貝到了核心緩衝區),立刻返回一個錯誤值(-1),而不是阻塞地等待。

非阻塞IO

作業系統提供了非阻塞IO功能,只需要在呼叫read前,將檔案描述符設定為非阻塞即可。

fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

這樣,就需要使用者執行緒迴圈呼叫read,直到返回值不為-1,再開始處理業務。

非阻塞的read只有一個阻塞階段,在資料到達前,即資料還未到達網絡卡或者到達網絡卡但還沒有拷貝到核心緩衝區之前,這個階段是非阻塞的。
當資料已到達核心緩衝區,此時呼叫read函式仍然是阻塞的,需要等待資料從核心緩衝區拷貝到使用者緩衝區,才能返回。

IO多路複用

阻塞IO,為每個客戶端建立一個執行緒,伺服器端的執行緒資源很容易被耗光。

非阻塞IO,每accept一個客戶端連線後,將這個檔案描述符(connfd)放到一個數組裡。

fdlist.add(connfd);

然後起一個新的執行緒去不斷遍歷這個陣列,呼叫每一個元素的非阻塞read方法。

while(1) {
  for(fd <-- fdlist) {
    if(read(fd) != -1) {
      doSomeThing();
    }
  }
}

這樣就成功用一個執行緒處理了多個客戶端連線。

有點多路複用的意思,但這和我們用多執行緒去將阻塞IO改造成看起來是非阻塞IO一樣,這種遍歷方式也只是我們使用者自己想出的小把戲,每次遍歷遇到read返回-1時仍然是一次浪費資源的系統呼叫。

所以,還是得懇請作業系統,提供一個函式,將一批檔案描述符通過一次系統呼叫傳給核心,由核心層去遍歷,才能真正解決這個問題。

select

select是作業系統提供的系統呼叫函式,通過它,我們可以把一個檔案描述符的陣列發給作業系統,讓作業系統去遍歷,確定哪個檔案描述符可以讀寫,然後告訴我們去處理:
select系統呼叫的函式定義:

int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
// nfds:監控的檔案描述符集裡最大檔案描述符加1
// readfds:監控有讀資料到達檔案描述符集合,傳入傳出引數
// writefds:監控寫資料到達檔案描述符集合,傳入傳出引數
// exceptfds:監控異常發生達檔案描述符集合, 傳入傳出引數
// timeout:定時阻塞監控時間,3種情況
//  1.NULL,永遠等下去
//  2.設定timeval,等待固定時間
//  3.設定timeval裡時間均為0,檢查描述字後立即返回,輪詢

服務端程式碼
首先一個執行緒不斷接受客戶端連線,並把socket檔案描述符放到一個 list 裡。

while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);
}

然後,另一個執行緒不再自己遍歷,而是呼叫select,將這批檔案描述符list交給作業系統去遍歷。

while(1) {
  // 把一堆檔案描述符 list 傳給 select 函式
  // 有已就緒的檔案描述符就返回,nready 表示有多少個就緒的
  nready = select(list);
  ...
}

當select函式返回後,使用者依然需要遍歷剛剛提交給作業系統的 list。
只不過,作業系統會將準備就緒的檔案描述符做上標識,使用者層將不會再有無意義的系統呼叫開銷。

while(1) {
  nready = select(list);   //阻塞,當有檔案描述符就緒時才會返回已就緒數量
  // 使用者層依然要遍歷,只不過少了很多無效的系統呼叫
  for(fd <-- fdlist) {
    if(fd != -1) {
      // 只讀已就緒的檔案描述符
      read(fd, buf);
      // 總共只有 nready 個已就緒描述符,不用過多遍歷
      if(--nready == 0) break;
    }
  }
}

select細節

  1. select 呼叫需要傳入 fd 陣列,需要拷貝一份到核心,高併發場景下這樣的拷貝消耗的資源是驚人的。(可優化為不復制)
  2. select 在核心層仍然是通過遍歷的方式檢查檔案描述符的就緒狀態,是個同步過程,只不過無系統呼叫切換上下文的開銷。(核心層可優化為非同步事件通知)
  3. select 僅僅返回可讀檔案描述符的個數,具體哪個可讀還是要使用者自己遍歷。(可優化為只返回給使用者就緒的檔案描述符,無需使用者做無效的遍歷)

select流程

這種方式,既做到了一個執行緒處理多個客戶端連線(檔案描述符),又減少了系統呼叫的開銷(多個檔案描述符只有一次select的系統呼叫 +n次就緒狀態的檔案描述符的read系統呼叫)。

poll

poll 也是作業系統提供的系統呼叫函式。

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*檔案描述符*/
  shortevents; /*監控的事件*/
  shortrevents; /*監控事件中滿足條件返回的事件*/
};

它和select的主要區別就是,去掉了select只能監聽 1024 個檔案描述符的限制。

epoll

epoll是最終的大boss,它解決了select和poll的一些問題。

select的三個細節:

  1. select 呼叫需要傳入 fd 陣列,需要拷貝一份到核心,高併發場景下這樣的拷貝消耗的資源是驚人的。(可優化為不復制)
  2. select 在核心層仍然是通過遍歷的方式檢查檔案描述符的就緒狀態,是個同步過程,只不過無系統呼叫切換上下文的開銷。(核心層可優化為非同步事件通知)
  3. select 僅僅返回可讀檔案描述符的個數,具體哪個可讀還是要使用者自己遍歷。(可優化為只返回給使用者就緒的檔案描述符,無需使用者做無效的遍歷)。

epoll改進select三個細節

  1. 核心中儲存一份檔案描述符集合,無需使用者每次都重新傳入,只需告訴核心修改的部分即可。
  2. 核心不再通過輪詢的方式找到就緒的檔案描述符,而是通過非同步IO事件喚醒。
  3. 核心僅會將有IO事件的檔案描述符返回給使用者,使用者也無需遍歷整個檔案描述符集合。

具體,作業系統提供了這三個函式。
第一步,建立一個 epoll 控制代碼

int epoll_create(int size);

第二步,向核心新增、修改或刪除要監控的檔案描述符。

int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);

第三步,類似發起了select()呼叫

int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

epoll流程

總結

一切的開始,都起源於這個 read 函式是作業系統提供的,而且是阻塞的,我們叫它 阻塞 IO。
為了破這個局,程式設計師在使用者態通過多執行緒來防止主執行緒卡死。
後來作業系統發現這個需求比較大,於是在作業系統層面提供了非阻塞的 read 函式,這樣程式設計師就可以在一個執行緒內完成多個檔案描述符的讀取,這就是 非阻塞 IO。
但多個檔案描述符的讀取就需要遍歷,當高併發場景越來越多時,使用者態遍歷的檔案描述符也越來越多,相當於在 while 迴圈裡進行了越來越多的系統呼叫。
後來作業系統又發現這個場景需求量較大,於是又在作業系統層面提供了這樣的遍歷檔案描述符的機制,這就是 IO 多路複用。
多路複用有三個函式,最開始是 select,然後又發明了 poll 解決了 select 檔案描述符的限制,然後又發明了 epoll 解決 select 的三個不足。

多路複用產生的效果,完全可以由使用者態去遍歷檔案描述符並呼叫其非阻塞的 read 函式實現。而多路複用快的原因在於,作業系統提供了這樣的系統呼叫,使得原來的 while 迴圈裡多次系統呼叫,變成了一次系統呼叫 + 核心層遍歷這些檔案描述符。
就好比我們平時寫業務程式碼,把原來 while 迴圈裡調 http 介面進行批量新增,改成了讓對方提供一個批量新增的 http 介面,然後我們一次 rpc 請求就完成了批量新增。

參考:
https://mp.weixin.qq.com/s/YdIdoZ_yusVWza1PU7lWaw
http://www.pulpcode.cn/2017/02/01/user-buffer-and-kernel-buffer/
https://mp.weixin.qq.com/s?__biz=MjM5Njg5NDgwNA==&mid=2247484905&idx=1&sn=a74ed5d7551c4fb80a8abe057405ea5e&chksm=a6e304d291948dc4fd7fe32498daaae715adb5f84ec761c31faf7a6310f4b595f95186647f12&scene=21#wechat_redirect

如果這篇文章對你有用,麻煩關注一下本人微信公眾號,關注送福利哦~

不定期安利各種外掛,程式設計技巧,程式設計思想,歡迎交流~