筆者在 《從 Linux 核心角度看 IO 模型的演變》一文中曾對 Socket 檔案在核心中的相關資料結構為大家做了詳盡的闡述。
又在此基礎之上介紹了針對 socket 檔案的相關操作及其對應在核心中的處理流程:
並與 epoll 的工作機制進行了串聯:
通過這些內容的串聯介紹,我想大家現在一定對 socket 檔案非常熟悉了,在我們利用 socket 檔案介面在與核心進行網路資料讀取,傳送的相關互動的時候,不可避免的涉及到一個新的問題,就是我們如何在使用者空間設計一個位元組緩衝區來高效便捷的儲存管理這些需要和 socket 檔案進行互動的網路資料。
於是筆者又在 《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同位元組序下的設計與實現》 一文中帶大家從 JDK NIO Buffer 的頂層設計開始,詳細介紹了 NIO Buffer 中的頂層抽象設計以及行為定義,隨後我們選取了在網路應用程式中比較常用的 ByteBuffer 來詳細介紹了這個Buffer具體型別的實現,並以 HeapByteBuffer 為例說明了JDK NIO 在不同位元組序下的 ByteBuffer 實現。
現在我們已經熟悉了 socket 檔案的相關操作及其在核心中的實現,但筆者覺得這還不夠,還是有必要在為大家介紹一下 JDK NIO 如何利用 ByteBuffer 對普通檔案進行讀寫的相關原理及其實現,為大家徹底打通 Linux 檔案操作相關知識的系統脈絡,於是就有了本文的內容。
下面就讓我們從一個普通的 IO 讀寫操作開始聊起吧~~~
我們先來看一個利用 NIO FileChannel 來讀寫普通檔案的例子,由這個簡單的例子開始,慢慢地來一步一步深入本質。
JDK NIO 中的 FileChannel 比較特殊,它只能是阻塞的,不能設定非阻塞模式。FileChannel的讀寫方法均是執行緒安全的。
注意:下面的例子並不是最佳實踐,之所以這裡引入 HeapByteBuffer 是為了將上篇文章的內容和本文銜接起來。事實上,對於 IO 的操作一般都會選擇 DirectByteBuffer ,關於 DirectByteBuffer 的相關內容筆者會在後面的文章中詳細為大家介紹。
FileChannel fileChannel = new RandomAccessFile(new File("file-read-write.txt"), "rw").getChannel();
ByteBuffer heapByteBuffer = ByteBuffer.allocate(4096);
fileChannel.read(heapByteBuffer);
我們首先利用 RandomAccessFile 在核心中開啟指定的檔案 file-read-write.txt 並獲取到它的檔案描述符 fd = 5000。
隨後我們在 JVM 堆中開闢一塊 4k 大小的虛擬記憶體 heapByteBuffer,用來讀取檔案中的資料。
作業系統在管理記憶體的時候是將記憶體分為一頁一頁來管理的,每頁大小為 4k ,我們在操作記憶體的時候一定要記得進行頁對齊,也就是偏移位置以及讀取的記憶體大小需要按照 4k 進行對齊。具體為什麼?文章後邊會從核心角度詳細為大家介紹。
最後通過 FileChannel#read
方法觸發底層系統呼叫 read。進行檔案讀取。
public class FileChannelImpl extends FileChannel {
// 前邊介紹開啟的檔案描述符 5000
private final FileDescriptor fd;
// NIO 中用它來觸發 native read 和 write 的系統呼叫
private final FileDispatcher nd;
// 讀寫檔案時加鎖,前邊介紹 FileChannel 的讀寫方法均是執行緒安全的
private final Object positionLock = new Object();
public int read(ByteBuffer dst) throws IOException {
synchronized (positionLock) {
.......... 省略 .......
try {
.......... 省略 .......
do {
n = IOUtil.read(fd, dst, -1, nd);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
return IOStatus.normalize(n);
} finally {
.......... 省略 .......
}
}
}
}
我們看到在 FileChannel 中會呼叫 IOUtil 的 read 方法,NIO 中的所有 IO 操作全部封裝在 IOUtil 類中。
而 NIO 中的 SocketChannel 以及這裡介紹的 FileChannel 底層依賴的系統呼叫可能不同,這裡會通過 NativeDispatcher 對具體 Channel 操作實現分發,呼叫具體的系統呼叫。對於 FileChannel 來說 NativeDispatcher 的實現類為 FileDispatcher。對於 SocketChannel 來說 NativeDispatcher 的實現類為 SocketDispatcher。
下面我們進入 IOUtil 裡面來一探究竟~~
public class IOUtil {
static int read(FileDescriptor fd, ByteBuffer dst, long position,
NativeDispatcher nd)
throws IOException
{
.......... 省略 .......
.... 建立一個臨時的directByteBuffer....
try {
int n = readIntoNativeBuffer(fd, directByteBuffer, position, nd);
.......... 省略 .......
.... 將directByteBuffer中讀取到的內容再次拷貝到heapByteBuffer中給使用者返回....
return n;
} finally {
.......... 省略 .......
}
}
private static int readIntoNativeBuffer(FileDescriptor fd, ByteBuffer bb,
long position, NativeDispatcher nd)
throws IOException
{
int pos = bb.position();
int lim = bb.limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
.......... 省略 .......
if (position != -1) {
.......... 省略 .......
} else {
n = nd.read(fd, ((DirectBuffer)bb).address() + pos, rem);
}
if (n > 0)
bb.position(pos + n);
return n;
}
}
我們看到 FileChannel 的 read 方法最終會呼叫到 NativeDispatcher 的 read 方法。前邊我們介紹了這裡的 NativeDispatcher 就是 FileDispatcher 在 NIO 中的實現類為 FileDispatcherImpl,用來觸發 native 方法執行底層系統呼叫。
class FileDispatcherImpl extends FileDispatcher {
int read(FileDescriptor fd, long address, int len) throws IOException {
return read0(fd, address, len);
}
static native int read0(FileDescriptor fd, long address, int len)
throws IOException;
}
最終在 FileDispatcherImpl 類中觸發了 native 方法 read0 的呼叫,我們繼續到 FileDispatcherImpl.c 檔案中去檢視 native 方法的實現。
// FileDispatcherImpl.c 檔案
JNIEXPORT jint JNICALL Java_sun_nio_ch_FileDispatcherImpl_read0(JNIEnv *env, jclass clazz,
jobject fdo, jlong address, jint len)
{
jint fd = fdval(env, fdo);
void *buf = (void *)jlong_to_ptr(address);
// 發起 read 系統呼叫進入核心
return convertReturnVal(env, read(fd, buf, len), JNI_TRUE);
}
系統呼叫 read(fd, buf, len) 最終是在 native 方法 read0 中被觸發的。下面是系統呼叫 read 在核心中的定義。
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count){
...... 省略 ......
}
這樣一來我們就從 JDK NIO 這一層逐步來到了使用者空間與核心空間的邊界處 --- OS 系統呼叫 read 這裡,馬上就要進入核心了。
下面我們就來看一下當系統呼叫 read 發起之後,使用者程序在核心態具體做了哪些事情?
核心將檔案的 IO 操作根據是否使用記憶體(頁快取記憶體 page cache)做磁碟熱點資料的快取,將檔案 IO 分為:Buffered IO 和 Direct IO 兩種型別。
程序在通過系統呼叫 open() 開啟檔案的時候,可以通過將引數 flags 賦值為 O_DIRECT 來指定檔案操作為 Direct IO。預設情況下為 Buffered IO。
int open(const char *pathname, int flags, mode_t mode);
而 Java 在 JDK 10 之前一直是不支援 Direct IO 的,到了 JDK 10 才開始支援 Direct IO。但是在 JDK 10 之前我們可以使用第三方的 Direct IO 框架 Jaydio 來通過 Direct IO 的方式對檔案進行讀寫操作。
Jaydio GitHub :https://github.com/smacke/jaydio
下面筆者就帶大家從核心角度深度剖析下這兩種 IO 型別各自的特點:
大部分檔案系統預設的檔案 IO 型別為 Buffered IO,當程序進行檔案讀取時,核心會首先檢查檔案對應的頁快取記憶體 page cache 中是否已經快取了檔案資料,如果有則直接返回,如果沒有才會去磁碟中去讀取檔案資料,而且還會根據非常精妙的預讀演演算法來預先讀取後續若干檔案資料到 page cache 中。這樣等程序下一次順序讀取檔案時,想要的資料已經預讀進 page cache 中了,程序直接返回,不用再到磁碟中去龜速讀取了,這樣一來就極大地提高了 IO 效能。
比如一些著名的訊息佇列中介軟體 Kafka , RocketMq 對訊息紀錄檔檔案進行順序讀取的時候,存取速度接近於記憶體。這就是 Buffered IO 中頁快取記憶體 page cache 的功勞。在本文的後面,筆者會為大家詳細的介紹這一部分內容。
如果我們使用在上篇文章 《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同位元組序下的設計與實現》 中介紹的 HeapByteBuffer 來接收 NIO 讀取檔案資料的時候,整個檔案讀取的過程分為如下幾個步驟:
具體為什麼會建立一個臨時的 DirectByteBuffer 來接收資料以及關於 DirectByteBuffer 的原理筆者會在後面的文章中為大家詳細介紹。這裡大家可以把它簡單看成在 OS 堆中的一塊虛擬記憶體地址。
隨後 NIO 會在使用者態呼叫系統呼叫 read 向核心發起檔案讀取的請求。此時發生第一次上下文切換。
使用者程序隨即轉到核心態執行,進入虛擬檔案系統層,在這一層核心首先會檢視讀取檔案對應的頁快取記憶體 page cache 中是否含有請求的檔案資料,如果有直接返回,避免一次磁碟 IO。並根據核心預讀演演算法從磁碟中非同步預讀若干檔案資料到 page cache 中(檔案順序讀取高效能的關鍵所在)。
在核心中,一個檔案對應一個 page cache 結構,注意:這個 page cache 在記憶體中只會有一份。
如果程序請求資料不在 page cache 中,則會進入檔案系統層,在這一層呼叫塊裝置驅動程式觸發真正的磁碟 IO。並根據核心預讀演演算法同步預讀若干檔案資料。請求的檔案資料和預讀的檔案資料將被一起填充到 page cache 中。
在塊裝置驅動層完成真正的磁碟 IO。在這一層會從磁碟中讀取程序請求的檔案資料以及核心預讀的檔案資料。
磁碟控制器 DMA 將從磁碟中讀取的資料拷貝到頁快取記憶體 page cache 中。發生第一次資料拷貝。
隨後 CPU 將 page cache 中的資料拷貝到 NIO 在使用者空間臨時建立的緩衝區 DirectByteBuffer 中,發生第二次資料拷貝。
最後系統呼叫 read 返回。程序從核心態切換回使用者態。發生第二次上下文切換。
NIO 將 DirectByteBuffer 中臨時存放的檔案資料拷貝到 JVM 堆中的 HeapBytebuffer 中。發生第三次資料拷貝。
我們看到如果使用 HeapByteBuffer 進行 NIO 檔案讀取的整個過程中,一共發生了 兩次上下文切換和三次資料拷貝,如果請求的資料命中 page cache 則發生兩次資料拷貝省去了一次磁碟的 DMA 拷貝。
在上一小節中,筆者介紹了 Buffered IO 的諸多好處,尤其是在程序對檔案進行順序讀取的時候,存取效能接近於記憶體。
但是有些情況,我們並不需要 page cache。比如一些高效能的資料庫應用程式,它們在使用者空間自己實現了一套高效的快取記憶體機制,以充分挖掘對資料庫獨特的查詢存取效能。所以這些資料庫應用程式並不希望核心中的 page cache起作用。否則核心會同時處理 page cache 以及預讀相關操作的指令,會使得效能降低。
另外還有一種情況是,當我們在隨機讀取檔案的時候,也不希望核心使用 page cache。因為這樣違反了程式區域性性原理,當我們隨機讀取檔案的時候,核心預讀進 page cache 中的資料將很久不會再次得到存取,白白浪費 page cache 空間不說,還額外增加了預讀的磁碟 IO。
基於以上兩點原因,我們很自然的希望核心能夠提供一種機制可以繞過 page cache 直接對磁碟進行讀寫操作。這種機制就是本小節要為大家介紹的 Direct IO。
下面是核心採用 Direct IO 讀取檔案的工作流程:
Direct IO 和 Buffered IO 在進入核心虛擬檔案系統層之前的流程全部都是一樣的。區別就是進入到虛擬檔案系統層之後,Direct IO 會繞過 page cache 直接來到檔案系統層通過 direct_io 呼叫來到塊驅動裝置層,在塊裝置驅動層呼叫 __blockdev_direct_IO 對磁碟內容直接進行讀寫。
和 Buffered IO 一樣,在系統呼叫 read 進入核心以及 Direct IO 完成從核心返回的時候各自會發生一次上下文切換。共兩次上下文切換
磁碟控制器 DMA 從磁碟中讀取資料後直接拷貝到使用者空間緩衝區 DirectByteBuffer 中。只發生一次 DMA 拷貝
隨後 NIO 將 DirectByteBuffer 中臨時存放的資料拷貝到 JVM 堆 HeapByteBuffer 中。發生第二次資料拷貝。
注意塊裝置驅動層的 __blockdev_direct_IO 需要等到所有的 Direct IO 傳送資料完成之後才會返回,這裡的傳送指的是直接從磁碟拷貝到使用者空間緩衝區中,當 Direct IO 模式下的 read() 或者 write() 系統呼叫返回之後,程序就可以安全放心地去讀取使用者緩衝區中的資料了。
從整個 Direct IO 的過程中我們看到,一共發生了兩次上下文的切換,兩次的資料拷貝。
下面是系統呼叫 read 在核心中的完整定義:
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) {
// 根據檔案描述符獲取檔案對應的 struct file結構
struct fd f = fdget_pos(fd);
.....
// 獲取當前檔案的讀取位置 offset
loff_t pos = file_pos_read(f.file);
// 進入虛擬檔案系統層,執行具體的檔案操作
ret = vfs_read(f.file, buf, count, &pos);
......
}
首先會根據檔案描述符 fd 通過 fdget_pos 方法獲取 struct fd 結構,進而可以獲取到檔案的 struct file 結構。
struct fd {
struct file *file;
int need_put;
};
file_pos_read 獲取當前檔案的讀取位置 offset,並通過 vfs_read 進入虛擬檔案系統層。
ssize_t __vfs_read (struct file *file, char __user *buf, size_t count, loff_t *pos) {
if (file->f_op->read)
return file->f_op->read(file, buf, count, pos);
else if (file->f_op->read_iter)
return new_sync_read(file, buf, count, pos);
else
return -EINVAL;
}
這裡我們看到核心對檔案的操作全部定義在 struct file 結構中的 f_op 欄位中。
struct file {
const struct file_operations *f_op;
}
對於 Java 程式設計師來說,file_operations 大家可以把它當做核心針對檔案相關操作定義的一個公共介面(其實就是一個函數指標),它只是一個介面。具體的實現根據不同的檔案型別有所不同。
比如我們在《聊聊Netty那些事兒之從核心角度看IO模型》一文中詳細介紹過的 Socket 檔案。針對 Socket 檔案型別,這裡的 file_operations 指向的是 socket_file_ops。
static const struct file_operations socket_file_ops = {
.owner = THIS_MODULE,
.llseek = no_llseek,
.read_iter = sock_read_iter,
.write_iter = sock_write_iter,
.poll = sock_poll,
.unlocked_ioctl = sock_ioctl,
.mmap = sock_mmap,
.release = sock_close,
.fasync = sock_fasync,
.sendpage = sock_sendpage,
.splice_write = generic_splice_sendpage,
.splice_read = sock_splice_read,
};
而本小節中我們討論的是對普通檔案的操作,針對普通檔案的操作定義在具體的檔案系統中,這裡我們以 Linux 中最為常見的 ext4 檔案系統為例說明:
在 ext4 檔案系統中管理的檔案對應的 file_operations 指向 ext4_file_operations,專門用於操作 ext4 檔案系統中的檔案。
const struct file_operations ext4_file_operations = {
......省略........
.read_iter = ext4_file_read_iter,
.write_iter = ext4_file_write_iter,
......省略.........
}
從圖中我們可以看到 ext4 檔案系統定義的相關檔案操作 ext4_file_operations 並未定義 .read 函數指標。而是定義了 .read_iter 函數指標,指向 ext4_file_read_iter 函數。
ssize_t __vfs_read (struct file *file, char __user *buf, size_t count, loff_t *pos) {
if (file->f_op->read)
return file->f_op->read(file, buf, count, pos);
else if (file->f_op->read_iter)
return new_sync_read(file, buf, count, pos);
else
return -EINVAL;
}
所以在虛擬檔案系統 VFS 中,__vfs_read 呼叫的是 new_sync_read 方法,在該方法中會對系統呼叫傳進來的引數進行重新封裝。比如:
struct file *filp : 要讀取檔案的 struct file 結構。
char __user *buf :使用者空間的 Buffer,這裡指的我們例子中 NIO 建立的臨時 DirectByteBuffer。
size_t count :進行讀取的位元組數。也就是我們傳入的使用者態緩衝區 DirectByteBuffer 剩餘可容納的容量大小。
loff_t *pos :檔案當前讀取位置偏移 offset。
將這些引數重新封裝到 struct iovec 和 struct kiocb 結構體中。
ssize_t new_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos)
{
// 將 DirectByteBuffer 以及要讀取的位元組數封裝進 iovec 結構體中
struct iovec iov = { .iov_base = buf, .iov_len = len };
struct kiocb kiocb;
struct iov_iter iter;
ssize_t ret;
// 利用檔案 struct file 初始化 kiocb 結構體
init_sync_kiocb(&kiocb, filp);
// 設定檔案讀取偏移
kiocb.ki_pos = *ppos;
// 讀取檔案位元組數
kiocb.ki_nbytes = len;
// 初始化 iov_iter 結構
iov_iter_init(&iter, READ, &iov, 1, len);
// 最終呼叫 ext4_file_read_iter
ret = filp->f_op->read_iter(&kiocb, &iter);
.......省略......
return ret;
}
struct iovec 結構體主要用來封裝用來接收檔案資料用的使用者快取區相關的資訊:
struct iovec
{
void __user *iov_base; // 使用者空間快取區地址 這裡是 DirectByteBuffer 的地址
__kernel_size_t iov_len; // 緩衝區長度
}
但是核心中一般會使用 struct iov_iter 結構體對 struct iovec 進行包裝,iov_iter 中可以包含多個 iovec。這一點從 struct iov_iter 結構體的命名關鍵字 iter
上可以看得出來。
struct iov_iter {
......省略.....
const struct iovec *iov;
}
之所以使用 struct iov_iter 結構體來包裝 struct iovec 是為了相容 readv() 系統呼叫,它允許使用者使用多個使用者快取區去讀取檔案中的資料。JDK NIO Channel 支援的 scatter 操作底層原理就是 readv 系統呼叫。
FileChannel fileChannel = new RandomAccessFile(new File("file-read-write.txt"), "rw").getChannel();
ByteBuffer heapByteBuffer1 = ByteBuffer.allocate(4096);
ByteBuffer heapByteBuffer2 = ByteBuffer.allocate(4096);
ByteBuffer[] scatter = { heapByteBuffer1, heapByteBuffer2 };
fileChannel.read(scatter);
struct kiocb 結構體則是用來封裝檔案 IO 相關操作的狀態和進度資訊:
struct kiocb {
struct file *ki_filp; // 要讀取的檔案 struct file 結構
loff_t ki_pos; // 檔案讀取位置偏移,表示檔案處理進度
void (*ki_complete)(struct kiocb *iocb, long ret); // IO完成回撥
int ki_flags; // IO型別,比如是 Direct IO 還是 Buffered IO
........省略.......
};
當 struct iovec 和 struct kiocb 在 new_sync_read 方法中被初始化好之後,最終通過 file_operations 中定義的函數指標 .read_iter 呼叫到 ext4_file_read_iter 方法中,從而進入 ext4 檔案系統執行具體的讀取操作。
static ssize_t ext4_file_read_iter(struct kiocb *iocb, struct iov_iter *to)
{
........省略........
return generic_file_read_iter(iocb, to);
}
ssize_t generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
........省略........
if (iocb->ki_flags & IOCB_DIRECT) {
........ Direct IO ........
// 獲取 page cache
struct address_space *mapping = file->f_mapping;
........省略........
// 繞過 page cache 直接從磁碟中讀取資料
retval = mapping->a_ops->direct_IO(iocb, iter);
}
........ Buffered IO ........
// 從 page cache 中讀取資料
retval = generic_file_buffered_read(iocb, iter, retval);
}
generic_file_read_iter 會根據 struct kiocb 中的 ki_flags 屬性判斷檔案 IO 操作是 Direct IO 還是 Buffered IO。
我們可以通過 open 系統呼叫在開啟檔案的時候指定相關 IO 操作的模式是 Direct IO 還是 Buffered IO:
int open(const char *pathname, int flags, mode_t mode);
char *pathname : 指定要檔案的路徑。
int flags :指定檔案的存取模式。比如:O_RDONLY(唯讀),O_WRONLY,(只寫), O_RDWR(讀寫),O_DIRECT(Direct IO)。預設為 Buffered IO。
mode_t mode :可選,指定開啟檔案的許可權
而 Java 在 JDK 10 之前一直是不支援 Direct IO,到了 JDK 10 才開始支援 Direct IO。
Path path = Paths.get("file-read-write.txt");
FileChannel fc = FileChannel.open(p, ExtendedOpenOption.DIRECT);
如果在檔案開啟的時候,我們設定了 Direct IO 模式,那麼以後在對檔案進行讀取的過程中,核心將會繞過 page cache,直接從磁碟中讀取資料到使用者空間緩衝區 DirectByteBuffer 中。這樣就可以避免一次資料從核心 page cache 到使用者空間緩衝區的拷貝。
當應用程式期望使用自定義的快取演演算法從而可以在使用者空間實現更加高效更加可控的快取邏輯時(比如資料庫等應用程式),這時應該使用直接 Direct IO。在隨機讀取,隨機寫入的場景中也是比較適合用 Direct IO。
作業系統程序在接下來使用 read() 或者 write() 系統呼叫去讀寫檔案的時候使用的是 Direct IO 方式,所傳輸的資料均不經過檔案對應的快取記憶體 page cache (這裡就是網上常說的核心緩衝區)。
我們都知道作業系統是將記憶體分為一頁一頁的單位進行組織管理的,每頁大小 4K ,那麼同樣檔案中的資料在磁碟中的組織形式也是按照一塊一塊的單位來組織管理的,每塊大小也是 4K ,所以我們在使用 Direct IO 讀寫資料時必須要按照檔案在磁碟中的組織單位進行磁碟塊大小對齊,緩衝區的大小也必須是磁碟塊大小的整數倍。具體表現在如下幾點:
檔案的讀寫位置偏移需要按照磁碟塊大小對齊。
使用者緩衝區 DirectByteBuffer 起始地址需要按照磁碟塊大小對齊。
使用 Direct IO 進行資料讀寫時,讀寫的資料大小需要按照磁碟塊大小進行對齊。這裡指 DirectByteBuffer 中剩餘資料的大小。
當我們採用 Direct IO 直接讀取磁碟中的檔案資料時,核心會從 struct file 結構中獲取到該檔案在記憶體中的 page cache。而我們多次提到的這個 page cache 在核心中的資料結構就是 struct address_space 。我們可以根據 file->f_mapping 獲取。
struct file {
// page cache
struct address_space *f_mapping;
}
和前面我們介紹的 struct file 結構中的 file_operations 一樣,核心中將 page cache 相關的操作全部定義在 struct address_space_operations 結構中。這裡和前邊介紹的 file_operations 的作用是一樣的,只是核心針對 page cache 操作定義的一個公共介面。
struct address_space {
const struct address_space_operations *a_ops;
}
具體的實現會根據檔案系統的不同而不同,這裡我們還是以 ext4 檔案系統為例:
static const struct address_space_operations ext4_aops = {
.direct_IO = ext4_direct_IO,
};
核心通過 struct address_space_operations 結構中定義的 .direct_IO 函數指標,具體函數為 ext4_direct_IO 來繞過 page cache 直接對磁碟進行讀寫。
採用 Direct IO 的方式對檔案的讀寫操作全部是在 ext4_direct_IO 這一個函數中完成的。
由於磁碟檔案中的資料是按照塊為單位來組織管理的,所以檔案系統其實就是一個塊裝置,通過 ext4_direct_IO 繞過 page cache 直接來到了檔案系統的塊裝置驅動層,最終在塊裝置驅動層呼叫 __blockdev_direct_IO 來完成磁碟的讀寫操作。
注意:塊裝置驅動層的 __blockdev_direct_IO 需要等到所有的 Direct IO 傳送資料完成之後才會返回,這裡的傳送指的是直接從磁碟拷貝到使用者空間緩衝區中,當 Direct IO 模式下的 read() 或者 write() 系統呼叫返回之後,程序就可以安全放心地去讀取使用者緩衝區中的資料了。
Buffered IO 相關的讀取操作封裝在 generic_file_buffered_read 函數中,其核心邏輯如下:
由於檔案在磁碟中是以塊為單位組織管理的,每塊大小為 4k,記憶體是按照頁為單位組織管理的,每頁大小也是 4k。檔案中的塊資料被快取在 page cache 中的快取頁中。所以首先通過 find_get_page 方法查詢我們要讀取的檔案資料是否已經快取在了 page cache 中。
如果 page cache 中不存在檔案資料的快取頁,就需要通過 page_cache_sync_readahead 方法從磁碟中讀取資料並快取到 page cache 中。於此同時還需要同步預讀若干相鄰的資料塊到 page cache 中。這樣在下一次順序讀取的時候,直接就可以從 page cache 中讀取了。
如果此次讀取的檔案資料已經存在於 page cache 中了,就需要呼叫 PageReadahead 來判斷是否需要進一步預讀資料到快取頁中。如果是,則從磁碟中非同步預讀若干頁到 page cache 中。具體預讀多少頁是根據核心相關預讀演演算法來動態調整的。
經過上面幾個流程,此時檔案資料已經存在於 page cache 中的快取頁中了,最後核心呼叫 copy_page_to_iter 方法將 page cache 中的資料拷貝到使用者空間緩衝區 DirectByteBuffer 中。
static ssize_t generic_file_buffered_read(struct kiocb *iocb,
struct iov_iter *iter, ssize_t written)
{
// 獲取檔案在核心中對應的 struct file 結構
struct file *filp = iocb->ki_filp;
// 獲取檔案對應的 page cache
struct address_space *mapping = filp->f_mapping;
// 獲取檔案的 inode
struct inode *inode = mapping->host;
...........省略...........
// 開始 Buffered IO 讀取邏輯
for (;;) {
// 用於從 page cache 中獲取快取的檔案資料 page
struct page *page;
// 根據檔案讀取偏移計算出 第一個位元組所在物理頁的索引
pgoff_t index;
// 根據檔案讀取偏移計算出 第一個位元組所在物理頁中的頁內偏移
unsigned long offset;
// 在 page cache 中查詢是否有讀取資料在記憶體中的快取頁
page = find_get_page(mapping, index);
if (!page) {
if (iocb->ki_flags & IOCB_NOWAIT) {
....... 如果設定的是非同步IO,則直接返回 -EAGAIN ......
}
// 要讀取的檔案資料在 page cache 中沒有對應的快取頁
// 則從磁碟中讀取檔案資料,並同步預讀若干相鄰的資料塊到 page cache中
page_cache_sync_readahead(mapping,
ra, filp,
index, last_index - index);
// 再一次觸發快取頁的查詢,這一次就可以找到了
page = find_get_page(mapping, index);
if (unlikely(page == NULL))
goto no_cached_page;
}
//如果讀取的檔案資料已經在 page cache 中了,則判斷是否進行近一步的預讀操作
if (PageReadahead(page)) {
//非同步預讀若干檔案資料塊到 page cache 中
page_cache_async_readahead(mapping,
ra, filp, page,
index, last_index - index);
}
..............省略..............
//將 page cache 中的資料拷貝到使用者空間緩衝區 DirectByteBuffer 中
ret = copy_page_to_iter(page, offset, nr, iter);
}
}
到這裡關於檔案讀取的兩種模式 Buffered IO 和 Direct IO 在核心中的主幹邏輯流程筆者就為大家介紹完了。
但是大家可能會對 Buffered IO 中的兩個細節比較感興趣:
如何在 page cache 中查詢我們要讀取的檔案資料 ?也就是說上面提到的 find_get_page 函數是如何實現的?
檔案預讀的過程是怎麼樣的?核心中的預讀演演算法又是什麼樣的呢?
在為大家解答這兩個疑問之前,筆者先為大家介紹一下核心中的頁快取記憶體 page cache。
筆者在《一文聊透物件在 JVM 中的記憶體佈局,以及記憶體對齊和壓縮指標的原理及應用》 文章中為大家介紹 CPU 的快取記憶體時曾提到過,根據摩爾定律:晶片中的電晶體數量每隔 18 個月就會翻一番。導致 CPU 的效能和處理速度變得越來越快,而提升 CPU 的執行速度比提升記憶體的執行速度要容易和便宜的多,所以就導致了 CPU 與記憶體之間的速度差距越來越大。
CPU 與記憶體之間的速度差異到底有多大呢? 我們知道暫存器是離 CPU 最近的,CPU 在存取暫存器的時候速度近乎於 0 個時鐘週期,存取速度最快,基本沒有時延。而存取記憶體則需要 50 - 200 個時鐘週期。
所以為了彌補 CPU 與記憶體之間巨大的速度差異,提高 CPU 的處理效率和吞吐,於是我們引入了 L1 , L2 , L3 快取記憶體整合到 CPU 中。CPU 存取快取記憶體僅需要用到 1 - 30 個時鐘週期,CPU 中的快取記憶體是對記憶體熱點資料的一個快取。
而本文我們討論的主題是記憶體與磁碟之間的關係,CPU 存取磁碟的速度就更慢了,需要用到大概約幾千萬個時鐘週期.
我們可以看到 CPU 存取快取記憶體的速度比存取記憶體的速度快大約10倍,而存取記憶體的速度要比存取磁碟的速度快大約 100000 倍。
引入 CPU 快取記憶體的目的在於消除 CPU 與記憶體之間的速度差距,CPU 用快取記憶體來存放記憶體中的熱點資料。那麼同樣的道理,本小節中我們引入的頁快取記憶體 page cache 的目的是為了消除記憶體與磁碟之間的巨大速度差距,page cache 中快取的是磁碟檔案的熱點資料。
另外我們根據程式的時間區域性性原理可以知道,磁碟檔案中的資料一旦被存取,那麼它很有可能在短期被再次存取,如果我們存取的磁碟檔案資料快取在 page cache 中,那麼當程序再次存取的時候資料就會在 page cache 中命中,這樣我們就可以把對磁碟的存取變為對實體記憶體的存取,極大提升了對磁碟的存取效能。
程式區域性性原理表現為:時間區域性性和空間區域性性。時間區域性性是指如果程式中的某條指令一旦執行,則不久之後該指令可能再次被執行;如果某塊資料被存取,則不久之後該資料可能再次被存取。空間區域性性是指一旦程式存取了某個儲存單元,則不久之後,其附近的儲存單元也將被存取。
在前邊的內容中我們多次提到作業系統是將實體記憶體分為一個一個的頁面來組織管理的,每頁大小為 4k ,而磁碟中的檔案資料在磁碟中是分為一個一個的塊來組織管理的,每塊大小也為 4k。
page cache 中快取的就是這些記憶體頁面,頁面中的資料對應於磁碟上物理塊中的資料。page cache 中快取的大小是可以動態調整的,它可以通過佔用空閒記憶體來擴大快取頁面的容量,當記憶體不足時也可以通過回收頁面來緩解記憶體使用的壓力。
正如我們上小節介紹的 read 系統呼叫在核心中的實現邏輯那樣,當用戶程序發起 read 系統呼叫之後,核心首先會在 page cache 中檢查請求資料所在頁面是否已經快取在 page cache 中。
如果快取命中,核心直接會把 page cache 中快取的磁碟檔案資料拷貝到使用者空間緩衝區 DirectByteBuffer 中,從而避免了龜速的磁碟 IO。
如果快取沒有命中,核心會分配一個物理頁面,將這個新分配的頁面插入 page cache 中,然後排程磁碟塊 IO 驅動從磁碟中讀取資料,最後用從磁碟中讀取的資料填充這個物裡頁面。
根據前面介紹的程式時間區域性性原理,當程序在不久之後再來讀取資料的時候,請求的資料已經在 page cache 中了。極大地提升了檔案 IO 的效能。
page cache 中快取的不僅有基於檔案的快取頁,還會快取記憶體對映檔案,以及磁碟塊裝置檔案。這裡大家只需要有這個概念就行,本文我們主要聚焦於基於檔案的快取頁。在筆者後面的文章中,我們還會再次介紹到這些剩餘型別的快取頁。
在我們瞭解了 page cache 引入的目的以及 page cache 在磁碟 IO 中所發揮的作用之後,大家一定會很好奇這個 page cache 在核心中到底是怎麼實現的呢?
讓我們先從 page cache 在核心中的資料結構開始聊起~~~~
page cache 在核心中的資料結構是一個叫做 address_space 的結構體:struct address_space。
這個名字起的真是有點詞不達意,從命名上根本無法看出它是表示 page cache 的,所以大家在日常開發中一定要注意命名的精準規範。
每個檔案都會有自己的 page cache。struct address_space 結構在記憶體中只會保留一份。
什麼意思呢?比如我們可以通過多個不同的程序開啟一個相同的檔案,程序每開啟一個檔案,核心就會為它建立 struct file 結構。這樣在核心中就會有多個 struct file 結構來表示同一個檔案,但是同一個檔案的 page cache 也就是 struct address_space 在核心中只會有一個。
struct address_space {
struct inode *host; // 關聯 page cache 對應檔案的 inode
struct radix_tree_root page_tree; // 這裡就是 page cache。裡邊快取了檔案的所有快取頁面
spinlock_t tree_lock; // 存取 page_tree 時用到的自旋鎖
unsigned long nrpages; // page cache 中快取的頁面總數
..........省略..........
const struct address_space_operations *a_ops; // 定義對 page cache 中快取頁的各種操作方法
..........省略..........
}
struct inode *host
:一個檔案對應一個 page cache 結構 struct address_space ,檔案的 inode 描述了一個檔案的所有元資訊。在 struct address_space 中通過 host 指標與檔案的 inode 關聯。而在 inode 結構體 struct inode 中又通過 i_mapping 指標與檔案的 page cache 進行關聯。struct inode {
struct address_space *i_mapping; // 關聯檔案的 page cache
}
struct radix_tree_root page_tree
: page cache 中快取的所有檔案頁全部儲存在 radix_tree 這樣一個高效搜尋樹結構當中。在檔案 IO 相關的操作中,核心需要頻繁大量地在 page cache 中搜尋請求頁是否已經快取在頁快取記憶體中,所以針對 page cache 的搜尋操作必須是高效的,否則引入 page cache 所帶來的效能提升將會被低效的搜尋開銷所抵消掉。
unsigned long nrpages
:記錄了當前檔案對應的 page cache 快取頁面的總數。
const struct address_space_operations *a_ops
:a_ops 定義了 page cache 中所有針對快取頁的 IO 操作,提供了管理 page cache 的各種行為。比如:常用的頁面讀取操作 readPage() 以及頁面寫入操作 writePage() 等。保證了所有針對快取頁的 IO 操作必須是通過 page cache 進行的。
struct address_space_operations {
// 寫入更新頁面快取
int (*writepage)(struct page *page, struct writeback_control *wbc);
// 讀取頁面快取
int (*readpage)(struct file *, struct page *);
// 設定快取頁為髒頁,等待後續核心回寫磁碟
int (*set_page_dirty)(struct page *page);
// Direct IO 繞過 page cache 直接操作磁碟
ssize_t (*direct_IO)(struct kiocb *, struct iov_iter *iter);
........省略..........
}
前邊我們提到 page cache 中快取的不僅僅是基於檔案的頁,它還會快取記憶體對映頁,以及磁碟塊裝置檔案,況且基於檔案的記憶體頁背後也有不同的檔案系統。所以核心只是通過 a_ops 定義了操作 page cache 快取頁 IO 的通用行為定義。而具體的實現需要各個具體的檔案系統通過自己定義的 address_space_operations 來描述自己如何與 page cache 進行互動。比如前邊我們介紹的 ext4 檔案系統就有自己的 address_space_operations 定義。
static const struct address_space_operations ext4_aops = {
.readpage = ext4_readpage,
.writepage = ext4_writepage,
.direct_IO = ext4_direct_IO,
........省略.....
};
在我們從整體上了解了 page cache 在核心中的資料結構 struct address_space 之後,我們接下來看一下 radix_tree 這個資料結構是如何支援核心來高效搜尋檔案頁的,以及 page cache 中這些被快取的檔案頁是如何組織管理的。
正如前邊我們提到的,在檔案 IO 相關的操作中,核心會頻繁大量地在 page cache 中查詢請求頁是否在頁快取記憶體中。還有就是當我們存取大檔案時(linux 能支援大到幾個 TB 的檔案),page cache 中將會充斥著大量的檔案頁。
基於上面提到的兩個原因:一個是核心對 page cache 的頻繁搜尋操作,另一個是 page cache 中會快取大量的檔案頁。所以核心需要採用一個高效的搜尋資料結構來組織管理 page cache 中的快取頁。
本小節我們就來介紹下,page cache 中用來儲存快取頁的資料結構 radix_tree。
在 linux 核心 5.0 版本中 radix_tree 已被替換成 xarray 結構。感興趣的同學可以自行了解下。
在 page cache 結構 struct address_space 中有一個型別為 struct radix_tree_root 的欄位 page_tree,它表示的是 radix_tree 的根節點。
struct address_space {
struct radix_tree_root page_tree; // 這裡就是 page cache。裡邊快取了檔案的所有快取頁面
..........省略..........
}
struct radix_tree_root {
gfp_t gfp_mask;
struct radix_tree_node __rcu *rnode; // radix_tree 根節點
};
radix_tree 中的節點型別為 struct radix_tree_node。
struct radix_tree_node {
void __rcu *slots[RADIX_TREE_MAP_SIZE]; //包含 64 個指標的陣列。用於指向下一層節點或者快取頁
unsigned char offset; //父節點中指向該節點的指標在父節點 slots 陣列中的偏移
unsigned char count;//記錄當前節點的 slots 陣列指向了多少個節點
struct radix_tree_node *parent; // 父節點指標
struct radix_tree_root *root; // 根節點
..........省略.........
unsigned long tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS]; // radix_tree 中的二維標記陣列,用於標記子節點的狀態。
};
void __rcu *slots[RADIX_TREE_MAP_SIZE]
:radix_tree 樹中的每個節點中包含一個 slots ,它是一個包含 64 個指標的陣列,每個指標指向它的下一層節點或者快取頁描述符 struct page。
radix_tree 將快取頁全部存放在它的葉子結點中,所以它的葉子結點型別為 struct page。其餘的節點型別為 radix_tree_node。最底層的 radix_tree_node 節點中的 slots 指向快取頁描述符 struct page。
unsigned char offset
用於表示父節點的 slots 陣列中指向當前節點的指標,在父節點的slots陣列中的索引。
unsigned char count
用於記錄當前 radix_tree_node 的 slots 陣列中指向的節點個數,因為 slots 陣列中的指標有可能指向 null 。
這裡大家可能已經注意到了在 struct radix_tree_node 結構中還有一個 long 型的 tags 二維陣列 tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS]
。那麼這個二維陣列到底是用來幹嘛的呢?我們接著往下看~~
經過前面的介紹我們知道,頁快取記憶體 page cache 的引入是為了在記憶體中快取磁碟的熱點資料儘可能避免龜速的磁碟 IO。
而在進行檔案 IO 的時候,核心會頻繁大量的在 page cache 中搜尋請求資料是否已經快取在 page cache 中,如果是,核心就直接將 page cache 中的資料拷貝到使用者緩衝區中。從而避免了一次磁碟 IO。
這就要求核心需要採用一種支援高效搜尋的資料結構來組織管理這些快取頁,所以引入了基樹 radix_tree。
到目前為止,我們還沒有涉及到快取頁的狀態,不過在文章的後面我們很快就會涉及到,這裡提前給大家引出來,讓大家腦海裡先有個概念。
那麼什麼是快取頁的狀態呢?
我們知道在 Buffered IO 模式下,對於檔案 IO 的操作都是需要經過 page cache 的,後面我們即將要介紹的 write 系統呼叫就會將資料直接寫到 page cache 中,並將該快取頁標記為髒頁(PG_dirty)直接返回,隨後核心會根據一定的規則來將這些髒頁回寫到磁碟中,在會寫的過程中這些髒頁又會被標記為 PG_writeback,表示該頁正在被回寫到磁碟。
PG_dirty 和 PG_writeback 就是快取頁的狀態,而核心不僅僅是需要在 page cache 中高效搜尋請求資料所在的快取頁,還需要高效搜尋給定狀態的快取頁。
比如:快速查詢 page cache 中的所有髒頁。但是如果此時 page cache 中的大部分快取頁都不是髒頁,那麼順序遍歷 radix_tree 的方式就實在是太慢了,所以為了快速搜尋到髒頁,就需要在 radix_tree 中的每個節點 radix_tree_node
中加入一個針對其所有子節點的髒頁標記,如果其中一個子節點被標記被髒時,那麼這個子節點對應的父節點 radix_tree_node 結構中的對應髒頁標記位就會被置 1 。
而用來儲存髒頁標記的正是上小節中提到的 tags 二維陣列。其中第一維 tags[] 用來表示標記型別,有多少標記型別,陣列大小就為多少,比如 tags[0] 表示 PG_dirty 標記陣列,tags[1] 表示 PG_writeback 標記陣列。
第二維 tags[][] 陣列則表示對應標記型別針對每一個子節點的標記位,因為一個 radix_tree_node 節點中包含 64 個指標指向對應的子節點,所以二維 tags[][] 陣列的大小也為 64 ,陣列中的每一位表示對應子節點的標記。tags[0][0] 指向 PG_dirty 標記陣列,tags[1][0] 指向PG_writeback 標記陣列。
而快取頁( radix_tree 中的葉子結點)這些標記是存放在其對應的頁描述符 struct page 裡的 flag 中。
struct page {
unsigned long flags;
}
只要一個快取頁(葉子結點)被標記,那麼從這個葉子結點一直到 radix_tree 根節點的路徑將會全部被標記。這就好比你在一盆清水中滴入一滴墨水,不久之後整盆水就會變為黑色。
這樣核心在 radix_tree 中搜尋被標記的髒頁(PG_dirty)或者正在回寫的頁(PG_writeback)時,就可以迅速跳過哪些標記為 0 的中間節點的所有子樹,中間節點對應的標記為 0 說明其所有的子樹中包含的快取頁(葉子結點)都是乾淨的(未標記)。從而達到在 radix_tree 中迅速搜尋指定狀態的快取頁的目的。
在我們明白了 radix_tree 這個資料結構之後,接下來我們來看一下在《4.2 Buffered IO》小節中遺留的問題:核心如何通過 find_get_page 在 page cache 中高效查詢快取頁?
在介紹 find_get_page 之前,筆者先來帶大家看看 radix_tree 具體是如何組織和管理其中的快取頁 page 的。
經過上小節相關內容的介紹,我們瞭解到在 radix_tree 中每個節點 radix_tree_node 包含一個大小為 64 的指標陣列 slots 用於指向它的子節點或者快取頁描述符(葉子節點)。
一個 radix_tree_node 節點下邊最多可容納 64 個子節點,如果 radix_tree 的深度為 1 (不包括葉子節點),那麼這顆 radix_tree 就可以快取 64 個檔案頁。而每頁大小為 4k,所以一顆深度為 1 的 radix_tree 可以快取 256k 的檔案內容。
而如果一顆 radix_tree 的深度為 2,那麼它就可以快取 64 * 64 = 4096 個檔案頁,總共可以快取 16M 的檔案內容。
依次類推我們可以得到不同的 radix_tree 深度可以快取多大的檔案內容:
radix_tree 深度 | page 最大索引值 | 快取檔案大小 |
---|---|---|
1 | 2^6 - 1 = 63 | 256K |
2 | 2^12 - 1 = 4095 | 16M |
3 | 2^18 - 1 = 262143 | 1G |
4 | 2^24 -1 =16777215 | 64G |
5 | 2^30 - 1 | 4T |
6 | 2^36 - 1 | 64T |
通過以上內容的介紹,我們看到在 radix_tree 是根據快取頁的 index (索引)來組織管理快取頁的,核心會根據這個 index 迅速找到對應的快取頁。在快取頁描述符 struct page 結構中儲存了其在 page cache 中的索引 index。
struct page {
unsigned long flags; //快取頁標記
struct address_space *mapping; // 快取頁所在的 page cache
unsigned long index; // 頁索引
...
}
事實上 find_get_page 函數也是根據快取頁描述符中的這個 index 來在 page cache 中高效查詢對應的快取頁。
static inline struct page *find_get_page(struct address_space *mapping,
pgoff_t offset)
{
return pagecache_get_page(mapping, offset, 0, 0);
}
struct address_space *mapping
: 為讀取檔案對應的 page cache 頁快取記憶體。
pgoff_t offset
: 為所請求的快取頁在 page cache 中的索引 index,型別為 long 型。
那麼在核心是如何利用這個 long 型的 offset 在 page cache 中高效搜尋指定的快取頁呢?
經過前邊我們對 radix_tree 結構的介紹,我們已經知道 radix_tree 中每個節點 radix_tree_node 包含一個大小為 64 的指標陣列 slots 用於指向它的子節點或者快取頁描述符。
一個 radix_tree_node 節點下邊最多可容納 64 個子節點,如果 radix_tree 的深度為 1 (不包括葉子節點),那麼這顆 radix_tree 就可以快取 64 個檔案頁。只能表示 0 - 63 的索引範圍,所以 long 型的快取頁 offset 的低 6 位可以表示這個範圍,對應於第一層 radix_tree_node 節點的 slots 陣列下標。
如果一顆 radix_tree 的深度為 2(不包括葉子節點),那麼它就可以快取 64 * 64 = 4096 個檔案頁,表示的索引範圍為 0 - 4095,在這種情況下,快取頁索引 offset 的低 12 位可以分成 兩個 6 位的欄位,高位的欄位用來表示第一層節點的 slots 陣列的下標,低位欄位用於表示第二層節點的 slots 陣列下標。
依次類推,如果 radix_tree 的深度為 6 那麼它可以快取 64T 的檔案頁,表示的索引範圍為:0 到 2^36 - 1。 快取頁索引 offset 的低 36 位可以分成 六 個 6 位的欄位。快取頁索引的最高位欄位來表示 radix_tree 中的第一層節點中的 slots 陣列下標,接下來的 6 位欄位表示第二層節點中的 slots 陣列下標,這樣一直到最低的 6 位欄位表示第 6 層節點中的 slots 陣列下標。
通過以上根據快取頁索引 offset 的查詢過程,我們看出核心在 page cache 查詢快取頁的時間複雜度和 radix_tree 的深度有關。
在我們理解了核心在 radix_tree 中的查詢快取頁邏輯之後,再來看 find_get_page 的程式碼實現就變得很簡單了~~
struct page *pagecache_get_page(struct address_space *mapping, pgoff_t offset,
int fgp_flags, gfp_t gfp_mask)
{
struct page *page;
repeat:
// 在 radix_tree 中根據 快取頁 offset 查詢快取頁
page = find_get_entry(mapping, offset);
// 快取頁不存在的話,跳轉到 no_page 處理邏輯
if (!page)
goto no_page;
.......省略.......
no_page:
if (!page && (fgp_flags & FGP_CREAT)) {
// 分配新頁
page = __page_cache_alloc(gfp_mask);
if (!page)
return NULL;
if (fgp_flags & FGP_ACCESSED)
//增加頁的參照計數
__SetPageReferenced(page);
// 將新分配的記憶體頁加入到頁快取記憶體 page cache 中
err = add_to_page_cache_lru(page, mapping, offset, gfp_mask);
.......省略.......
}
return page;
}
核心首先呼叫 find_get_entry 方法根據快取頁的 offset 到 page cache 中去查詢看請求的檔案頁是否已經在頁快取記憶體中。如果存在直接返回。
如果請求的檔案頁不在 page cache 中,核心則會首先會在實體記憶體中分配一個記憶體頁,然後將新分配的記憶體頁加入到 page cache 中,並增加頁參照計數。
隨後會通過 address_space_operations 重定義的 readpage 啟用塊裝置驅動從磁碟中讀取請求資料,然後用讀取到的資料填充新分配的記憶體頁。
static const struct address_space_operations ext4_aops = {
.readpage = ext4_readpage,
.writepage = ext4_writepage,
.direct_IO = ext4_direct_IO,
........省略.....
};
之前我們在引入 page cache 的時候提到過,根據程式時間區域性性原理:如果程序在存取某一塊資料,那麼在存取的不久之後,程序還會再次存取這塊資料。所以核心引入了 page cache 在記憶體中快取磁碟中的熱點資料,從而減少對磁碟的 IO 存取,提升系統效能。
而本小節我們要介紹的檔案頁預讀特性是根據程式空間區域性性原理:當程序存取一段資料之後,那麼在不就的將來和其臨近的一段資料也會被存取到。所以當程序在存取檔案中的某頁資料的時候,核心會將它和臨近的幾個頁一起預讀到 page cache 中。這樣當程序再次存取檔案的時候,就不需要進行龜速的磁碟 IO 了,因為它所請求的資料已經預讀進 page cache 中了。
我們常提到的當你順序讀取檔案的時候,效能會非常的高,因為相當於是在讀記憶體,這就是檔案預讀的功勞。
但是在我們隨機存取檔案的時候,檔案預讀不僅不會提高效能,返回會降低檔案讀取的效能,因為隨機讀取檔案並不符合程式空間區域性性原理,因此預讀進 page cache 中的檔案頁通常是無效的,下一次根本不會再去讀取,這無疑是白白浪費了 page cache 的空間,還額外增加了不必要的預讀磁碟 IO。
事實上,在我們對檔案進行隨機讀取的場景下,更適合用 Direct IO 的方式繞過 page cache 直接從磁碟中讀取檔案,還能減少一次從 page cache 到使用者緩衝區的拷貝。
所以核心需要一套非常精密的預讀演演算法來根據程序是順序讀檔案還是隨機讀檔案來精確地調控預讀的檔案頁數,或者直接關閉預讀。
如果程序持續的順序存取一個檔案,那麼預讀頁數也會隨著逐步增加。
當發現程序開始隨機存取檔案了(當前存取的檔案頁和最後一次存取的檔案頁 offset 不是連續的),核心就會逐步減少預讀頁數或者徹底禁止預讀。
當核心發現程序再重複的存取同一檔案頁時或者檔案中的檔案頁已經幾乎全部快取在 page cache 中了,核心此時就會禁止預讀。
以上幾點就是核心的預讀演演算法的核心邏輯,從這個預讀邏輯中我們可以看出,程序在進行檔案讀取的時候涉及到兩種不同型別的頁面集合,一個是程序可以請求的檔案頁(已經快取在 page cache 中的檔案頁),另一個是核心預讀的檔案頁。
而核心也確實按照這兩種頁面集合分為兩個視窗:
當前視窗(current window): 表示程序本次檔案請求可以直接讀取的頁面集合,這個集合中的頁面全部已經快取在 page cache 中,程序可以直接讀取返回。當前視窗中包含程序本次請求的檔案頁以及上次核心預讀的檔案頁集合。表示程序本次可以從 page cache 直接獲取的頁面範圍。
預讀視窗(ahead window):預讀視窗的頁面都是核心正在預讀的檔案頁,它們此時並不在 page cache 中。這些頁面並不是程序請求的檔案頁,但是核心根據空間區域性性原理假定它們遲早會被程序請求。預讀視窗內的頁面緊跟著當前視窗後面,並且核心會動態調整預讀視窗的大小(有點類似於 TCP 中的滑動視窗)。
如果程序本次檔案請求的第一頁的 offset,緊跟著上一次檔案請求的最後一頁的 offset,核心就認為是順序讀取。在順序讀取檔案的場景下,如果請求的第一頁在當前視窗內,核心隨後就會檢查是否建立了預讀視窗,如果沒有就會建立預讀視窗並觸發相應頁的讀取操作。
在理想情況下,程序會繼續在當前視窗內請求頁,於此同時,預讀視窗內的預讀頁同時非同步傳送著,這樣程序在順序讀取檔案的時候就相當於直接讀取記憶體,極大地提高了檔案 IO 的效能。
以上包含的這些檔案預讀資訊,比如:如何判斷程序是順序讀取還是隨機讀取,當前視窗資訊,預讀視窗資訊。全部儲存在 struct file 結構中的 f_ra 欄位中。
struct file {
struct file_ra_state f_ra;
}
用於描述檔案預讀資訊的結構體在核心中用 struct file_ra_state 結構體來表示:
struct file_ra_state {
pgoff_t start; // 當前視窗第一頁的索引
unsigned int size; // 當前視窗的頁數,-1表示臨時禁止預讀
unsigned int async_size; // 非同步預讀頁面的頁數
unsigned int ra_pages; // 檔案允許的最大預讀頁數
loff_t prev_pos; // 程序最後一次請求頁的索引
};
核心可以根據 start 和 prev_pos 這兩個欄位來判斷程序是否在順序存取檔案。
ra_pages 表示當前檔案允許預讀的最大頁數,程序可以通過系統呼叫 posix_fadvise() 來改變已開啟檔案的 ra_page 值來調優預讀演演算法。
int posix_fadvise(int fd, off_t offset, off_t len, int advice);
該系統呼叫用來通知核心,我們將來打算以特定的模式 advice 存取檔案資料,從而允許核心執行適當的優化。
advice 引數主要有下面幾種數值:
POSIX_FADV_NORMAL : 設定檔案最大預讀頁數 ra_pages 為預設值 32 頁。
POSIX_FADV_SEQUENTIAL : 程序期望順序存取指定的檔案資料,ra_pages 值為預設值的兩倍。
POSIX_FADV_RANDOM :程序期望以隨機順序存取指定的檔案資料。ra_pages 設定為 0,表示禁止預讀。
後來人們發現當禁止預讀後,這樣一頁一頁的讀取效能非常的低下,於是 linux 3.19.8 之後 POSIX_FADV_RANDOM 的語意被改變了,它會在 file->f_flags 中設定 FMODE_RANDOM 屬性(後面我們分析核心預讀相關原始碼的時候還會提到),當遇到 FMODE_RANDOM 的時候核心就會走強制預讀的邏輯,按最大 2MB 單元大小的 chunk 進行預讀。
This fixes inefficient page-by-page reads on POSIX_FADV_RANDOM.
POSIX_FADV_RANDOM used to set ra_pages=0, which leads to poor
performance: a 16K read will be carried out in 4 _sync_ 1-page reads.
而觸發核心進行檔案預讀的場景,分為以下幾種:
當程序採用 Buffered IO 模式通過系統呼叫 read 進行檔案讀取時,核心會觸發預讀。
通過 POSIX_FADV_WILLNEED 引數執行系統呼叫 posix_fadvise,會通知核心這個指定範圍的檔案頁不就將會被存取。觸發預讀。
當程序顯示執行 readahead() 系統呼叫時,會顯示觸發核心的預讀動作。
當核心為記憶體檔案對映區域分配一個物理頁面時,會觸發預讀。關於記憶體對映的相關內容,筆者會在後面的文章為大家詳細介紹。
和 posix_fadvise 一樣的道理,系統呼叫 madvise 主要用來指定記憶體檔案對映區域的存取模式。可通過 advice = MADV_WILLNEED 通知核心,某個檔案記憶體對映區域中的指定範圍的檔案頁在不久將會被存取。觸發預讀。
int madvise(caddr_t addr, size_t len, int advice);
從觸發核心預讀的這幾種場景中我們可以看出,預讀分為主動觸發和被動觸發,在《4.2 Buffered IO》小節中遺留的 page_cache_sync_readahead 函數為被動觸發,接下來我們來看下它在核心中的實現邏輯。
void page_cache_sync_readahead(struct address_space *mapping,
struct file_ra_state *ra, struct file *filp,
pgoff_t offset, unsigned long req_size)
{
// 禁止預讀,直接返回
if (!ra->ra_pages)
return;
if (blk_cgroup_congested())
return;
// 通過 posix_fadvise 設定了 POSIX_FADV_RANDOM,核心走強制預讀邏輯
if (filp && (filp->f_mode & FMODE_RANDOM)) {
// 按最大2MB單元大小的chunk進行預讀
force_page_cache_readahead(mapping, filp, offset, req_size);
return;
}
// 執行預讀邏輯
ondemand_readahead(mapping, ra, filp, false, offset, req_size);
}
!ra->ra_pages
表示 ra_pages 設定為 0 ,預讀被禁止,直接返回。
如果程序通過前邊介紹的 posix_fadvise 系統呼叫並且 advice 引數設定為 POSIX_FADV_RANDOM。在 linux 3.19.8 之後檔案的 file->f_flags 屬性會被設定為 FMODE_RANDOM,這樣核心會走強制預讀邏輯,按最大 2MB 單元大小的 chunk 進行預讀。
int posix_fadvise(int fd, off_t offset, off_t len, int advice);
// mm/fadvise.c
switch (advice) {
.........省略........
case POSIX_FADV_RANDOM:
.........省略........
file->f_flags |= FMODE_RANDOM;
.........省略........
break;
.........省略........
}
而真正的預讀邏輯封裝在 ondemand_readahead 函數中。
該方法中封裝了前邊介紹的預讀演演算法邏輯,動態的調整當前視窗以及預讀視窗的大小。
/*
* A minimal readahead algorithm for trivial sequential/random reads.
*/
static unsigned long
ondemand_readahead(struct address_space *mapping,
struct file_ra_state *ra, struct file *filp,
bool hit_readahead_marker, pgoff_t offset,
unsigned long req_size)
{
struct backing_dev_info *bdi = inode_to_bdi(mapping->host);
unsigned long max_pages = ra->ra_pages; // 預設32頁
unsigned long add_pages;
pgoff_t prev_offset;
........預讀演演算法邏輯,動態調整當前視窗和預讀視窗.........
//根據條件,計算本次預讀最大預讀取多少個頁,一般情況下是max_pages=32個頁
if (req_size > max_pages && bdi->io_pages > max_pages)
max_pages = min(req_size, bdi->io_pages);
//offset即page index,如果page index=0,表示這是檔案第一個頁,
//核心認為是順序讀,跳轉到initial_readahead進行處理
if (!offset)
goto initial_readahead;
initial_readahead:
// 當前視窗第一頁的索引
ra->start = offset;
// get_init_ra_size初始化第一次預讀的頁的個數,一般情況下第一次預讀是4個頁
ra->size = get_init_ra_size(req_size, max_pages);
// 非同步預讀頁面個數也就是預讀視窗大小
ra->async_size = ra->size > req_size ? ra->size - req_size : ra->size;
// 預設情況下是 ra->start=0, ra->size=0, ra->async_size=0 ra->prev_pos=0
// 但是經過第一次預讀後,上面三個值會出現變化
if ((offset == (ra->start + ra->size - ra->async_size) ||
offset == (ra->start + ra->size))) {
ra->start += ra->size;
ra->size = get_next_ra_size(ra, max_pages);
ra->async_size = ra->size;
goto readit;
}
//非同步預讀的時候會進入這個判斷,更新ra的值,然後預讀特定的範圍的頁
//非同步預讀的呼叫表示Readahead出來的頁連續命中
if (hit_readahead_marker) {
pgoff_t start;
rcu_read_lock();
// 這個函數用於找到offset + 1開始到offset + 1 + max_pages這個範圍內,第一個不在page cache的頁的index
start = page_cache_next_miss(mapping, offset + 1, max_pages);
rcu_read_unlock();
if (!start || start - offset > max_pages)
return 0;
ra->start = start;
ra->size = start - offset; /* old async_size */
ra->size += req_size;
// 由於連續命中,get_next_ra_size會加倍上次的預讀頁數
// 第一次預讀了4個頁
// 第二次命中以後,預讀8個頁
// 第三次命中以後,預讀16個頁
// 第四次命中以後,預讀32個頁,達到預設情況下最大的讀取頁數
// 第五次、第六次、第N次命中都是預讀32個頁
ra->size = get_next_ra_size(ra, max_pages);
ra->async_size = ra->size;
goto readit;
........ 省略.........
return __do_page_cache_readahead(mapping, filp, offset, req_size, 0);
}
struct address_space *mapping
: 讀取檔案對應的 page cache 結構。
struct file_ra_state *ra
: 檔案對應的預讀狀態資訊,封裝在 file->f_ra 中。
struct file *filp
: 讀取檔案對應的 struct file 結構。
pgoff_t offset
: 本次請求檔案頁在 page cache 中的索引。(檔案頁偏移)
long req_size
: 要完成當前讀操作還需要讀取的頁數。
在預讀演演算法邏輯中,核心通過 struct file_ra_state 結構中封裝的檔案預讀資訊來判斷檔案的讀取是否為順序讀。比如:
通過檢查 ra->prev_pos 和 offset 是否相同,來判斷當前請求頁是否和最近一次請求的頁相同,如果重複存取同一頁,預讀就會停止。
通過檢查 ra->prev_pos 和 offset 是否相鄰,來判斷程序是否順序讀取檔案。如果是順序存取檔案,預讀就會增加。
當程序第一次存取檔案時,並且請求的第一個檔案頁在檔案中的偏移量為 0 時表示程序從頭開始讀取檔案,那麼核心就會認為程序想要順序的存取檔案,隨後核心就會從檔案的第一頁開始建立一個新的當前視窗,初始的當前視窗總是 2 的次冪,視窗具體大小與程序的讀操作所請求的頁數有一定的關係。請求頁數越大,當前視窗就越大,直到最大值 ra->ra_pages 。
static unsigned long get_init_ra_size(unsigned long size, unsigned long max)
{
unsigned long newsize = roundup_pow_of_two(size);
if (newsize <= max / 32)
newsize = newsize * 4;
else if (newsize <= max / 4)
newsize = newsize * 2;
else
newsize = max;
return newsize;
}
相反,當程序第一次存取檔案,但是請求頁在檔案中的偏移量不為 0 時,核心就會假定程序不準備順序讀取檔案,函數就會暫時禁止預讀。
一旦核心發現程序在當前視窗內執行了順序讀取,那麼預讀視窗就會被建立,預讀視窗總是緊挨著當前視窗的最後一頁。
預讀視窗的大小和當前視窗有關,如果已經被預讀的頁不在 page cache 中(可能記憶體緊張,預讀頁被回收),那麼預讀視窗就會是 當前視窗大小 - 2
,最小值為 4。否則預讀視窗就會是當前視窗的4倍或者2倍。
當程序繼續順序存取檔案時,最終預讀視窗就會變為當前視窗,隨後新的預讀視窗就會被建立,隨著程序順序地讀取檔案,預讀會越來越大,但是核心一旦發現對於檔案的存取 offset 相對於上一次的請求頁 ra->prev_pos 不是順序的時候,當前視窗和預讀視窗就會被清空,預讀被暫時禁止。
當核心通過以上介紹的預讀演演算法確定了預讀視窗的大小之後,就開始呼叫 __do_page_cache_readahead 從磁碟去預讀指定的頁數到 page cache 中。
unsigned int __do_page_cache_readahead(struct address_space *mapping,
struct file *filp, pgoff_t offset, unsigned long nr_to_read,
unsigned long lookahead_size)
{
struct inode *inode = mapping->host;
struct page *page;
unsigned long end_index; /* The last page we want to read */
int page_idx;
unsigned int nr_pages = 0;
loff_t isize = i_size_read(inode);
end_index = ((isize - 1) >> PAGE_SHIFT);
/*
* 儘可能的一次性分配全部需要預讀的頁 nr_to_read
* 注意這裡是儘可能的分配,意思就是能分配多少就分配多少,並不一定要全部分配
*/
for (page_idx = 0; page_idx < nr_to_read; page_idx++) {
pgoff_t page_offset = offset + page_idx;
if (page_offset > end_index)
break;
.......省略.....
// 首先在記憶體中為預讀資料分配物理頁面
page = __page_cache_alloc(gfp_mask);
if (!page)
break;
// 設定新分配的物理頁在 page cache 中的索引
page->index = page_offset;
// 將新分配的物理頁面加入到 page cache 中
list_add(&page->lru, &page_pool);
if (page_idx == nr_to_read - lookahead_size)
// 設定頁面屬性為 PG_readahead 後續會開啟非同步預讀
SetPageReadahead(page);
nr_pages++;
}
/*
* 當需要預讀的頁面分配完畢之後,開始真正的 IO 動作,從磁碟中讀取
* 資料填充 page cache 中的快取頁。
*/
if (nr_pages)
read_pages(mapping, filp, &page_pool, nr_pages, gfp_mask);
BUG_ON(!list_empty(&page_pool));
out:
return nr_pages;
}
核心呼叫 read_pages 方法啟用磁碟塊裝置驅動程式從磁碟中讀取檔案資料之前,需要為本次程序讀取請求所需要的所有頁面儘可能地一次性全部分配,如果不能一次性分配全部頁面,預讀操作就只在分配好的快取頁面上進行,也就是說只從磁碟中讀取資料填充已經分配好的頁面。
注意:下面的例子並不是最佳實踐,之所以這裡引入 HeapByteBuffer 是為了將上篇文章的內容和本文銜接起來。事實上,對於 IO 的操作一般都會選擇 DirectByteBuffer ,關於 DirectByteBuffer 的相關內容筆者會在後面的文章中詳細為大家介紹。
FileChannel fileChannel = new RandomAccessFile(new File("file-read-write.txt"), "rw").getChannel();
ByteBuffer heapByteBuffer = ByteBuffer.allocate(4096);
fileChannel.write(heapByteBuffer);
在對檔案進行讀寫之前,我們需要首先利用 RandomAccessFile 在核心中開啟指定的檔案 file-read-write.txt ,並獲取到它的檔案描述符 fd = 5000。
本例 heapByteBuffer 中存放著需要寫入檔案的內容,隨後來到 FileChannelImpl 實現類呼叫 IOUtil 觸發底層系統呼叫 write 來寫入檔案。
public class FileChannelImpl extends FileChannel {
// 前邊介紹開啟的檔案描述符 5000
private final FileDescriptor fd;
// NIO中用它來觸發 native read 和 write 的系統呼叫
private final FileDispatcher nd;
// 讀寫檔案時加鎖,前邊介紹 FileChannel 的讀寫方法均是執行緒安全的
private final Object positionLock = new Object();
public int write(ByteBuffer src) throws IOException {
ensureOpen();
if (!writable)
throw new NonWritableChannelException();
synchronized (positionLock) {
//寫入的位元組數
int n = 0;
try {
......省略......
if (!isOpen())
return 0;
do {
n = IOUtil.write(fd, src, -1, nd);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
// 返回寫入的位元組數
return IOStatus.normalize(n);
} finally {
......省略......
}
}
}
}
NIO 中的所有 IO 操作全部封裝在 IOUtil 類中,而 NIO 中的 SocketChannel 以及這裡介紹的 FileChannel 底層依賴的系統呼叫可能不同,這裡會通過 NativeDispatcher 對具體 Channel 操作實現分發,呼叫具體的系統呼叫。對於 FileChannel 來說 NativeDispatcher 的實現類為 FileDispatcher。對於 SocketChannel 來說 NativeDispatcher 的實現類為 SocketDispatcher。
public class IOUtil {
static int write(FileDescriptor fd, ByteBuffer src, long position,
NativeDispatcher nd)
throws IOException
{
// 標記傳遞進來的 heapByteBuffer 的 position 位置用於後續恢復
int pos = src.position();
// 獲取 heapByteBuffer 的 limit 用於計算 寫入位元組數
int lim = src.limit();
assert (pos <= lim);
// 寫入的位元組數
int rem = (pos <= lim ? lim - pos : 0);
// 建立臨時的 DirectByteBuffer,用於通過系統呼叫 write 寫入資料到核心
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
// 將 heapByteBuffer 中的內容拷貝到臨時 DirectByteBuffer 中
bb.put(src);
// DirectByteBuffer 切換為讀模式,用於後續傳送資料
bb.flip();
// 恢復 heapByteBuffer 中的 position
src.position(pos);
int n = writeFromNativeBuffer(fd, bb, position, nd);
if (n > 0) {
// 此時 heapByteBuffer 中的內容已經傳送完畢,更新它的 postion + n
// 這裡表達的語意是從 heapByteBuffer 中讀取了 n 個位元組並行送成功
src.position(pos + n);
}
// 返回傳送成功的位元組數
return n;
} finally {
// 釋放臨時建立的 DirectByteBuffer
Util.offerFirstTemporaryDirectBuffer(bb);
}
}
private static int writeFromNativeBuffer(FileDescriptor fd, ByteBuffer bb,
long position, NativeDispatcher nd)
throws IOException
{
int pos = bb.position();
int lim = bb.limit();
assert (pos <= lim);
// 要傳送的位元組數
int rem = (pos <= lim ? lim - pos : 0);
int written = 0;
if (rem == 0)
return 0;
if (position != -1) {
........省略.......
} else {
written = nd.write(fd, ((DirectBuffer)bb).address() + pos, rem);
}
if (written > 0)
// 傳送完畢之後更新 DirectByteBuffer 的position
bb.position(pos + written);
// 返回寫入的位元組數
return written;
}
}
在 IOUtil 中首先建立一個臨時的 DirectByteBuffer,然後將本例中 HeapByteBuffer 中的資料全部拷貝到這個臨時的 DirectByteBuffer 中。這個 DirectByteBuffer 就是我們在 IO 系統呼叫中經常提到的使用者空間緩衝區。
隨後在 writeFromNativeBuffer 方法中通過 FileDispatcher 觸發 JNI 層的
native 方法執行底層系統呼叫 write 。
class FileDispatcherImpl extends FileDispatcher {
int write(FileDescriptor fd, long address, int len) throws IOException {
return write0(fd, address, len);
}
static native int write0(FileDescriptor fd, long address, int len)
throws IOException;
}
NIO 中關於檔案 IO 相關的系統呼叫全部封裝在 JNI 層中的 FileDispatcherImpl.c 檔案中。裡邊定義了各種 IO 相關的系統呼叫的 native 方法。
// FileDispatcherImpl.c 檔案
JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_write0(JNIEnv *env, jclass clazz,
jobject fdo, jlong address, jint len)
{
jint fd = fdval(env, fdo);
void *buf = (void *)jlong_to_ptr(address);
// 發起 write 系統呼叫進入核心
return convertReturnVal(env, write(fd, buf, len), JNI_FALSE);
}
系統呼叫 write 在核心中的定義如下所示:
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
struct fd f = fdget_pos(fd);
......
loff_t pos = file_pos_read(f.file);
ret = vfs_write(f.file, buf, count, &pos);
......
}
現在我們就從使用者空間的 JDK NIO 這一層逐步來到了核心空間的邊界處 --- OS 系統呼叫 write 這裡,馬上就要進入核心了。
這一次我們來看一下當系統呼叫 write 發起之後,使用者程序在核心態具體做了哪些事情?
現在讓我們再次進入核心,來看一下核心中具體是如何處理檔案寫入操作的,這個過程會比檔案讀取要複雜很多,大家需要有點耐心~~
再次強調一下,本文所舉範例中用到的 HeapByteBuffer 只是為了與上篇文章 《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同位元組序下的設計與實現》介紹的內容做出呼應,並不是最佳實踐。筆者會在後續的文章中一步一步為大家展開這塊內容的最佳實踐。
使用 JDK NIO 中的 HeapByteBuffer 在對檔案進行寫入的過程,主要分為如下幾個核心步驟:
首先會在使用者空間的 JDK 層將位於 JVM 堆中的 HeapByteBuffer 中的待寫入資料拷貝到位於 OS 堆中的 DirectByteBuffer 中。這裡發生第一次拷貝
隨後 NIO 會在使用者態通過系統呼叫 write 發起檔案寫入的請求,此時發生第一次上下文切換。
隨後使用者程序進入核心態,在虛擬檔案系統層呼叫 vfs_write 觸發對 page cache 寫入的操作。相關操作封裝在 generic_perform_write 函數中。這個後面筆者會細講,這裡我們只關注核心總體流程。
核心呼叫 iov_iter_copy_from_user_atomic 函數將使用者空間緩衝區 DirectByteBuffer 中的待寫入資料拷貝到 page cache 中。發生第二次拷貝動作,這裡的操作就是我們常說的 CPU 拷貝。
當待寫入資料拷貝到 page cache 中時,核心會將對應的檔案頁標記為髒頁。
髒頁表示記憶體中的資料要比磁碟中對應檔案資料要新。
從這裡我們看到在對檔案進行寫入時,核心只會將資料寫入到 page cache 中。整個寫入過程就完成了,並不會寫到磁碟中。
所謂核心非同步回寫就是核心會定時喚醒一個 flusher 執行緒,定時將記憶體中的髒頁回寫到磁碟中。這部分的內容筆者會在後續的章節中詳細講解。
在 NIO 使用 HeapByteBuffer 在對檔案進行寫入的過程中,一般只會發生兩次拷貝動作和兩次上下文切換,因為核心將資料拷貝到 page cache 中後,檔案寫入過程就結束了。如果髒頁在記憶體中的佔比太高了,達到了程序同步回寫的閾值,那麼就會發生第三次 DMA 拷貝,將髒頁資料回寫到磁碟檔案中。
如果程序需要同步回寫髒頁資料時,在本例中是要發生三次拷貝動作。但一般情況下,在本例中只會發生兩次,沒有第三次的 DMA 拷貝。
在 JDK 10 中我們可以通過如下的方式採用 Direct IO 模式開啟檔案:
FileChannel fc = FileChannel.open(p, StandardOpenOption.WRITE,
ExtendedOpenOption.DIRECT)
在 Direct IO 模式下的檔案寫入操作最明顯的特點就是繞過 page cache 直接通過 DMA 拷貝將使用者空間緩衝區 DirectByteBuffer 中的待寫入資料寫入到磁碟中。
同樣發生兩次上下文切換、
在本例中只會發生兩次資料拷貝,第一次是將 JVM 堆中的 HeapByteBuffer 中的待寫入資料拷貝到位於 OS 堆中的 DirectByteBuffer 中。第二次則是 DMA 拷貝,將使用者空間緩衝區 DirectByteBuffer 中的待寫入資料寫入到磁碟中。
下面是系統呼叫 write 在核心中的完整定義:
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
// 根據檔案描述符獲取檔案對應的 struct file 結構
struct fd f = fdget_pos(fd);
......
// 獲取當前檔案的寫入位置 offset
loff_t pos = file_pos_read(f.file);
// 進入虛擬檔案系統層,執行具體的檔案寫入操作
ret = vfs_write(f.file, buf, count, &pos);
......
}
這裡和檔案讀取的流程基本一樣,也是通過 vfs_write 進入虛擬檔案系統層。
ssize_t __vfs_write(struct file *file, const char __user *p, size_t count,
loff_t *pos)
{
if (file->f_op->write)
return file->f_op->write(file, p, count, pos);
else if (file->f_op->write_iter)
return new_sync_write(file, p, count, pos);
else
return -EINVAL;
}
在虛擬檔案系統層,通過 struct file 中定義的函數指標 file_operations 在具體的檔案系統中執行相應的檔案 IO 操作。我們還是以 ext4 檔案系統為例。
struct file {
const struct file_operations *f_op;
}
在 ext4 檔案系統中 .write_iter 函數指標指向的是 ext4_file_write_iter 函數執行具體的檔案寫入操作。
const struct file_operations ext4_file_operations = {
......省略........
.read_iter = ext4_file_read_iter,
.write_iter = ext4_file_write_iter,
......省略.........
}
由於 ext4_file_operations 中只定義了 .write_iter 函數指標,所以在 __vfs_write 函數中流程進入 else if {......} 分支來到 new_sync_write 函數中:
static ssize_t new_sync_write(struct file *filp, const char __user *buf, size_t len, loff_t *ppos)
{
// 將 DirectByteBuffer 以及要寫入的位元組數封裝進 iovec 結構體中
struct iovec iov = { .iov_base = (void __user *)buf, .iov_len = len };
// 用來封裝檔案 IO 相關操作的狀態和進度資訊:
struct kiocb kiocb;
// 用來封裝用使用者快取區 DirectByteBuffer 的相關的資訊
struct iov_iter iter;
ssize_t ret;
// 利用檔案 struct file 初始化 kiocb 結構體
init_sync_kiocb(&kiocb, filp);
// 設定檔案寫入偏移位置
kiocb.ki_pos = (ppos ? *ppos : 0);
iov_iter_init(&iter, WRITE, &iov, 1, len);
// 呼叫 ext4_file_write_iter
ret = call_write_iter(filp, &kiocb, &iter);
BUG_ON(ret == -EIOCBQUEUED);
if (ret > 0 && ppos)
*ppos = kiocb.ki_pos;
return ret;
}
在檔案讀取的相關章節中,我們介紹了用於封裝傳遞進來的使用者空間緩衝區 DirectByteBuffer 相關資訊的 struct iovec 結構體,也介紹了用於封裝檔案 IO 相關操作的狀態和進度資訊的 struct kiocb 結構體,這裡筆者不在贅述。
不過在這裡筆者還是想強調的一下,核心中一般會使用 struct iov_iter 結構體對 struct iovec 進行包裝,iov_iter 中包含多個 iovec。
struct iov_iter {
......省略.....
const struct iovec *iov;
}
這是為了相容 readv() ,writev() 等系統呼叫,它允許使用者使用多個快取區去讀取檔案中的資料或者從多個緩衝區中寫入資料到檔案中。
JDK NIO Channel 支援的 Scatter 操作底層原理就是 readv 系統呼叫。
JDK NIO Channel 支援的 Gather 操作底層原理就是 writev 系統呼叫。
FileChannel fileChannel = new RandomAccessFile(new File("file-read-write.txt"), "rw").getChannel();
ByteBuffer heapByteBuffer1 = ByteBuffer.allocate(4096);
ByteBuffer heapByteBuffer2 = ByteBuffer.allocate(4096);
ByteBuffer[] gather = { heapByteBuffer1, heapByteBuffer2 };
fileChannel.write(gather);
最終在 call_write_iter 中觸發 ext4_file_write_iter 的呼叫,從虛擬檔案系統層進入到具體檔案系統 ext4 中。
static inline ssize_t call_write_iter(struct file *file, struct kiocb *kio,
struct iov_iter *iter)
{
return file->f_op->write_iter(kio, iter);
}
static ssize_t
ext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
..........省略..........
ret = __generic_file_write_iter(iocb, from);
return ret;
}
我們看到在檔案系統 ext4 中呼叫的是 __generic_file_write_iter 方法。核心針對檔案寫入的所有邏輯都封裝在這裡。
ssize_t __generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
struct file *file = iocb->ki_filp;
struct address_space * mapping = file->f_mapping;
struct inode *inode = mapping->host;
ssize_t written = 0;
ssize_t err;
ssize_t status;
........省略基本校驗邏輯和更新檔案原資料邏輯........
if (iocb->ki_flags & IOCB_DIRECT) {
loff_t pos, endbyte;
// Direct IO
written = generic_file_direct_write(iocb, from);
.......省略......
} else {
// Buffered IO
written = generic_perform_write(file, from, iocb->ki_pos);
if (likely(written > 0))
iocb->ki_pos += written;
}
.......省略......
// 返回寫入檔案的位元組數 或者 錯誤
return written ? written : err;
}
這裡和我們在介紹檔案讀取時候提到的 generic_file_read_iter 函數中的邏輯是一樣的。都會處理 Direct IO 和 Buffered IO 的場景。
這裡對於 Direct IO 的處理都是一樣的,在 generic_file_direct_write 中也是會呼叫 address_space 中的 address_space_operations 定義的 .direct_IO 函數指標來繞過 page cache 直接寫入磁碟。
struct address_space {
const struct address_space_operations *a_ops;
}
written = mapping->a_ops->direct_IO(iocb, from);
在 ext4 檔案系統中實現 Direct IO 的函數是 ext4_direct_IO,這裡直接會呼叫到塊裝置驅動層,通過 do_blockdev_direct_IO 直接將使用者空間緩衝區 DirectByteBuffer 中的內容寫入磁碟中。do_blockdev_direct_IO 函數會等到所有的 Direct IO 寫入到磁碟之後才會返回。
static const struct address_space_operations ext4_aops = {
.direct_IO = ext4_direct_IO,
};
Direct IO 是由 DMA 直接從使用者空間緩衝區 DirectByteBuffer 中拷貝到磁碟中。
下面我們主要介紹下 Buffered IO 的寫入邏輯 generic_perform_write 方法。
ssize_t generic_perform_write(struct file *file,
struct iov_iter *i, loff_t pos)
{
// 獲取 page cache。資料將會被寫入到這裡
struct address_space *mapping = file->f_mapping;
// 獲取 page cache 相關的操作函數
const struct address_space_operations *a_ops = mapping->a_ops;
long status = 0;
ssize_t written = 0;
unsigned int flags = 0;
do {
// 用於參照要寫入的檔案頁
struct page *page;
// 要寫入的檔案頁在 page cache 中的 index
unsigned long offset; /* Offset into pagecache page */
unsigned long bytes; /* Bytes to write to page */
size_t copied; /* Bytes copied from user */
offset = (pos & (PAGE_SIZE - 1));
bytes = min_t(unsigned long, PAGE_SIZE - offset,
iov_iter_count(i));
again:
// 檢查使用者空間緩衝區 DirectByteBuffer 地址是否有效
if (unlikely(iov_iter_fault_in_readable(i, bytes))) {
status = -EFAULT;
break;
}
// 從 page cache 中獲取要寫入的檔案頁並準備記錄檔案後設資料紀錄檔工作
status = a_ops->write_begin(file, mapping, pos, bytes, flags,
&page, &fsdata);
// 將使用者空間緩衝區 DirectByteBuffer 中的資料拷貝到 page cache 中的檔案頁中
copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
flush_dcache_page(page);
// 將寫入的檔案頁標記為髒頁並完成檔案後設資料紀錄檔的寫入
status = a_ops->write_end(file, mapping, pos, bytes, copied,
page, fsdata);
// 更新檔案 ppos
pos += copied;
written += copied;
// 判斷是否需要回寫髒頁
balance_dirty_pages_ratelimited(mapping);
} while (iov_iter_count(i));
// 返回寫入位元組數
return written ? written : status;
}
由於本文中筆者是以 ext4 檔案系統為例來介紹檔案的讀寫流程,本小節中介紹的檔案寫入流程涉及到與檔案系統相關的兩個操作:write_begin,write_end。這兩個函數在不同的檔案系統中都有不同的實現,在不同的檔案系統中,寫入每一個檔案頁都需要呼叫一次 write_begin,write_end 這兩個方法。
static const struct address_space_operations ext4_aops = {
......省略.......
.write_begin = ext4_write_begin,
.write_end = ext4_write_end,
......省略.......
}
下圖為本文中涉及檔案讀寫的所有核心資料結構圖:
經過前邊介紹檔案讀取的章節我們知道在讀取檔案的時候都是先從 page cache 中讀取,如果 page cache 正好快取了檔案頁就直接返回。如果沒有在進行磁碟 IO。
檔案的寫入過程也是一樣,核心會將使用者緩衝區 DirectByteBuffer 中的待寫資料先拷貝到 page cache 中,寫完就直接返回。後續核心會根據一定的規則把這些檔案頁回寫到磁碟中。
從這個過程我們可以看出,核心將資料先是寫入 page cache 中但是不會立刻寫入磁碟中,如果突然斷電或者系統崩潰就可能導致檔案系統處於不一致的狀態。
為了解決這種場景,於是 linux 核心引入了 ext3 , ext4 等紀錄檔檔案系統。而紀錄檔檔案系統比非紀錄檔檔案系統在磁碟中多了一塊 Journal 區域,Journal 區域就是存放管理檔案後設資料和檔案資料操作紀錄檔的磁碟區域。
檔案後設資料的紀錄檔用於恢復檔案系統的一致性。
檔案資料的紀錄檔用於防止系統故障造成的檔案內容損壞,
ext3 , ext4 等紀錄檔檔案系統分為三種模式,我們可以在掛載的時候選擇不同的模式。
紀錄檔模式(Journal 模式):這種模式在將資料寫入檔案系統前,必須等待後設資料和資料的紀錄檔已經落盤才能發揮作用。這樣效能比較差,但是最安全。
順序模式(Order 模式): 在 Order 模式不會記錄資料的紀錄檔,只會記錄後設資料的紀錄檔,但是在寫後設資料的紀錄檔前,必須先確保資料已經落盤。這樣可以減少檔案內容損壞的機會,這種模式是對效能的一種折中,是預設模式。
回寫模式(WriteBack 模式):WriteBack 模式 和 Order 模式一樣它們都不會記錄資料的紀錄檔,只會記錄後設資料的紀錄檔,不同的是在 WriteBack 模式下不會保證資料比後設資料先落盤。這個效能最好,但是最不安全。
而 write_begin,write_end 正是對檔案系統中相關紀錄檔的操作,在 ext4 檔案系統中對應的是 ext4_write_begin,ext4_write_end。下面我們就來看一下在 Buffered IO 模式下對於 ext4 檔案系統中的檔案寫入的核心步驟。
static int ext4_write_begin(struct file *file, struct address_space *mapping,
loff_t pos, unsigned len, unsigned flags,
struct page **pagep, void **fsdata)
{
struct inode *inode = mapping->host;
struct page *page;
pgoff_t index;
...........省略.......
retry_grab:
// 從 page cache 中查詢要寫入檔案頁
page = grab_cache_page_write_begin(mapping, index, flags);
if (!page)
return -ENOMEM;
unlock_page(page);
retry_journal:
// 相關紀錄檔的準備工作
handle = ext4_journal_start(inode, EXT4_HT_WRITE_PAGE, needed_blocks);
...........省略.......
在寫入檔案資料之前,核心在 ext4_write_begin 方法中呼叫 ext4_journal_start 方法做一些相關紀錄檔的準備工作。
還有一個重要的事情是在 grab_cache_page_write_begin 方法中從 page cache 中根據 index 查詢要寫入資料的檔案快取頁。
struct page *grab_cache_page_write_begin(struct address_space *mapping,
pgoff_t index, unsigned flags)
{
struct page *page;
int fgp_flags = FGP_LOCK|FGP_WRITE|FGP_CREAT;
// 在 page cache 中查詢寫入資料的快取頁
page = pagecache_get_page(mapping, index, fgp_flags,
mapping_gfp_mask(mapping));
if (page)
wait_for_stable_page(page);
return page;
}
通過 pagecache_get_page 在 page cache 中查詢要寫入資料的快取頁。如果快取頁不在 page cache 中,核心則會首先會在實體記憶體中分配一個記憶體頁,然後將新分配的記憶體頁加入到 page cache 中。
相關的查詢過程筆者已經在 《8. page cache 中查詢快取頁》小節中詳細介紹過了,這裡不在贅述。
這裡就是寫入過程的關鍵所在,圖中描述的 CPU 拷貝是將使用者空間快取區 DirectByteBuffer 中的待寫入資料拷貝到核心裡的 page cache 中,這個過程就發生在這裡。
size_t iov_iter_copy_from_user_atomic(struct page *page,
struct iov_iter *i, unsigned long offset, size_t bytes)
{
// 將快取頁臨時對映到核心虛擬地址空間的高階地址上
char *kaddr = kmap_atomic(page),
*p = kaddr + offset;
// 將使用者快取區 DirectByteBuffer 中的待寫入資料拷貝到檔案快取頁中
iterate_all_kinds(i, bytes, v,
copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
v.bv_offset, v.bv_len),
memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
)
// 解除核心虛擬地址空間與快取頁之間的臨時對映,這裡對映只是為了拷貝資料用
kunmap_atomic(kaddr);
return bytes;
}
但是這裡不能直接進行拷貝,因為此時從 page cache 中取出的快取頁 page 是實體地址,而在核心中是不能夠直接操作實體地址的,只能操作虛擬地址。
那怎麼辦呢?所以就需要呼叫 kmap_atomic 將快取頁臨時對映到核心空間的一段虛擬地址上,然後將使用者空間快取區 DirectByteBuffer 中的待寫入資料通過這段對映的虛擬地址拷貝到 page cache 中的相應快取頁中。這時檔案的寫入操作就已經完成了。
從這裡我們看出,核心對於檔案的寫入只是將資料寫入到 page cache 中就完事了並沒有真正地寫入磁碟。
由於是臨時對映,所以在拷貝完成之後,呼叫 kunmap_atomic 將這段對映再解除掉。
static int ext4_write_end(struct file *file,
struct address_space *mapping,
loff_t pos, unsigned len, unsigned copied,
struct page *page, void *fsdata)
{
handle_t *handle = ext4_journal_current_handle();
struct inode *inode = mapping->host;
......省略.......
// 將寫入的快取頁在 page cache 中標記為髒頁
copied = block_write_end(file, mapping, pos, len, copied, page, fsdata);
......省略.......
// 完成相關紀錄檔的寫入
ret2 = ext4_journal_stop(handle);
......省略.......
}
在這裡會對檔案的寫入流程做一些收尾的工作,比如在 block_write_end 方法中會呼叫 mark_buffer_dirty 將寫入的快取頁在 page cache 中標記為髒頁。後續核心會根據一定的規則將 page cache 中的這些髒頁回寫進磁碟中。
具體的標記過程筆者已經在《7.1 radix_tree 的標記》小節中詳細介紹過了,這裡不在贅述。
另一個核心的步驟就是呼叫 ext4_journal_stop 完成相關紀錄檔的寫入。這裡紀錄檔也只是會先寫到快取裡,不會直接落盤。
當程序將待寫資料寫入 page cache 中之後,相應的快取頁就變為了髒頁,我們需要找一個時機將這些髒頁回寫到磁碟中。防止斷電導致資料丟失。
本小節我們主要聚焦於髒頁回寫的主體流程,相應細節部分以及核心對髒頁的回寫時機我們放在下一小節中在詳細為大家介紹。
void balance_dirty_pages_ratelimited(struct address_space *mapping)
{
struct inode *inode = mapping->host;
struct backing_dev_info *bdi = inode_to_bdi(inode);
struct bdi_writeback *wb = NULL;
int ratelimit;
......省略......
if (unlikely(current->nr_dirtied >= ratelimit))
balance_dirty_pages(mapping, wb, current->nr_dirtied);
......省略......
}
在 balance_dirty_pages_ratelimited 會判斷如果髒頁數量在記憶體中達到了一定的規模 ratelimit 就會觸發 balance_dirty_pages 回寫髒頁邏輯。
static void balance_dirty_pages(struct address_space *mapping,
struct bdi_writeback *wb,
unsigned long pages_dirtied)
{
.......根據核心非同步回寫閾值判斷是否需要喚醒 flusher 執行緒非同步回寫髒頁...
if (nr_reclaimable > gdtc->bg_thresh)
wb_start_background_writeback(wb);
}
如果達到了髒頁回寫的條件,那麼核心就會喚醒 flusher 執行緒去將這些髒頁非同步回寫到磁碟中。
void wb_start_background_writeback(struct bdi_writeback *wb)
{
/*
* We just wake up the flusher thread. It will perform background
* writeback as soon as there is no other work to do.
*/
wb_wakeup(wb);
}
經過前邊對檔案寫入過程的介紹我們看到,使用者程序在對檔案進行寫操作的時候只是將待寫入資料從使用者空間的緩衝區 DirectByteBuffer 寫入到核心中的 page cache 中就結束了。後面核心會對髒頁進行延時寫入到磁碟中。
當 page cache 中的快取頁比磁碟中對應的檔案頁的資料要新時,就稱這些快取頁為髒頁。
延時寫入的好處就是程序可以多次頻繁的對檔案進行寫入但都是寫入到 page cache 中不會有任何磁碟 IO 發生。隨後核心可以將程序的這些多次寫入操作轉換為一次磁碟 IO ,將這些寫入的髒頁一次性重新整理回磁碟中,這樣就把多次磁碟 IO 轉換為一次磁碟 IO 極大地提升檔案 IO 的效能。
那麼核心在什麼情況下才會去觸發 page cache 中的髒頁回寫呢?
核心在初始化的時候,會建立一個 timer 定時器去定時喚醒核心 flusher 執行緒回寫髒頁。
當記憶體中髒頁的數量太多了達到了一定的比例,就會主動喚醒核心中的 flusher 執行緒去回寫髒頁。
髒頁在記憶體中停留的時間太久了,等到 flusher 執行緒下一次被喚醒的時候就會回寫這些駐留太久的髒頁。
使用者程序可以通過 sync() 回寫記憶體中的所有髒頁和 fsync() 回寫指定檔案的所有髒頁,這些是程序主動發起髒頁回寫請求。
在記憶體比較緊張的情況下,需要回收物理頁或者將物理頁中的內容 swap 到磁碟上時,如果發現通過頁面置換演演算法置換出來的頁是髒頁,那麼就會觸發回寫。
現在我們瞭解了核心回寫髒頁的一個大概時機,這裡大家可能會問了:
核心通過 timer 定時喚醒 flush 執行緒回寫髒頁,那麼到底間隔多久喚醒呢?
記憶體中的髒頁數量太多會觸發回寫,那麼這裡的太多指的具體是多少呢?
髒頁在記憶體中駐留太久也會觸發回寫,那麼這裡的太久指的到底是多久呢?
其實這三個問題中涉及到的具體數值,核心都提供了引數供我們來設定。這些引數的組態檔存在於 proc/sys/vm
目錄下:
下面筆者就為大家介紹下核心回寫髒頁涉及到的這 6 個引數,並解答上面我們提出的這三個問題。
核心中通過 dirty_writeback_centisecs 引數來設定喚醒 flusher 執行緒的間隔時間。
該引數可以通過修改 /proc/sys/vm/dirty_writeback_centisecs
檔案來設定引數,我們也可以通過 sysctl 命令或者通過修改 /etc/sysctl.conf
組態檔來對這些引數進行修改。
這裡我們先主要關注這些核心引數的含義以及原始碼實現,文章後面筆者有一個專門的章節來介紹這些核心引數各種不同的設定方式。
dirty_writeback_centisecs 核心引數的預設值為 500。單位為 0.01 s。也就是說核心會每隔 5s 喚醒一次 flusher 執行緒來執行相關髒頁的回寫。該引數在核心原始碼中對應的變數名為 dirty_writeback_interval。
筆者這裡在列舉一個生活中的例子來解釋下這個 dirty_writeback_interval 的作用。
假設大家的工作都非常繁忙,於是大家就到家政公司請了專門的保潔阿姨(核心 flusher 回寫執行緒)來幫助我們打掃房間衛生(回寫髒頁)。你和保潔阿姨約定每週(dirty_writeback_interval)來你房間(記憶體)打掃一次衛生(回寫髒頁),保潔阿姨會固定每週日按時來到你房間打掃。記住這個例子,我們後面還會用到~~~
在磁碟中資料是以塊的形式儲存於磁區中的,前邊在介紹檔案讀寫的章節中,讀寫流程的最後都會從檔案系統層到塊裝置驅動層,由塊裝置驅動程式將資料寫入對應的磁碟塊中儲存。
記憶體中的檔案頁對應於磁碟中的一個資料塊,而這塊磁碟就是我們常說的塊裝置。而每個塊裝置在核心中對應一個 backing_dev_info 結構用於儲存相關資訊。其中最重要的資訊是 workqueue_struct *bdi_wq 用於快取塊裝置上所有的回寫髒頁非同步任務的佇列。
/* bdi_wq serves all asynchronous writeback tasks */
struct workqueue_struct *bdi_wq;
static int __init default_bdi_init(void)
{
int err;
// 建立 bdi_wq 佇列
bdi_wq = alloc_workqueue("writeback", WQ_MEM_RECLAIM | WQ_FREEZABLE |
WQ_UNBOUND | WQ_SYSFS, 0);
if (!bdi_wq)
return -ENOMEM;
// 初始化 backing_dev_info
err = bdi_init(&noop_backing_dev_info);
return err;
}
在系統啟動的時候,核心會呼叫 default_bdi_init 來建立 bdi_wq 佇列和初始化 backing_dev_info。
static int bdi_init(struct backing_dev_info *bdi)
{
int ret;
bdi->dev = NULL;
// 初始化 backing_dev_info 相關資訊
kref_init(&bdi->refcnt);
bdi->min_ratio = 0;
bdi->max_ratio = 100;
bdi->max_prop_frac = FPROP_FRAC_BASE;
INIT_LIST_HEAD(&bdi->bdi_list);
INIT_LIST_HEAD(&bdi->wb_list);
init_waitqueue_head(&bdi->wb_waitq);
// 這裡會設定 flusher 執行緒的定時器 timer
ret = cgwb_bdi_init(bdi);
return ret;
}
在 bdi_init 中初始化 backing_dev_info 結構的相關資訊,並在 cgwb_bdi_init 中呼叫 wb_init 初始化回寫髒頁任務 bdi_writeback *wb,並建立一個 timer 用於定時啟動 flusher 執行緒。
static int wb_init(struct bdi_writeback *wb, struct backing_dev_info *bdi,
int blkcg_id, gfp_t gfp)
{
......... 初始化 bdi_writeback 結構該結構表示回寫髒頁任務相關資訊.....
// 建立 timer 定時執行 flusher 執行緒
INIT_DELAYED_WORK(&wb->dwork, wb_workfn);
......
}
#define __INIT_DELAYED_WORK(_work, _func, _tflags) \
do { \
INIT_WORK(&(_work)->work, (_func)); \
__setup_timer(&(_work)->timer, delayed_work_timer_fn, \
(unsigned long)(_work), \
bdi_writeback 有個成員變數 struct delayed_work dwork,bdi_writeback 就是把 delayed_work 結構掛到 bdi_wq 佇列上的。
而 wb_workfn 函數則是 flusher 執行緒要執行的回寫核心邏輯,全部封裝在 wb_workfn 函數中。
/*
* Handle writeback of dirty data for the device backed by this bdi. Also
* reschedules periodically and does kupdated style flushing.
*/
void wb_workfn(struct work_struct *work)
{
struct bdi_writeback *wb = container_of(to_delayed_work(work),
struct bdi_writeback, dwork);
long pages_written;
set_worker_desc("flush-%s", bdi_dev_name(wb->bdi));
current->flags |= PF_SWAPWRITE;
.......在迴圈中不斷的回寫髒頁..........
// 如果 work-list 中還有回寫髒頁的任務,則立即喚醒flush執行緒
if (!list_empty(&wb->work_list))
wb_wakeup(wb);
// 如果回寫任務已經被全部執行完畢,但是記憶體中還有髒頁,則延時喚醒
else if (wb_has_dirty_io(wb) && dirty_writeback_interval)
wb_wakeup_delayed(wb);
current->flags &= ~PF_SWAPWRITE;
}
在 wb_workfn 中會不斷的迴圈執行 work_list 中的髒頁回寫任務。當這些回寫任務執行完畢之後呼叫 wb_wakeup_delayed 延時喚醒 flusher執行緒。大家注意到這裡的 dirty_writeback_interval 設定項終於出現了,後續會根據 dirty_writeback_interval 計算下次喚醒 flusher 執行緒的時機。
void wb_wakeup_delayed(struct bdi_writeback *wb)
{
unsigned long timeout;
// 使用 dirty_writeback_interval 設定設定下次喚醒時間
timeout = msecs_to_jiffies(dirty_writeback_interval * 10);
spin_lock_bh(&wb->work_lock);
if (test_bit(WB_registered, &wb->state))
queue_delayed_work(bdi_wq, &wb->dwork, timeout);
spin_unlock_bh(&wb->work_lock);
}
這一節的內容中涉及到四個核心引數分別是:
drity_background_ratio :當髒頁數量在系統的可用記憶體 available 中佔用的比例達到 drity_background_ratio 的設定值時,核心就會呼叫 wakeup_flusher_threads 來喚醒 flusher 執行緒非同步回寫髒頁。預設值為:10。表示如果 page cache 中的髒頁數量達到系統可用記憶體的 10% 的話,就主動喚醒 flusher 執行緒去回寫髒頁到磁碟。
系統的可用記憶體 = 空閒記憶體 + 可回收記憶體。可以通過 free 命令的 available 項檢視。
dirty_background_bytes :如果 page cache 中髒頁佔用的記憶體用量絕對值達到指定的 dirty_background_bytes。核心就會呼叫 wakeup_flusher_threads 來喚醒 flusher 執行緒非同步回寫髒頁。預設為:0。
dirty_background_bytes 的優先順序大於 drity_background_ratio 的優先順序。
dirty_ratio : dirty_background_* 相關的核心設定引數均是核心通過喚醒 flusher 執行緒來非同步回寫髒頁。下面要介紹的 dirty_* 設定引數,均是由使用者程序同步回寫髒頁。表示記憶體中的髒頁太多了,使用者程序自己都看不下去了,不用等核心 flusher 執行緒喚醒,使用者程序自己主動去回寫髒頁到磁碟中。當髒頁佔用系統可用記憶體的比例達到 dirty_ratio 設定的值時,使用者程序同步回寫髒頁。預設值為:20 。
dirty_bytes :如果 page cache 中髒頁佔用的記憶體用量絕對值達到指定的 dirty_bytes。使用者程序同步回寫髒頁。預設值為:0。
*_bytes 相關設定引數的優先順序要大於 *_ratio 相關設定引數。
我們繼續使用上小節中保潔阿姨的例子說明:
之前你們已經約定好了,保潔阿姨會每週日固定(dirty_writeback_centisecs)來到你的房間打掃衛生(髒頁),但是你週三回家的時候,發現屋子裡太髒了,是在是髒到一定程度了(drity_background_ratio ,dirty_background_bytes),你實在是看不去了,這時你就不會等這週日(dirty_writeback_centisecs)保潔阿姨過來才打掃,你會直接給阿姨打電話讓阿姨週三就來打掃一下(核心主動喚醒 flusher 執行緒非同步回寫髒頁)。
還有一種更極端的情況就是,你的房間已經髒到很誇張的程度了(dirty_ratio ,dirty_byte)連你自己都忍不了了,於是你都不用等保潔阿姨了(核心 flusher 回寫執行緒),你自己就乖乖的開始打掃房間衛生了。這就是使用者程序同步回寫髒頁。
通過 《12.5 balance_dirty_pages_ratelimited》小節的介紹,我們知道在 generic_perform_write 函數的最後一步會呼叫 balance_dirty_pages_ratelimited 來判斷是否要觸發髒頁回寫。
void balance_dirty_pages_ratelimited(struct address_space *mapping)
{
................省略............
if (unlikely(current->nr_dirtied >= ratelimit))
balance_dirty_pages(mapping, wb, current->nr_dirtied);
wb_put(wb);
}
這裡會觸發 balance_dirty_pages 函數進行髒頁回寫。
static void balance_dirty_pages(struct address_space *mapping,
struct bdi_writeback *wb,
unsigned long pages_dirtied)
{
..................省略.............
for (;;) {
// 獲取系統可用記憶體
gdtc->avail = global_dirtyable_memory();
// 根據 *_ratio 或者 *_bytes 相關核心設定計算髒頁回寫觸發的閾值
domain_dirty_limits(gdtc);
.............省略..........
}
.............省略..........
在 balance_dirty_pages 中首先通過 global_dirtyable_memory() 獲取系統當前可用記憶體。在 domain_dirty_limits 函數中根據前邊我們介紹的 *_ratio 或者 *_bytes 相關核心設定計算髒頁回寫觸發的閾值。
static void domain_dirty_limits(struct dirty_throttle_control *dtc)
{
// 獲取可用記憶體
const unsigned long available_memory = dtc->avail;
// 封裝觸發髒頁回寫相關閾值資訊
struct dirty_throttle_control *gdtc = mdtc_gdtc(dtc);
// 這裡就是核心引數 dirty_bytes 指定的值
unsigned long bytes = vm_dirty_bytes;
// 核心引數 dirty_background_bytes 指定的值
unsigned long bg_bytes = dirty_background_bytes;
// 將核心引數 dirty_ratio 指定的值轉換為以 頁 為單位
unsigned long ratio = (vm_dirty_ratio * PAGE_SIZE) / 100;
// 將核心引數 dirty_background_ratio 指定的值轉換為以 頁 為單位
unsigned long bg_ratio = (dirty_background_ratio * PAGE_SIZE) / 100;
// 程序同步回寫 dirty_* 相關閾值
unsigned long thresh;
// 核心非同步回寫 direty_background_* 相關閾值
unsigned long bg_thresh;
struct task_struct *tsk;
if (gdtc) {
// 系統可用記憶體
unsigned long global_avail = gdtc->avail;
// 這裡可以看出 bytes 相關設定的優先順序大於 ratio 相關設定的優先順序
if (bytes)
// 將 bytes 相關的設定轉換為以頁為單位的記憶體佔用比例ratio
ratio = min(DIV_ROUND_UP(bytes, global_avail),
PAGE_SIZE);
// 設定 dirty_backgound_* 相關閾值
if (bg_bytes)
bg_ratio = min(DIV_ROUND_UP(bg_bytes, global_avail),
PAGE_SIZE);
bytes = bg_bytes = 0;
}
// 這裡可以看出 bytes 相關設定的優先順序大於 ratio 相關設定的優先順序
if (bytes)
// 將 bytes 相關的設定轉換為以頁為單位的記憶體佔用比例ratio
thresh = DIV_ROUND_UP(bytes, PAGE_SIZE);
else
thresh = (ratio * available_memory) / PAGE_SIZE;
// 設定 dirty_background_* 相關閾值
if (bg_bytes)
// 將 dirty_background_bytes 相關的設定轉換為以頁為單位的記憶體佔用比例ratio
bg_thresh = DIV_ROUND_UP(bg_bytes, PAGE_SIZE);
else
bg_thresh = (bg_ratio * available_memory) / PAGE_SIZE;
// 保證非同步回寫 backgound 的相關閾值要比同步回寫的閾值要低
if (bg_thresh >= thresh)
bg_thresh = thresh / 2;
dtc->thresh = thresh;
dtc->bg_thresh = bg_thresh;
..........省略..........
}
domain_dirty_limits 函數會分別計算使用者程序同步回寫髒頁的相關閾值 thresh 以及核心非同步回寫髒頁的相關閾值 bg_thresh。邏輯比較好懂,筆者將每一步的註釋已經為大家標註出來了。這裡只列出幾個關鍵核心點:
從原始碼中的 if (bytes) {....} else {.....} 分支以及 if (bg_bytes) {....} else {.....} 我們可以看出核心設定 *_bytes 相關的優先順序會高於 *_ratio 相關設定的優先順序。
*_bytes 相關設定我們只會指定髒頁佔用記憶體的 bytes 閾值,但在核心實現中會將其轉換為 頁 為單位。(每頁 4K 大小)。
核心中對於髒頁回寫閾值的判斷是通過 ratio 比例來進行判斷的。
核心非同步回寫的閾值要小於程序同步回寫的閾值,如果超過,那麼核心非同步回寫的閾值將會被設定為程序通過回寫的一半。
static void balance_dirty_pages(struct address_space *mapping,
struct bdi_writeback *wb,
unsigned long pages_dirtied)
{
..................省略.............
for (;;) {
// 獲取系統可用記憶體
gdtc->avail = global_dirtyable_memory();
// 根據 *_ratio 或者 *_bytes 相關核心設定計算 髒頁回寫觸發的閾值
domain_dirty_limits(gdtc);
.............省略..........
}
// 根據程序同步回寫閾值判斷是否需要程序直接同步回寫髒頁
if (writeback_in_progress(wb))
return
// 根據核心非同步回寫閾值判斷是否需要喚醒flusher非同步回寫髒頁
if (nr_reclaimable > gdtc->bg_thresh)
wb_start_background_writeback(wb);
如果是非同步回寫,核心則喚醒 flusher 執行緒開始非同步回寫髒頁,直到髒頁數量低於閾值或者全部回寫到磁碟。
void wb_start_background_writeback(struct bdi_writeback *wb)
{
/*
* We just wake up the flusher thread. It will perform background
* writeback as soon as there is no other work to do.
*/
trace_writeback_wake_background(wb);
wb_wakeup(wb);
}
核心為了避免 page cache 中的髒頁在記憶體中長久的停留,所以會給髒頁在記憶體中的駐留時間設定一定的期限,這個期限可由前邊提到的 dirty_expire_centisecs 核心引數設定。預設為:3000。單位為:0.01 s。
也就是說在預設設定下,髒頁在記憶體中的駐留時間為 30 s。超過 30 s 之後,flusher 執行緒將會在下次被喚醒的時候將這些髒頁回寫到磁碟中。
這些過期的髒頁最終會在 flusher 執行緒下一次被喚醒時候被 flusher 執行緒回寫到磁碟中。而前邊我們也多次提到過 flusher 執行緒執行邏輯全部封裝在 wb_workfn 函數中。接下來的呼叫鏈為 wb_workfn->wb_do_writeback->wb_writeback。在 wb_writeback 中會判斷根據 dirty_expire_interval 判斷哪些是過期的髒頁。
/*
* Explicit flushing or periodic writeback of "old" data.
*
* Define "old": the first time one of an inode's pages is dirtied, we mark the
* dirtying-time in the inode's address_space. So this periodic writeback code
* just walks the superblock inode list, writing back any inodes which are
* older than a specific point in time.
*
* Try to run once per dirty_writeback_interval. But if a writeback event
* takes longer than a dirty_writeback_interval interval, then leave a
* one-second gap.
*
* older_than_this takes precedence over nr_to_write. So we'll only write back
* all dirty pages if they are all attached to "old" mappings.
*/
static long wb_writeback(struct bdi_writeback *wb,
struct wb_writeback_work *work)
{
........省略.......
work->older_than_this = &oldest_jif;
for (;;) {
........省略.......
if (work->for_kupdate) {
oldest_jif = jiffies -
msecs_to_jiffies(dirty_expire_interval * 10);
} else if (work->for_background)
oldest_jif = jiffies;
}
........省略.......
}
前面的幾個小節筆者結合核心原始碼實現為大家介紹了影響核心回寫髒頁時機的六個引數。
核心越頻繁的觸發髒頁回寫,資料的安全性就越高,但是同時系統效能會消耗很大。所以我們在日常工作中需要結合資料的安全性和 IO 效能綜合考慮這六個核心引數的設定。
本小節筆者就為大家介紹一下設定這些核心引數的方式,前面的小節中也提到過,核心提供的這些引數存在於 proc/sys/vm
目錄下。
比如我們直接將要設定的具體數值寫入對應的組態檔中:
echo "value" > /proc/sys/vm/dirty_background_ratio
我們還可以使用 sysctl 來對這些核心引數進行設定:
sysctl -w variable=value
sysctl 命令中定義的這些變數 variable 全部定義在核心 kernel/sysctl.c
原始檔中。
其中 .procname 定義的就是 sysctl 命令中指定的設定變數名字。
.data 定義的是核心原始碼中參照的變數名字。這在前邊我們介紹核心程式碼的時候介紹過了。比如設定引數 dirty_writeback_centisecs 在核心原始碼中的變數名為 dirty_writeback_interval , dirty_ratio 在核心中的變數名為 vm_dirty_ratio。
static struct ctl_table vm_table[] = {
........省略........
{
.procname = "dirty_background_ratio",
.data = &dirty_background_ratio,
.maxlen = sizeof(dirty_background_ratio),
.mode = 0644,
.proc_handler = dirty_background_ratio_handler,
.extra1 = SYSCTL_ZERO,
.extra2 = SYSCTL_ONE_HUNDRED,
},
{
.procname = "dirty_background_bytes",
.data = &dirty_background_bytes,
.maxlen = sizeof(dirty_background_bytes),
.mode = 0644,
.proc_handler = dirty_background_bytes_handler,
.extra1 = SYSCTL_LONG_ONE,
},
{
.procname = "dirty_ratio",
.data = &vm_dirty_ratio,
.maxlen = sizeof(vm_dirty_ratio),
.mode = 0644,
.proc_handler = dirty_ratio_handler,
.extra1 = SYSCTL_ZERO,
.extra2 = SYSCTL_ONE_HUNDRED,
},
{
.procname = "dirty_bytes",
.data = &vm_dirty_bytes,
.maxlen = sizeof(vm_dirty_bytes),
.mode = 0644,
.proc_handler = dirty_bytes_handler,
.extra1 = (void *)&dirty_bytes_min,
},
{
.procname = "dirty_writeback_centisecs",
.data = &dirty_writeback_interval,
.maxlen = sizeof(dirty_writeback_interval),
.mode = 0644,
.proc_handler = dirty_writeback_centisecs_handler,
},
{
.procname = "dirty_expire_centisecs",
.data = &dirty_expire_interval,
.maxlen = sizeof(dirty_expire_interval),
.mode = 0644,
.proc_handler = proc_dointvec_minmax,
.extra1 = SYSCTL_ZERO,
}
........省略........
}
而前邊介紹的這兩種設定方式全部是臨時的,我們可以通過編輯 /etc/sysctl.conf
檔案來永久的修改核心相關的設定。
我們也可以在目錄
/etc/sysctl.d/
下建立自定義的組態檔。
vi /etc/sysctl.conf
在 /etc/sysctl.conf
檔案中直接以 variable = value
的形式新增到檔案的末尾。
最後呼叫 sysctl -p /etc/sysctl.conf
使 /etc/sysctl.conf
組態檔中新新增的那些設定生效。
本文筆者帶大家從 Linux 核心的角度詳細解析了 JDK NIO 檔案讀寫在 Buffered IO 以及 Direct IO 這兩種模式下的核心原始碼實現,探祕了檔案讀寫的本質。並對比了 Buffered IO 和 Direct IO 的不同之處以及各自的適用場景。
在這個過程中又詳細地介紹了與 Buffered IO 密切相關的檔案頁快取記憶體 page cache 在核心中的實現以及相關操作。
最後我們詳細介紹了影響檔案 IO 的兩個關鍵步驟:檔案預讀和髒頁回寫的詳細核心原始碼實現,以及核心中影響髒頁回寫時機的 6 個關鍵核心設定引數相關的實現及應用。
dirty_background_bytes
dirty_background_ratio
dirty_bytes
dirty_ratio
dirty_expire_centisecs
dirty_writeback_centisecs
以及關於核心引數的三種設定方式:
通過直接修改 proc/sys/vm
目錄下的相關引陣列態檔。
使用 sysctl 命令來對相關引數進行修改。
通過編輯 /etc/sysctl.conf
檔案來永久的修改核心相關設定。
好了,本文的內容到這裡就結束了,能夠看到這裡的大家一定是個狠人兒,但是辛苦的付出總會有所收穫,恭喜大家現在已經徹底打通了 Linux 檔案操作相關知識的系統脈絡。感謝大家的耐心觀看,我們下篇文章見~~~