1. 程式人生 > >7-檔案IO-阻塞與非阻塞IO

7-檔案IO-阻塞與非阻塞IO

1. 阻塞 IO

通常來說,從普通檔案讀資料,無論你是採用 fscanf,fgets 也好,read 也好,一定會在有限的時間內返回。但是如果你從裝置,比如終端(標準輸入裝置)讀資料,只要沒有遇到換行符(‘\n’),read 一定會“堵”在那而不返回。還有比如從網路讀資料,如果網路一直沒有資料到來,read 函式也會一直堵在那而不返回。

read 的這種行為,稱之為 block,一旦發生 block,本程序將會被作業系統投入睡眠,直到等待的事件發生了(比如有資料到來),程序才會被喚醒

系統呼叫 write 同樣有可能被阻塞,比如向網路寫入資料,如果對方一直不接收,本端的緩衝區一旦被寫滿,就會被阻塞。

1.1 阻塞讀終端實驗

  • 程式碼
// 檔名:blockdemo.c
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
  char buf[10];
  int len;
  while(1) {
    // STDIN_FILENO 是標準輸入的描述符,它的值是 0. STDOUT_FILENO 是標準輸出的描述符,它的值是 1.
    len = read(STDIN_FILENO, buf, 10
); write(STDOUT_FILENO, buf, len); } return 0; }
  • 編譯
$ gcc blockdemo.c -o blockdemo
  • 執行
$ ./blockdemo

如果你不向終端鍵入任何字元,程式將永遠阻塞在 read 系統呼叫處。

1.2 阻塞呼叫面臨的問題

假設有這樣一個場景,我要從 2 個不同裝置讀取資料進行資料,分別進行處理。虛擬碼如下。

while(1) {
  阻塞 read(裝置1);
  處理裝置1資料;

  阻塞 read(裝置2);
  處理裝置2資料;
}

上面有什麼問題呢?仔細想想,假如裝置1一直沒有資料到來,那麼程式就一直停在 read(裝置1)這一行,即使裝置2有資料到來,也將得不到處理。

經驗很豐富的同學肯定想出了各種方案,比如什麼多程序多執行緒什麼的。抱歉,我們是新手,目前只會單執行緒單程序。

既然如此,可否有一種方案,讓 read 不阻塞?不管有沒有資料到來,read 執行完立即返回,然後再通過某種特殊的變數來判斷本次呼叫到底有沒有資料到來?

實際上,這個方案是可行的。請續讀下文。

2. 非阻塞 IO

  • 如何解決從不同裝置讀資料而造成的干擾

現在,把剛剛上面面臨問題的程式碼改成這樣。

while(1) {
  非阻塞 read(裝置1);
  if (裝置1有資料){
    處理裝置1資料;
  }

  非阻塞 read(裝置2);
  if (裝置2有資料) {
    處理裝置2資料;
  }
}

且不論這樣的程式碼執行效率如何,我們先看看它是否解決了前面的問題。

如果裝置1沒有資料到來,read(裝置1)也會立即返回,有資料就處理資料,沒資料接著執行 read(裝置2),有資料就處理資料,沒有的話緊接著又去 read(裝置1)……如此往復。

我們會發現,裝置1和裝置2之間,不論有沒有資料到來,都不會互相影響,而不像之前阻塞IO那樣,如果裝置1沒有資料,將會影響到裝置2的資料處理。

這種方案非常不錯,總之目前來說是這樣的,先輩們給這種解決方案取了一個很好聽的名字——Poll (輪詢)。

  • 效率

現在,是時候把效率搬上來談談了。

如果裝置1和裝置2一直沒有資料到來,這個 while 迴圈將不斷空轉,CPU將面臨高負荷。這是一種極大的浪費。不像阻塞方式,沒有資料,就直接被作業系統投入睡眠。

那麼,我們把上面的程式碼再改改。

  • 修改方案

新增 sleep,主動讓出 CPU。

while(1) {
  非阻塞 read(裝置1);
  if (裝置1有資料){
    處理裝置1資料;
  }

  非阻塞 read(裝置2);
  if (裝置2有資料) {
    處理裝置2資料;
  }
  sleep(5); // 加了一行
}

這種方案仍然有問題,雖然可以每次讓出一定時間的CPU,但是也導致了裝置的資料得不到及時處理。可是以目前的知識,我們只能做到這個份上。未來,我們有機會學習更加先進的技術,來完美解決這個問題。提前預告一下,它的大名是——select。

2.1 非阻塞IO實驗

有幾個需要注意的地方:

  1. 阻塞非阻塞是檔案本身的特性,不是系統呼叫read/write本身可以控制的。
  2. 終端預設是阻塞的,我們可以重新 open 裝置檔案 /dev/tty(表示當前終端),開啟的時候指定 O_NONBLOCK 標誌就行了。
  3. 非阻塞 read,如果有資料到到來,返回讀取到的資料的位元組數。如果沒有資料到來,返回 -1,這時候我們沒有辦法判斷到底是因為出錯而返回,還是因為沒有資料返回。所以需要藉助 errno 全域性變數,來判斷是什麼原因。如果 errno 的值為 E_WOULDBLOCK或 E_AGAIN(這兩個巨集的值是一樣的),表示當前沒有資料到達,希望你再嘗試一次。因為 read 返回 -1 前,linux 系統會在 read 返回前給 errno 賦值,來告訴應用層,到底是什麼原因。
  • 非阻塞IO讀終端資料
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <errno.h> // errno 變數的標頭檔案
#include <stdlib.h>

char MSG_TRY[] =  "try again!\n";

int main() {
  char buffer[10];
  int len;
  int fd; 

  fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);

  while(1) {
    len = read(fd, buffer, 10);
    if (len < 0) {
      if (errno == EAGAIN) {
        write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
        sleep(1); // 讓出 CPU,避免CPU長時間空轉
      }   
      else {
        perror("read");
        exit(1);
      }   
    }   
    else {
      break;
    }   
  }

  write(STDOUT_FILENO, buffer, len);
  return 0;
}
  • 非阻塞IO讀終端資料結合等待超時
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>

char MSG_TRY[] =  "try again!\n";
char MSG_TMOUT[] = "time out!\n";

int main() {
  char buffer[10];
  int len;
  int fd; 
  int i;

  fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);

  // 超過 5 秒後,無論有沒有資料都退出。
  for (i = 0; i < 5; ++i) {
    len = read(fd, buffer, 10);
    if (len < 0) {
      if (errno == EAGAIN) {
        write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
        sleep(1);
      }   
      else {
        perror("read");
        exit(1);
      }   
    }   
    else {
      break;
    }   
  }

  if (i == 5) {
    write(STDOUT_FILENO, MSG_TMOUT, strlen(MSG_TMOUT)); 
  }
  else {
    write(STDOUT_FILENO, buffer, len);
  }
  return 0;
}

3. 總結

本文簡單介紹了阻塞與非阻塞IO的概念,並給出一個實際生產環境可能遇到的例子,利用單執行緒來解決多裝置資料處理的方法。

因為還沒有學習多程序與多執行緒,我們只能藉助非阻塞IO來完成這個功能。在後面的深入學習中,我們將出給出更加完美的解決方案,解決因為沒有資料到來而使 CPU 空轉的問題。