1.linux基本I/O接口介紹

1.linux基本I/O接口介紹

ssize_t read(intfd, void *buf, size_t count);

ssize_t write(intfd, void *buf, size_t count);

·     1

·     2

以上兩個是linux下的兩個系統調用,用於對文件行基本的I/O操作。fd是非負文件描述符,其實相當於標識一個文件的唯一編號。默認標號0是標準輸入(終端輸入),1是標準輸出(終端輸出),2是標準錯誤。所以用戶通過 open 能夠打開的文件得到的文件描述符的最小編號是3

Linux中,read write 是基本的系統級I/O函數。當用戶進程使用read write 讀寫linux的文件時,進程會從用戶態進入內核態,通過I/O操作讀取文件中的數據。內核態(內核模式)和用戶態(用戶模式)是linux的一種機制,用於限制應用可以執行的指令和可訪問的地址空間,這通過設置某個控制寄存器的位來實現。進程處於用戶模式下,它不允許發起I/O操作,所以它必須通過系統調用進入內核模式才能對文件進行讀取。

從用戶模式切換到內核模式,主要的開銷是處理器要將返回地址(當前指令的下一條指令地址)和額外的處理器狀態(寄存器)壓入到棧中,這些數據到會被壓到內核棧而不是用戶棧。另外,一個進程使用系統調用還隱含了一點——調用系統調用的進程可能會被搶佔。當內核代表用戶執行系統調用時,若該系統調用被阻塞,該進程就會進入休眠,然後由內核選擇一個就緒狀態,當前優先級最高的進程運行。另外,即使系統調用沒有被阻塞,當系統調用結束,從內核態返回時,若在系統調用期間出現了一個優先級更高的進程,則該進程會搶佔使用了系統調用的進程。內核態返回會返回到優先級高的進程,而不是原本的進程。

雖然我們可以每次進行讀寫時都使用系統調用,但這樣會增大系統的負擔。當一個進程需要頻繁調用read從文件中讀取數據時,它便要頻繁地在用戶態與內核態之間進行切換,極端點地設想一個情景,每次read調用都只讀取一個字節,然後循環調用read讀取n個字節,這便意味着進程要在用戶態和內核態之間切換n次,雖然這是一個及其愚蠢的編程方法,但能夠毫無疑問說明系統調用的開銷。下圖是調用read(int fd, void *buf, size_t count)讀取516,581,760字節,每次read可以讀取的最大字節數量(count的值)的不同對CPU的存取效率的影響。

這張表的運行結果是基於塊大小爲4096-byteext4文件系統上的,所以可以看到當 BUFFSIZE=4096時,System CPU 幾乎達到了最小值,之後塊大小若繼續增加,System CPU時間減小的幅度很小,甚至還有所增加。這是若 BUFFSIZE 過大,其緩衝區便跨越了不同的塊,導致存取效率降低。

2.RIO

RIO,全稱 Robust I/O,即健壯的IO包。它提供了與系統I/O類似的函數接口,在讀取操作時,RIO包加入了讀緩衝區,一定程度上增加了程序的讀取效率。另外,帶緩衝的輸入函數是線程安全的,這與Stevens UNP 3rd Edition(中文版) P74 中介紹的那個輸入函數不同。UNP的那個版本的帶緩衝的輸入函數的緩衝區是以靜態全局變量存在,所以對於多線程來說是不可重入的。RIO包中有專門的數據結構爲每一個文件描述符都分配了相應的獨立的讀緩衝區,這樣不同線程對不同文件描述符的讀訪問也就不會出現併發問題(然而若多線程同時讀同一個文件描述符則有可能發生併發訪問問題,需要利用鎖機制封鎖臨界區)。

另外,RIO還幫助我們處理了可修復的錯誤類型:EINTR。考慮readwrite在阻塞時被某個信號中斷,在中斷前它們還未讀取/寫入任何字節,則這兩個系統調用便會返回-1表示錯誤,並將errno置爲EINTR。這個錯誤是可以修復的,並且應該是對用戶透明的,用戶無需在意read write有沒有被中斷,他們只需要直到read write成功讀取/寫入了多少字節,所以在RIOrio_read()rio_write()中便對中斷進行了處理。

#defineRIO_BUFSIZE     4096

typedef struct

{

    int rio_fd;      //與緩衝區綁定的文件描述符的編號

    int rio_cnt;        //緩衝區中還未讀取的字節數

    char *rio_bufptr;   //當前下一個未讀取字符的地址

    char rio_buf[RIO_BUFSIZE];

}rio_t;

這個是rio的數據結構,通過rio_readinitb(rio_t *, int)可以將文件描述符與rio數據結構綁定起來。注意到這裏的rio_buf的大小是4096,這個參考了上圖,爲linux中文件的塊大小。

voidrio_readinitb(rio_t *rp, int fd)

/**

 * @brief rio_readinitb     rio_t 結構體初始化,並綁定文件描述符與緩衝區

 *

 * @param rp                rio_t結構體

 * @param fd                文件描述符

 */

{

    rp->rio_fd = fd;

    rp->rio_cnt = 0;

    rp->rio_bufptr = rp->rio_buf;

 

    return;

}

 

 

 

static ssize_trio_read(rio_t *rp, char *usrbuf, size_t n)

/**

 * @brief rio_read  RIO--Robust I/O包 底層讀取函數。當緩衝區數據充足時,此函數直接拷貝緩

 *                  衝區的數據給上層讀取函數;當緩衝區不足時,該函數通過系統調用

 *                  從文件中讀取最大數量的字節到緩衝區,再拷貝緩衝區數據給上層函數

 *

 * @param rp        rio_t,裏面包含了文件描述符和其對應的緩衝區數據

 * @param usrbuf    讀取的目的地址

 * @param n         讀取的字節數量

 *

 * @returns         返回真正讀取到的字節數(<=n

 */

{

    int cnt;

 

    while(rp->rio_cnt <= 0)    

    {

        rp->rio_cnt = read(rp->rio_fd,rp->rio_buf, sizeof(rp->rio_buf));

        if(rp->rio_cnt < 0)

        {

            if(errno != EINTR)  //遇到中斷類型錯誤的話應該進行讀取,否則就返回錯誤

                return -1;

        }

        else if(rp->rio_cnt == 0)   //讀取到了EOF

            return 0;

        else

            rp->rio_bufptr =rp->rio_buf;       //重置bufptr指針,令其指向第一個未讀取字節,然後便退出循環

    }

 

    cnt = n;

    if((size_t)rp->rio_cnt < n)    

        cnt = rp->rio_cnt;

    memcpy(usrbuf, rp->rio_bufptr, n);

    rp->rio_bufptr += cnt;      //讀取後需要更新指針

    rp->rio_cnt -= cnt;         //未讀取字節也會減少

 

    return cnt;

}

 

 

ssize_trio_readnb(rio_t *rp, void *usrbuf, size_t n)

/**

 * @brief rio_readnb    供用戶使用的讀取函數。從緩衝區中讀取最大maxlen字節數據

 *

 * @param rp           rio_t,文件描述符與其對應的緩衝區

 * @param usrbuf        void *, 目的地址

 * @param n             size_t, 用戶想要讀取的字節數量

 *

 * @returns             真正讀取到的字節數。讀到EOF返回0,讀取失敗返回-1。

 */

{

    size_t leftcnt = n;

    ssize_t nread;

    char *buf = (char *)usrbuf;

 

    while(leftcnt > 0)

    {

        if((nread = rio_read(rp, buf, n)) < 0)

        {

            if(errno == EINTR)      //其實這裏可以不用判斷EINTR,rio_read()中已經對其處理了

                nread = 0;

            else

                return -1;

        }

        leftcnt -= nread;

        buf += nread;

    }

 

    return n-leftcnt;

}

 

 

ssize_trio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen)

/**

 * @brief rio_readlineb 讀取一行的數據,遇到'\n'結尾代表一行

 *

 * @param rp            rio_t

 * @param usrbuf        用戶地址,即目的地址

 * @param maxlen        size_t, 一行最大的長度。若一行數據超過最大長度,則以'\0'截斷

 *

 * @returns             真正讀取到的字符數量

 */

{

    size_t n;

    int rd;

    char c, *bufp = (char *)usrbuf;

 

    for(n=1; n<maxlen; n++)     //n代表已接收字符的數量

    {

        if((rd=rio_read(rp, &c, 1)) == 1)

        {

            *bufp++ = c;

            if(c == '\n')

                break;

        }

        else if(rd == 0)        //沒有接收到數據

        {

            if(n == 1)          //如果第一次循環就沒接收到數據,則代表無數據可接收

                return 0;

            else

                break;

        }

        else                   

            return -1;

    }

    *bufp = 0;

 

    return n;

}

 

 

ssize_t rio_writen(intfd, void *usrbuf, size_t n)

{

    size_t nleft = n;

    ssize_t nwritten;

    char *bufp = (char *)usrbuf;

 

    while(nleft > 0)

    {

        if((nwritten = write(fd, bufp, nleft))<= 0)

        {

            if(errno == EINTR)

                nwritten = 0;

            else

                return -1;

        }

        bufp += nwritten;

        nleft -= nwritten;

    }

 

    return n;

}

以上便是rio的基本輸入輸出函數。注意到rio_writen(int fd, void *, size_t)代表文件描述符的參數是int類型,而不是rio_t類型。因爲rio_writen不需要寫緩衝。這是爲什麼呢?按道理來說,既然我們爲read封裝的rio_readn提供了緩衝區,爲什麼不也爲write提供一個有緩衝的rio_writen函數呢?

試想一個場景,你正在寫一個http的請求報文,然後將這個報文寫入了對應socket的文件描述符的緩衝區,假設緩衝區大小爲8K,該請求報文大小爲1K。那麼,如果緩衝區被設置爲被填滿纔會自動將其真正寫入文件(而且一般也是這樣做的),那就是說如果沒有提供一個刷新緩衝區的函數手動刷新,我還需要額外發送7K的數據將緩衝區填滿,這個請求報文才能真正被寫入到socket當中。所以,一般帶有緩衝區的函數庫都會一個刷新緩衝區的函數,用於將在緩衝區的數據真正寫入文件當中,即使緩衝區沒有被填滿,而這也是C標準庫的做法。然而,如果一個程序員一不小心忘記在寫入操作完成後手動刷新,那麼該數據(請求報文)便一直駐留在緩衝區,而你的進程還在傻傻地等待響應。

3.C標準IO

絕大部分的系統都提供了C接口的標準IO庫,與RIO包相比,標準IO庫有更加健全的,帶緩衝的並且支持格式化輸入輸出。標準IORIO包都是利用read, write等系統調用實現的(在windows等非Unix標準的系統則有其他對應的調用)。既然已經存在一個健全的,帶緩衝的IO藉口,那爲什麼還需要上述的RIO包呢?正是標準IO的緩衝機制對文件描述符的讀寫產生了一點負面影響,如果程序員忽略這些問題,那麼在對網絡套接字進行讀寫操作時就會出現很大的問題。

標準IO操作的對象與Unix I/O的不太相同,標準IO接口的操作對象是圍繞流(stream)進行的。當使用標準I/O接口打開或創建一個文件時,我們令一個流和一個文件相關聯。在默認的情況下,使用標準IO打開的文件流是帶有緩衝的(或許是全緩衝,或許是行緩衝)。這樣,在使用fputs等輸出函數時,數據會先被寫入文件流的緩衝區中,等到緩衝滿才真正將數據寫入文件。當FILE *fopen(const char *path, const char*mode);中的參數mode以讀和寫類型(r+,w+,a+)打開文件時,具有如下限制:
-
如果中間沒有fflush, fseek, fsetpo rewind,則在輸出的後面不能直接跟隨輸入。
-
如果中間沒有fseek, fsetpos rewind,或者一個輸入操作沒有達到文件尾端,則在輸入操作之後不能直接跟隨輸入。

Ubuntu15.10 x64中,經過測試,對於普通文件(socket)的操作,似乎不遵守這個規則讀寫也正常。然而,爲了程序的可移植性和健壯性,依然建議遵守標準的規定編程。

man fopen 中的一段話:

If thiscondition is not met, then a read is allowed to return
the result of writes other than the most recent.) Therefore it is good
practice (and indeed sometimes necessary under Linux) to put an
seek(3) or fgetpos(3) operation between write and read operations on
such a stream. This operation may be an apparent no-op (as in
fseek(…, 0L, SEEK_CUR) called for its synchronizing side effect).

在網絡套接字的編程中,對套接字使用lseek函數是非法的,而fseek,fsetposrewind都是通過lseek函數重置當前的文件位置,所以對於套接字來說,可使用的便只有fflush函數,這個函數的作用是刷新緩衝區,將緩衝區中的數據真正寫入文件中。

所以,對於大多數應用程序而言,標準IO更簡單,是優於Unix I/O的選擇。然而在網絡套接字的編程中,建議不要使用標準IO函數進行操作,而要使用健壯的RIO函數。RIO函數提供了帶緩衝的讀操作,與無緩衝的寫操作(對於套接字來說不需要),且是線程安全的。通過RIO包的學習,理解底層Unix I/O的實現也能更好避免在使用上層IO接口中犯錯。

參考書籍:
《深入理解計算機系統》
Unix網絡編程卷1第三版》
Unix高級編程第二版》

 

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