1. 程式人生 > >Linux 中直接 I/O 機制的介紹

Linux 中直接 I/O 機制的介紹

在介紹直接 I/O 之前,這一小節先介紹一下為什麼會出現直接 I/O 這種機制,即傳統的 I/O 操作存在哪些缺點。

快取 I/O 又被稱作標準 I/O,大多數檔案系統的預設 I/O 操作都是快取 I/O。在 Linux 的快取 I/O 機制中,作業系統會將 I/O 的資料快取在檔案系統的頁快取( page cache )中,也就是說,資料會先被拷貝到作業系統核心的緩衝區中,然後才會從作業系統核心的緩衝區拷貝到應用程式的地址空間。快取 I/O 有以下這些優點:

  • 快取 I/O 使用了作業系統核心緩衝區,在一定程度上分離了應用程式空間和實際的物理裝置。
  • 快取 I/O 可以減少讀盤的次數,從而提高效能。

當應用程式嘗試讀取某塊資料的時候,如果這塊資料已經存放在了頁快取中,那麼這塊資料就可以立即返回給應用程式,而不需要經過實際的物理讀盤操作。當然,如果資料在應用程式讀取之前並未被存放在頁快取中,那麼就需要先將資料從磁碟讀到頁快取中去。對於寫操作來說,應用程式也會將資料先寫到頁快取中去,資料是否被立即寫到磁碟上去取決於應用程式所採用的寫操作機制:如果使用者採用的是同步寫機制( synchronous writes ), 那麼資料會立即被寫回到磁碟上,應用程式會一直等到資料被寫完為止;如果使用者採用的是延遲寫機制( deferred writes ),那麼應用程式就完全不需要等到資料全部被寫回到磁碟,資料只要被寫到頁快取中去就可以了。在延遲寫機制的情況下,作業系統會定期地將放在頁快取中的資料刷到磁碟上。與非同步寫機制( asynchronous writes )不同的是,延遲寫機制在資料完全寫到磁碟上的時候不會通知應用程式,而非同步寫機制在資料完全寫到磁碟上的時候是會返回給應用程式的。所以延遲寫機制本身是存在資料丟失的風險的,而非同步寫機制則不會有這方面的擔心。

在快取 I/O 機制中,DMA 方式可以將資料直接從磁碟讀到頁快取中,或者將資料從頁快取直接寫回到磁碟上,而不能直接在應用程式地址空間和磁碟之間進行資料傳輸,這樣的話,資料在傳輸過程中需要在應用程式地址空間和頁快取之間進行多次資料拷貝操作,這些資料拷貝操作所帶來的 CPU 以及記憶體開銷是非常大的。

對於某些特殊的應用程式來說,避開作業系統核心緩衝區而直接在應用程式地址空間和磁碟之間傳輸資料會比使用作業系統核心緩衝區獲取更好的效能,下邊這一小節中提到的自快取應用程式就是其中的一種。

對於某些應用程式來說,它會有它自己的資料快取機制,比如,它會將資料快取在應用程式地址空間,這類應用程式完全不需要使用作業系統核心中的高速緩衝儲存器,這類應用程式就被稱作是自快取應用程式( self-caching applications )。資料庫管理系統是這類應用程式的一個代表。自快取應用程式傾向於使用資料的邏輯表達方式,而非物理表達方式;當系統記憶體較低的時候,自快取應用程式會讓這種資料的邏輯快取被換出,而並非是磁碟上實際的資料被換出。自快取應用程式對要操作的資料的語義瞭如指掌,所以它可以採用更加高效的快取替換演算法。自快取應用程式有可能會在多臺主機之間共享一塊記憶體,那麼自快取應用程式就需要提供一種能夠有效地將使用者地址空間的快取資料置為無效的機制,從而確保應用程式地址空間快取資料的一致性。

對於自快取應用程式來說,快取 I/O 明顯不是一個好的選擇。由此引出我們這篇文章著重要介紹的 Linux 中的直接 I/O 技術。Linux 中的直接 I/O 技術非常適用於自快取這類應用程式,該技術省略掉快取 I/O 技術中作業系統核心緩衝區的使用,資料直接在應用程式地址空間和磁碟之間進行傳輸,從而使得自快取應用程式可以省略掉複雜的系統級別的快取結構,而執行程式自己定義的資料讀寫管理,從而降低系統級別的管理對應用程式訪問資料的影響。在下面一節中,我們會著重介紹 Linux 中提供的直接 I/O 機制的設計與實現,該機制為自快取應用程式提供了很好的支援。

所有的 I/O 操作都是通過讀檔案或者寫檔案來完成的。在這裡,我們把所有的外圍裝置,包括鍵盤和顯示器,都看成是檔案系統中的檔案。訪問檔案的方法多種多樣,這裡列出下邊這幾種 Linux 2.6 中支援的檔案訪問方式。

在 Linux 中,這種訪問檔案的方式是通過兩個系統呼叫實現的:read() 和 write()。當應用程式呼叫 read() 系統呼叫讀取一塊資料的時候,如果該塊資料已經在記憶體中了,那麼就直接從記憶體中讀出該資料並返回給應用程式;如果該塊資料不在記憶體中,那麼資料會被從磁碟上讀到頁高快取中去,然後再從頁快取中拷貝到使用者地址空間中去。如果一個程序讀取某個檔案,那麼其他程序就都不可以讀取或者更改該檔案;對於寫資料操作來說,當一個程序呼叫了 write() 系統呼叫往某個檔案中寫資料的時候,資料會先從使用者地址空間拷貝到作業系統核心地址空間的頁快取中去,然後才被寫到磁碟上。但是對於這種標準的訪問檔案的方式來說,在資料被寫到頁快取中的時候,write() 系統呼叫就算執行完成,並不會等資料完全寫入到磁碟上。Linux 在這裡採用的是我們前邊提到的延遲寫機制( deferred writes )。


圖 1. 以標準的方式對檔案進行讀寫
圖 1. 以標準的方式對檔案進行讀寫

同步訪問檔案的方式與上邊這種標準的訪問檔案的方式比較類似,這兩種方法一個很關鍵的區別就是:同步訪問檔案的時候,寫資料的操作是在資料完全被寫回磁碟上才算完成的;而標準訪問檔案方式的寫資料操作是在資料被寫到頁高速緩衝儲存器中的時候就算執行完成了。


圖 2. 資料同步寫回磁碟
圖 2. 資料同步寫回磁碟

在很多作業系統包括 Linux 中,記憶體區域( memory region )是可以跟一個普通的檔案或者塊裝置檔案的某一個部分關聯起來的,若程序要訪問記憶體頁中某個位元組的資料,作業系統就會將訪問該記憶體區域的操作轉換為相應的訪問檔案的某個位元組的操作。Linux 中提供了系統呼叫 mmap() 來實現這種檔案訪問方式。與標準的訪問檔案的方式相比,記憶體對映方式可以減少標準訪問檔案方式中 read() 系統呼叫所帶來的資料拷貝操作,即減少資料在使用者地址空間和作業系統核心地址空間之間的拷貝操作。對映通常適用於較大範圍,對於相同長度的資料來講,對映所帶來的開銷遠遠低於 CPU 拷貝所帶來的開銷。當大量資料需要傳輸的時候,採用記憶體對映方式去訪問檔案會獲得比較好的效率。


圖 3. 利用 mmap 代替 read
圖 3. 利用 mmap 代替 read

凡是通過直接 I/O 方式進行資料傳輸,資料均直接在使用者地址空間的緩衝區和磁碟之間直接進行傳輸,完全不需要頁快取的支援。作業系統層提供的快取往往會使應用程式在讀寫資料的時候獲得更好的效能,但是對於某些特殊的應用程式,比如說資料庫管理系統這類應用,他們更傾向於選擇他們自己的快取機制,因為資料庫管理系統往往比作業系統更瞭解資料庫中存放的資料,資料庫管理系統可以提供一種更加有效的快取機制來提高資料庫中資料的存取效能。


圖 4. 資料傳輸不經過作業系統核心緩衝區
圖 4. 資料傳輸不經過作業系統核心緩衝區

Linux 非同步 I/O 是 Linux 2.6 中的一個標準特性,其本質思想就是程序發出資料傳輸請求之後,程序不會被阻塞,也不用等待任何操作完成,程序可以在資料傳輸的時候繼續執行其他的操作。相對於同步訪問檔案的方式來說,非同步訪問檔案的方式可以提高應用程式的效率,並且提高系統資源利用率。直接 I/O 經常會和非同步訪問檔案的方式結合在一起使用。


圖 5.CPU 處理其他任務和 I/O 操作可以重疊執行
圖 5.CPU 處理其他任務和 I/O 操作可以重疊執行

在下邊這一小節中,我們會重點介紹 Linux 2.6 核心中直接 I/O 的設計與實現。

在塊裝置或者網路裝置中執行直接 I/O 完全不用擔心實現直接 I/O 的問題,Linux 2.6 作業系統核心中高層程式碼已經設定和使用了直接 I/O,驅動程式級別的程式碼甚至不需要知道已經執行了直接 I/O;但是對於字元裝置來說,執行直接 I/O 是不可行的,Linux 2.6 提供了函式 get_user_pages() 用於實現直接 I/O。本小節會分別對這兩種情況進行介紹。 

要在塊裝置中執行直接 I/O,程序必須在開啟檔案的時候設定對檔案的訪問模式為 O_DIRECT,這樣就等於告訴作業系統程序在接下來使用 read() 或者 write() 系統呼叫去讀寫檔案的時候使用的是直接 I/O 方式,所傳輸的資料均不經過作業系統核心快取空間。使用直接 I/O 讀寫資料必須要注意緩衝區對齊( buffer alignment )以及緩衝區的大小的問題,即對應 read() 以及 write() 系統呼叫的第二個和第三個引數。這裡邊說的對齊指的是檔案系統塊大小的對齊,緩衝區的大小也必須是該塊大小的整數倍。

這一節主要介紹三個函式:open(),read() 以及 write()。Linux 中訪問檔案具有多樣性,所以這三個函式對於處理不同的檔案訪問方式定義了不同的處理方法,本文主要介紹其與直接 I/O 方式相關的函式與功能.首先,先來看 open() 系統呼叫,其函式原型如下所示:

int open(const char *pathname, int oflag, … /*, mode_t mode * / ) ;
			

以下列出了 Linux 2.6 核心定義的系統呼叫 open() 所使用的識別符號巨集定義:


表 1. open() 系統呼叫提供的識別符號
識別符號名 識別符號描述
O_RDONLY 以只讀的方式開啟檔案
O_WRONLY 以只寫的方式開啟檔案
O_RDWR 以讀寫的方式開啟檔案
O_CREAT 若檔案不存在,則建立該檔案
O_EXCL 以獨佔模式開啟檔案;若同時設定 O_EXCL 和 O_CREATE, 那麼若檔案已經存在,則開啟操作會失敗
O_NOCTTY 若設定該描述符,則該檔案不可以被當成終端處理
O_TRUNC 截斷檔案,若檔案存在,則刪除該檔案
O_APPEND 若設定了該描述符,則在寫檔案之前,檔案指標會被設定到檔案的底部
O_NONBLOCK 以非阻塞的方式開啟檔案
O_NELAY 同 O_NELAY,若同時設定 O_NELAY 和 O_NONBLOCK,O_NONBLOCK 優先起作用
O_SYNC 該描述符會對普通檔案的寫操作產生影響,若設定了該描述符,則對該檔案的寫操作會等到資料被寫到磁碟上才算結束
FASYNC 若設定該描述符,則 I/O 事件通知是通過訊號發出的
O_DIRECT 該描述符提供對直接 I/O 的支援
O_LARGEFILE 該描述符提供對超過 2GB 大檔案的支援
O_DIRECTORY 該描述符表明所開啟的檔案必須是目錄,否則開啟操作失敗
O_NOFOLLOW 若設定該描述符,則不解析路徑名尾部的符號連結

當應用程式需要直接訪問檔案而不經過作業系統頁高速緩衝儲存器的時候,它開啟檔案的時候需要指定 O_DIRECT 識別符號。

作業系統核心中處理 open() 系統呼叫的核心函式是 sys_open(),sys_open() 會呼叫 do_sys_open() 去處理主要的開啟操作。它主要做了三件事情:首先, 它呼叫 getname() 從程序地址空間中讀取檔案的路徑名;接著,do_sys_open() 呼叫 get_unused_fd() 從程序的檔案表中找到一個空閒的檔案表指標,相應的新檔案描述符就存放在本地變數 fd 中;之後,函式 do_filp_open() 會根據傳入的引數去執行相應的開啟操作。清單 1 列出了作業系統核心中處理 open() 系統呼叫的一個主要函式關係圖。


清單 1. 主要呼叫函式關係圖
				
 sys_open() 
   |-----do_sys_open() 
          |---------getname() 
          |---------get_unused_fd() 
          |---------do_filp_open() 
                     |--------nameidata_to_filp() 
                               |----------__dentry_open() 

函式 do_flip_open() 在執行的過程中會呼叫函式 nameidata_to_filp(),而 nameidata_to_filp() 最終會呼叫 __dentry_open() 函式,若程序指定了 O_DIRECT 識別符號,則該函式會檢查直接 I./O 操作是否可以作用於該檔案。清單 2 列出了 __dentry_open() 函式中與直接 I/O 操作相關的程式碼。


清單 2. 函式 dentry_open() 中與直接 I/O 相關的程式碼
				
	 if (f->f_flags & O_DIRECT) { 
		 if (!f->f_mapping->a_ops || 
		    ((!f->f_mapping->a_ops->direct_IO) && 
		    (!f->f_mapping->a_ops->get_xip_page))) { 
			 fput(f); 
			 f = ERR_PTR(-EINVAL); 
		 } 
	 } 

當檔案開啟時指定了 O_DIRECT 識別符號,那麼作業系統就會知道接下來對檔案的讀或者寫操作都是要使用直接 I/O 方式的。

下邊我們來看一下當程序通過 read() 系統呼叫讀取一個已經設定了 O_DIRECT 識別符號的檔案的時候,系統都做了哪些處理。 函式 read() 的原型如下所示:

	 ssize_t read(int feledes, void *buff, size_t nbytes) ; 

作業系統中處理 read() 函式的入口函式是 sys_read(),其主要的呼叫函式關係圖如下清單 3 所示:


清單 3. 主呼叫函式關係圖
				
	 sys_read() 
	   |-----vfs_read() 
	        |----generic_file_read() 
	              |----generic_file_aio_read() 
	                   |--------- generic_file_direct_IO() 

函式 sys_read() 從程序中獲取檔案描述符以及檔案當前的操作位置後會呼叫 vfs_read() 函式去執行具體的操作過程,而 vfs_read() 函式最終是呼叫了 file 結構中的相關操作去完成檔案的讀操作,即呼叫了 generic_file_read() 函式,其程式碼如下所示:


清單 4. 函式 generic_file_read()
				
	 ssize_t 
	 generic_file_read(struct file *filp, 
	 char __user *buf, size_t count, loff_t *ppos) 
	 { 
		 struct iovec local_iov = { .iov_base = buf, .iov_len = count }; 
		 struct kiocb kiocb; 
		 ssize_t ret; 
	
		 init_sync_kiocb(&kiocb, filp); 
		 ret = __generic_file_aio_read(&kiocb, &local_iov, 1, ppos); 
		 if (-EIOCBQUEUED == ret) 
			 ret = wait_on_sync_kiocb(&kiocb); 
		 return ret; 
	 } 

函式 generic_file_read() 初始化了 iovec 以及 kiocb 描述符。描述符 iovec 主要是用於存放兩個內容:用來接收所讀取資料的使用者地址空間緩衝區的地址和緩衝區的大小;描述符 kiocb 用來跟蹤 I/O 操作的完成狀態。之後,函式 generic_file_read() 凋用函式 __generic_file_aio_read()。該函式檢查 iovec 中描述的使用者地址空間緩衝區是否可用,接著檢查訪問模式,若訪問模式描述符設定了 O_DIRECT,則執行與直接 I/O 相關的程式碼。函式 __generic_file_aio_read() 中與直接 I/O 有關的程式碼如下所示:


清單 5. 函式 __generic_file_aio_read() 中與直接 I/O 有關的程式碼
				
	 if (filp->f_flags & O_DIRECT) { 
		 loff_t pos = *ppos, size; 
		 struct address_space *mapping; 
		 struct inode *inode; 

		 mapping = filp->f_mapping; 
		 inode = mapping->host; 
		 retval = 0; 
		 if (!count) 
			 goto out; 
		 size = i_size_read(inode); 
		 if (pos < size) { 
			 retval = generic_file_direct_IO(READ, iocb, 
						 iov, pos, nr_segs); 
			 if (retval > 0 && !is_sync_kiocb(iocb)) 
				 retval = -EIOCBQUEUED; 
			 if (retval > 0) 
				 *ppos = pos + retval; 
		 } 
		 file_accessed(filp); 
		 goto out; 
	 } 
	

上邊的程式碼段主要是檢查了檔案指標的值,檔案的大小以及所請求讀取的位元組數目等,之後,該函式呼叫 generic_file_direct_io(),並將操作型別 READ,描述符 iocb,描述符 iovec,當前檔案指標的值以及在描述符 io_vec  中指定的使用者地址空間緩衝區的個數等值作為引數傳給它。當 generic_file_direct_io() 函式執行完成,函式 __generic_file_aio_read()會繼續執行去完成後續操作:更新檔案指標,設定訪問檔案 i 節點的時間戳;這些操作全部執行完成以後,函式返回。 函式 generic_file_direct_IO() 會用到五個引數,各引數的含義如下所示:

  • rw:操作型別,可以是 READ 或者 WRITE
  • iocb:指標,指向 kiocb 描述符 
  • iov:指標,指向 iovec 描述符陣列
  • offset:file 結構偏移量
  • nr_segs:iov 陣列中 iovec 的個數
	函式 generic_file_direct_IO() 程式碼如下所示:


清單 6. 函式 generic_file_direct_IO()
				
	 static ssize_t 
	 generic_file_direct_IO(int rw, struct kiocb *iocb, const struct iovec *iov, 
		 loff_t offset, unsigned long nr_segs) 
	 { 
		 struct file *file = iocb->ki_filp; 
		 struct address_space *mapping = file->f_mapping; 
		 ssize_t retval; 
		 size_t write_len = 0; 
	
		 if (rw == WRITE) { 
			 write_len = iov_length(iov, nr_segs); 
		       	 if (mapping_mapped(mapping)) 
				 unmap_mapping_range(mapping, offset, write_len, 0); 
		 } 
	
		 retval = filemap_write_and_wait(mapping); 
		 if (retval == 0) { 
			 retval = mapping->a_ops->direct_IO(rw, iocb, iov, 
							 offset, nr_segs); 
			 if (rw == WRITE && mapping->nrpages) { 
				 pgoff_t end = (offset + write_len - 1) 
							 >> PAGE_CACHE_SHIFT; 
				 int err = invalidate_inode_pages2_range(mapping, 
						 offset >> PAGE_CACHE_SHIFT, end); 
				 if (err) 
					 retval = err; 
			 } 
		 } 
		 return retval; 
	 } 

函式 generic_file_direct_IO() 對 WRITE 操作型別進行了一些特殊處理,這在下邊介紹 write() 系統呼叫的時候再做說明。除此之外,它主要是呼叫了 direct_IO 方法去執行直接 I/O 的讀或者寫操作。在進行直接  I/O  讀操作之前,先將頁快取中的相關髒資料刷回到磁碟上去,這樣做可以確保從磁碟上讀到的是最新的資料。這裡的 direct_IO 方法最終會對應到 __blockdev_direct_IO() 函式上去。__blockdev_direct_IO() 函式的程式碼如下所示:


清單 7. 函式 __blockdev_direct_IO()
				
	 ssize_t 
	 __blockdev_direct_IO(int rw, struct kiocb *iocb, struct inode *inode, 
		 struct block_device *bdev, const struct iovec *iov, loff_t offset, 
		 unsigned long nr_segs, get_block_t get_block, dio_iodone_t end_io, 
		 int dio_lock_type) 
	 { 
		 int seg; 
		 size_t size; 
		 unsigned long addr; 
		 unsigned blkbits = inode->i_blkbits; 
		 unsigned bdev_blkbits = 0; 
		 unsigned blocksize_mask = (1 << blkbits) - 1; 
		 ssize_t retval = -EINVAL; 
		 loff_t end = offset; 
		 struct dio *dio; 
		 int release_i_mutex = 0; 
		 int acquire_i_mutex = 0; 
	
		 if (rw & WRITE) 
			 rw = WRITE_SYNC; 
	
		 if (bdev) 
			 bdev_blkbits = blksize_bits(bdev_hardsect_size(bdev)); 
	
		 if (offset & blocksize_mask) { 
			 if (bdev) 
				 blkbits = bdev_blkbits; 
			 blocksize_mask = (1 << blkbits) - 1; 
			 if (offset & blocksize_mask) 
				 goto out; 
		 } 
	
		 for (seg = 0; seg < nr_segs; seg++) { 
			 addr = (unsigned long)iov[seg].iov_base; 
			 size = iov[seg].iov_len; 
			 end += size; 
			 if ((addr & blocksize_mask) || (size & blocksize_mask))  { 
				 if (bdev) 
					 blkbits = bdev_blkbits; 
				 blocksize_mask = (1 << blkbits) - 1; 
				 if ((addr & blocksize_mask) || (size & blocksize_mask))  
					 goto out; 
			 } 
		 } 
	
		 dio = kmalloc(sizeof(*dio), GFP_KERNEL); 
		 retval = -ENOMEM; 
		 if (!dio) 
			 goto out; 
		 dio->lock_type = dio_lock_type; 
		 if (dio_lock_type != DIO_NO_LOCKING) { 
			 if (rw == READ && end > offset) { 
				 struct address_space *mapping; 
	
				 mapping = iocb->ki_filp->f_mapping; 
				 if (dio_lock_type != DIO_OWN_LOCKING) { 
					 mutex_lock(&inode->i_mutex); 
					 release_i_mutex = 1; 
				 } 
	
				 retval = filemap_write_and_wait_range(mapping, offset, 
								      end - 1); 
				 if (retval) { 
					 kfree(dio); 
					 goto out; 
				 } 
	
				 if (dio_lock_type == DIO_OWN_LOCKING) { 
					 mutex_unlock(&inode->i_mutex); 
					 acquire_i_mutex = 1; 
				 } 
			 } 
	
			 if (dio_lock_type == DIO_LOCKING) 
				 down_read_non_owner(&inode->i_alloc_sem); 
		 } 
	
		 dio->is_async = !is_sync_kiocb(iocb) && !((rw & WRITE) && 
			 (end > i_size_read(inode))); 
	
		 retval = direct_io_worker(rw, iocb, inode, iov, offset, 
					 nr_segs, blkbits, get_block, end_io, dio); 
	
		 if (rw == READ && dio_lock_type == DIO_LOCKING) 
			 release_i_mutex = 0; 
	
	 out: 
		 if (release_i_mutex) 
			 mutex_unlock(&inode->i_mutex); 
		 else if (acquire_i_mutex) 
			 mutex_lock(&inode->i_mutex); 
		 return retval; 
	 } 

該函式將要讀或者要寫的資料進行拆分,並檢查緩衝區對齊的情況。本文在前邊介紹 open() 函式的時候指出,使用直接 I/O 讀寫資料的時候必須要注意緩衝區對齊的問題,從上邊的程式碼可以看出,緩衝區對齊的檢查是在 __blockdev_direct_IO() 函式裡邊進行的。使用者地址空間的緩衝區可以通過 iov 陣列中的 iovec 描述符確定。直接 I/O 的讀操作或者寫操作都是同步進行的,也就是說,函式 __blockdev_direct_IO() 會一直等到所有的 I/O 操作都結束才會返回,因此,一旦應用程式 read() 系統呼叫返回,應用程式就可以訪問使用者地址空間中含有相應資料的緩衝區。但是,這種方法在應用程式讀操作完成之前不能關閉應用程式,這將會導致關閉應用程式緩慢。

	 
	接下來我們看一下 write() 系統呼叫中與直接 I/O 相關的處理實現過程。函式 write() 的原型如下所示:
	
	 ssize_t write(int filedes, const void * buff, size_t nbytes) ; 
	
	作業系統中處理 write() 系統呼叫的入口函式是 sys_write()。其主要的呼叫函式關係如下所示:


清單 8. 主呼叫函式關係圖
				
 sys_write() 
	   |-----vfs_write() 
	      |----generic_file_write() 
	            |----generic_file_aio_read() 
	                  |---- __generic_file_write_nolock() 
	                        |-- __generic_file_aio_write_nolock 
	                            |-- generic_file_direct_write() 
	                                |-- generic_file_direct_IO() 

函式 sys_write() 幾乎與 sys_read() 執行相同的步驟,它從程序中獲取檔案描述符以及檔案當前的操作位置後即呼叫 vfs_write() 函式去執行具體的操作過程,而 vfs_write() 函式最終是呼叫了 file 結構中的相關操作完成檔案的寫操作,即呼叫了 generic_file_write() 函式。在函式 generic_file_write() 中, 函式 generic_file_write_nolock() 最終呼叫 generic_file_aio_write_nolock() 函式去檢查 O_DIRECT 的設定,並且呼叫  generic_file_direct_write() 函式去執行直接 I/O 寫操作。

	函式 generic_file_aio_write_nolock() 中與直接 I/O 相關的程式碼如下所示:


清單 9. 函式 generic_file_aio_write_nolock() 中與直接 I/O 相關的程式碼
				
		 if (unlikely(file->f_flags & O_DIRECT)) { 
			 written = generic_file_direct_write(iocb, iov, 
					 &nr_segs, pos, ppos, count, ocount); 
			 if (written < 0 || written == count) 
				 goto out; 
			
			 pos += written; 
			 count -= written; 
		 } 

從上邊程式碼可以看出, generic_file_aio_write_nolock() 呼叫了 generic_file_direct_write() 函式去執行直接 I/O 操作;而在 generic_file_direct_write() 函式中,跟讀操作過程類似,它最終也是呼叫了 generic_file_direct_IO() 函式去執行直接 I/O 寫操作。與直接 I/O 讀操作不同的是,這次需要將操作型別 WRITE 作為引數傳給函式 generic_file_direct_IO()。

前邊介紹了 generic_file_direct_IO() 的主體 direct_IO 方法:__blockdev_direct_IO()。函式 generic_file_direct_IO() 對 WRITE 操作型別進行了一些額外的處理。當操作型別是 WRITE 的時候,若發現該使用直接 I/O 的檔案已經與其他一個或者多個程序存在關聯的記憶體對映,那麼就呼叫 unmap_mapping_range() 函式去取消建立在該檔案上的所有的記憶體對映,並將頁快取中相關的所有 dirty 位被置位的髒頁面刷回到磁碟上去。對於直接  I/O  寫操作來說,這樣做可以保證寫到磁碟上的資料是最新的,否則,即將用直接  I/O  方式寫入到磁碟上的資料很可能會因為頁快取中已經存在的髒資料而失效。在直接  I/O  寫操作完成之後,在頁快取中相關的髒資料就都已經失效了,磁碟與頁快取中的資料內容必須保持同步。

在字元裝置中執行直接 I/O 可能是有害的,只有在確定了設定緩衝 I/O 的開銷非常巨大的時候才建議使用直接 I/O。在 Linux 2.6 的核心中,實現直接 I/O 的關鍵是函式 get_user_pages() 函式。其函式原型如下所示:

	
	 int get_user_pages(struct task_struct *tsk, 
	 struct mm_struct *mm, 
	 unsigned long start, 
		 int len, 
	 int write, 
	 int force, 
	 struct page **pages, 
	 struct vm_area_struct **vmas); 



該函式的引數含義如下所示:

  • tsk:指向執行對映的程序的指標;該引數的主要用途是用來告訴作業系統核心,對映頁面所產生的頁錯誤由誰來負責,該引數幾乎總是 current。
  • mm:指向被對映的使用者地址空間的記憶體管理結構的指標,該引數通常是 current->mm 。
  • start: 需要對映的使用者地址空間的地址。
  • len:頁內緩衝區的長度。
  • write:如果需要對所對映的頁面有寫許可權,該引數的設定得是非零。
  • force:該引數的設定通知 get_user_pages() 函式無需考慮對指定記憶體頁的保護,直接提供所請求的讀或者寫訪問。
  • page:輸出引數。呼叫成功後,該引數中包含一個描述使用者空間頁面的 page 結構的指標列表。
  • vmas:輸出引數。若該引數非空,則該引數包含一個指向 vm_area_struct 結構的指標,該 vm_area_struct 結構包含了每一個所對映的頁面。

在使用 get_user_pages() 函式的時候,往往還需要配合使用以下這些函式:

	
	 void down_read(struct rw_semaphore *sem); 
	 void up_read(struct rw_semaphore *sem); 
	 void SetPageDirty(struct page *page); 
	 void page_cache_release(struct page *page); 
	

首先,在使用 get_user_pages() 函式之前,需要先呼叫 down_read() 函式將 mmap 為獲得使用者地址空間的讀取者 / 寫入者訊號量設定為讀模式;在呼叫完 get_user_pages() 函式之後,再呼叫配對函式 up_read() 釋放訊號量 sem。若 get_user_pages() 呼叫失敗,則返回錯誤程式碼;若呼叫成功,則返回實際被對映的頁面數,該數目有可能比請求的數量少。呼叫成功後所對映的使用者頁面被鎖在記憶體中,呼叫者可以通過 page 結構的指標去訪問這些使用者頁面。

直接 I/O 的呼叫者必須進行善後工作,一旦直接 I/O 操作完成,使用者記憶體頁面必須從頁快取中釋放。在使用者記憶體頁被釋放之前,如果這些頁面中的內容改變了,那麼呼叫者必須要通知作業系統核心,否則虛擬儲存子系統會認為這些頁面是乾淨的,從而導致這些資料被修改了的頁面在被釋放之前無法被寫回到永久儲存中去。因此,如果改變了頁中的資料,那麼就必須使用 SetPageDirty() 函式標記出每個被改變的頁。對於 Linux 2.6.18.1,該巨集定義在 /include/linux/page_flags.h 中。執行該操作的程式碼一般需要先檢查頁,以確保該頁不在記憶體對映的保留區域內,因為這個區的頁是不會被交換出去的,其程式碼如下所示:

	    if (!PageReserved(page)) 
	        SetPageDirty(page); 
	       

但是,由於使用者空間所對映的頁面通常不會被標記為保留,所以上述程式碼中的檢查並不是嚴格要求的。

最終,在直接 I/O 操作完成之後,不管頁面是否被改變,它們都必須從頁快取中釋放,否則那些頁面永遠都會存在在那裡。函式 page_cache_release() 就是用於釋放這些頁的。頁面被釋放之後,呼叫者就不能再次訪問它們。

關於如何在字元裝置驅動程式中加入對直接 I/O 的支援,Linux 2.6.18.1 原始碼中 /drivers/scsi/st.c 給出了一個完整的例子。其中,函式 sgl_map_user_pages()和 sgl_map_user_pages()幾乎涵蓋了本節中介紹的所有內容。

直接 I/O 最主要的優點就是通過減少作業系統核心緩衝區和應用程式地址空間的資料拷貝次數,降低了對檔案讀取和寫入時所帶來的 CPU 的使用以及記憶體頻寬的佔用。這對於某些特殊的應用程式,比如自快取應用程式來說,不失為一種好的選擇。如果要傳輸的資料量很大,使用直接 I/O 的方式進行資料傳輸,而不需要作業系統核心地址空間拷貝資料操作的參與,這將會大大提高效能。

直接 I/O 並不一定總能提供令人滿意的效能上的飛躍。設定直接 I/O 的開銷非常大,而直接 I/O 又不能提供快取 I/O 的優勢。快取 I/O 的讀操作可以從高速緩衝儲存器中獲取資料,而直接 I/O 的讀資料操作會造成磁碟的同步讀,這會帶來效能上的差異 , 並且導致程序需要較長的時間才能執行完;對於寫資料操作來說,使用直接 I/O 需要 write() 系統呼叫同步執行,否則應用程式將會不知道什麼時候才能夠再次使用它的 I/O 緩衝區。與直接 I/O 讀操作類似的是,直接 I/O 寫操作也會導致應用程式關閉緩慢。所以,應用程式使用直接 I/O 進行資料傳輸的時候通常會和使用非同步 I/O 結合使用。

總結

Linux 中的直接 I/O 訪問檔案方式可以減少 CPU 的使用率以及記憶體頻寬的佔用,但是直接 I/O 有時候也會對效能產生負面影響。所以在使用直接 I/O 之前一定要對應用程式有一個很清醒的認識,只有在確定了設定緩衝 I/O 的開銷非常巨大的情況下,才考慮使用直接 I/O。直接 I/O 經常需要跟非同步 I/O 結合起來使用,本文對非同步 I/O 沒有作詳細介紹,有興趣的讀者可以參看 Linux 2.6 中相關的文件介紹。


參考資料

學習

討論

關於作者

黃曉晨,IBM system Z 自動化技術支援軟體工程師。