4. read/write

read函數從打開的設備或檔案中讀取數據。

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
返回值:成功返回讀取的位元組數,出錯返回-1並設置errno,如果在調read之前已到達檔案末尾,則這次read返回0

參數count是請求讀取的位元組數,讀上來的數據保存在緩衝區buf中,同時檔案的當前讀寫位置向後移。注意這個讀寫位置和使用C標準I/O庫時的讀寫位置有可能不同,這個讀寫位置是記在內核中的,而使用C標準I/O庫時的讀寫位置是用戶空間I/O緩衝區中的位置。比如用fgetc讀一個位元組,fgetc有可能從內核中預讀1024個位元組到I/O緩衝區中,再返回第一個位元組,這時該檔案在內核中記錄的讀寫位置是1024,而在FILE結構體中記錄的讀寫位置是1。注意返回值類型是ssize_t,表示有符號的size_t,這樣既可以返回正的位元組數、0(表示到達檔案末尾)也可以返回負值-1(表示出錯)。read函數返回時,返回值說明了buf中前多少個位元組是剛讀上來的。有些情況下,實際讀到的位元組數(返回值)會小於請求讀的位元組數count,例如:

write函數向打開的設備或檔案中寫數據。

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);
返回值:成功返回寫入的位元組數,出錯返回-1並設置errno

寫常規檔案時,write的返回值通常等於請求寫的位元組數count,而向終端設備或網絡寫則不一定。

讀常規檔案是不會阻塞的,不管讀多少位元組,read一定會在有限的時間內返回。從終端設備或網絡讀則不一定,如果從終端輸入的數據沒有換行符,調用read讀終端設備就會阻塞,如果網絡上沒有接收到數據包,調用read從網絡讀就會阻塞,至于會阻塞多長時間也是不確定的,如果一直沒有數據到達就一直阻塞在那裡。同樣,寫常規檔案是不會阻塞的,而向終端設備或網絡寫則不一定。

現在明確一下阻塞(Block)這個概念。當進程調用一個阻塞的系統函數時,該進程被置於睡眠(Sleep)狀態,這時內核調度其它進程運行,直到該進程等待的事件發生了(比如網絡上接收到數據包,或者調用sleep指定的睡眠時間到了)它才有可能繼續運行。與睡眠狀態相對的是運行(Running)狀態,在Linux內核中,處于運行狀態的進程分為兩種情況:

下面這個小程序從終端讀數據再寫回終端。

例 28.2. 阻塞讀終端

#include <unistd.h>
#include <stdlib.h>

int main(void)
{
	char buf[10];
	int n;
	n = read(STDIN_FILENO, buf, 10);
	if (n < 0) {
		perror("read STDIN_FILENO");
		exit(1);
	}
	write(STDOUT_FILENO, buf, n);
	return 0;
}

執行結果如下:

$ ./a.out 
hello(回車)
hello
$ ./a.out 
hello world(回車)
hello worl$ d
bash: d: command not found

第一次執行a.out的結果很正常,而第二次執行的過程有點特殊,現在分析一下:

  1. Shell進程創建a.out進程,a.out進程開始執行,而Shell進程睡眠等待a.out進程退出。

  2. a.out調用read時睡眠等待,直到終端設備輸入了換行符才從read返回,read只讀走10個字元,剩下的字元仍然保存在內核的終端設備輸入緩衝區中。

  3. a.out進程打印並退出,這時Shell進程恢復運行,Shell繼續從終端讀取用戶輸入的命令,於是讀走了終端設備輸入緩衝區中剩下的字元d和換行符,把它當成一條命令解釋執行,結果發現執行不了,沒有d這個命令。

如果在open一個設備時指定了O_NONBLOCK標誌,read/write就不會阻塞。以read為例,如果設備暫時沒有數據可讀就返回-1,同時置errnoEWOULDBLOCK(或者EAGAIN,這兩個宏定義的值相同),表示本來應該阻塞在這裡(would block,虛擬語氣),事實上並沒有阻塞而是直接返回錯誤,調用者應該試着再讀一次(again)。這種行為方式稱為輪詢(Poll),調用者只是查詢一下,而不是阻塞在這裡死等,這樣可以同時監視多個設備:

while(1) {
	非阻塞read(設備1);
	if(設備1有數據到達)
		處理數據;
	非阻塞read(設備2);
	if(設備2有數據到達)
		處理數據;
	...
}

如果read(設備1)是阻塞的,那麼只要設備1沒有數據到達就會一直阻塞在設備1的read調用上,即使設備2有數據到達也不能處理,使用非阻塞I/O就可以避免設備2得不到及時處理。

非阻塞I/O有一個缺點,如果所有設備都一直沒有數據到達,調用者需要反覆查詢做無用功,如果阻塞在那裡,操作系統可以調度別的進程執行,就不會做無用功了。在使用非阻塞I/O時,通常不會在一個while循環中一直不停地查詢(這稱為Tight Loop),而是每延遲等待一會兒來查詢一下,以免做太多無用功,在延遲等待的時候可以調度其它進程執行。

while(1) {
	非阻塞read(設備1);
	if(設備1有數據到達)
		處理數據;
	非阻塞read(設備2);
	if(設備2有數據到達)
		處理數據;
	...
	sleep(n);
}

這樣做的問題是,設備1有數據到達時可能不能及時處理,最長需延遲n秒才能處理,而且反覆查詢還是做了很多無用功。以後要學習的select(2)函數可以阻塞地同時監視多個設備,還可以設定阻塞等待的超時時間,從而圓滿地解決了這個問題。

以下是一個非阻塞I/O的例子。目前我們學過的可能引起阻塞的設備只有終端,所以我們用終端來做這個實驗。程序開始執行時在0、1、2檔案描述符上自動打開的檔案就是終端,但是沒有O_NONBLOCK標誌。所以就像例 28.2 “阻塞讀終端”一樣,讀標準輸入是阻塞的。我們可以重新打開一遍設備檔案/dev/tty(表示當前終端),在打開時指定O_NONBLOCK標誌。

例 28.3. 非阻塞讀終端

#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>

#define MSG_TRY "try again\n"

int main(void)
{
	char buf[10];
	int fd, n;
	fd = open("/dev/tty", O_RDONLY|O_NONBLOCK);
	if(fd<0) {
		perror("open /dev/tty");
		exit(1);
	}
tryagain:
	n = read(fd, buf, 10);
	if (n < 0) {
		if (errno == EAGAIN) {
			sleep(1);
			write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
			goto tryagain;
		}	
		perror("read /dev/tty");
		exit(1);
	}
	write(STDOUT_FILENO, buf, n);
	close(fd);
	return 0;
}

以下是用非阻塞I/O實現等待超時的例子。既保證了超時退出的邏輯又保證了有數據到達時處理延遲較小。

例 28.4. 非阻塞讀終端和等待超時

#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>

#define MSG_TRY "try again\n"
#define MSG_TIMEOUT "timeout\n"

int main(void)
{
	char buf[10];
	int fd, n, i;
	fd = open("/dev/tty", O_RDONLY|O_NONBLOCK);
	if(fd<0) {
		perror("open /dev/tty");
		exit(1);
	}
	for(i=0; i<5; i++) {
		n = read(fd, buf, 10);
		if(n>=0)
			break;
		if(errno!=EAGAIN) {
			perror("read /dev/tty");
			exit(1);
		}
		sleep(1);
		write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
	}
	if(i==5)
		write(STDOUT_FILENO, MSG_TIMEOUT, strlen(MSG_TIMEOUT));
	else
		write(STDOUT_FILENO, buf, n);
	close(fd);
	return 0;
}