一文教你從Linux內(nèi)核角度探秘JDK NIO文件讀寫本質(zhì)(上)
1. 前言
在?深入講解Netty那些事兒之從內(nèi)核角度看IO模型一文中曾對 Socket 文件在內(nèi)核中的相關(guān)數(shù)據(jù)結(jié)構(gòu)為大家做了詳盡的闡述。

又在此基礎(chǔ)之上介紹了針對 socket 文件的相關(guān)操作及其對應(yīng)在內(nèi)核中的處理流程:

并與 epoll 的工作機(jī)制進(jìn)行了串聯(lián):

通過這些內(nèi)容的串聯(lián)介紹,我想大家現(xiàn)在一定對 socket 文件非常熟悉了,在我們利用 socket 文件接口在與內(nèi)核進(jìn)行網(wǎng)絡(luò)數(shù)據(jù)讀取,發(fā)送的相關(guān)交互的時(shí)候,不可避免的涉及到一個(gè)新的問題,就是我們?nèi)绾卧谟脩艨臻g設(shè)計(jì)一個(gè)字節(jié)緩沖區(qū)來高效便捷的存儲管理這些需要和 socket 文件進(jìn)行交互的網(wǎng)絡(luò)數(shù)據(jù)。
于是筆者又在?《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同字節(jié)序下的設(shè)計(jì)與實(shí)現(xiàn)》?一文中帶大家從 JDK NIO Buffer 的頂層設(shè)計(jì)開始,詳細(xì)介紹了 NIO Buffer 中的頂層抽象設(shè)計(jì)以及行為定義,隨后我們選取了在網(wǎng)絡(luò)應(yīng)用程序中比較常用的 ByteBuffer 來詳細(xì)介紹了這個(gè)Buffer具體類型的實(shí)現(xiàn),并以 HeapByteBuffer 為例說明了JDK NIO 在不同字節(jié)序下的 ByteBuffer 實(shí)現(xiàn)。

現(xiàn)在我們已經(jīng)熟悉了 socket 文件的相關(guān)操作及其在內(nèi)核中的實(shí)現(xiàn),但筆者覺得這還不夠,還是有必要在為大家介紹一下 JDK NIO 如何利用 ByteBuffer 對普通文件進(jìn)行讀寫的相關(guān)原理及其實(shí)現(xiàn),為大家徹底打通 Linux 文件操作相關(guān)知識的系統(tǒng)脈絡(luò),于是就有了本文的內(nèi)容。
下面就讓我們從一個(gè)普通的 IO 讀寫操作開始聊起吧~~~

2. JDK NIO 讀取普通文件
我們先來看一個(gè)利用 NIO FileChannel 來讀寫普通文件的例子,由這個(gè)簡單的例子開始,慢慢地來一步一步深入本質(zhì)。
JDK NIO ?中的 FileChannel 比較特殊,它只能是阻塞的,不能設(shè)置非阻塞模式。FileChannel的讀寫方法均是線程安全的。
注意:下面的例子并不是最佳實(shí)踐,之所以這里引入 HeapByteBuffer 是為了將上篇文章的內(nèi)容和本文銜接起來。事實(shí)上,對于 IO 的操作一般都會選擇 DirectByteBuffer ,關(guān)于 DirectByteBuffer 的相關(guān)內(nèi)容筆者會在后面的文章中詳細(xì)為大家介紹。
我們首先利用 RandomAccessFile 在內(nèi)核中打開指定的文件 file-read-write.txt 并獲取到它的文件描述符 fd = 5000。

隨后我們在 JVM 堆中開辟一塊 4k 大小的虛擬內(nèi)存 heapByteBuffer,用來讀取文件中的數(shù)據(jù)。

操作系統(tǒng)在管理內(nèi)存的時(shí)候是將內(nèi)存分為一頁一頁來管理的,每頁大小為 4k ,我們在操作內(nèi)存的時(shí)候一定要記得進(jìn)行頁對齊,也就是偏移位置以及讀取的內(nèi)存大小需要按照 4k 進(jìn)行對齊。具體為什么?文章后邊會從內(nèi)核角度詳細(xì)為大家介紹。
最后通過?FileChannel#read
?方法觸發(fā)底層系統(tǒng)調(diào)用 read。進(jìn)行文件讀取。
我們看到在 FileChannel 中會調(diào)用 IOUtil 的 read 方法,NIO 中的所有 IO 操作全部封裝在 IOUtil 類中。
而 NIO 中的 SocketChannel 以及這里介紹的 FileChannel 底層依賴的系統(tǒng)調(diào)用可能不同,這里會通過 NativeDispatcher 對具體 Channel 操作實(shí)現(xiàn)分發(fā),調(diào)用具體的系統(tǒng)調(diào)用。對于 FileChannel 來說 NativeDispatcher 的實(shí)現(xiàn)類為 FileDispatcher。對于 SocketChannel 來說 NativeDispatcher 的實(shí)現(xiàn)類為 SocketDispatcher。
下面我們進(jìn)入 IOUtil 里面來一探究竟~~
我們看到 FileChannel 的 read ?方法最終會調(diào)用到 NativeDispatcher 的 read 方法。前邊我們介紹了這里的 NativeDispatcher 就是 FileDispatcher 在 NIO 中的實(shí)現(xiàn)類為 FileDispatcherImpl,用來觸發(fā) native 方法執(zhí)行底層系統(tǒng)調(diào)用。
最終在 FileDispatcherImpl 類中觸發(fā)了 native 方法 read0 的調(diào)用,我們繼續(xù)到 FileDispatcherImpl.c 文件中去查看 native 方法的實(shí)現(xiàn)。
系統(tǒng)調(diào)用 read(fd, buf, len) 最終是在 native 方法 read0 中被觸發(fā)的。下面是系統(tǒng)調(diào)用 read 在內(nèi)核中的定義。
這樣一來我們就從 JDK NIO 這一層逐步來到了用戶空間與內(nèi)核空間的邊界處 --- OS 系統(tǒng)調(diào)用 read 這里,馬上就要進(jìn)入內(nèi)核了。

下面我們就來看一下當(dāng)系統(tǒng)調(diào)用 read 發(fā)起之后,用戶進(jìn)程在內(nèi)核態(tài)具體做了哪些事情?
【文章福利】小編推薦自己的Linux內(nèi)核技術(shù)交流群:【749907784】整理了一些個(gè)人覺得比較好的學(xué)習(xí)書籍、視頻資料共享在群文件里面,有需要的可以自行添加哦?。。。ê曨l教程、電子書、實(shí)戰(zhàn)項(xiàng)目及代碼)? ?


3. 從內(nèi)核角度探秘文件讀取本質(zhì)
內(nèi)核將文件的 IO 操作根據(jù)是否使用內(nèi)存(頁高速緩存 page cache)做磁盤熱點(diǎn)數(shù)據(jù)的緩存,將文件 IO 分為:Buffered IO 和 Direct IO 兩種類型。
進(jìn)程在通過系統(tǒng)調(diào)用 open() 打開文件的時(shí)候,可以通過將參數(shù) flags 賦值為 O_DIRECT 來指定文件操作為 Direct IO。默認(rèn)情況下為 Buffered IO。
而 Java 在 JDK 10 之前一直是不支持 Direct IO 的,到了 JDK 10 才開始支持 Direct IO。但是在 JDK 10 之前我們可以使用第三方的 Direct IO 框架 Jaydio 來通過 Direct IO 的方式對文件進(jìn)行讀寫操作。
Jaydio GitHub :https://github.com/smacke/jaydio
下面筆者就帶大家從內(nèi)核角度深度剖析下這兩種 IO 類型各自的特點(diǎn):
3.1 Buffered IO
大部分文件系統(tǒng)默認(rèn)的文件 IO 類型為 Buffered IO,當(dāng)進(jìn)程進(jìn)行文件讀取時(shí),內(nèi)核會首先檢查文件對應(yīng)的頁高速緩存 page cache 中是否已經(jīng)緩存了文件數(shù)據(jù),如果有則直接返回,如果沒有才會去磁盤中去讀取文件數(shù)據(jù),而且還會根據(jù)非常精妙的預(yù)讀算法來預(yù)先讀取后續(xù)若干文件數(shù)據(jù)到 page cache 中。這樣等進(jìn)程下一次順序讀取文件時(shí),想要的數(shù)據(jù)已經(jīng)預(yù)讀進(jìn) page ?cache 中了,進(jìn)程直接返回,不用再到磁盤中去龜速讀取了,這樣一來就極大地提高了 IO ?性能。
比如一些著名的消息隊(duì)列中間件 Kafka , RocketMq 對消息日志文件進(jìn)行順序讀取的時(shí)候,訪問速度接近于內(nèi)存。這就是 Buffered IO 中頁高速緩存 page cache 的功勞。在本文的后面,筆者會為大家詳細(xì)的介紹這一部分內(nèi)容。

?HeapByteBuffer 來接收 NIO 讀取文件數(shù)據(jù)的時(shí)候,整個(gè)文件讀取的過程分為如下幾個(gè)步驟:
NIO 首先會將創(chuàng)建一個(gè)臨時(shí)的 DirectByteBuffer 用于臨時(shí)接收文件數(shù)據(jù)。
具體為什么會創(chuàng)建一個(gè)臨時(shí)的 DirectByteBuffer 來接收數(shù)據(jù)以及關(guān)于 DirectByteBuffer 的原理筆者會在后面的文章中為大家詳細(xì)介紹。這里大家可以把它簡單看成在 OS 堆中的一塊虛擬內(nèi)存地址。
隨后 NIO 會在用戶態(tài)調(diào)用系統(tǒng)調(diào)用 read 向內(nèi)核發(fā)起文件讀取的請求。此時(shí)發(fā)生第一次上下文切換。
用戶進(jìn)程隨即轉(zhuǎn)到內(nèi)核態(tài)運(yùn)行,進(jìn)入虛擬文件系統(tǒng)層,在這一層內(nèi)核首先會查看讀取文件對應(yīng)的頁高速緩存 page cache 中是否含有請求的文件數(shù)據(jù),如果有直接返回,避免一次磁盤 IO。并根據(jù)內(nèi)核預(yù)讀算法從磁盤中異步預(yù)讀若干文件數(shù)據(jù)到 page cache 中(文件順序讀取高性能的關(guān)鍵所在)。
在內(nèi)核中,一個(gè)文件對應(yīng)一個(gè) page cache 結(jié)構(gòu),注意:這個(gè) page cache 在內(nèi)存中只會有一份。
如果進(jìn)程請求數(shù)據(jù)不在 page cache 中,則會進(jìn)入文件系統(tǒng)層,在這一層調(diào)用塊設(shè)備驅(qū)動程序觸發(fā)真正的磁盤 IO。并根據(jù)內(nèi)核預(yù)讀算法同步預(yù)讀若干文件數(shù)據(jù)。請求的文件數(shù)據(jù)和預(yù)讀的文件數(shù)據(jù)將被一起填充到 page cache 中。
在塊設(shè)備驅(qū)動層完成真正的磁盤 IO。在這一層會從磁盤中讀取進(jìn)程請求的文件數(shù)據(jù)以及內(nèi)核預(yù)讀的文件數(shù)據(jù)。
磁盤控制器 DMA 將從磁盤中讀取的數(shù)據(jù)拷貝到頁高速緩存 page cache 中。發(fā)生第一次數(shù)據(jù)拷貝。
隨后 CPU 將 page cache 中的數(shù)據(jù)拷貝到 NIO 在用戶空間臨時(shí)創(chuàng)建的緩沖區(qū) DirectByteBuffer 中,發(fā)生第二次數(shù)據(jù)拷貝。
最后系統(tǒng)調(diào)用 read 返回。進(jìn)程從內(nèi)核態(tài)切換回用戶態(tài)。發(fā)生第二次上下文切換。
NIO 將 DirectByteBuffer 中臨時(shí)存放的文件數(shù)據(jù)拷貝到 JVM 堆中的 HeapBytebuffer 中。發(fā)生第三次數(shù)據(jù)拷貝。
我們看到如果使用 HeapByteBuffer 進(jìn)行 NIO 文件讀取的整個(gè)過程中,一共發(fā)生了?兩次上下文切換和三次數(shù)據(jù)拷貝,如果請求的數(shù)據(jù)命中 page cache 則發(fā)生兩次數(shù)據(jù)拷貝省去了一次磁盤的 DMA 拷貝。
3.2 Direct IO
在上一小節(jié)中,筆者介紹了 Buffered IO 的諸多好處,尤其是在進(jìn)程對文件進(jìn)行順序讀取的時(shí)候,訪問性能接近于內(nèi)存。
但是有些情況,我們并不需要 page cache。比如一些高性能的數(shù)據(jù)庫應(yīng)用程序,它們在用戶空間自己實(shí)現(xiàn)了一套高效的高速緩存機(jī)制,以充分挖掘?qū)?shù)據(jù)庫獨(dú)特的查詢訪問性能。所以這些數(shù)據(jù)庫應(yīng)用程序并不希望內(nèi)核中的 page cache起作用。否則內(nèi)核會同時(shí)處理 page cache 以及預(yù)讀相關(guān)操作的指令,會使得性能降低。
另外還有一種情況是,當(dāng)我們在隨機(jī)讀取文件的時(shí)候,也不希望內(nèi)核使用 page cache。因?yàn)檫@樣違反了程序局部性原理,當(dāng)我們隨機(jī)讀取文件的時(shí)候,內(nèi)核預(yù)讀進(jìn) page cache 中的數(shù)據(jù)將很久不會再次得到訪問,白白浪費(fèi) page cache 空間不說,還額外增加了預(yù)讀的磁盤 IO。
基于以上兩點(diǎn)原因,我們很自然的希望內(nèi)核能夠提供一種機(jī)制可以繞過 page cache 直接對磁盤進(jìn)行讀寫操作。這種機(jī)制就是本小節(jié)要為大家介紹的 Direct IO。
下面是內(nèi)核采用 Direct IO 讀取文件的工作流程:

Direct IO 和 Buffered IO 在進(jìn)入內(nèi)核虛擬文件系統(tǒng)層之前的流程全部都是一樣的。區(qū)別就是進(jìn)入到虛擬文件系統(tǒng)層之后,Direct IO 會繞過 page cache 直接來到文件系統(tǒng)層通過 direct_io 調(diào)用來到塊驅(qū)動設(shè)備層,在塊設(shè)備驅(qū)動層調(diào)用 __blockdev_direct_IO 對磁盤內(nèi)容直接進(jìn)行讀寫。
和 Buffered IO 一樣,在系統(tǒng)調(diào)用 read 進(jìn)入內(nèi)核以及 Direct IO 完成從內(nèi)核返回的時(shí)候各自會發(fā)生一次上下文切換。共兩次上下文切換
磁盤控制器 DMA 從磁盤中讀取數(shù)據(jù)后直接拷貝到用戶空間緩沖區(qū) DirectByteBuffer 中。只發(fā)生一次 DMA 拷貝
隨后 NIO 將 DirectByteBuffer 中臨時(shí)存放的數(shù)據(jù)拷貝到 JVM 堆 HeapByteBuffer 中。發(fā)生第二次數(shù)據(jù)拷貝。
注意塊設(shè)備驅(qū)動層的 __blockdev_direct_IO 需要等到所有的 Direct IO 傳送數(shù)據(jù)完成之后才會返回,這里的傳送指的是直接從磁盤拷貝到用戶空間緩沖區(qū)中,當(dāng) Direct IO 模式下的 read() 或者 write() 系統(tǒng)調(diào)用返回之后,進(jìn)程就可以安全放心地去讀取用戶緩沖區(qū)中的數(shù)據(jù)了。
從整個(gè) Direct IO 的過程中我們看到,一共發(fā)生了兩次上下文的切換,兩次的數(shù)據(jù)拷貝。
4. Talk is cheap ! show you the code
下面是系統(tǒng)調(diào)用 read 在內(nèi)核中的完整定義:
首先會根據(jù)文件描述符 fd 通過 fdget_pos 方法獲取 struct fd 結(jié)構(gòu),進(jìn)而可以獲取到文件的 struct file 結(jié)構(gòu)。
file_pos_read 獲取當(dāng)前文件的讀取位置 offset,并通過 vfs_read 進(jìn)入虛擬文件系統(tǒng)層。
這里我們看到內(nèi)核對文件的操作全部定義在 struct file 結(jié)構(gòu)中的 f_op 字段中。
對于 Java 程序員來說,file_operations 大家可以把它當(dāng)做內(nèi)核針對文件相關(guān)操作定義的一個(gè)公共接口(其實(shí)就是一個(gè)函數(shù)指針),它只是一個(gè)接口。具體的實(shí)現(xiàn)根據(jù)不同的文件類型有所不同。
比如我們在深入講解Netty那些事兒之從內(nèi)核角度看IO模型一文中詳細(xì)介紹過的 Socket 文件。針對 Socket 文件類型,這里的 file_operations 指向的是 socket_file_ops。

而本小節(jié)中我們討論的是對普通文件的操作,針對普通文件的操作定義在具體的文件系統(tǒng)中,這里我們以 Linux 中最為常見的 ext4 文件系統(tǒng)為例說明:
在 ext4 文件系統(tǒng)中管理的文件對應(yīng)的 file_operations 指向 ext4_file_operations,專門用于操作 ext4 文件系統(tǒng)中的文件。

從圖中我們可以看到 ext4 文件系統(tǒng)定義的相關(guān)文件操作 ext4_file_operations 并未定義 .read 函數(shù)指針。而是定義了 .read_iter 函數(shù)指針,指向 ext4_file_read_iter 函數(shù)。
所以在虛擬文件系統(tǒng) VFS 中,__vfs_read 調(diào)用的是 ?new_sync_read 方法,在該方法中會對系統(tǒng)調(diào)用傳進(jìn)來的參數(shù)進(jìn)行重新封裝。比如:
struct file *filp :要讀取文件的 struct file 結(jié)構(gòu)。
char __user *buf :用戶空間的 Buffer,這里指的我們例子中 NIO 創(chuàng)建的臨時(shí) ?DirectByteBuffer。
size_t count :進(jìn)行讀取的字節(jié)數(shù)。也就是我們傳入的用戶態(tài)緩沖區(qū) DirectByteBuffer 剩余可容納的容量大小。
loff_t *pos :文件當(dāng)前讀取位置偏移 offset。
將這些參數(shù)重新封裝到 struct iovec 和 struct kiocb 結(jié)構(gòu)體中。
struct iovec 結(jié)構(gòu)體主要用來封裝用來接收文件數(shù)據(jù)用的用戶緩存區(qū)相關(guān)的信息:
但是內(nèi)核中一般會使用 struct iov_iter 結(jié)構(gòu)體對 struct iovec 進(jìn)行包裝,iov_iter 中可以包含多個(gè) iovec。這一點(diǎn)從 struct iov_iter 結(jié)構(gòu)體的命名關(guān)鍵字?iter
?上可以看得出來。
之所以使用 struct iov_iter 結(jié)構(gòu)體來包裝 struct iovec 是為了兼容 readv() 系統(tǒng)調(diào)用,它允許用戶使用多個(gè)用戶緩存區(qū)去讀取文件中的數(shù)據(jù)。JDK NIO Channel 支持的 scatter 操作底層原理就是 readv 系統(tǒng)調(diào)用。
struct kiocb 結(jié)構(gòu)體則是用來封裝文件 IO 相關(guān)操作的狀態(tài)和進(jìn)度信息:
當(dāng) struct iovec ?和 struct kiocb 在 new_sync_read 方法中被初始化好之后,最終通過 file_operations 中定義的函數(shù)指針 ?.read_iter 調(diào)用到 ext4_file_read_iter 方法中,從而進(jìn)入 ext4 文件系統(tǒng)執(zhí)行具體的讀取操作。
generic_file_read_iter 會根據(jù) struct kiocb 中的 ki_flags 屬性判斷文件 IO 操作是 Direct IO 還是 Buffered IO。
4.1 Direct IO

我們可以通過 open 系統(tǒng)調(diào)用在打開文件的時(shí)候指定相關(guān) IO 操作的模式是 Direct IO 還是 Buffered IO:
char *pathname :指定要文件的路徑。
int flags :指定文件的訪問模式。比如:O_RDONLY(只讀),O_WRONLY,(只寫), O_RDWR(讀寫),O_DIRECT(Direct IO)。默認(rèn)為 Buffered IO。
mode_t mode :可選,指定打開文件的權(quán)限
而 Java 在 JDK 10 之前一直是不支持 Direct IO,到了 JDK 10 才開始支持 Direct IO。
如果在文件打開的時(shí)候,我們設(shè)置了 Direct IO 模式,那么以后在對文件進(jìn)行讀取的過程中,內(nèi)核將會繞過 page cache,直接從磁盤中讀取數(shù)據(jù)到用戶空間緩沖區(qū) DirectByteBuffer 中。這樣就可以避免一次數(shù)據(jù)從內(nèi)核 page cache 到用戶空間緩沖區(qū)的拷貝。
當(dāng)應(yīng)用程序期望使用自定義的緩存算法從而可以在用戶空間實(shí)現(xiàn)更加高效更加可控的緩存邏輯時(shí)(比如數(shù)據(jù)庫等應(yīng)用程序),這時(shí)應(yīng)該使用直接 Direct IO。在隨機(jī)讀取,隨機(jī)寫入的場景中也是比較適合用 Direct IO。
操作系統(tǒng)進(jìn)程在接下來使用 read() 或者 write() 系統(tǒng)調(diào)用去讀寫文件的時(shí)候使用的是 Direct IO 方式,所傳輸?shù)臄?shù)據(jù)均不經(jīng)過文件對應(yīng)的高速緩存 page cache (這里就是網(wǎng)上常說的內(nèi)核緩沖區(qū))。
我們都知道操作系統(tǒng)是將內(nèi)存分為一頁一頁的單位進(jìn)行組織管理的,每頁大小 4K ,那么同樣文件中的數(shù)據(jù)在磁盤中的組織形式也是按照一塊一塊的單位來組織管理的,每塊大小也是 4K ,所以我們在使用 Direct IO 讀寫數(shù)據(jù)時(shí)必須要按照文件在磁盤中的組織單位進(jìn)行磁盤塊大小對齊,緩沖區(qū)的大小也必須是磁盤塊大小的整數(shù)倍。具體表現(xiàn)在如下幾點(diǎn):
文件的讀寫位置偏移需要按照磁盤塊大小對齊。
用戶緩沖區(qū) DirectByteBuffer 起始地址需要按照磁盤塊大小對齊。
使用 Direct IO 進(jìn)行數(shù)據(jù)讀寫時(shí),讀寫的數(shù)據(jù)大小需要按照磁盤塊大小進(jìn)行對齊。這里指 DirectByteBuffer 中剩余數(shù)據(jù)的大小。
當(dāng)我們采用 Direct IO 直接讀取磁盤中的文件數(shù)據(jù)時(shí),內(nèi)核會從 struct file 結(jié)構(gòu)中獲取到該文件在內(nèi)存中的 page cache。而我們多次提到的這個(gè) page cache 在內(nèi)核中的數(shù)據(jù)結(jié)構(gòu)就是 struct address_space 。我們可以根據(jù) file->f_mapping 獲取。
和前面我們介紹的 struct file 結(jié)構(gòu)中的 file_operations 一樣,內(nèi)核中將 page cache 相關(guān)的操作全部定義在 struct address_space_operations 結(jié)構(gòu)中。這里和前邊介紹的 file_operations 的作用是一樣的,只是內(nèi)核針對 page cache 操作定義的一個(gè)公共接口。
具體的實(shí)現(xiàn)會根據(jù)文件系統(tǒng)的不同而不同,這里我們還是以 ext4 文件系統(tǒng)為例:
內(nèi)核通過 struct address_space_operations 結(jié)構(gòu)中定義的 .direct_IO 函數(shù)指針,具體函數(shù)為 ext4_direct_IO 來繞過 page cache 直接對磁盤進(jìn)行讀寫。
采用 Direct IO 的方式對文件的讀寫操作全部是在 ?ext4_direct_IO 這一個(gè)函數(shù)中完成的。
由于磁盤文件中的數(shù)據(jù)是按照塊為單位來組織管理的,所以文件系統(tǒng)其實(shí)就是一個(gè)塊設(shè)備,通過 ext4_direct_IO 繞過 page cache 直接來到了文件系統(tǒng)的塊設(shè)備驅(qū)動層,最終在塊設(shè)備驅(qū)動層調(diào)用 __blockdev_direct_IO 來完成磁盤的讀寫操作。
注意:塊設(shè)備驅(qū)動層的 __blockdev_direct_IO 需要等到所有的 Direct IO 傳送數(shù)據(jù)完成之后才會返回,這里的傳送指的是直接從磁盤拷貝到用戶空間緩沖區(qū)中,當(dāng) Direct IO 模式下的 read() 或者 write() 系統(tǒng)調(diào)用返回之后,進(jìn)程就可以安全放心地去讀取用戶緩沖區(qū)中的數(shù)據(jù)了。
4.2 Buffered IO

Buffered IO 相關(guān)的讀取操作封裝在 generic_file_buffered_read 函數(shù)中,其核心邏輯如下:
由于文件在磁盤中是以塊為單位組織管理的,每塊大小為 4k,內(nèi)存是按照頁為單位組織管理的,每頁大小也是 4k。文件中的塊數(shù)據(jù)被緩存在 page cache 中的緩存頁中。所以首先通過 find_get_page 方法查找我們要讀取的文件數(shù)據(jù)是否已經(jīng)緩存在了 page cache 中。
如果 page cache 中不存在文件數(shù)據(jù)的緩存頁,就需要通過 page_cache_sync_readahead 方法從磁盤中讀取數(shù)據(jù)并緩存到 page cache 中。于此同時(shí)還需要同步預(yù)讀若干相鄰的數(shù)據(jù)塊到 page cache 中。這樣在下一次順序讀取的時(shí)候,直接就可以從 page cache 中讀取了。
如果此次讀取的文件數(shù)據(jù)已經(jīng)存在于 page cache 中了,就需要調(diào)用 PageReadahead 來判斷是否需要進(jìn)一步預(yù)讀數(shù)據(jù)到緩存頁中。如果是,則從磁盤中異步預(yù)讀若干頁到 page cache 中。具體預(yù)讀多少頁是根據(jù)內(nèi)核相關(guān)預(yù)讀算法來動態(tài)調(diào)整的。
經(jīng)過上面幾個(gè)流程,此時(shí)文件數(shù)據(jù)已經(jīng)存在于 page cache 中的緩存頁中了,最后內(nèi)核調(diào)用 copy_page_to_iter 方法將 page cache 中的數(shù)據(jù)拷貝到用戶空間緩沖區(qū) DirectByteBuffer 中。

到這里關(guān)于文件讀取的兩種模式 Buffered IO 和 Direct IO 在內(nèi)核中的主干邏輯流程筆者就為大家介紹完了。
但是大家可能會對 Buffered IO 中的兩個(gè)細(xì)節(jié)比較感興趣:
如何在 page cache 中查找我們要讀取的文件數(shù)據(jù) ?也就是說上面提到的 find_get_page 函數(shù)是如何實(shí)現(xiàn)的?
文件預(yù)讀的過程是怎么樣的?內(nèi)核中的預(yù)讀算法又是什么樣的呢?
在為大家解答這兩個(gè)疑問之前,筆者先為大家介紹一下內(nèi)核中的頁高速緩存 page cache。
5. 頁高速緩存 page cache
CPU 的高速緩存時(shí)曾提到過,根據(jù)摩爾定律:芯片中的晶體管數(shù)量每隔 18 個(gè)月就會翻一番。導(dǎo)致 CPU 的性能和處理速度變得越來越快,而提升 CPU 的運(yùn)行速度比提升內(nèi)存的運(yùn)行速度要容易和便宜的多,所以就導(dǎo)致了 CPU 與內(nèi)存之間的速度差距越來越大。
CPU 與內(nèi)存之間的速度差異到底有多大呢?我們知道寄存器是離 CPU 最近的,CPU 在訪問寄存器的時(shí)候速度近乎于 0 個(gè)時(shí)鐘周期,訪問速度最快,基本沒有時(shí)延。而訪問內(nèi)存則需要 50 - 200 個(gè)時(shí)鐘周期。
所以為了彌補(bǔ) CPU 與內(nèi)存之間巨大的速度差異,提高 CPU 的處理效率和吞吐,于是我們引入了 L1 , L2 , L3 高速緩存集成到 CPU 中。CPU 訪問高速緩存僅需要用到 1 - 30 個(gè)時(shí)鐘周期,CPU 中的高速緩存是對內(nèi)存熱點(diǎn)數(shù)據(jù)的一個(gè)緩存。

而本文我們討論的主題是內(nèi)存與磁盤之間的關(guān)系,CPU 訪問磁盤的速度就更慢了,需要用到大概約幾千萬個(gè)時(shí)鐘周期.
我們可以看到 CPU 訪問高速緩存的速度比訪問內(nèi)存的速度快大約10倍,而訪問內(nèi)存的速度要比訪問磁盤的速度快大約 100000 倍。
引入 CPU 高速緩存的目的在于消除 CPU 與內(nèi)存之間的速度差距,CPU 用高速緩存來存放內(nèi)存中的熱點(diǎn)數(shù)據(jù)。那么同樣的道理,本小節(jié)中我們引入的頁高速緩存 page cache 的目的是為了消除內(nèi)存與磁盤之間的巨大速度差距,page cache 中緩存的是磁盤文件的熱點(diǎn)數(shù)據(jù)。
另外我們根據(jù)程序的時(shí)間局部性原理可以知道,磁盤文件中的數(shù)據(jù)一旦被訪問,那么它很有可能在短期被再次訪問,如果我們訪問的磁盤文件數(shù)據(jù)緩存在 page cache 中,那么當(dāng)進(jìn)程再次訪問的時(shí)候數(shù)據(jù)就會在 page cache 中命中,這樣我們就可以把對磁盤的訪問變?yōu)閷ξ锢韮?nèi)存的訪問,極大提升了對磁盤的訪問性能。
程序局部性原理表現(xiàn)為:時(shí)間局部性和空間局部性。時(shí)間局部性是指如果程序中的某條指令一旦執(zhí)行,則不久之后該指令可能再次被執(zhí)行;如果某塊數(shù)據(jù)被訪問,則不久之后該數(shù)據(jù)可能再次被訪問??臻g局部性是指一旦程序訪問了某個(gè)存儲單元,則不久之后,其附近的存儲單元也將被訪問。
在前邊的內(nèi)容中我們多次提到操作系統(tǒng)是將物理內(nèi)存分為一個(gè)一個(gè)的頁面來組織管理的,每頁大小為 4k ,而磁盤中的文件數(shù)據(jù)在磁盤中是分為一個(gè)一個(gè)的塊來組織管理的,每塊大小也為 4k。
page cache 中緩存的就是這些內(nèi)存頁面,頁面中的數(shù)據(jù)對應(yīng)于磁盤上物理塊中的數(shù)據(jù)。page cache 中緩存的大小是可以動態(tài)調(diào)整的,它可以通過占用空閑內(nèi)存來擴(kuò)大緩存頁面的容量,當(dāng)內(nèi)存不足時(shí)也可以通過回收頁面來緩解內(nèi)存使用的壓力。
正如我們上小節(jié)介紹的 read 系統(tǒng)調(diào)用在內(nèi)核中的實(shí)現(xiàn)邏輯那樣,當(dāng)用戶進(jìn)程發(fā)起 read 系統(tǒng)調(diào)用之后,內(nèi)核首先會在 page cache 中檢查請求數(shù)據(jù)所在頁面是否已經(jīng)緩存在 page cache 中。
如果緩存命中,內(nèi)核直接會把 page cache 中緩存的磁盤文件數(shù)據(jù)拷貝到用戶空間緩沖區(qū) DirectByteBuffer 中,從而避免了龜速的磁盤 IO。
如果緩存沒有命中,內(nèi)核會分配一個(gè)物理頁面,將這個(gè)新分配的頁面插入 page cache 中,然后調(diào)度磁盤塊 IO 驅(qū)動從磁盤中讀取數(shù)據(jù),最后用從磁盤中讀取的數(shù)據(jù)填充這個(gè)物里頁面。
根據(jù)前面介紹的程序時(shí)間局部性原理,當(dāng)進(jìn)程在不久之后再來讀取數(shù)據(jù)的時(shí)候,請求的數(shù)據(jù)已經(jīng)在 page cache 中了。極大地提升了文件 IO 的性能。
page cache 中緩存的不僅有基于文件的緩存頁,還會緩存內(nèi)存映射文件,以及磁盤塊設(shè)備文件。這里大家只需要有這個(gè)概念就行,本文我們主要聚焦于基于文件的緩存頁。在筆者后面的文章中,我們還會再次介紹到這些剩余類型的緩存頁。
在我們了解了 page cache 引入的目的以及 page cache 在磁盤 IO 中所發(fā)揮的作用之后,大家一定會很好奇這個(gè) page cache 在內(nèi)核中到底是怎么實(shí)現(xiàn)的呢?
讓我們先從 page cache 在內(nèi)核中的數(shù)據(jù)結(jié)構(gòu)開始聊起~~~~
6. page cache 在內(nèi)核中的數(shù)據(jù)結(jié)構(gòu)
page cache 在內(nèi)核中的數(shù)據(jù)結(jié)構(gòu)是一個(gè)叫做 address_space 的結(jié)構(gòu)體:struct address_space。
這個(gè)名字起的真是有點(diǎn)詞不達(dá)意,從命名上根本無法看出它是表示 page cache 的,所以大家在日常開發(fā)中一定要注意命名的精準(zhǔn)規(guī)范。
每個(gè)文件都會有自己的 page cache。struct address_space 結(jié)構(gòu)在內(nèi)存中只會保留一份。
什么意思呢?比如我們可以通過多個(gè)不同的進(jìn)程打開一個(gè)相同的文件,進(jìn)程每打開一個(gè)文件,內(nèi)核就會為它創(chuàng)建 struct file 結(jié)構(gòu)。這樣在內(nèi)核中就會有多個(gè) struct file 結(jié)構(gòu)來表示同一個(gè)文件,但是同一個(gè)文件的 page cache 也就是 struct address_space 在內(nèi)核中只會有一個(gè)。

struct inode *host
?:一個(gè)文件對應(yīng)一個(gè) page cache 結(jié)構(gòu) struct address_space ,文件的 inode 描述了一個(gè)文件的所有元信息。在 struct address_space 中通過 host 指針與文件的 inode 關(guān)聯(lián)。而在 inode 結(jié)構(gòu)體 struct inode 中又通過 i_mapping 指針與文件的 page cache 進(jìn)行關(guān)聯(lián)。
struct radix_tree_root page_tree
?: ?page cache 中緩存的所有文件頁全部存儲在 radix_tree 這樣一個(gè)高效搜索樹結(jié)構(gòu)當(dāng)中。在文件 IO 相關(guān)的操作中,內(nèi)核需要頻繁大量地在 page cache 中搜索請求頁是否已經(jīng)緩存在頁高速緩存中,所以針對 page cache 的搜索操作必須是高效的,否則引入 page cache 所帶來的性能提升將會被低效的搜索開銷所抵消掉。unsigned long nrpages
?:記錄了當(dāng)前文件對應(yīng)的 page cache 緩存頁面的總數(shù)。const struct address_space_operations *a_ops
?:a_ops 定義了 page cache 中所有針對緩存頁的 IO 操作,提供了管理 page cache 的各種行為。比如:常用的頁面讀取操作 readPage() 以及頁面寫入操作 writePage() 等。保證了所有針對緩存頁的 IO 操作必須是通過 page cache 進(jìn)行的。
前邊我們提到 page cache 中緩存的不僅僅是基于文件的頁,它還會緩存內(nèi)存映射頁,以及磁盤塊設(shè)備文件,況且基于文件的內(nèi)存頁背后也有不同的文件系統(tǒng)。所以內(nèi)核只是通過 a_ops 定義了操作 page cache 緩存頁 IO 的通用行為定義。而具體的實(shí)現(xiàn)需要各個(gè)具體的文件系統(tǒng)通過自己定義的 address_space_operations 來描述自己如何與 page cache 進(jìn)行交互。比如前邊我們介紹的 ext4 文件系統(tǒng)就有自己的 address_space_operations 定義。
在我們從整體上了解了 page cache 在內(nèi)核中的數(shù)據(jù)結(jié)構(gòu) struct address_space ?之后,我們接下來看一下 radix_tree 這個(gè)數(shù)據(jù)結(jié)構(gòu)是如何支持內(nèi)核來高效搜索文件頁的,以及 page cache 中這些被緩存的文件頁是如何組織管理的。
7. 基樹 radix_tree
正如前邊我們提到的,在文件 IO 相關(guān)的操作中,內(nèi)核會頻繁大量地在 page cache 中查找請求頁是否在頁高速緩存中。還有就是當(dāng)我們訪問大文件時(shí)(linux 能支持大到幾個(gè) TB 的文件),page cache 中將會充斥著大量的文件頁。
基于上面提到的兩個(gè)原因:一個(gè)是內(nèi)核對 page cache 的頻繁搜索操作,另一個(gè)是 page cache 中會緩存大量的文件頁。所以內(nèi)核需要采用一個(gè)高效的搜索數(shù)據(jù)結(jié)構(gòu)來組織管理 page cache 中的緩存頁。
本小節(jié)我們就來介紹下,page cache 中用來存儲緩存頁的數(shù)據(jù)結(jié)構(gòu) radix_tree。
在 linux 內(nèi)核 5.0 版本中 radix_tree 已被替換成 xarray 結(jié)構(gòu)。感興趣的同學(xué)可以自行了解下。
在 page cache 結(jié)構(gòu) struct address_space 中有一個(gè)類型為 ?struct radix_tree_root ?的字段 page_tree,它表示的是 radix_tree 的根節(jié)點(diǎn)。
radix_tree 中的節(jié)點(diǎn)類型為 struct radix_tree_node。

void __rcu *slots[RADIX_TREE_MAP_SIZE]
?:radix_tree 樹中的每個(gè)節(jié)點(diǎn)中包含一個(gè) slots ,它是一個(gè)包含 64 個(gè)指針的數(shù)組,每個(gè)指針指向它的下一層節(jié)點(diǎn)或者緩存頁描述符 struct page。
radix_tree 將緩存頁全部存放在它的葉子結(jié)點(diǎn)中,所以它的葉子結(jié)點(diǎn)類型為 struct page。其余的節(jié)點(diǎn)類型為 radix_tree_node。最底層的 radix_tree_node 節(jié)點(diǎn)中的 slots 指向緩存頁描述符 struct page。
unsigned char offset
?用于表示父節(jié)點(diǎn)的 slots 數(shù)組中指向當(dāng)前節(jié)點(diǎn)的指針,在父節(jié)點(diǎn)的slots數(shù)組中的索引。
unsigned char count
?用于記錄當(dāng)前 radix_tree_node 的 slots 數(shù)組中指向的節(jié)點(diǎn)個(gè)數(shù),因?yàn)?slots 數(shù)組中的指針有可能指向 null 。
這里大家可能已經(jīng)注意到了在 struct radix_tree_node ?結(jié)構(gòu)中還有一個(gè) long 型的 tags 二維數(shù)組?tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS]
。那么這個(gè)二維數(shù)組到底是用來干嘛的呢?我們接著往下看~~
7.1 radix_tree 的標(biāo)記
經(jīng)過前面的介紹我們知道,頁高速緩存 page cache 的引入是為了在內(nèi)存中緩存磁盤的熱點(diǎn)數(shù)據(jù)盡可能避免龜速的磁盤 IO。
而在進(jìn)行文件 IO 的時(shí)候,內(nèi)核會頻繁大量的在 page cache 中搜索請求數(shù)據(jù)是否已經(jīng)緩存在 page cache 中,如果是,內(nèi)核就直接將 page cache 中的數(shù)據(jù)拷貝到用戶緩沖區(qū)中。從而避免了一次磁盤 IO。
這就要求內(nèi)核需要采用一種支持高效搜索的數(shù)據(jù)結(jié)構(gòu)來組織管理這些緩存頁,所以引入了基樹 radix_tree。
到目前為止,我們還沒有涉及到緩存頁的狀態(tài),不過在文章的后面我們很快就會涉及到,這里提前給大家引出來,讓大家腦海里先有個(gè)概念。
那么什么是緩存頁的狀態(tài)呢?
我們知道在 Buffered IO ?模式下,對于文件 IO 的操作都是需要經(jīng)過 page cache 的,后面我們即將要介紹的 write 系統(tǒng)調(diào)用就會將數(shù)據(jù)直接寫到 page cache 中,并將該緩存頁標(biāo)記為臟頁(PG_dirty)直接返回,隨后內(nèi)核會根據(jù)一定的規(guī)則來將這些臟頁回寫到磁盤中,在會寫的過程中這些臟頁又會被標(biāo)記為 PG_writeback,表示該頁正在被回寫到磁盤。
PG_dirty 和 PG_writeback 就是緩存頁的狀態(tài),而內(nèi)核不僅僅是需要在 page cache 中高效搜索請求數(shù)據(jù)所在的緩存頁,還需要高效搜索給定狀態(tài)的緩存頁。
比如:快速查找 page cache 中的所有臟頁。但是如果此時(shí) page cache 中的大部分緩存頁都不是臟頁,那么順序遍歷 radix_tree 的方式就實(shí)在是太慢了,所以為了快速搜索到臟頁,就需要在 radix_tree 中的每個(gè)節(jié)點(diǎn) radix_tree_node 中加入一個(gè)針對其所有子節(jié)點(diǎn)的臟頁標(biāo)記,如果其中一個(gè)子節(jié)點(diǎn)被標(biāo)記被臟時(shí),那么這個(gè)子節(jié)點(diǎn)對應(yīng)的父節(jié)點(diǎn) radix_tree_node 結(jié)構(gòu)中的對應(yīng)臟頁標(biāo)記位就會被置 1 。
而用來存儲臟頁標(biāo)記的正是上小節(jié)中提到的 tags 二維數(shù)組。其中第一維 tags[] 用來表示標(biāo)記類型,有多少標(biāo)記類型,數(shù)組大小就為多少,比如 tags[0] 表示 PG_dirty 標(biāo)記數(shù)組,tags[1] 表示 PG_writeback 標(biāo)記數(shù)組。

第二維 tags[][] 數(shù)組則表示對應(yīng)標(biāo)記類型針對每一個(gè)子節(jié)點(diǎn)的標(biāo)記位,因?yàn)橐粋€(gè) radix_tree_node 節(jié)點(diǎn)中包含 64 個(gè)指針指向?qū)?yīng)的子節(jié)點(diǎn),所以二維 tags[][] 數(shù)組的大小也為 64 ,數(shù)組中的每一位表示對應(yīng)子節(jié)點(diǎn)的標(biāo)記。tags[0][0] 指向 PG_dirty 標(biāo)記數(shù)組,tags[1][0] 指向PG_writeback 標(biāo)記數(shù)組。
而緩存頁( radix_tree 中的葉子結(jié)點(diǎn))這些標(biāo)記是存放在其對應(yīng)的頁描述符 struct page 里的 flag 中。

只要一個(gè)緩存頁(葉子結(jié)點(diǎn))被標(biāo)記,那么從這個(gè)葉子結(jié)點(diǎn)一直到 radix_tree 根節(jié)點(diǎn)的路徑將會全部被標(biāo)記。這就好比你在一盆清水中滴入一滴墨水,不久之后整盆水就會變?yōu)楹谏?/p>
這樣內(nèi)核在 radix_tree 中搜索被標(biāo)記的臟頁(PG_dirty)或者正在回寫的頁(PG_writeback)時(shí),就可以迅速跳過哪些標(biāo)記為 0 的中間節(jié)點(diǎn)的所有子樹,中間節(jié)點(diǎn)對應(yīng)的標(biāo)記為 0 說明其所有的子樹中包含的緩存頁(葉子結(jié)點(diǎn))都是干凈的(未標(biāo)記)。從而達(dá)到在 radix_tree 中迅速搜索指定狀態(tài)的緩存頁的目的。
8. page cache 中查找緩存頁
在我們明白了 radix_tree 這個(gè)數(shù)據(jù)結(jié)構(gòu)之后,接下來我們來看一下在《4.2 Buffered IO》小節(jié)中遺留的問題:內(nèi)核如何通過 find_get_page 在 page cache 中高效查找緩存頁?
在介紹 find_get_page 之前,筆者先來帶大家看看 radix_tree 具體是如何組織和管理其中的緩存頁 page 的。

經(jīng)過上小節(jié)相關(guān)內(nèi)容的介紹,我們了解到在 radix_tree 中每個(gè)節(jié)點(diǎn) radix_tree_node 包含一個(gè)大小為 64 的指針數(shù)組 slots 用于指向它的子節(jié)點(diǎn)或者緩存頁描述符(葉子節(jié)點(diǎn))。
一個(gè) radix_tree_node 節(jié)點(diǎn)下邊最多可容納 64 個(gè)子節(jié)點(diǎn),如果 radix_tree 的深度為 1 (不包括葉子節(jié)點(diǎn)),那么這顆 radix_tree 就可以緩存 64 個(gè)文件頁。而每頁大小為 4k,所以一顆深度為 1 的 radix_tree 可以緩存 256k 的文件內(nèi)容。

而如果一顆 radix_tree 的深度為 2,那么它就可以緩存 64 * 64 = 4096 個(gè)文件頁,總共可以緩存 16M 的文件內(nèi)容。

依次類推我們可以得到不同的 radix_tree 深度可以緩存多大的文件內(nèi)容:

通過以上內(nèi)容的介紹,我們看到在 radix_tree 是根據(jù)緩存頁的 index (索引)來組織管理緩存頁的,內(nèi)核會根據(jù)這個(gè) index 迅速找到對應(yīng)的緩存頁。在緩存頁描述符 struct page 結(jié)構(gòu)中保存了其在 page cache 中的索引 index。
事實(shí)上 find_get_page 函數(shù)也是根據(jù)緩存頁描述符中的這個(gè) index 來在 page cache 中高效查找對應(yīng)的緩存頁。
struct address_space *mapping
?: 為讀取文件對應(yīng)的 page cache 頁高速緩存。pgoff_t offset
?:為所請求的緩存頁在 page cache 中的索引 index,類型為 long 型。
那么在內(nèi)核是如何利用這個(gè) long 型的 offset 在 page cache 中高效搜索指定的緩存頁呢?
經(jīng)過前邊我們對 radix_tree 結(jié)構(gòu)的介紹,我們已經(jīng)知道 radix_tree 中每個(gè)節(jié)點(diǎn) radix_tree_node 包含一個(gè)大小為 64 的指針數(shù)組 slots 用于指向它的子節(jié)點(diǎn)或者緩存頁描述符。
一個(gè) radix_tree_node 節(jié)點(diǎn)下邊最多可容納 64 個(gè)子節(jié)點(diǎn),如果 radix_tree 的深度為 1 (不包括葉子節(jié)點(diǎn)),那么這顆 radix_tree 就可以緩存 64 個(gè)文件頁。只能表示 0 - 63 的索引范圍,所以 long 型的緩存頁 offset 的低 6 位可以表示這個(gè)范圍,對應(yīng)于第一層 radix_tree_node 節(jié)點(diǎn)的 slots 數(shù)組下標(biāo)。

如果一顆 radix_tree 的深度為 2(不包括葉子節(jié)點(diǎn)),那么它就可以緩存 64 * 64 = 4096 個(gè)文件頁,表示的索引范圍為 0 - 4095,在這種情況下,緩存頁索引 offset 的低 12 位可以分成 兩個(gè) 6 位的字段,高位的字段用來表示第一層節(jié)點(diǎn)的 slots 數(shù)組的下標(biāo),低位字段用于表示第二層節(jié)點(diǎn)的 slots 數(shù)組下標(biāo)。
依次類推,如果 radix_tree 的深度為 6 那么它可以緩存 64T 的文件頁,表示的索引范圍為:0 到 2^36 - 1。緩存頁索引 offset 的低 36 位可以分成 六 個(gè) 6 位的字段。緩存頁索引的最高位字段來表示 radix_tree 中的第一層節(jié)點(diǎn)中的 slots 數(shù)組下標(biāo),接下來的 6 位字段表示第二層節(jié)點(diǎn)中的 slots 數(shù)組下標(biāo),這樣一直到最低的 6 位字段表示第 6 層節(jié)點(diǎn)中的 slots 數(shù)組下標(biāo)。
通過以上根據(jù)緩存頁索引 offset 的查找過程,我們看出內(nèi)核在 page cache 查找緩存頁的時(shí)間復(fù)雜度和 radix_tree 的深度有關(guān)。
在我們理解了內(nèi)核在 radix_tree 中的查找緩存頁邏輯之后,再來看 find_get_page 的代碼實(shí)現(xiàn)就變得很簡單了~~
內(nèi)核首先調(diào)用 find_get_entry 方法根據(jù)緩存頁的 offset 到 page cache 中去查找看請求的文件頁是否已經(jīng)在頁高速緩存中。如果存在直接返回。
如果請求的文件頁不在 page cache 中,內(nèi)核則會首先會在物理內(nèi)存中分配一個(gè)內(nèi)存頁,然后將新分配的內(nèi)存頁加入到 page cache 中,并增加頁引用計(jì)數(shù)。
隨后會通過 address_space_operations 重定義的 readpage 激活塊設(shè)備驅(qū)動從磁盤中讀取請求數(shù)據(jù),然后用讀取到的數(shù)據(jù)填充新分配的內(nèi)存頁。
9. 文件頁的預(yù)讀
之前我們在引入 page cache 的時(shí)候提到過,根據(jù)程序時(shí)間局部性原理:如果進(jìn)程在訪問某一塊數(shù)據(jù),那么在訪問的不久之后,進(jìn)程還會再次訪問這塊數(shù)據(jù)。所以內(nèi)核引入了 page cache 在內(nèi)存中緩存磁盤中的熱點(diǎn)數(shù)據(jù),從而減少對磁盤的 IO 訪問,提升系統(tǒng)性能。
而本小節(jié)我們要介紹的文件頁預(yù)讀特性是根據(jù)程序空間局部性原理:當(dāng)進(jìn)程訪問一段數(shù)據(jù)之后,那么在不就的將來和其臨近的一段數(shù)據(jù)也會被訪問到。所以當(dāng)進(jìn)程在訪問文件中的某頁數(shù)據(jù)的時(shí)候,內(nèi)核會將它和臨近的幾個(gè)頁一起預(yù)讀到 page cache 中。這樣當(dāng)進(jìn)程再次訪問文件的時(shí)候,就不需要進(jìn)行龜速的磁盤 IO 了,因?yàn)樗埱蟮臄?shù)據(jù)已經(jīng)預(yù)讀進(jìn) page cache 中了。
我們常提到的當(dāng)你順序讀取文件的時(shí)候,性能會非常的高,因?yàn)橄喈?dāng)于是在讀內(nèi)存,這就是文件預(yù)讀的功勞。
但是在我們隨機(jī)訪問文件的時(shí)候,文件預(yù)讀不僅不會提高性能,返回會降低文件讀取的性能,因?yàn)殡S機(jī)讀取文件并不符合程序空間局部性原理,因此預(yù)讀進(jìn) page cache 中的文件頁通常是無效的,下一次根本不會再去讀取,這無疑是白白浪費(fèi)了 page cache 的空間,還額外增加了不必要的預(yù)讀磁盤 IO。
事實(shí)上,在我們對文件進(jìn)行隨機(jī)讀取的場景下,更適合用 Direct IO 的方式繞過 page cache 直接從磁盤中讀取文件,還能減少一次從 page cache 到用戶緩沖區(qū)的拷貝。
所以內(nèi)核需要一套非常精密的預(yù)讀算法來根據(jù)進(jìn)程是順序讀文件還是隨機(jī)讀文件來精確地調(diào)控預(yù)讀的文件頁數(shù),或者直接關(guān)閉預(yù)讀。
進(jìn)程在讀取文件數(shù)據(jù)的時(shí)候都是逐頁進(jìn)行讀取的,因此在預(yù)讀文件頁的時(shí)候內(nèi)核并不會考慮頁內(nèi)偏移,而是根據(jù)請求數(shù)據(jù)在文件內(nèi)部的頁偏移進(jìn)行讀取。

如果進(jìn)程持續(xù)的順序訪問一個(gè)文件,那么預(yù)讀頁數(shù)也會隨著逐步增加。
當(dāng)發(fā)現(xiàn)進(jìn)程開始隨機(jī)訪問文件了(當(dāng)前訪問的文件頁和最后一次訪問的文件頁 offset 不是連續(xù)的),內(nèi)核就會逐步減少預(yù)讀頁數(shù)或者徹底禁止預(yù)讀。
當(dāng)內(nèi)核發(fā)現(xiàn)進(jìn)程再重復(fù)的訪問同一文件頁時(shí)或者文件中的文件頁已經(jīng)幾乎全部緩存在 page cache 中了,內(nèi)核此時(shí)就會禁止預(yù)讀。
以上幾點(diǎn)就是內(nèi)核的預(yù)讀算法的核心邏輯,從這個(gè)預(yù)讀邏輯中我們可以看出,進(jìn)程在進(jìn)行文件讀取的時(shí)候涉及到兩種不同類型的頁面集合,一個(gè)是進(jìn)程可以請求的文件頁(已經(jīng)緩存在 page cache 中的文件頁),另一個(gè)是內(nèi)核預(yù)讀的文件頁。
而內(nèi)核也確實(shí)按照這兩種頁面集合分為兩個(gè)窗口:
當(dāng)前窗口(current window): ?表示進(jìn)程本次文件請求可以直接讀取的頁面集合,這個(gè)集合中的頁面全部已經(jīng)緩存在 page cache 中,進(jìn)程可以直接讀取返回。當(dāng)前窗口中包含進(jìn)程本次請求的文件頁以及上次內(nèi)核預(yù)讀的文件頁集合。表示進(jìn)程本次可以從 page cache 直接獲取的頁面范圍。
預(yù)讀窗口(ahead window):預(yù)讀窗口的頁面都是內(nèi)核正在預(yù)讀的文件頁,它們此時(shí)并不在 page cache 中。這些頁面并不是進(jìn)程請求的文件頁,但是內(nèi)核根據(jù)空間局部性原理假定它們遲早會被進(jìn)程請求。預(yù)讀窗口內(nèi)的頁面緊跟著當(dāng)前窗口后面,并且內(nèi)核會動態(tài)調(diào)整預(yù)讀窗口的大?。ㄓ悬c(diǎn)類似于 TCP 中的滑動窗口)。

如果進(jìn)程本次文件請求的第一頁的 offset,緊跟著上一次文件請求的最后一頁的 offset,內(nèi)核就認(rèn)為是順序讀取。在順序讀取文件的場景下,如果請求的第一頁在當(dāng)前窗口內(nèi),內(nèi)核隨后就會檢查是否建立了預(yù)讀窗口,如果沒有就會創(chuàng)建預(yù)讀窗口并觸發(fā)相應(yīng)頁的讀取操作。
在理想情況下,進(jìn)程會繼續(xù)在當(dāng)前窗口內(nèi)請求頁,于此同時(shí),預(yù)讀窗口內(nèi)的預(yù)讀頁同時(shí)異步傳送著,這樣進(jìn)程在順序讀取文件的時(shí)候就相當(dāng)于直接讀取內(nèi)存,極大地提高了文件 IO 的性能。
以上包含的這些文件預(yù)讀信息,比如:如何判斷進(jìn)程是順序讀取還是隨機(jī)讀取,當(dāng)前窗口信息,預(yù)讀窗口信息。全部保存在 struct file 結(jié)構(gòu)中的 f_ra 字段中。
用于描述文件預(yù)讀信息的結(jié)構(gòu)體在內(nèi)核中用 struct file_ra_state 結(jié)構(gòu)體來表示:
內(nèi)核可以根據(jù) start 和 prev_pos 這兩個(gè)字段來判斷進(jìn)程是否在順序訪問文件。
ra_pages 表示當(dāng)前文件允許預(yù)讀的最大頁數(shù),進(jìn)程可以通過系統(tǒng)調(diào)用 posix_fadvise() 來改變已打開文件的 ra_page 值來調(diào)優(yōu)預(yù)讀算法。
該系統(tǒng)調(diào)用用來通知內(nèi)核,我們將來打算以特定的模式 advice 訪問文件數(shù)據(jù),從而允許內(nèi)核執(zhí)行適當(dāng)?shù)膬?yōu)化。
advice 參數(shù)主要有下面幾種數(shù)值:
POSIX_FADV_NORMAL :設(shè)置文件最大預(yù)讀頁數(shù) ra_pages 為默認(rèn)值 32 頁。
POSIX_FADV_SEQUENTIAL :進(jìn)程期望順序訪問指定的文件數(shù)據(jù),ra_pages 值為默認(rèn)值的兩倍。
POSIX_FADV_RANDOM :進(jìn)程期望以隨機(jī)順序訪問指定的文件數(shù)據(jù)。ra_pages 設(shè)置為 0,表示禁止預(yù)讀。
后來人們發(fā)現(xiàn)當(dāng)禁止預(yù)讀后,這樣一頁一頁的讀取性能非常的低下,于是 linux 3.19.8 之后 POSIX_FADV_RANDOM 的語義被改變了,它會在 file->f_flags 中設(shè)置 FMODE_RANDOM 屬性(后面我們分析內(nèi)核預(yù)讀相關(guān)源碼的時(shí)候還會提到),當(dāng)遇到 FMODE_RANDOM 的時(shí)候內(nèi)核就會走強(qiáng)制預(yù)讀的邏輯,按最大 2MB 單元大小的 chunk 進(jìn)行預(yù)讀。
POSIX_FADV_WILLNEED :通知內(nèi)核,進(jìn)程指定這段文件數(shù)據(jù)將在不久之后被訪問。
而觸發(fā)內(nèi)核進(jìn)行文件預(yù)讀的場景,分為以下幾種:
當(dāng)進(jìn)程采用 Buffered IO 模式通過系統(tǒng)調(diào)用 read 進(jìn)行文件讀取時(shí),內(nèi)核會觸發(fā)預(yù)讀。
通過 POSIX_FADV_WILLNEED 參數(shù)執(zhí)行系統(tǒng)調(diào)用 posix_fadvise,會通知內(nèi)核這個(gè)指定范圍的文件頁不就將會被訪問。觸發(fā)預(yù)讀。
當(dāng)進(jìn)程顯示執(zhí)行 readahead() 系統(tǒng)調(diào)用時(shí),會顯示觸發(fā)內(nèi)核的預(yù)讀動作。
當(dāng)內(nèi)核為內(nèi)存文件映射區(qū)域分配一個(gè)物理頁面時(shí),會觸發(fā)預(yù)讀。關(guān)于內(nèi)存映射的相關(guān)內(nèi)容,筆者會在后面的文章為大家詳細(xì)介紹。
和 posix_fadvise 一樣的道理,系統(tǒng)調(diào)用 madvise 主要用來指定內(nèi)存文件映射區(qū)域的訪問模式??赏ㄟ^ advice = MADV_WILLNEED 通知內(nèi)核,某個(gè)文件內(nèi)存映射區(qū)域中的指定范圍的文件頁在不久將會被訪問。觸發(fā)預(yù)讀。
從觸發(fā)內(nèi)核預(yù)讀的這幾種場景中我們可以看出,預(yù)讀分為主動觸發(fā)和被動觸發(fā),在《4.2 Buffered IO》小節(jié)中遺留的 page_cache_sync_readahead 函數(shù)為被動觸發(fā),接下來我們來看下它在內(nèi)核中的實(shí)現(xiàn)邏輯。
9.1 page_cache_sync_readahead
!ra->ra_pages
?表示 ra_pages 設(shè)置為 0 ,預(yù)讀被禁止,直接返回。
如果進(jìn)程通過前邊介紹的 posix_fadvise 系統(tǒng)調(diào)用并且 advice 參數(shù)設(shè)置為 POSIX_FADV_RANDOM。在 linux 3.19.8 之后文件的 ?file->f_flags 屬性會被設(shè)置為 FMODE_RANDOM,這樣內(nèi)核會走強(qiáng)制預(yù)讀邏輯,按最大 2MB 單元大小的 chunk 進(jìn)行預(yù)讀。
而真正的預(yù)讀邏輯封裝在 ondemand_readahead 函數(shù)中。
9.2 ondemand_readahead
該方法中封裝了前邊介紹的預(yù)讀算法邏輯,動態(tài)的調(diào)整當(dāng)前窗口以及預(yù)讀窗口的大小。
struct address_space *mapping
?: ?讀取文件對應(yīng)的 page cache 結(jié)構(gòu)。struct file_ra_state *ra
?: 文件對應(yīng)的預(yù)讀狀態(tài)信息,封裝在 file->f_ra 中。struct file *filp
?: 讀取文件對應(yīng)的 struct file 結(jié)構(gòu)。pgoff_t offset?
: 本次請求文件頁在 page cache 中的索引。(文件頁偏移)long req_size?
: 要完成當(dāng)前讀操作還需要讀取的頁數(shù)。
在預(yù)讀算法邏輯中,內(nèi)核通過 struct file_ra_state 結(jié)構(gòu)中封裝的文件預(yù)讀信息來判斷文件的讀取是否為順序讀。比如:
通過檢查 ra->prev_pos 和 offset 是否相同,來判斷當(dāng)前請求頁是否和最近一次請求的頁相同,如果重復(fù)訪問同一頁,預(yù)讀就會停止。
通過檢查 ra->prev_pos 和 offset 是否相鄰,來判斷進(jìn)程是否順序讀取文件。如果是順序訪問文件,預(yù)讀就會增加。
當(dāng)進(jìn)程第一次訪問文件時(shí),并且請求的第一個(gè)文件頁在文件中的偏移量為 0 時(shí)表示進(jìn)程從頭開始讀取文件,那么內(nèi)核就會認(rèn)為進(jìn)程想要順序的訪問文件,隨后內(nèi)核就會從文件的第一頁開始創(chuàng)建一個(gè)新的當(dāng)前窗口,初始的當(dāng)前窗口總是 2 的次冪,窗口具體大小與進(jìn)程的讀操作所請求的頁數(shù)有一定的關(guān)系。請求頁數(shù)越大,當(dāng)前窗口就越大,直到最大值 ra->ra_pages 。
相反,當(dāng)進(jìn)程第一次訪問文件,但是請求頁在文件中的偏移量不為 0 時(shí),內(nèi)核就會假定進(jìn)程不準(zhǔn)備順序讀取文件,函數(shù)就會暫時(shí)禁止預(yù)讀。
一旦內(nèi)核發(fā)現(xiàn)進(jìn)程在當(dāng)前窗口內(nèi)執(zhí)行了順序讀取,那么預(yù)讀窗口就會被建立,預(yù)讀窗口總是緊挨著當(dāng)前窗口的最后一頁。
預(yù)讀窗口的大小和當(dāng)前窗口有關(guān),如果已經(jīng)被預(yù)讀的頁不在 page cache 中(可能內(nèi)存緊張,預(yù)讀頁被回收),那么預(yù)讀窗口就會是?
當(dāng)前窗口大小 - 2
,最小值為 4。否則預(yù)讀窗口就會是當(dāng)前窗口的4倍或者2倍。當(dāng)進(jìn)程繼續(xù)順序訪問文件時(shí),最終預(yù)讀窗口就會變?yōu)楫?dāng)前窗口,隨后新的預(yù)讀窗口就會被建立,隨著進(jìn)程順序地讀取文件,預(yù)讀會越來越大,但是內(nèi)核一旦發(fā)現(xiàn)對于文件的訪問 offset 相對于上一次的請求頁 ra->prev_pos 不是順序的時(shí)候,當(dāng)前窗口和預(yù)讀窗口就會被清空,預(yù)讀被暫時(shí)禁止。
當(dāng)內(nèi)核通過以上介紹的預(yù)讀算法確定了預(yù)讀窗口的大小之后,就開始調(diào)用 __do_page_cache_readahead 從磁盤去預(yù)讀指定的頁數(shù)到 page cache 中。
9.3 __do_page_cache_readahead
內(nèi)核調(diào)用 read_pages 方法激活磁盤塊設(shè)備驅(qū)動程序從磁盤中讀取文件數(shù)據(jù)之前,需要為本次進(jìn)程讀取請求所需要的所有頁面盡可能地一次性全部分配,如果不能一次性分配全部頁面,預(yù)讀操作就只在分配好的緩存頁面上進(jìn)行,也就是說只從磁盤中讀取數(shù)據(jù)填充已經(jīng)分配好的頁面。
文章篇幅有限,下文繼續(xù)講解
原文作者:bin的技術(shù)小屋
