1. 程式人生 > 實用技巧 >Linux核心之 檔案I/O

Linux核心之 檔案I/O

Linux下一切皆檔案,所以檔案於Linux系統的重要性是不言而喻的。本文站在系統程式設計這一層來介紹Linux系統的檔案I/O。

1、檔案描述符

首先介紹檔案相關的系統呼叫前,我們需要知道一個非常重要的概念,檔案描述符,它是一切檔案操作的基礎,是核心與檔案操作之間的紐帶。那什麼是檔案描述符?

(1)檔案描述符其實質是一個數字,這個數字在一個程序中表示一個特定的含義,當我們open開啟一個檔案時,作業系統在記憶體中構建了一些資料結構來表示這個動態檔案,然後返回給應用程式一個數字作為檔案描述符file description),這個數字就和我們記憶體中維護這個動態檔案的這些資料結構掛鉤繫結上了,以後我們應用程式如果要操作這一個動態檔案,只需要用這個檔案描述符進行區分。

(2)檔案描述符就是用來區分一個程式開啟的多個檔案

(3)檔案描述符的作用域就是當前程序,出了當前程序這個檔案描述符就沒有意義了。

(4)檔案描述符fd的合法範圍是0開始,到上限值減1。預設情況下上限值是1024,可以配置最大為1048576

(5)open返回的fd必須記錄好,以後向這個檔案的所有操作都要靠這個fd去對應這個檔案,最後關閉檔案時也需要fd去指定關閉這個檔案。如果在我們關閉檔案前fd丟了,那麼這個檔案就沒法關閉了也沒法讀寫了。

在linux系統中,核心佔用了0、1、2這三個fd,當我們執行一個程式得到一個程序時,內部就預設已經打開了3個檔案,對應的fd就是0、1、2

分別叫stdin、stdout、stderr。也就是標準輸入、標準輸出、標準錯誤

事實上,核心會為每個程序維護一個開啟檔案的列表,該列表稱為檔案表(file table)。而檔案表通過檔案描述符fd進行索引,從而組成了一個程序的檔案描述符表

檔案描述符表的每項包括了一個檔案描述符和一個指向檔案資訊的指標,即指向檔案表。每個檔案表包括以下四項:

(1)檔案的狀態標誌,即是否可讀是否可寫。

(2)當前檔案的偏移量。

(3)refcnt,被引用數量。

(4)v節點指標,指向一個v節點表。

  v節點表:每個檔案對應一個,無論被被多少個程序開啟都只有一個,它包括v節點資訊(主要是stat結構體中的資訊),i節點(inode)資訊。

一個程序的檔案描述符表示例如下:

複製檔案描述符

檔案表可以共享,當多個檔案描述符指向同一個檔案表時,檔案表中的refcnt欄位會相應變化。

複製前:

複製後:

複製後,兩個檔案描述符都指向了同一個檔案表refcnt=2

複製檔案描述符有三種方法:

(1)dup()

(2)dup2()

int dup(int oldfd);
int dup2(int oldfd, int newfd);

dup()或dup2(),通過oldfd複製出一個新的檔案描述符newfd,返回值為newfd。最終oldfd和newfd都指向同一個檔案

dup()的返回值newfd是呼叫程序檔案描述符表中最小可用的檔案描述符

dup2()可以指定任意一個合法的數字newfd所以dup2中如果newfd該描述符已經存在則先將其關閉;而若newfd等於oldfd,則什麼都不做直接返回newfd

(3)fcntl()

#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

功能:操縱檔案描述符,改變已開啟的檔案的屬性。由第二個引數指定操作型別,後面點的可變引數指定該命令所需的引數。

這裡我們進行檔案描述符複製,可將cmd設為:F_DUPFD:

fd2 = fcntl(fd, F_DUPFD, 0); 

cmd其他值有FD_CLOEXEC,F_GETFD,F_SETFD等等,具體使用時可以查手冊。

2、檔案操作的系統呼叫

我們已經介紹了檔案描述符,以及如何複製檔案描述符,上面提到的函式都是系統呼叫。那麼檔案操作常見的系統呼叫還有哪些呢?

(1)open()開啟檔案;成功返回檔案描述符,否則返回-1。

(2)close(),關閉一個檔案。注意:關閉檔案,並不意味著資料已經寫到磁碟,如果需要被保證,請使用後面講的同步方法。

(3)write(),向檔案中寫資料。

(4)read(),讀檔案中資料。

(5)lseek(),修改檔案偏移量。

(6)ftruncate()與truncate(),截斷檔案長度。兩種的區別在於所帶的第一個引數,前者為檔案描述符,後者是路徑名。檔案長度可以變大變小,變大時用0填充。不修改當前檔案位置。

(7)access(),判斷檔案是否具有讀,寫,可執行或者是否存在。

(8)creat(),其主要作用為建立檔案,這個函式是作為保留使用。現在Linux作業系統都可以用open來替換,其引數設定為"O_WRONLY | O_CREAT | O_TRUNC"的組合即可。

其他還有很多的系統呼叫,例如目錄、元資料stat等系列操作函式在此不一一列舉,主要因為不是常見的也不是本文的重點。

3、同步I/O

上面我們簡單介紹了檔案的系統呼叫,其中最重要的是讀寫操作,即對檔案進行讀取和寫入操作。那麼它們的實現機制是否一樣呢?答案不一樣,各有各的機制。

讀取時,一般是採用阻塞操作,即一直等到緩衝區有資料可讀。當然也可以設定為非阻塞讀!

寫入時,一般是採用阻塞操作,即核心將資料從提供的緩衝區拷貝到核心緩衝區,就可以返回。所以並沒保證資料是否真正寫入目的地(常見的磁碟中)。

讀取時,當然可以預先從磁碟中讀取到核心緩衝區,以供下次讀取,這種方式叫預讀(readahead)

寫入時,核心緩衝區資料會在適當時機寫到磁碟,我們把這種方式叫做寫回(writeback)。我們也叫延遲寫,這種機制可以極大提高系統效能,可以將寫操作推遲到系統空閒時期,且可以批量操作。當然也帶來丟失資料的可能,比如突然斷電,沒有寫入磁碟還在緩衝的資料就會丟失;這樣會造成一定的資料不一致問題。

以上是通常情況下的讀寫行為。在有必要時,我們可以手動呼叫同步I/O,針對寫回機制,即犧牲效能換來同步操作。

Linux核心提供了一些選擇:

#include <unistd.h>

int fsync(int fd); int fdatasync(int fd);
void sync(void);  

(1)fsync()和fdatasync()

為了確保資料寫入磁碟,最簡單的方式是使用系統呼叫fsync(),即可以確保和檔案描述符fd所指向的檔案相關的所有髒資料(被修改過的)都會回寫到磁碟上。檔案描述符fd必須以寫方式開啟。該呼叫會回寫資料和元資料,如建立的時間戳以及索引節點中的其他屬性。該呼叫在硬體驅動器確認資料和元資料已經全部寫到磁碟之後才會返回

對於包含寫快取的硬碟(還記得我們以前說過的RAID卡嗎),fsync()無法知道資料是否已經真正在物理磁碟上了。硬碟會報告說資料已經寫完了,但是實際上資料還在硬碟驅動器的寫快取上。好在,在硬碟驅動器快取中的資料會很快寫入到磁碟上

fdatasync()的功能和fsync()類似,其區別在於fdatasync()只會寫入資料以及以後要訪問檔案所需要的元資料。例如,呼叫fdatasync()會寫檔案的大小,因為以後要讀該檔案需要檔案大小這個屬性。fdatasync()不保證非基礎的元資料也寫到磁碟上,因此一般而言,它執行更快。對於大多數使用場景,除了最基本的事務外,不會考慮元資料如檔案修改時間戳,因此fdatasync()就能夠滿足需求,而且執行更快。

fsync()通常會涉及至少兩個I/O操作:一是回寫修改的資料,二是更新索引節點的修改時間戳。因為索引節點和檔案資料在磁碟上可能不是緊挨著——因而會帶來代價很高的seek操作——在很多場景下,關注正確的事務順序,但不包括那些對於以後訪問檔案無關緊要的元資料(比如修改時間戳),使用fdatasync()是提高效能的簡單方式

(2)sync()

sync()系統呼叫用來對磁碟上的所有緩衝區進行同步,雖然它效率不高,但還是被廣泛應用。

該函式沒有引數,也沒有返回值。它總是成功返回,並確保所有的緩衝區——包括資料和元資料——都能夠寫入磁碟。

POSIX標準並不要求sync()一直等待所有緩衝區都寫到磁碟後才返回,只需要呼叫它來啟動把所有緩衝區寫到磁碟上即可。因此,一般建議多次呼叫sync(),確保所有資料都安全地寫入磁碟。但是對於Linux而言,sync()一定是等到所有緩衝區都寫入了才返回,因此呼叫一次sync()就夠了。

sync()的真正用途在於同步功能的實現。應用應該使用fsync()和fdatasync()檔案描述符指定的資料同步到磁碟中。注意,當系統繁忙時,sync()操作可能需要幾分鐘甚至更長的時間才能完成。(還記得dd磁碟讀寫測試嗎?)

(3)O_SYNC標誌位

系統呼叫open()可以使用O_SYNC標誌位,表示該檔案的所有I/O操作都需要同步。

讀請求總是同步操作。如果不同步,無法保證讀取緩衝區中的資料的有效性。但是,正如前面所提到的,write()呼叫通常是非同步操作。呼叫返回和把資料寫入磁碟沒有什麼關係,而標誌位O_SYNC則將二者強制關聯,從而保證write()呼叫會執行I/O同步。

O_SYNC標誌位的功能可以理解成每次呼叫write()操作後,隱式執行fsync(),然後才返回。這就是O_SYNC的語義,雖然Linux核心在實現上做了優化。

對於寫操作,O_SYNC對使用者時間和核心時間(分別指使用者空間和核心空間消耗的時間)有些負面影響。此外,根據寫入檔案的大小,O_SYNC可能會使程序消耗大量的時間在I/O等待時間,因而導致總耗時增加一兩個數量級。O_SYNC帶來的時間開銷增長是非常可觀的,因此一般只在沒有其他方式下才選擇同步I/O

一般來說,應用要確保通過fsync()或fdatasync()寫資料到磁碟上。和O_SYNC相比,呼叫fsync()和fdatasync()不會那麼頻繁(只在某些操作完成之後才會呼叫),因此其開銷也更低。

(4)O_DSYNC和O_RSYNC

POSIX標準為open()呼叫定義了另外兩個同步I/O相關的標誌位O_DSYNCO_RSYNC。在Linux上,這些標誌位的定義和O_SYNC一致,其行為完全相同。

O_DSYNC標誌位指定每次寫操作後,只同步普通資料,不同步元資料。O_DSYNC的功能可以理解為在每次寫請求後,隱式呼叫fdatasync()。因為O_SYNC提供了更嚴格的限制,把O_DSYNC替換成O_SYNC在功能上完全沒有問題,只有在某些嚴格需求場景下才會有效能損失。

O_RSYNC標誌位指定讀請求和寫請求之間的同步。該標誌位必須和O_SYNC或O_DSYNC一起使用。正如前面所提到的,讀操作總是同步的——只有當有資料返回給使用者時,才會返回。O_RSYNC標誌位保證讀操作帶來的任何影響也是同步的。也就是說,由於讀操作導致的元資料更新必須在呼叫返回前寫入磁碟。在實際應用中,可以理解成在read()呼叫返回前,檔案訪問時間必須更新到磁碟索引節點的副本中。在Linux中,O_RSYNC和O_SYNC的含義相同,雖然這沒有什麼意義(與O_SYNC和O_DSYNC的子集關係不同)。在Linux中,O_RSYNC無法通過當前行為來解釋,最接近的理解是在每次read()呼叫後,再呼叫fdatasync()。實際上,這種行為極少發生。

O_RSYNC的作用用簡單一句話概括,保證讀到的資料都是最新的即同步的。事實上這是預設行為,所以該巨集作用不大。

4、直接I/O

和其他現代作業系統核心一樣,Linux核心實現了複雜的快取、緩衝以及裝置和應用之間的I/O管理的層次結構。高效能的應用可能希望越過這個複雜的層次結構,進行獨立的I/O管理。但是,建立一個自己的I/O系統往往會事倍功半,實際上,作業系統層的工具往往比應用層的工具有更好的效能。此外,資料庫系統往往傾向於使用自己的快取,以儘可能減少作業系統帶來的開銷。

open()中指定O_DIRECT標誌位會使得核心對I/O管理的影響最小化。如果提供O_DIRECT標誌位,I/O操作會忽略頁快取機制,直接對使用者空間緩衝區和裝置進行初始化。所有的I/O操作都是同步的,操作在完成之前不會返回

使用直接I/O時,請求長度、緩衝區對齊以及檔案偏移都必須是底層裝置扇區大小(通常是512位元組)的整數倍。在Linux核心2.6以前,這項要求更加嚴格:在Linux核心2.4中,所有的操作都必須和檔案系統的邏輯塊大小對齊(一般是4KB)。為了保持相容性,應用需要對齊到更大(而且操作更難)的邏輯塊大小。

5、緩衝I/O

前面我們描述的緩衝I/O特指核心的,因為前面介紹的都是系統呼叫,在作業系統內部。上一節也提到了Linux系統存在一個非常複雜的I/O管理層次結構。其中扮演重要角色的是各種型別的緩衝。

也就是說在現代計算機系統中緩衝無處不在,不管是硬體、核心還是應用程式。核心裡有頁高速緩衝,記憶體高速緩衝,CPU硬體的L1.L2 cache,應用程式更是多的數不清,基本寫的好的軟體都有,專用的硬體比如Raid卡也是自己的記憶體和緩衝機制。不過歸根結底這些緩衝的作用是相同的,都是為了提高機器或者程式的效能。而需要緩衝大部分的情況都是為了協調兩個裝置或者兩個系統間速度的不匹配,又是利用區域性性原理。

我們這裡主要介紹Linux系統中“使用者緩衝I/O”。其中常見的實現方式之一是通過“標準I/O 庫(C標準庫)”,當然應用程式完全可以自己實現一套快取機制比如資料庫系統。一般而言,如果只是普通檔案操作或者輕量級I/O請求的程式通常會使用標準I/O。而且理解標準I/O緩衝的實現機制,也有利於自己開發一套專用的緩衝機制。

標準I/O庫對應的檔案操作主要是fopen(),fclose(),fread(),fwrite(),fseek()等。fopen()返回不再是檔案描述符,而是FILE*指標。執行包括檔案描述符在內的結構體指標。

標準I/O操作在本質上是執行緒安全的。在每個函式的內部實現中,都關聯了一把鎖、一個鎖計數器、以及持有該鎖並開啟一個流的執行緒。所以,在單個函式呼叫中,是原子操作

標準I/O庫提供緩衝的目的儘可能地減少使用read和write呼叫的次數。它也對每個I/O流自動地進行緩衝管理,從而避免了應用程式需要考慮這一點所帶來的麻煩。不幸的是,標準I/O庫最令人迷惑的也是它的緩衝。

標準I/O提供了三種類型的緩衝:

(1)全緩衝(塊緩衝)

填滿標準I/O緩衝區後才進行實際I/O操作。常規檔案(如普通文字檔案)通常是全緩衝的。

(2行緩衝

當在輸入和輸出中遇到換行符時,標準I/O庫執行I/O操作提交給核心。這允許我們一次輸出一個字元,但只有在寫了一行之後才進行實際I/O操作。標準輸入和標準輸出對應終端裝置(如螢幕)時通常是行緩衝的。

(3)不帶緩衝

使用者程式每次調庫函式做寫操作都通過系統呼叫寫回核心。標準錯誤輸出通常是無緩衝的,這樣使用者程式產生的錯誤資訊可以儘快輸出到裝置。

修改IO緩衝

對於上面提到的每種檔案流,IO庫都預設分配一個對應的緩衝給它,但有時候我們想自己設定這些緩衝,不要預設的,那麼我們可以使用下面兩個函式來達到目的:

#include <stdio.h>
void
setbuf(FILE *fp, char *buf); void setvbuf(FILE *fp, char *buf, int mode, size_t size);

第一個函式用來開啟或者關閉緩衝機制,如果buf為NULL,則關閉緩衝,否則buf指向緩衝區,不過緩衝區的型別和檔案流有關。

第二個函式修改緩衝模式和指定緩衝區,mode指定我們所需要的緩衝型別(上面提到的三種之一),buf指定一個緩衝空間,size為該空間長度

Flush(重新整理輸出)流

標準I/O庫提供了一個介面,可以將使用者緩衝區資料寫入核心,即“強制沖刷一個流”:

#include <stdio.h>
int fflush(FILE *fp);

如果fp為NULL,沖刷所有輸出流。

需要注意的是,這裡的沖刷只是把使用者緩衝區的資料寫入核心緩衝區。我們通過一張圖來捋一下使用者緩衝區、核心緩衝區以及同步的關係:

標準I/O的兩次複製

上面我們提到,標準I/O庫緩衝的目的是儘可能地減少使用read和write呼叫的次數。普通檔案在全緩衝模式下,可能將多次I/O操作減少或者合併到一次。但是這是對於輕量級資料,也就是說每次讀寫資料長度較小,遠小於緩衝的長度。相反如果對於資料讀寫粒度較大,大於該長度,這個優勢就不存在了。而真正問題在於每次的標準I/O都需要兩次複製,嚴重影響效能。

讀取普通檔案

第一次複製:核心緩衝區->標準I/O緩衝區

第二次複製:標準I/O緩衝區->應用程式指定的緩衝區

事實上,這只是標準I/O的兩次複製,如果在一次實際應用中,可能涉及到四次。比如說從主機磁碟上讀取資料再發送到網路。

就會加多兩次拷貝,首先需要從磁碟複製到核心緩衝區,再經過標準I/O的兩次複製,最後再將指定緩衝區資料拷貝到網絡卡的緩衝區上。

而且資料拷貝需要由CPU來調控,所以如果反覆這樣,將大大增加系統的開銷!

常見的解決方式之一為使用mmap()系統呼叫。

mmap記憶體對映就可以繞過標準I/O緩衝區,直接將核心資料直接拷貝到指定的對映區,而mmap記憶體對映區可以在應用程式和作業系統之間共享,也可以在程序之間共享!

其他方式可以直接使用read和write的系統呼叫而不是標準I/O的fread和fwrite這一系列函式;亦可以使用直接I/O(O_DIRECT)。

所以很多開源庫大都使用了經自己高度優化的使用者緩衝庫。

推薦閱讀:儲存系列之 總結:儲存分層

參考資料:

《linux系統程式設計》第2版

《UNIX環境高階程式設計》(簡稱APUE)第2版