在线观看www成人影院-在线观看www日本免费网站-在线观看www视频-在线观看操-欧美18在线-欧美1级

0
  • 聊天消息
  • 系統消息
  • 評論與回復
登錄后你可以
  • 下載海量資料
  • 學習在線課程
  • 觀看技術視頻
  • 寫文章/發帖/加入社區
會員中心
創作中心

完善資料讓更多小伙伴認識你,還能領取20積分哦,立即完善>

3天內不再提示

如何從Linux內核角度探秘Java NIO文件并讀寫本質呢?

小林coding ? 來源:bin的技術小屋 ? 2023-06-12 14:14 ? 次閱讀

1. 前言

筆者在 《聊聊Netty那些事兒之從內核角度看IO模型》一文中曾對 Socket 文件在內核中的相關數據結構為大家做了詳盡的闡述。

b0bc585a-08e7-11ee-962d-dac502259ad0.png

Socket內核結構.png

又在此基礎之上介紹了針對 socket 文件的相關操作及其對應在內核中的處理流程:

b0e882d6-08e7-11ee-962d-dac502259ad0.png

系統IO調用結構.png

并與 epoll 的工作機制進行了串聯:

b10944da-08e7-11ee-962d-dac502259ad0.png

數據到來epoll_wait流程.png

通過這些內容的串聯介紹,我想大家現在一定對 socket 文件非常熟悉了,在我們利用 socket 文件接口在與內核進行網絡數據讀取,發送的相關交互的時候,不可避免的涉及到一個新的問題,就是我們如何在用戶空間設計一個字節緩沖區來高效便捷的存儲管理這些需要和 socket 文件進行交互的網絡數據。

于是筆者又在 《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同字節序下的設計與實現》 一文中帶大家從 JDK NIO Buffer 的頂層設計開始,詳細介紹了 NIO Buffer 中的頂層抽象設計以及行為定義,隨后我們選取了在網絡應用程序中比較常用的 ByteBuffer 來詳細介紹了這個Buffer具體類型的實現,并以 HeapByteBuffer 為例說明了JDK NIO 在不同字節序下的 ByteBuffer 實現。

b12ec64c-08e7-11ee-962d-dac502259ad0.png

HeapByteBuffer結構.png

現在我們已經熟悉了 socket 文件的相關操作及其在內核中的實現,但筆者覺得這還不夠,還是有必要在為大家介紹一下 JDK NIO 如何利用 ByteBuffer 對普通文件進行讀寫的相關原理及其實現,為大家徹底打通 Linux 文件操作相關知識的系統脈絡,于是就有了本文的內容。

下面就讓我們從一個普通的 IO 讀寫操作開始聊起吧~~~

b1fb1b16-08e7-11ee-962d-dac502259ad0.png

本文概要.png

2. JDK NIO 讀取普通文件

我們先來看一個利用 NIO FileChannel 來讀寫普通文件的例子,由這個簡單的例子開始,慢慢地來一步一步深入本質。

JDK NIO 中的 FileChannel 比較特殊,它只能是阻塞的,不能設置非阻塞模式。FileChannel的讀寫方法均是線程安全的。

注意:下面的例子并不是最佳實踐,之所以這里引入 HeapByteBuffer 是為了將上篇文章的內容和本文銜接起來。事實上,對于 IO 的操作一般都會選擇 DirectByteBuffer ,關于 DirectByteBuffer 的相關內容筆者會在后面的文章中詳細為大家介紹。

FileChannelfileChannel=newRandomAccessFile(newFile("file-read-write.txt"),"rw").getChannel();
ByteBufferheapByteBuffer=ByteBuffer.allocate(4096);
fileChannel.read(heapByteBuffer);

我們首先利用 RandomAccessFile 在內核中打開指定的文件 file-read-write.txt 并獲取到它的文件描述符 fd = 5000。

b232244e-08e7-11ee-962d-dac502259ad0.png

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

b254a096-08e7-11ee-962d-dac502259ad0.png

操作系統在管理內存的時候是將內存分為一頁一頁來管理的,每頁大小為 4k ,我們在操作內存的時候一定要記得進行頁對齊,也就是偏移位置以及讀取的內存大小需要按照 4k 進行對齊。具體為什么?文章后邊會從內核角度詳細為大家介紹。

最后通過 FileChannel#read 方法觸發底層系統調用 read。進行文件讀取。

publicclassFileChannelImplextendsFileChannel{
//前邊介紹打開的文件描述符5000
privatefinalFileDescriptorfd;
//NIO中用它來觸發nativeread和write的系統調用
privatefinalFileDispatchernd;
//讀寫文件時加鎖,前邊介紹FileChannel的讀寫方法均是線程安全的
privatefinalObjectpositionLock=newObject();

publicintread(ByteBufferdst)throwsIOException{

synchronized(positionLock){
..........省略.......
try{
..........省略.......
do{
n=IOUtil.read(fd,dst,-1,nd);
}while((n==IOStatus.INTERRUPTED)&&isOpen());
returnIOStatus.normalize(n);
}finally{
..........省略.......
}
}
}
}

我們看到在 FileChannel 中會調用 IOUtil 的 read 方法,NIO 中的所有 IO 操作全部封裝在 IOUtil 類中。

而 NIO 中的 SocketChannel 以及這里介紹的 FileChannel 底層依賴的系統調用可能不同,這里會通過 NativeDispatcher 對具體 Channel 操作實現分發,調用具體的系統調用。對于 FileChannel 來說 NativeDispatcher 的實現類為 FileDispatcher。對于 SocketChannel 來說 NativeDispatcher 的實現類為 SocketDispatcher。

下面我們進入 IOUtil 里面來一探究竟~~

publicclassIOUtil{

staticintread(FileDescriptorfd,ByteBufferdst,longposition,
NativeDispatchernd)
throwsIOException
{
..........省略.......

....創建一個臨時的directByteBuffer....

try{
intn=readIntoNativeBuffer(fd,directByteBuffer,position,nd);

..........省略.......

....將directByteBuffer中讀取到的內容再次拷貝到heapByteBuffer中給用戶返回....

returnn;
}finally{
..........省略.......
}
}

privatestaticintreadIntoNativeBuffer(FileDescriptorfd,ByteBufferbb,
longposition,NativeDispatchernd)
throwsIOException
{
intpos=bb.position();
intlim=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);
returnn;
}
}

我們看到 FileChannel 的 read 方法最終會調用到 NativeDispatcher 的 read 方法。前邊我們介紹了這里的 NativeDispatcher 就是 FileDispatcher 在 NIO 中的實現類為 FileDispatcherImpl,用來觸發 native 方法執行底層系統調用。

classFileDispatcherImplextendsFileDispatcher{

intread(FileDescriptorfd,longaddress,intlen)throwsIOException{
returnread0(fd,address,len);
}

staticnativeintread0(FileDescriptorfd,longaddress,intlen)
throwsIOException;
}

最終在 FileDispatcherImpl 類中觸發了 native 方法 read0 的調用,我們繼續到 FileDispatcherImpl.c 文件中去查看 native 方法的實現。

//FileDispatcherImpl.c文件
JNIEXPORTjintJNICALLJava_sun_nio_ch_FileDispatcherImpl_read0(JNIEnv*env,jclassclazz,
jobjectfdo,jlongaddress,jintlen)
{
jintfd=fdval(env,fdo);
void*buf=(void*)jlong_to_ptr(address);
//發起read系統調用進入內核
returnconvertReturnVal(env,read(fd,buf,len),JNI_TRUE);
}

系統調用 read(fd, buf, len) 最終是在 native 方法 read0 中被觸發的。下面是系統調用 read 在內核中的定義。

SYSCALL_DEFINE3(read,unsignedint,fd,char__user*,buf,size_t,count){

......省略......
}

這樣一來我們就從 JDK NIO 這一層逐步來到了用戶空間與內核空間的邊界處 --- OS 系統調用 read 這里,馬上就要進入內核了。

b28a5fec-08e7-11ee-962d-dac502259ad0.png

下面我們就來看一下當系統調用 read 發起之后,用戶進程在內核態具體做了哪些事情?

3. 從內核角度探秘文件讀取本質

內核將文件的 IO 操作根據是否使用內存(頁高速緩存 page cache)做磁盤熱點數據的緩存,將文件 IO 分為:Buffered IO 和 Direct IO 兩種類型。

進程在通過系統調用 open() 打開文件的時候,可以通過將參數 flags 賦值為 O_DIRECT 來指定文件操作為 Direct IO。默認情況下為 Buffered IO。

intopen(constchar*pathname,intflags,mode_tmode);

而 Java 在 JDK 10 之前一直是不支持 Direct IO 的,到了 JDK 10 才開始支持 Direct IO。但是在 JDK 10 之前我們可以使用第三方的 Direct IO 框架 Jaydio 來通過 Direct IO 的方式對文件進行讀寫操作。

Jaydio GitHub :https://github.com/smacke/jaydio

下面筆者就帶大家從內核角度深度剖析下這兩種 IO 類型各自的特點:

3.1 Buffered IO

大部分文件系統默認的文件 IO 類型為 Buffered IO,當進程進行文件讀取時,內核會首先檢查文件對應的頁高速緩存 page cache 中是否已經緩存了文件數據,如果有則直接返回,如果沒有才會去磁盤中去讀取文件數據,而且還會根據非常精妙的預讀算法來預先讀取后續若干文件數據到 page cache 中。這樣等進程下一次順序讀取文件時,想要的數據已經預讀進 page cache 中了,進程直接返回,不用再到磁盤中去龜速讀取了,這樣一來就極大地提高了 IO 性能。

比如一些著名的消息隊列中間件 Kafka , RocketMq 對消息日志文件進行順序讀取的時候,訪問速度接近于內存。這就是 Buffered IO 中頁高速緩存 page cache 的功勞。在本文的后面,筆者會為大家詳細的介紹這一部分內容。

b2c29a38-08e7-11ee-962d-dac502259ad0.png

如果我們使用在上篇文章 《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同字節序下的設計與實現》 中介紹的 HeapByteBuffer 來接收 NIO 讀取文件數據的時候,整個文件讀取的過程分為如下幾個步驟:

NIO 首先會將創建一個臨時的 DirectByteBuffer 用于臨時接收文件數據。

具體為什么會創建一個臨時的 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 拷貝。

3.2 Direct IO

在上一小節中,筆者介紹了 Buffered IO 的諸多好處,尤其是在進程對文件進行順序讀取的時候,訪問性能接近于內存。

但是有些情況,我們并不需要 page cache。比如一些高性能的數據庫應用程序,它們在用戶空間自己實現了一套高效的高速緩存機制,以充分挖掘對數據庫獨特的查詢訪問性能。所以這些數據庫應用程序并不希望內核中的 page cache起作用。否則內核會同時處理 page cache 以及預讀相關操作的指令,會使得性能降低。

另外還有一種情況是,當我們在隨機讀取文件的時候,也不希望內核使用 page cache。因為這樣違反了程序局部性原理,當我們隨機讀取文件的時候,內核預讀進 page cache 中的數據將很久不會再次得到訪問,白白浪費 page cache 空間不說,還額外增加了預讀的磁盤 IO。

基于以上兩點原因,我們很自然的希望內核能夠提供一種機制可以繞過 page cache 直接對磁盤進行讀寫操作。這種機制就是本小節要為大家介紹的 Direct IO。

下面是內核采用 Direct IO 讀取文件的工作流程:

b31ee7ac-08e7-11ee-962d-dac502259ad0.png

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 的過程中我們看到,一共發生了兩次上下文的切換兩次的數據拷貝

4. Talk is cheap ! show you the code

下面是系統調用 read 在內核中的完整定義:

SYSCALL_DEFINE3(read,unsignedint,fd,char__user*,buf,size_t,count){
//根據文件描述符獲取文件對應的structfile結構
structfdf=fdget_pos(fd);
.....
//獲取當前文件的讀取位置offset
loff_tpos=file_pos_read(f.file);

//進入虛擬文件系統層,執行具體的文件操作
ret=vfs_read(f.file,buf,count,&pos);
......
}

首先會根據文件描述符 fd 通過 fdget_pos 方法獲取 struct fd 結構,進而可以獲取到文件的 struct file 結構。

structfd{
structfile*file;
intneed_put;
};

file_pos_read 獲取當前文件的讀取位置 offset,并通過 vfs_read 進入虛擬文件系統層。

ssize_t__vfs_read(structfile*file,char__user*buf,size_tcount,loff_t*pos){

if(file->f_op->read)
returnfile->f_op->read(file,buf,count,pos);
elseif(file->f_op->read_iter)
returnnew_sync_read(file,buf,count,pos);
else
return-EINVAL;
}

這里我們看到內核對文件的操作全部定義在 struct file 結構中的 f_op 字段中。

structfile{
conststructfile_operations*f_op;
}

對于 Java 程序員來說,file_operations 大家可以把它當做內核針對文件相關操作定義的一個公共接口(其實就是一個函數指針),它只是一個接口。具體的實現根據不同的文件類型有所不同。

比如我們在《聊聊Netty那些事兒之從內核角度看IO模型》一文中詳細介紹過的 Socket 文件。針對 Socket 文件類型,這里的 file_operations 指向的是 socket_file_ops。

staticconststructfile_operationssocket_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,
};
b3c6b608-08e7-11ee-962d-dac502259ad0.png

進程中管理文件列表結構.png

而本小節中我們討論的是對普通文件的操作,針對普通文件的操作定義在具體的文件系統中,這里我們以 Linux 中最為常見的 ext4 文件系統為例說明:

在 ext4 文件系統中管理的文件對應的 file_operations 指向 ext4_file_operations,專門用于操作 ext4 文件系統中的文件。

conststructfile_operationsext4_file_operations={

......省略........

.read_iter=ext4_file_read_iter,
.write_iter=ext4_file_write_iter,

......省略.........
}
b4272e84-08e7-11ee-962d-dac502259ad0.png

從圖中我們可以看到 ext4 文件系統定義的相關文件操作 ext4_file_operations 并未定義 .read 函數指針。而是定義了 .read_iter 函數指針,指向 ext4_file_read_iter 函數。

ssize_t__vfs_read(structfile*file,char__user*buf,size_tcount,loff_t*pos){

if(file->f_op->read)
returnfile->f_op->read(file,buf,count,pos);
elseif(file->f_op->read_iter)
returnnew_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_tnew_sync_read(structfile*filp,char__user*buf,size_tlen,loff_t*ppos)
{
//將DirectByteBuffer以及要讀取的字節數封裝進iovec結構體中
structioveciov={.iov_base=buf,.iov_len=len};
structkiocbkiocb;
structiov_iteriter;
ssize_tret;

//利用文件structfile初始化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);
.......省略......
returnret;
}

struct iovec 結構體主要用來封裝用來接收文件數據用的用戶緩存區相關的信息

structiovec
{
void__user*iov_base;//用戶空間緩存區地址這里是DirectByteBuffer的地址
__kernel_size_tiov_len;//緩沖區長度
}

但是內核中一般會使用 struct iov_iter 結構體對 struct iovec 進行包裝,iov_iter 中可以包含多個 iovec。這一點從 struct iov_iter 結構體的命名關鍵字 iter 上可以看得出來。

structiov_iter{
......省略.....
conststructiovec*iov;
}

之所以使用 struct iov_iter 結構體來包裝 struct iovec 是為了兼容 readv() 系統調用,它允許用戶使用多個用戶緩存區去讀取文件中的數據。JDK NIO Channel 支持的 scatter 操作底層原理就是 readv 系統調用

FileChannelfileChannel=newRandomAccessFile(newFile("file-read-write.txt"),"rw").getChannel();

ByteBufferheapByteBuffer1=ByteBuffer.allocate(4096);
ByteBufferheapByteBuffer2=ByteBuffer.allocate(4096);

ByteBuffer[]scatter={heapByteBuffer1,heapByteBuffer2};

fileChannel.read(scatter);

struct kiocb 結構體則是用來封裝文件 IO 相關操作的狀態和進度信息:

structkiocb{
structfile*ki_filp;//要讀取的文件structfile結構
loff_tki_pos;//文件讀取位置偏移,表示文件處理進度
void(*ki_complete)(structkiocb*iocb,longret);//IO完成回調
intki_flags;//IO類型,比如是DirectIO還是BufferedIO

........省略.......
};

當 struct iovec 和 struct kiocb 在 new_sync_read 方法中被初始化好之后,最終通過 file_operations 中定義的函數指針 .read_iter 調用到 ext4_file_read_iter 方法中,從而進入 ext4 文件系統執行具體的讀取操作。

staticssize_text4_file_read_iter(structkiocb*iocb,structiov_iter*to)
{
........省略........

returngeneric_file_read_iter(iocb,to);
}
ssize_tgeneric_file_read_iter(structkiocb*iocb,structiov_iter*iter)
{
........省略........

if(iocb->ki_flags&IOCB_DIRECT){

........DirectIO........
//獲取pagecache
structaddress_space*mapping=file->f_mapping;

........省略........
//繞過pagecache直接從磁盤中讀取數據
retval=mapping->a_ops->direct_IO(iocb,iter);
}

........BufferedIO........
//從pagecache中讀取數據
retval=generic_file_buffered_read(iocb,iter,retval);
}

generic_file_read_iter 會根據 struct kiocb 中的 ki_flags 屬性判斷文件 IO 操作是 Direct IO 還是 Buffered IO。

4.1 Direct IO

b31ee7ac-08e7-11ee-962d-dac502259ad0.png

我們可以通過 open 系統調用在打開文件的時候指定相關 IO 操作的模式是 Direct IO 還是 Buffered IO:

intopen(constchar*pathname,intflags,mode_tmode);

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。

Pathpath=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 獲取。

structfile{
//pagecache
structaddress_space*f_mapping;
}

和前面我們介紹的 struct file 結構中的 file_operations 一樣,內核中將 page cache 相關的操作全部定義在 struct address_space_operations 結構中。這里和前邊介紹的 file_operations 的作用是一樣的,只是內核針對 page cache 操作定義的一個公共接口。

structaddress_space{
conststructaddress_space_operations*a_ops;
}

具體的實現會根據文件系統的不同而不同,這里我們還是以 ext4 文件系統為例:

staticconststructaddress_space_operationsext4_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() 系統調用返回之后,進程就可以安全放心地去讀取用戶緩沖區中的數據了。

4.2 Buffered IO

b2c29a38-08e7-11ee-962d-dac502259ad0.png

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 中。

staticssize_tgeneric_file_buffered_read(structkiocb*iocb,
structiov_iter*iter,ssize_twritten)
{
//獲取文件在內核中對應的structfile結構
structfile*filp=iocb->ki_filp;
//獲取文件對應的pagecache
structaddress_space*mapping=filp->f_mapping;
//獲取文件的inode
structinode*inode=mapping->host;

...........省略...........

//開始BufferedIO讀取邏輯
for(;;){
//用于從pagecache中獲取緩存的文件數據page
structpage*page;
//根據文件讀取偏移計算出第一個字節所在物理頁的索引
pgoff_tindex;
//根據文件讀取偏移計算出第一個字節所在物理頁中的頁內偏移
unsignedlongoffset;
//在pagecache中查找是否有讀取數據在內存中的緩存頁
page=find_get_page(mapping,index);
if(!page){
if(iocb->ki_flags&IOCB_NOWAIT){
.......如果設置的是異步IO,則直接返回-EAGAIN......
}
//要讀取的文件數據在pagecache中沒有對應的緩存頁
//則從磁盤中讀取文件數據,并同步預讀若干相鄰的數據塊到pagecache中
page_cache_sync_readahead(mapping,
ra,filp,
index,last_index-index);

//再一次觸發緩存頁的查找,這一次就可以找到了
page=find_get_page(mapping,index);
if(unlikely(page==NULL))
gotono_cached_page;
}

//如果讀取的文件數據已經在pagecache中了,則判斷是否進行近一步的預讀操作
if(PageReadahead(page)){
//異步預讀若干文件數據塊到pagecache中
page_cache_async_readahead(mapping,
ra,filp,page,
index,last_index-index);
}

..............省略..............
//將pagecache中的數據拷貝到用戶空間緩沖區DirectByteBuffer中
ret=copy_page_to_iter(page,offset,nr,iter);
}
}

到這里關于文件讀取的兩種模式 Buffered IO 和 Direct IO 在內核中的主干邏輯流程筆者就為大家介紹完了。

但是大家可能會對 Buffered IO 中的兩個細節比較感興趣:

如何在 page cache 中查找我們要讀取的文件數據 ?也就是說上面提到的 find_get_page 函數是如何實現的?

文件預讀的過程是怎么樣的?內核中的預讀算法又是什么樣的呢?

在為大家解答這兩個疑問之前,筆者先為大家介紹一下內核中的頁高速緩存 page cache。

5. 頁高速緩存 page cache

筆者在《一文聊透對象在 JVM 中的內存布局,以及內存對齊和壓縮指針的原理及應用》 文章中為大家介紹 CPU 的高速緩存時曾提到過,根據摩爾定律:芯片中的晶體管數量每隔 18 個月就會翻一番。導致 CPU 的性能和處理速度變得越來越快,而提升 CPU 的運行速度比提升內存的運行速度要容易和便宜的多,所以就導致了 CPU 與內存之間的速度差距越來越大。

CPU 與內存之間的速度差異到底有多大呢?我們知道寄存器是離 CPU 最近的,CPU 在訪問寄存器的時候速度近乎于 0 個時鐘周期,訪問速度最快,基本沒有時延。而訪問內存則需要 50 - 200 個時鐘周期。

所以為了彌補 CPU 與內存之間巨大的速度差異,提高 CPU 的處理效率和吞吐,于是我們引入了 L1 , L2 , L3 高速緩存集成到 CPU 中。CPU 訪問高速緩存僅需要用到 1 - 30 個時鐘周期,CPU 中的高速緩存是對內存熱點數據的一個緩存。

b59e892e-08e7-11ee-962d-dac502259ad0.png

CPU緩存結構.png

而本文我們討論的主題是內存與磁盤之間的關系,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 在內核中的數據結構開始聊起~~~~

6. 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 在內核中只會有一個。

b5dddab6-08e7-11ee-962d-dac502259ad0.png

structaddress_space{
structinode*host;//關聯pagecache對應文件的inode
structradix_tree_rootpage_tree;//這里就是 page cache。里邊緩存了文件的所有緩存頁面
spinlock_ttree_lock;//訪問page_tree時用到的自旋鎖
unsignedlongnrpages;//pagecache中緩存的頁面總數
..........省略..........
conststructaddress_space_operations*a_ops;//定義對pagecache中緩存頁的各種操作方法
..........省略..........
}

struct inode *host :一個文件對應一個 page cache 結構 struct address_space ,文件的 inode 描述了一個文件的所有元信息。在 struct address_space 中通過 host 指針與文件的 inode 關聯。而在 inode 結構體 struct inode 中又通過 i_mapping 指針與文件的 page cache 進行關聯。

structinode{
structaddress_space*i_mapping;//關聯文件的pagecache
}

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 進行的。

structaddress_space_operations{
//寫入更新頁面緩存
int(*writepage)(structpage*page,structwriteback_control*wbc);
//讀取頁面緩存
int(*readpage)(structfile*,structpage*);
//設置緩存頁為臟頁,等待后續內核回寫磁盤
int(*set_page_dirty)(structpage*page);
//DirectIO繞過pagecache直接操作磁盤
ssize_t(*direct_IO)(structkiocb*,structiov_iter*iter);

........省略..........
}

前邊我們提到 page cache 中緩存的不僅僅是基于文件的頁,它還會緩存內存映射頁,以及磁盤塊設備文件,況且基于文件的內存頁背后也有不同的文件系統。所以內核只是通過 a_ops 定義了操作 page cache 緩存頁 IO 的通用行為定義。而具體的實現需要各個具體的文件系統通過自己定義的 address_space_operations 來描述自己如何與 page cache 進行交互。比如前邊我們介紹的 ext4 文件系統就有自己的 address_space_operations 定義。

staticconststructaddress_space_operationsext4_aops={
.readpage=ext4_readpage,
.writepage=ext4_writepage,
.direct_IO=ext4_direct_IO,

........省略.....
};

在我們從整體上了解了 page cache 在內核中的數據結構 struct address_space 之后,我們接下來看一下 radix_tree 這個數據結構是如何支持內核來高效搜索文件頁的,以及 page cache 中這些被緩存的文件頁是如何組織管理的。

7. 基樹 radix_tree

正如前邊我們提到的,在文件 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 的根節點。

structaddress_space{

structradix_tree_rootpage_tree;//這里就是 page cache。里邊緩存了文件的所有緩存頁面

..........省略..........
}
structradix_tree_root{
gfp_tgfp_mask;
structradix_tree_node__rcu*rnode;//radix_tree根節點
};

radix_tree 中的節點類型為 struct radix_tree_node。

structradix_tree_node{
void__rcu*slots[RADIX_TREE_MAP_SIZE];//包含 64 個指針的數組。用于指向下一層節點或者緩存頁
unsignedcharoffset;//父節點中指向該節點的指針在父節點slots數組中的偏移
unsignedcharcount;//記錄當前節點的slots數組指向了多少個節點
structradix_tree_node*parent;//父節點指針
structradix_tree_root*root;//根節點

..........省略.........

unsignedlongtags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS];// radix_tree 中的二維標記數組,用于標記子節點的狀態。
};
b64dd9f6-08e7-11ee-962d-dac502259ad0.png

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]。那么這個二維數組到底是用來干嘛的呢?我們接著往下看~~

7.1 radix_tree 的標記

經過前面的介紹我們知道,頁高速緩存 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 標記數組。

b670df28-08e7-11ee-962d-dac502259ad0.png

第二維 tags[][] 數組則表示對應標記類型針對每一個子節點的標記位,因為一個 radix_tree_node 節點中包含 64 個指針指向對應的子節點,所以二維 tags[][] 數組的大小也為 64 ,數組中的每一位表示對應子節點的標記。tags[0][0] 指向 PG_dirty 標記數組,tags[1][0] 指向PG_writeback 標記數組。

而緩存頁( radix_tree 中的葉子結點)這些標記是存放在其對應的頁描述符 struct page 里的 flag 中。

structpage{
unsignedlongflags;
}
b6a4d5a8-08e7-11ee-962d-dac502259ad0.png

只要一個緩存頁(葉子結點)被標記,那么從這個葉子結點一直到 radix_tree 根節點的路徑將會全部被標記。這就好比你在一盆清水中滴入一滴墨水,不久之后整盆水就會變為黑色。

b6fa1dd8-08e7-11ee-962d-dac502259ad0.png

這樣內核在 radix_tree 中搜索被標記的臟頁(PG_dirty)或者正在回寫的頁(PG_writeback)時,就可以迅速跳過哪些標記為 0 的中間節點的所有子樹,中間節點對應的標記為 0 說明其所有的子樹中包含的緩存頁(葉子結點)都是干凈的(未標記)。從而達到在 radix_tree 中迅速搜索指定狀態的緩存頁的目的。

8. page cache 中查找緩存頁

在我們明白了 radix_tree 這個數據結構之后,接下來我們來看一下在《4.2 Buffered IO》小節中遺留的問題:內核如何通過 find_get_page 在 page cache 中高效查找緩存頁?

在介紹 find_get_page 之前,筆者先來帶大家看看 radix_tree 具體是如何組織和管理其中的緩存頁 page 的。

b6a4d5a8-08e7-11ee-962d-dac502259ad0.png

經過上小節相關內容的介紹,我們了解到在 radix_tree 中每個節點 radix_tree_node 包含一個大小為 64 的指針數組 slots 用于指向它的子節點或者緩存頁描述符(葉子節點)。

一個 radix_tree_node 節點下邊最多可容納 64 個子節點,如果 radix_tree 的深度為 1 (不包括葉子節點),那么這顆 radix_tree 就可以緩存 64 個文件頁。而每頁大小為 4k,所以一顆深度為 1 的 radix_tree 可以緩存 256k 的文件內容。

b75f54c8-08e7-11ee-962d-dac502259ad0.png

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

b786df98-08e7-11ee-962d-dac502259ad0.png

依次類推我們可以得到不同的 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。

structpage{
unsignedlongflags;//緩存頁標記
structaddress_space*mapping;//緩存頁所在的pagecache
unsignedlongindex;//頁索引
...
}

事實上 find_get_page 函數也是根據緩存頁描述符中的這個 index 來在 page cache 中高效查找對應的緩存頁。

staticinlinestructpage*find_get_page(structaddress_space*mapping,
pgoff_toffset)
{
returnpagecache_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 數組下標。

b64dd9f6-08e7-11ee-962d-dac502259ad0.png

如果一顆 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 的代碼實現就變得很簡單了~~

structpage*pagecache_get_page(structaddress_space*mapping,pgoff_toffset,
intfgp_flags,gfp_tgfp_mask)
{
structpage*page;

repeat:
//在radix_tree中根據緩存頁offset查找緩存頁
page=find_get_entry(mapping,offset);
//緩存頁不存在的話,跳轉到no_page處理邏輯
if(!page)
gotono_page;

.......省略.......
no_page:
if(!page&&(fgp_flags&FGP_CREAT)){
//分配新頁
page=__page_cache_alloc(gfp_mask);
if(!page)
returnNULL;

if(fgp_flags&FGP_ACCESSED)
//增加頁的引用計數
__SetPageReferenced(page);
//將新分配的內存頁加入到頁高速緩存pagecache中
err=add_to_page_cache_lru(page,mapping,offset,gfp_mask);

.......省略.......
}

returnpage;
}

內核首先調用 find_get_entry 方法根據緩存頁的 offset 到 page cache 中去查找看請求的文件頁是否已經在頁高速緩存中。如果存在直接返回。

如果請求的文件頁不在 page cache 中,內核則會首先會在物理內存中分配一個內存頁,然后將新分配的內存頁加入到 page cache 中,并增加頁引用計數。

隨后會通過 address_space_operations 重定義的 readpage 激活塊設備驅動從磁盤中讀取請求數據,然后用讀取到的數據填充新分配的內存頁。

staticconststructaddress_space_operationsext4_aops={
.readpage=ext4_readpage,
.writepage=ext4_writepage,
.direct_IO=ext4_direct_IO,

........省略.....
};

9. 文件頁的預讀

之前我們在引入 page cache 的時候提到過,根據程序時間局部性原理:如果進程在訪問某一塊數據,那么在訪問的不久之后,進程還會再次訪問這塊數據。所以內核引入了 page cache 在內存中緩存磁盤中的熱點數據,從而減少對磁盤的 IO 訪問,提升系統性能。

而本小節我們要介紹的文件頁預讀特性是根據程序空間局部性原理:當進程訪問一段數據之后,那么在不就的將來和其臨近的一段數據也會被訪問到。所以當進程在訪問文件中的某頁數據的時候,內核會將它和臨近的幾個頁一起預讀到 page cache 中。這樣當進程再次訪問文件的時候,就不需要進行龜速的磁盤 IO 了,因為它所請求的數據已經預讀進 page cache 中了。

我們常提到的當你順序讀取文件的時候,性能會非常的高,因為相當于是在讀內存,這就是文件預讀的功勞。

但是在我們隨機訪問文件的時候,文件預讀不僅不會提高性能,返回會降低文件讀取的性能,因為隨機讀取文件并不符合程序空間局部性原理,因此預讀進 page cache 中的文件頁通常是無效的,下一次根本不會再去讀取,這無疑是白白浪費了 page cache 的空間,還額外增加了不必要的預讀磁盤 IO。

事實上,在我們對文件進行隨機讀取的場景下,更適合用 Direct IO 的方式繞過 page cache 直接從磁盤中讀取文件,還能減少一次從 page cache 到用戶緩沖區的拷貝。

所以內核需要一套非常精密的預讀算法來根據進程是順序讀文件還是隨機讀文件來精確地調控預讀的文件頁數,或者直接關閉預讀。

進程在讀取文件數據的時候都是逐頁進行讀取的,因此在預讀文件頁的時候內核并不會考慮頁內偏移,而是根據請求數據在文件內部的頁偏移進行讀取。

b83c963a-08e7-11ee-962d-dac502259ad0.png

如果進程持續的順序訪問一個文件,那么預讀頁數也會隨著逐步增加。

當發現進程開始隨機訪問文件了(當前訪問的文件頁和最后一次訪問的文件頁 offset 不是連續的),內核就會逐步減少預讀頁數或者徹底禁止預讀。

當內核發現進程再重復的訪問同一文件頁時或者文件中的文件頁已經幾乎全部緩存在 page cache 中了,內核此時就會禁止預讀。

以上幾點就是內核的預讀算法的核心邏輯,從這個預讀邏輯中我們可以看出,進程在進行文件讀取的時候涉及到兩種不同類型的頁面集合,一個是進程可以請求的文件頁(已經緩存在 page cache 中的文件頁),另一個是內核預讀的文件頁。

而內核也確實按照這兩種頁面集合分為兩個窗口:

當前窗口(current window): 表示進程本次文件請求可以直接讀取的頁面集合,這個集合中的頁面全部已經緩存在 page cache 中,進程可以直接讀取返回。當前窗口中包含進程本次請求的文件頁以及上次內核預讀的文件頁集合。表示進程本次可以從 page cache 直接獲取的頁面范圍。

預讀窗口(ahead window):預讀窗口的頁面都是內核正在預讀的文件頁,它們此時并不在 page cache 中。這些頁面并不是進程請求的文件頁,但是內核根據空間局部性原理假定它們遲早會被進程請求。預讀窗口內的頁面緊跟著當前窗口后面,并且內核會動態調整預讀窗口的大小(有點類似于 TCP 中的滑動窗口)。

b85e8e66-08e7-11ee-962d-dac502259ad0.png

如果進程本次文件請求的第一頁的 offset,緊跟著上一次文件請求的最后一頁的 offset,內核就認為是順序讀取。在順序讀取文件的場景下,如果請求的第一頁在當前窗口內,內核隨后就會檢查是否建立了預讀窗口,如果沒有就會創建預讀窗口并觸發相應頁的讀取操作。

在理想情況下,進程會繼續在當前窗口內請求頁,于此同時,預讀窗口內的預讀頁同時異步傳送著,這樣進程在順序讀取文件的時候就相當于直接讀取內存,極大地提高了文件 IO 的性能。

以上包含的這些文件預讀信息,比如:如何判斷進程是順序讀取還是隨機讀取,當前窗口信息,預讀窗口信息。全部保存在 struct file 結構中的 f_ra 字段中。

structfile{
structfile_ra_statef_ra;
}

用于描述文件預讀信息的結構體在內核中用 struct file_ra_state 結構體來表示:

structfile_ra_state{
pgoff_tstart;//當前窗口第一頁的索引
unsignedintsize;//當前窗口的頁數,-1表示臨時禁止預讀
unsignedintasync_size;//異步預讀頁面的頁數
unsignedintra_pages;//文件允許的最大預讀頁數
loff_tprev_pos;//進程最后一次請求頁的索引
};

內核可以根據 start 和 prev_pos 這兩個字段來判斷進程是否在順序訪問文件。

ra_pages 表示當前文件允許預讀的最大頁數,進程可以通過系統調用 posix_fadvise() 來改變已打開文件的 ra_page 值來調優預讀算法。

intposix_fadvise(intfd,off_toffset,off_tlen,intadvice);

該系統調用用來通知內核,我們將來打算以特定的模式 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 進行預讀。

Thisfixesinefficientpage-by-pagereadsonPOSIX_FADV_RANDOM.
POSIX_FADV_RANDOMusedtosetra_pages=0,whichleadstopoor
performance:a16Kreadwillbecarriedoutin4_sync_1-pagereads.

POSIX_FADV_WILLNEED :通知內核,進程指定這段文件數據將在不久之后被訪問。

而觸發內核進行文件預讀的場景,分為以下幾種:

當進程采用 Buffered IO 模式通過系統調用 read 進行文件讀取時,內核會觸發預讀。

通過 POSIX_FADV_WILLNEED 參數執行系統調用 posix_fadvise,會通知內核這個指定范圍的文件頁不就將會被訪問。觸發預讀。

當進程顯示執行 readahead() 系統調用時,會顯示觸發內核的預讀動作。

當內核為內存文件映射區域分配一個物理頁面時,會觸發預讀。關于內存映射的相關內容,筆者會在后面的文章為大家詳細介紹。

和 posix_fadvise 一樣的道理,系統調用 madvise 主要用來指定內存文件映射區域的訪問模式。可通過 advice = MADV_WILLNEED 通知內核,某個文件內存映射區域中的指定范圍的文件頁在不久將會被訪問。觸發預讀。

intmadvise(caddr_taddr,size_tlen,intadvice);

從觸發內核預讀的這幾種場景中我們可以看出,預讀分為主動觸發和被動觸發,在《4.2 Buffered IO》小節中遺留的 page_cache_sync_readahead 函數為被動觸發,接下來我們來看下它在內核中的實現邏輯。

9.1 page_cache_sync_readahead

voidpage_cache_sync_readahead(structaddress_space*mapping,
structfile_ra_state*ra,structfile*filp,
pgoff_toffset,unsignedlongreq_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 進行預讀。

intposix_fadvise(intfd,off_toffset,off_tlen,intadvice);
//mm/fadvise.c
switch(advice){

.........省略........

casePOSIX_FADV_RANDOM:
.........省略........
file->f_flags|=FMODE_RANDOM;
.........省略........
break;

.........省略........
}

而真正的預讀邏輯封裝在 ondemand_readahead 函數中。

9.2 ondemand_readahead

該方法中封裝了前邊介紹的預讀算法邏輯,動態的調整當前窗口以及預讀窗口的大小。

/*
*Aminimalreadaheadalgorithmfortrivialsequential/randomreads.
*/
staticunsignedlong
ondemand_readahead(structaddress_space*mapping,
structfile_ra_state*ra,structfile*filp,
boolhit_readahead_marker,pgoff_toffset,
unsignedlongreq_size)
{
structbacking_dev_info*bdi=inode_to_bdi(mapping->host);
unsignedlongmax_pages=ra->ra_pages;//默認32頁
unsignedlongadd_pages;
pgoff_tprev_offset;

........預讀算法邏輯,動態調整當前窗口和預讀窗口.........

//根據條件,計算本次預讀最大預讀取多少個頁,一般情況下是max_pages=32個頁
if(req_size>max_pages&&bdi->io_pages>max_pages)
max_pages=min(req_size,bdi->io_pages);


//offset即pageindex,如果pageindex=0,表示這是文件第一個頁,
//內核認為是順序讀,跳轉到initial_readahead進行處理
if(!offset)
gotoinitial_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=0ra->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;
gotoreadit;
}

//異步預讀的時候會進入這個判斷,更新ra的值,然后預讀特定的范圍的頁
//異步預讀的調用表示Readahead出來的頁連續命中
if(hit_readahead_marker){
pgoff_tstart;

rcu_read_lock();
//這個函數用于找到offset+1開始到offset+1+max_pages這個范圍內,第一個不在pagecache的頁的index
start=page_cache_next_miss(mapping,offset+1,max_pages);
rcu_read_unlock();

if(!start||start-offset>max_pages)
return0;

ra->start=start;
ra->size=start-offset;/*oldasync_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;
gotoreadit;

........省略.........
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 。

staticunsignedlongget_init_ra_size(unsignedlongsize,unsignedlongmax)
{
unsignedlongnewsize=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 中。

9.3 __do_page_cache_readahead

unsignedint__do_page_cache_readahead(structaddress_space*mapping,
structfile*filp,pgoff_toffset,unsignedlongnr_to_read,
unsignedlonglookahead_size)
{
structinode*inode=mapping->host;
structpage*page;
unsignedlongend_index;/*Thelastpagewewanttoread*/
intpage_idx;
unsignedintnr_pages=0;
loff_tisize=i_size_read(inode);
end_index=((isize-1)>>PAGE_SHIFT);

/*
*盡可能的一次性分配全部需要預讀的頁nr_to_read
*注意這里是盡可能的分配,意思就是能分配多少就分配多少,并不一定要全部分配
*/
for(page_idx=0;page_idxend_index)
break;

.......省略.....

//首先在內存中為預讀數據分配物理頁面
page=__page_cache_alloc(gfp_mask);
if(!page)
break;
//設置新分配的物理頁在pagecache中的索引
page->index=page_offset;
//將新分配的物理頁面加入到pagecache中
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:
returnnr_pages;
}

內核調用 read_pages 方法激活磁盤塊設備驅動程序從磁盤中讀取文件數據之前,需要為本次進程讀取請求所需要的所有頁面盡可能地一次性全部分配,如果不能一次性分配全部頁面,預讀操作就只在分配好的緩存頁面上進行,也就是說只從磁盤中讀取數據填充已經分配好的頁面。

10. JDK NIO 對普通文件的寫入

注意:下面的例子并不是最佳實踐,之所以這里引入 HeapByteBuffer 是為了將上篇文章的內容和本文銜接起來。事實上,對于 IO 的操作一般都會選擇 DirectByteBuffer ,關于 DirectByteBuffer 的相關內容筆者會在后面的文章中詳細為大家介紹。

FileChannelfileChannel=newRandomAccessFile(newFile("file-read-write.txt"),"rw").getChannel();
ByteBufferheapByteBuffer=ByteBuffer.allocate(4096);
fileChannel.write(heapByteBuffer);

在對文件進行讀寫之前,我們需要首先利用 RandomAccessFile 在內核中打開指定的文件 file-read-write.txt ,并獲取到它的文件描述符 fd = 5000。

b232244e-08e7-11ee-962d-dac502259ad0.png

本例 heapByteBuffer 中存放著需要寫入文件的內容,隨后來到 FileChannelImpl 實現類調用 IOUtil 觸發底層系統調用 write 來寫入文件。

publicclassFileChannelImplextendsFileChannel{
//前邊介紹打開的文件描述符5000
privatefinalFileDescriptorfd;
//NIO中用它來觸發nativeread和write的系統調用
privatefinalFileDispatchernd;
//讀寫文件時加鎖,前邊介紹FileChannel的讀寫方法均是線程安全的
privatefinalObjectpositionLock=newObject();

publicintwrite(ByteBuffersrc)throwsIOException{
ensureOpen();
if(!writable)
thrownewNonWritableChannelException();
synchronized(positionLock){
//寫入的字節數
intn=0;
try{
......省略......
if(!isOpen())
return0;
do{
n=IOUtil.write(fd,src,-1,nd);
}while((n==IOStatus.INTERRUPTED)&&isOpen());
//返回寫入的字節數
returnIOStatus.normalize(n);
}finally{
......省略......
}
}
}

}

NIO 中的所有 IO 操作全部封裝在 IOUtil 類中,而 NIO 中的 SocketChannel 以及這里介紹的 FileChannel 底層依賴的系統調用可能不同,這里會通過 NativeDispatcher 對具體 Channel 操作實現分發,調用具體的系統調用。對于 FileChannel 來說 NativeDispatcher 的實現類為 FileDispatcher。對于 SocketChannel 來說 NativeDispatcher 的實現類為 SocketDispatcher。

publicclassIOUtil{

staticintwrite(FileDescriptorfd,ByteBuffersrc,longposition,
NativeDispatchernd)
throwsIOException
{
//標記傳遞進來的heapByteBuffer的position位置用于后續恢復
intpos=src.position();
//獲取heapByteBuffer的limit用于計算寫入字節數
intlim=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);
}
//返回發送成功的字節數
returnn;
}finally{
//釋放臨時創建的DirectByteBuffer
Util.offerFirstTemporaryDirectBuffer(bb);
}
}

privatestaticintwriteFromNativeBuffer(FileDescriptorfd,ByteBufferbb,
longposition,NativeDispatchernd)
throwsIOException
{
intpos=bb.position();
intlim=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);
//返回寫入的字節數
returnwritten;
}
}

在 IOUtil 中首先創建一個臨時的 DirectByteBuffer,然后將本例中 HeapByteBuffer 中的數據全部拷貝到這個臨時的 DirectByteBuffer 中。這個 DirectByteBuffer 就是我們在 IO 系統調用中經常提到的用戶空間緩沖區。

隨后在 writeFromNativeBuffer 方法中通過 FileDispatcher 觸發 JNI 層的native 方法執行底層系統調用 write 。

classFileDispatcherImplextendsFileDispatcher{

intwrite(FileDescriptorfd,longaddress,intlen)throwsIOException{
returnwrite0(fd,address,len);
}

staticnativeintwrite0(FileDescriptorfd,longaddress,intlen)
throwsIOException;
}

NIO 中關于文件 IO 相關的系統調用全部封裝在 JNI 層中的 FileDispatcherImpl.c 文件中。里邊定義了各種 IO 相關的系統調用的 native 方法。

//FileDispatcherImpl.c文件
JNIEXPORTjintJNICALL
Java_sun_nio_ch_FileDispatcherImpl_write0(JNIEnv*env,jclassclazz,
jobjectfdo,jlongaddress,jintlen)
{
jintfd=fdval(env,fdo);
void*buf=(void*)jlong_to_ptr(address);
//發起write系統調用進入內核
returnconvertReturnVal(env,write(fd,buf,len),JNI_FALSE);
}

系統調用 write 在內核中的定義如下所示:

SYSCALL_DEFINE3(write,unsignedint,fd,constchar__user*,buf,
size_t,count)
{
structfdf=fdget_pos(fd);
......
loff_tpos=file_pos_read(f.file);
ret=vfs_write(f.file,buf,count,&pos);
......
}

現在我們就從用戶空間的 JDK NIO 這一層逐步來到了內核空間的邊界處 --- OS 系統調用 write 這里,馬上就要進入內核了。

b8b70dc0-08e7-11ee-962d-dac502259ad0.png

這一次我們來看一下當系統調用 write 發起之后,用戶進程在內核態具體做了哪些事情?

11. 從內核角度探秘文件寫入本質

現在讓我們再次進入內核,來看一下內核中具體是如何處理文件寫入操作的,這個過程會比文件讀取要復雜很多,大家需要有點耐心~~

再次強調一下,本文所舉示例中用到的 HeapByteBuffer 只是為了與上篇文章 《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同字節序下的設計與實現》介紹的內容做出呼應,并不是最佳實踐。筆者會在后續的文章中一步一步為大家展開這塊內容的最佳實踐。

11.1 Buffered IO

b8efe94c-08e7-11ee-962d-dac502259ad0.png

使用 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 中的臟頁進行回寫,如果不需要同步回寫,進程直接返回。文件寫入操作完成。這里發生第二次上下文切換

從這里我們看到在對文件進行寫入時,內核只會將數據寫入到 page cache 中。整個寫入過程就完成了,并不會寫到磁盤中。

臟頁回寫又會根據臟頁數量在內存中的占比分為:進程同步回寫和內核異步回寫。當臟頁太多了,進程自己都看不下去的時候,會同步回寫內存中的臟頁,直到回寫完畢才會返回。在回寫的過程中會發生第三次拷貝,通過DMA 將 page cache 中的臟頁寫入到磁盤中。

所謂內核異步回寫就是內核會定時喚醒一個 flusher 線程,定時將內存中的臟頁回寫到磁盤中。這部分的內容筆者會在后續的章節中詳細講解。

在 NIO 使用 HeapByteBuffer 在對文件進行寫入的過程中,一般只會發生兩次拷貝動作和兩次上下文切換,因為內核將數據拷貝到 page cache 中后,文件寫入過程就結束了。如果臟頁在內存中的占比太高了,達到了進程同步回寫的閾值,那么就會發生第三次 DMA 拷貝,將臟頁數據回寫到磁盤文件中。

如果進程需要同步回寫臟頁數據時,在本例中是要發生三次拷貝動作。但一般情況下,在本例中只會發生兩次,沒有第三次的 DMA 拷貝。

11.2 Direct IO

在 JDK 10 中我們可以通過如下的方式采用 Direct IO 模式打開文件:

FileChannelfc=FileChannel.open(p,StandardOpenOption.WRITE,
ExtendedOpenOption.DIRECT)
b8ff52ce-08e7-11ee-962d-dac502259ad0.png

在 Direct IO 模式下的文件寫入操作最明顯的特點就是繞過 page cache 直接通過 DMA 拷貝將用戶空間緩沖區 DirectByteBuffer 中的待寫入數據寫入到磁盤中。

同樣發生兩次上下文切換、

在本例中只會發生兩次數據拷貝,第一次是將 JVM 堆中的 HeapByteBuffer 中的待寫入數據拷貝到位于 OS 堆中的 DirectByteBuffer 中。第二次則是 DMA 拷貝,將用戶空間緩沖區 DirectByteBuffer 中的待寫入數據寫入到磁盤中。

12. Talk is cheap ! show you the code

下面是系統調用 write 在內核中的完整定義:

SYSCALL_DEFINE3(write,unsignedint,fd,constchar__user*,buf,
size_t,count)
{
//根據文件描述符獲取文件對應的structfile結構
structfdf=fdget_pos(fd);
......
//獲取當前文件的寫入位置offset
loff_tpos=file_pos_read(f.file);
//進入虛擬文件系統層,執行具體的文件寫入操作
ret=vfs_write(f.file,buf,count,&pos);
......
}

這里和文件讀取的流程基本一樣,也是通過 vfs_write 進入虛擬文件系統層。

ssize_t__vfs_write(structfile*file,constchar__user*p,size_tcount,
loff_t*pos)
{
if(file->f_op->write)
returnfile->f_op->write(file,p,count,pos);
elseif(file->f_op->write_iter)
returnnew_sync_write(file,p,count,pos);
else
return-EINVAL;
}

在虛擬文件系統層,通過 struct file 中定義的函數指針 file_operations 在具體的文件系統中執行相應的文件 IO 操作。我們還是以 ext4 文件系統為例。

structfile{
conststructfile_operations*f_op;
}

在 ext4 文件系統中 .write_iter 函數指針指向的是 ext4_file_write_iter 函數執行具體的文件寫入操作。

conststructfile_operationsext4_file_operations={

......省略........

.read_iter=ext4_file_read_iter,
.write_iter=ext4_file_write_iter,

......省略.........
}
b4272e84-08e7-11ee-962d-dac502259ad0.png

由于 ext4_file_operations 中只定義了 .write_iter 函數指針,所以在 __vfs_write 函數中流程進入 else if {......} 分支來到 new_sync_write 函數中:

staticssize_tnew_sync_write(structfile*filp,constchar__user*buf,size_tlen,loff_t*ppos)
{
//將DirectByteBuffer以及要寫入的字節數封裝進iovec結構體中
structioveciov={.iov_base=(void__user*)buf,.iov_len=len};
//用來封裝文件 IO 相關操作的狀態和進度信息:
structkiocbkiocb;
//用來封裝用用戶緩存區DirectByteBuffer的相關的信息
structiov_iteriter;
ssize_tret;
//利用文件structfile初始化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;
returnret;
}

在文件讀取的相關章節中,我們介紹了用于封裝傳遞進來的用戶空間緩沖區 DirectByteBuffer 相關信息的 struct iovec 結構體,也介紹了用于封裝文件 IO 相關操作的狀態和進度信息的 struct kiocb 結構體,這里筆者不在贅述。

不過在這里筆者還是想強調的一下,內核中一般會使用 struct iov_iter 結構體對 struct iovec 進行包裝,iov_iter 中包含多個 iovec。

structiov_iter{
......省略.....
conststructiovec*iov;
}

這是為了兼容 readv() ,writev() 等系統調用,它允許用戶使用多個緩存區去讀取文件中的數據或者從多個緩沖區中寫入數據到文件中。

JDK NIO Channel 支持的 Scatter 操作底層原理就是 readv 系統調用。

JDK NIO Channel 支持的 Gather 操作底層原理就是 writev 系統調用。

FileChannelfileChannel=newRandomAccessFile(newFile("file-read-write.txt"),"rw").getChannel();

ByteBufferheapByteBuffer1=ByteBuffer.allocate(4096);
ByteBufferheapByteBuffer2=ByteBuffer.allocate(4096);

ByteBuffer[]gather={heapByteBuffer1,heapByteBuffer2};

fileChannel.write(gather);

最終在 call_write_iter 中觸發 ext4_file_write_iter 的調用,從虛擬文件系統層進入到具體文件系統 ext4 中。

staticinlinessize_tcall_write_iter(structfile*file,structkiocb*kio,
structiov_iter*iter)
{
returnfile->f_op->write_iter(kio,iter);
}
staticssize_t
ext4_file_write_iter(structkiocb*iocb,structiov_iter*from)
{
..........省略..........
ret=__generic_file_write_iter(iocb,from);
returnret;
}

我們看到在文件系統 ext4 中調用的是 __generic_file_write_iter 方法。內核針對文件寫入的所有邏輯都封裝在這里。

ssize_t__generic_file_write_iter(structkiocb*iocb,structiov_iter*from)
{
structfile*file=iocb->ki_filp;
structaddress_space*mapping=file->f_mapping;
structinode*inode=mapping->host;
ssize_twritten=0;
ssize_terr;
ssize_tstatus;

........省略基本校驗邏輯和更新文件原數據邏輯........

if(iocb->ki_flags&IOCB_DIRECT){
loff_tpos,endbyte;
//DirectIO
written=generic_file_direct_write(iocb,from);
.......省略......
}else{
//BufferedIO
written=generic_perform_write(file,from,iocb->ki_pos);
if(likely(written>0))
iocb->ki_pos+=written;
}
.......省略......
//返回寫入文件的字節數或者錯誤
returnwritten?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 直接寫入磁盤。

structaddress_space{
conststructaddress_space_operations*a_ops;
}
written=mapping->a_ops->direct_IO(iocb,from);
b8ff52ce-08e7-11ee-962d-dac502259ad0.png

在 ext4 文件系統中實現 Direct IO 的函數是 ext4_direct_IO,這里直接會調用到塊設備驅動層,通過 do_blockdev_direct_IO 直接將用戶空間緩沖區 DirectByteBuffer 中的內容寫入磁盤中。do_blockdev_direct_IO 函數會等到所有的 Direct IO 寫入到磁盤之后才會返回

staticconststructaddress_space_operationsext4_aops={
.direct_IO=ext4_direct_IO,
};

Direct IO 是由 DMA 直接從用戶空間緩沖區 DirectByteBuffer 中拷貝到磁盤中。

下面我們主要介紹下 Buffered IO 的寫入邏輯 generic_perform_write 方法。

12.1 Buffered IO

b8efe94c-08e7-11ee-962d-dac502259ad0.png

ssize_tgeneric_perform_write(structfile*file,
structiov_iter*i,loff_tpos)
{
//獲取 page cache。數據將會被寫入到這里
structaddress_space*mapping=file->f_mapping;
//獲取pagecache相關的操作函數
conststructaddress_space_operations*a_ops=mapping->a_ops;
longstatus=0;
ssize_twritten=0;
unsignedintflags=0;

do{
//用于引用要寫入的文件頁
structpage*page;
//要寫入的文件頁在pagecache中的index
unsignedlongoffset;/*Offsetintopagecachepage*/
unsignedlongbytes;/*Bytestowritetopage*/
size_tcopied;/*Bytescopiedfromuser*/

offset=(pos&(PAGE_SIZE-1));
bytes=min_t(unsignedlong,PAGE_SIZE-offset,
iov_iter_count(i));

again:
//檢查用戶空間緩沖區DirectByteBuffer地址是否有效
if(unlikely(iov_iter_fault_in_readable(i,bytes))){
status=-EFAULT;
break;
}
//從pagecache中獲取要寫入的文件頁并準備記錄文件元數據日志工作
status=a_ops->write_begin(file,mapping,pos,bytes,flags,
&page,&fsdata);
//將用戶空間緩沖區DirectByteBuffer中的數據拷貝到pagecache中的文件頁中
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));
//返回寫入字節數
returnwritten?written:status;
}

由于本文中筆者是以 ext4 文件系統為例來介紹文件的讀寫流程,本小節中介紹的文件寫入流程涉及到與文件系統相關的兩個操作:write_begin,write_end。這兩個函數在不同的文件系統中都有不同的實現,在不同的文件系統中,寫入每一個文件頁都需要調用一次 write_begin,write_end 這兩個方法。

staticconststructaddress_space_operationsext4_aops={
......省略.......
.write_begin=ext4_write_begin,
.write_end=ext4_write_end,
......省略.......
}

下圖為本文中涉及文件讀寫的所有內核數據結構圖:

ba10e4fc-08e7-11ee-962d-dac502259ad0.png

經過前邊介紹文件讀取的章節我們知道在讀取文件的時候都是先從 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 文件系統中的文件寫入的核心步驟。

12.2 ext4_write_begin

staticintext4_write_begin(structfile*file,structaddress_space*mapping,
loff_tpos,unsignedlen,unsignedflags,
structpage**pagep,void**fsdata)
{
structinode*inode=mapping->host;
structpage*page;
pgoff_tindex;

...........省略.......

retry_grab:
//從pagecache中查找要寫入文件頁
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 查找要寫入數據的文件緩存頁。

structpage*grab_cache_page_write_begin(structaddress_space*mapping,
pgoff_tindex,unsignedflags)
{
structpage*page;
intfgp_flags=FGP_LOCK|FGP_WRITE|FGP_CREAT;
//在pagecache中查找寫入數據的緩存頁
page=pagecache_get_page(mapping,index,fgp_flags,
mapping_gfp_mask(mapping));
if(page)
wait_for_stable_page(page);
returnpage;
}

通過 pagecache_get_page 在 page cache 中查找要寫入數據的緩存頁。如果緩存頁不在 page cache 中,內核則會首先會在物理內存中分配一個內存頁,然后將新分配的內存頁加入到 page cache 中。

相關的查找過程筆者已經在 《8. page cache 中查找緩存頁》小節中詳細介紹過了,這里不在贅述。

12.3 iov_iter_copy_from_user_atomic

這里就是寫入過程的關鍵所在,圖中描述的 CPU 拷貝是將用戶空間緩存區 DirectByteBuffer 中的待寫入數據拷貝到內核里的 page cache 中,這個過程就發生在這里。

size_tiov_iter_copy_from_user_atomic(structpage*page,
structiov_iter*i,unsignedlongoffset,size_tbytes)
{
//將緩存頁臨時映射到內核虛擬地址空間的高端地址上
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);
returnbytes;
}

但是這里不能直接進行拷貝,因為此時從 page cache 中取出的緩存頁 page 是物理地址,而在內核中是不能夠直接操作物理地址的,只能操作虛擬地址

那怎么辦呢?所以就需要調用 kmap_atomic 將緩存頁臨時映射到內核空間的一段虛擬地址上,然后將用戶空間緩存區 DirectByteBuffer 中的待寫入數據通過這段映射的虛擬地址拷貝到 page cache 中的相應緩存頁中。這時文件的寫入操作就已經完成了。

從這里我們看出,內核對于文件的寫入只是將數據寫入到 page cache 中就完事了并沒有真正地寫入磁盤。

由于是臨時映射,所以在拷貝完成之后,調用 kunmap_atomic 將這段映射再解除掉。

12.4 ext4_write_end

staticintext4_write_end(structfile*file,
structaddress_space*mapping,
loff_tpos,unsignedlen,unsignedcopied,
structpage*page,void*fsdata)
{
handle_t*handle=ext4_journal_current_handle();
structinode*inode=mapping->host;

......省略.......
//將寫入的緩存頁在pagecache中標記為臟頁
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 的標記》小節中詳細介紹過了,這里不在贅述。

b6fa1dd8-08e7-11ee-962d-dac502259ad0.png

另一個核心的步驟就是調用 ext4_journal_stop 完成相關日志的寫入。這里日志也只是會先寫到緩存里,不會直接落盤。

12.5 balance_dirty_pages_ratelimited

當進程將待寫數據寫入 page cache 中之后,相應的緩存頁就變為了臟頁,我們需要找一個時機將這些臟頁回寫到磁盤中。防止斷電導致數據丟失。

本小節我們主要聚焦于臟頁回寫的主體流程,相應細節部分以及內核對臟頁的回寫時機我們放在下一小節中在詳細為大家介紹。

voidbalance_dirty_pages_ratelimited(structaddress_space*mapping)
{
structinode*inode=mapping->host;
structbacking_dev_info*bdi=inode_to_bdi(inode);
structbdi_writeback*wb=NULL;
intratelimit;
......省略......
if(unlikely(current->nr_dirtied>=ratelimit))
balance_dirty_pages(mapping,wb,current->nr_dirtied);
......省略......
}

在 balance_dirty_pages_ratelimited 會判斷如果臟頁數量在內存中達到了一定的規模 ratelimit 就會觸發 balance_dirty_pages 回寫臟頁邏輯。

staticvoidbalance_dirty_pages(structaddress_space*mapping,
structbdi_writeback*wb,
unsignedlongpages_dirtied)
{
.......根據內核異步回寫閾值判斷是否需要喚醒flusher線程異步回寫臟頁...

if(nr_reclaimable>gdtc->bg_thresh)
wb_start_background_writeback(wb);
}

如果達到了臟頁回寫的條件,那么內核就會喚醒 flusher 線程去將這些臟頁異步回寫到磁盤中。

voidwb_start_background_writeback(structbdi_writeback*wb)
{
/*
*Wejustwakeuptheflusherthread.Itwillperformbackground
*writebackassoonasthereisnootherworktodo.
*/
wb_wakeup(wb);
}

13. 內核回寫臟頁的觸發時機

經過前邊對文件寫入過程的介紹我們看到,用戶進程在對文件進行寫操作的時候只是將待寫入數據從用戶空間的緩沖區 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 目錄下:

ba52c7d2-08e7-11ee-962d-dac502259ad0.png

下面筆者就為大家介紹下內核回寫臟頁涉及到的這 6 個參數,并解答上面我們提出的這三個問題。

13.1 內核中的定時器間隔多久喚醒 flusher 線程

內核中通過 dirty_writeback_centisecs 參數來配置喚醒 flusher 線程的間隔時間。

ba7bd2ee-08e7-11ee-962d-dac502259ad0.png

該參數可以通過修改 /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)來你房間(內存)打掃一次衛生(回寫臟頁),保潔阿姨會固定每周日按時來到你房間打掃。記住這個例子,我們后面還會用到~~~

13.2 內核中如何使用 dirty_writeback_interval 來控制 flusher 喚醒頻率

在磁盤中數據是以塊的形式存儲于扇區中的,前邊在介紹文件讀寫的章節中,讀寫流程的最后都會從文件系統層到塊設備驅動層,由塊設備驅動程序將數據寫入對應的磁盤塊中存儲。

內存中的文件頁對應于磁盤中的一個數據塊,而這塊磁盤就是我們常說的塊設備。而每個塊設備在內核中對應一個 backing_dev_info 結構用于存儲相關信息。其中最重要的信息是 workqueue_struct *bdi_wq 用于緩存塊設備上所有的回寫臟頁異步任務的隊列。

/*bdi_wqservesallasynchronouswritebacktasks*/
structworkqueue_struct*bdi_wq;

staticint__initdefault_bdi_init(void)
{
interr;
//創建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);

returnerr;
}

在系統啟動的時候,內核會調用 default_bdi_init 來創建 bdi_wq 隊列和初始化 backing_dev_info。

staticintbdi_init(structbacking_dev_info*bdi)
{
intret;

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);
returnret;
}

在 bdi_init 中初始化 backing_dev_info 結構的相關信息,并在 cgwb_bdi_init 中調用 wb_init 初始化回寫臟頁任務 bdi_writeback *wb,并創建一個 timer 用于定時啟動 flusher 線程。

staticintwb_init(structbdi_writeback*wb,structbacking_dev_info*bdi,
intblkcg_id,gfp_tgfp)
{
.........初始化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,
(unsignedlong)(_work),

bdi_writeback 有個成員變量 struct delayed_work dwork,bdi_writeback 就是把 delayed_work 結構掛到 bdi_wq 隊列上的。

而 wb_workfn 函數則是 flusher 線程要執行的回寫核心邏輯,全部封裝在 wb_workfn 函數中。

/*
*Handlewritebackofdirtydataforthedevicebackedbythisbdi.Also
*reschedulesperiodicallyanddoeskupdatedstyleflushing.
*/
voidwb_workfn(structwork_struct*work)
{
structbdi_writeback*wb=container_of(to_delayed_work(work),
structbdi_writeback,dwork);
longpages_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);
//如果回寫任務已經被全部執行完畢,但是內存中還有臟頁,則延時喚醒
elseif(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 線程的時機。

voidwb_wakeup_delayed(structbdi_writeback*wb)
{
unsignedlongtimeout;

//使用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);
}

13.3 臟頁數量多到什么程度會主動喚醒 flusher 線程

這一節的內容中涉及到四個內核參數分別是:

drity_background_ratio :當臟頁數量在系統的可用內存 available 中占用的比例達到 drity_background_ratio 的配置值時,內核就會調用 wakeup_flusher_threads 來喚醒 flusher 線程異步回寫臟頁。默認值為:10。表示如果 page cache 中的臟頁數量達到系統可用內存的 10% 的話,就主動喚醒 flusher 線程去回寫臟頁到磁盤。

ba8b7028-08e7-11ee-962d-dac502259ad0.pngimage.png

系統的可用內存 = 空閑內存 + 可回收內存。可以通過 free 命令的 available 項查看。

ba995436-08e7-11ee-962d-dac502259ad0.png

dirty_background_bytes :如果 page cache 中臟頁占用的內存用量絕對值達到指定的 dirty_background_bytes。內核就會調用 wakeup_flusher_threads 來喚醒 flusher 線程異步回寫臟頁。默認為:0。

babd431e-08e7-11ee-962d-dac502259ad0.pngimage.png

dirty_background_bytes 的優先級大于 drity_background_ratio 的優先級。

dirty_ratio :dirty_background_* 相關的內核配置參數均是內核通過喚醒 flusher 線程來異步回寫臟頁。下面要介紹的 dirty_* 配置參數,均是由用戶進程同步回寫臟頁。表示內存中的臟頁太多了,用戶進程自己都看不下去了,不用等內核 flusher 線程喚醒,用戶進程自己主動去回寫臟頁到磁盤中。當臟頁占用系統可用內存的比例達到 dirty_ratio 配置的值時,用戶進程同步回寫臟頁。默認值為:20 。

bad17faa-08e7-11ee-962d-dac502259ad0.png

dirty_bytes :如果 page cache 中臟頁占用的內存用量絕對值達到指定的 dirty_bytes。用戶進程同步回寫臟頁。默認值為:0。

*_bytes 相關配置參數的優先級要大于 *_ratio 相關配置參數。

bae4b890-08e7-11ee-962d-dac502259ad0.png

我們繼續使用上小節中保潔阿姨的例子說明:

之前你們已經約定好了,保潔阿姨會每周日固定(dirty_writeback_centisecs)來到你的房間打掃衛生(臟頁),但是你周三回家的時候,發現屋子里太臟了,實在是臟到一定程度了(drity_background_ratio ,dirty_background_bytes),你實在是看不去了,這時你就不會等這周日(dirty_writeback_centisecs)保潔阿姨過來才打掃,你會直接給阿姨打電話讓阿姨周三就來打掃一下(內核主動喚醒 flusher 線程異步回寫臟頁)。

還有一種更極端的情況就是,你的房間已經臟到很夸張的程度了(dirty_ratio ,dirty_byte)連你自己都忍不了了,于是你都不用等保潔阿姨了(內核 flusher 回寫線程),你自己就乖乖的開始打掃房間衛生了。這就是用戶進程同步回寫臟頁。

13.4 內核如何主動喚醒 flusher 線程

通過 《12.5 balance_dirty_pages_ratelimited》小節的介紹,我們知道在 generic_perform_write 函數的最后一步會調用 balance_dirty_pages_ratelimited 來判斷是否要觸發臟頁回寫。

voidbalance_dirty_pages_ratelimited(structaddress_space*mapping)
{
................省略............

if(unlikely(current->nr_dirtied>=ratelimit))
balance_dirty_pages(mapping,wb,current->nr_dirtied);

wb_put(wb);
}

這里會觸發 balance_dirty_pages 函數進行臟頁回寫。

staticvoidbalance_dirty_pages(structaddress_space*mapping,
structbdi_writeback*wb,
unsignedlongpages_dirtied)
{
..................省略.............

for(;;){
//獲取系統可用內存
gdtc->avail=global_dirtyable_memory();
//根據*_ratio或者*_bytes相關內核配置計算臟頁回寫觸發的閾值
domain_dirty_limits(gdtc);
.............省略..........
}

.............省略..........

在 balance_dirty_pages 中首先通過 global_dirtyable_memory() 獲取系統當前可用內存。在 domain_dirty_limits 函數中根據前邊我們介紹的 *_ratio 或者 *_bytes 相關內核配置計算臟頁回寫觸發的閾值。

staticvoiddomain_dirty_limits(structdirty_throttle_control*dtc)
{
//獲取可用內存
constunsignedlongavailable_memory=dtc->avail;
//封裝觸發臟頁回寫相關閾值信息
structdirty_throttle_control*gdtc=mdtc_gdtc(dtc);
//這里就是內核參數dirty_bytes指定的值
unsignedlongbytes=vm_dirty_bytes;
//內核參數dirty_background_bytes指定的值
unsignedlongbg_bytes=dirty_background_bytes;
//將內核參數dirty_ratio指定的值轉換為以頁為單位
unsignedlongratio=(vm_dirty_ratio*PAGE_SIZE)/100;
//將內核參數dirty_background_ratio指定的值轉換為以頁為單位
unsignedlongbg_ratio=(dirty_background_ratio*PAGE_SIZE)/100;
//進程同步回寫dirty_*相關閾值
unsignedlongthresh;
//內核異步回寫direty_background_*相關閾值
unsignedlongbg_thresh;
structtask_struct*tsk;

if(gdtc){
//系統可用內存
unsignedlongglobal_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 比例來進行判斷的。

內核異步回寫的閾值要小于進程同步回寫的閾值,如果超過,那么內核異步回寫的閾值將會被設置為進程通過回寫的一半。

staticvoidbalance_dirty_pages(structaddress_space*mapping,
structbdi_writeback*wb,
unsignedlongpages_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 線程開始異步回寫臟頁,直到臟頁數量低于閾值或者全部回寫到磁盤。

voidwb_start_background_writeback(structbdi_writeback*wb)
{
/*
*Wejustwakeuptheflusherthread.Itwillperformbackground
*writebackassoonasthereisnootherworktodo.
*/
trace_writeback_wake_background(wb);
wb_wakeup(wb);
}

13.5 臟頁到底在內存中能駐留多久

內核為了避免 page cache 中的臟頁在內存中長久的停留,所以會給臟頁在內存中的駐留時間設置一定的期限,這個期限可由前邊提到的 dirty_expire_centisecs 內核參數配置。默認為:3000。單位為:0.01 s。

bafa0042-08e7-11ee-962d-dac502259ad0.png

也就是說在默認配置下,臟頁在內存中的駐留時間為 30 s。超過 30 s 之后,flusher 線程將會在下次被喚醒的時候將這些臟頁回寫到磁盤中

這些過期的臟頁最終會在 flusher 線程下一次被喚醒時候被 flusher 線程回寫到磁盤中。而前邊我們也多次提到過 flusher 線程執行邏輯全部封裝在 wb_workfn 函數中。接下來的調用鏈為 wb_workfn->wb_do_writeback->wb_writeback。在 wb_writeback 中會判斷根據 dirty_expire_interval 判斷哪些是過期的臟頁。

/*
*Explicitflushingorperiodicwritebackof"old"data.
*
*Define"old":thefirsttimeoneofaninode'spagesisdirtied,wemarkthe
*dirtying-timeintheinode'saddress_space.Sothisperiodicwritebackcode
*justwalksthesuperblockinodelist,writingbackanyinodeswhichare
*olderthanaspecificpointintime.
*
*Trytorunonceperdirty_writeback_interval.Butifawritebackevent
*takeslongerthanadirty_writeback_intervalinterval,thenleavea
*one-secondgap.
*
*older_than_thistakesprecedenceovernr_to_write.Sowe'llonlywriteback
*alldirtypagesiftheyareallattachedto"old"mappings.
*/
staticlongwb_writeback(structbdi_writeback*wb,
structwb_writeback_work*work)
{
........省略.......
work->older_than_this=&oldest_jif;
for(;;){
........省略.......
if(work->for_kupdate){
oldest_jif=jiffies-
msecs_to_jiffies(dirty_expire_interval*10);
}elseif(work->for_background)
oldest_jif=jiffies;
}
........省略.......
}

13.6 臟頁回寫參數的相關配置方式

前面的幾個小節筆者結合內核源碼實現為大家介紹了影響內核回寫臟頁時機的六個參數。

內核越頻繁的觸發臟頁回寫,數據的安全性就越高,但是同時系統性能會消耗很大。所以我們在日常工作中需要結合數據的安全性和 IO 性能綜合考慮這六個內核參數的配置。

本小節筆者就為大家介紹一下配置這些內核參數的方式,前面的小節中也提到過,內核提供的這些參數存在于 proc/sys/vm 目錄下。

ba52c7d2-08e7-11ee-962d-dac502259ad0.png

比如我們直接將要配置的具體數值寫入對應的配置文件中:

echo"value">/proc/sys/vm/dirty_background_ratio

我們還可以使用 sysctl 來對這些內核參數進行配置:

sysctl-wvariable=value

sysctl 命令中定義的這些變量 variable 全部定義在內核 kernel/sysctl.c 源文件中。

其中 .procname 定義的就是 sysctl 命令中指定的配置變量名字。

.data 定義的是內核源碼中引用的變量名字。這在前邊我們介紹內核代碼的時候介紹過了。比如配置參數 dirty_writeback_centisecs 在內核源碼中的變量名為 dirty_writeback_interval , dirty_ratio 在內核中的變量名為 vm_dirty_ratio。

staticstructctl_tablevm_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 的形式添加到文件的末尾。

bb394608-08e7-11ee-962d-dac502259ad0.png

最后調用 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 文件操作相關知識的系統脈絡。感謝大家的耐心觀看,我們下篇文章見~~~





審核編輯:劉清

聲明:本文內容及配圖由入駐作者撰寫或者入駐合作網站授權轉載。文章觀點僅代表作者本人,不代表電子發燒友網立場。文章及其配圖僅供工程師學習之用,如有內容侵權或者其他違規問題,請聯系本站處理。 舉報投訴
  • JAVA
    +關注

    關注

    19

    文章

    2972

    瀏覽量

    104862
  • Socket
    +關注

    關注

    0

    文章

    212

    瀏覽量

    34743
  • dma
    dma
    +關注

    關注

    3

    文章

    565

    瀏覽量

    100676
  • LINUX內核
    +關注

    關注

    1

    文章

    316

    瀏覽量

    21675
  • vfs
    vfs
    +關注

    關注

    0

    文章

    14

    瀏覽量

    5263

原文標題:從 Linux 內核角度探秘 Java NIO 文件讀寫本質

文章出處:【微信號:小林coding,微信公眾號:小林coding】歡迎添加關注!文章轉載請注明出處。

收藏 人收藏

    評論

    相關推薦

    Java NIO編程理論基礎之Java IO及linux網絡IO模型發展

    Java NIO編程理論基礎篇——Java IO的發展以及linux網絡IO模型
    發表于 07-18 12:40

    i/o本質與庫函數的本質分別是什么

    【1】i/o本質就是輸入輸出函數,也是讀寫函數【2】系統調用和庫函數系統調用:使用函數控制linux內核linux
    發表于 12-15 09:11

    JAVA教程之網絡取得文件

    JAVA教程之網絡取得文件,很好的JAVA的資料,快來學習吧
    發表于 04-11 17:28 ?5次下載

    Java NIO (中文版)編程總結

    Java NIO 編程總結
    發表于 09-21 11:17 ?0次下載

    Linux文件文件描述符概述

    、鏈接文件和設備文件。 那么,內核如何區分和引用特定的文件?這里用到了一個重要的概念文件描述符
    發表于 10-18 14:35 ?0次下載

    linux內核啟動流程

    Linux的啟動代碼真的挺大,匯編到C,Makefile到LDS文件,需要理解的東西很多。畢竟Linux
    發表于 11-14 16:19 ?4358次閱讀
    <b class='flag-5'>linux</b><b class='flag-5'>內核</b>啟動流程

    Linux 內核/sys 文件系統介紹

    linux2.6內核引入sysfs文件系統,sysfs可以看成與proc,devfs和devpty同類別的文件系統,該文件系統是虛擬的
    發表于 04-25 16:20 ?4303次閱讀
    <b class='flag-5'>Linux</b> <b class='flag-5'>內核</b>/sys <b class='flag-5'>文件</b>系統介紹

    需要了解的Linux內核讀寫文件

    在用戶態,讀寫文件可以通過read和write這兩個系統調用來完成(C庫函數實際上是對系統調用的封裝)。 但是,在內核態沒有這樣的系統調用,我們又該如何讀寫
    發表于 04-28 16:43 ?1078次閱讀

    JAVANIO通過MappedByteBuffer操作大文件

    java io操作中通常采用BufferedReader,BufferedInputStream等帶緩沖的IO類處理大文件,不過java nio中引入了一種基于MappedByteBu
    的頭像 發表于 05-05 23:42 ?3512次閱讀

    linux內核是什么_linux內核學習路線

    Linux內核是一個操作系統(OS)內核本質上定義為類Unix。它用于不同的操作系統,主要是以不同的Linux發行版的形式。
    發表于 09-16 15:49 ?2659次閱讀

    如何使用Linux內核實現USB驅動程序框架

    Linux內核提供了完整的USB驅動程序框架。USB總線采用樹形結構,在一條總線上只能有唯一的主機設備。 Linux內核主機和設備兩個
    發表于 11-06 17:59 ?20次下載
    如何使用<b class='flag-5'>Linux</b><b class='flag-5'>內核</b>實現USB驅動程序框架

    Linux環境編程:應用到內核

    Linux環境編程:應用到內核資料下載。
    發表于 06-01 14:51 ?18次下載

    Linux內核文件Cache機制

    Linux內核文件Cache機制(開關電源技術與設計 第二版)-Linux內核文件Cache
    發表于 08-31 16:34 ?4次下載
    <b class='flag-5'>Linux</b><b class='flag-5'>內核</b><b class='flag-5'>文件</b>Cache機制

    軟件角度分析linux內核USB子系統的熱插拔過程

    本文軟件角度分析linux內核USB子系統的熱插拔過程,以實際分析思路和過程行文,基于linux內核
    的頭像 發表于 01-15 09:28 ?5718次閱讀

    linux內核源代碼詳解

     在安裝好的Linux系統中,內核的源代碼位于/ust/src/linux.如果是GNU網站下載的Linux
    發表于 09-06 17:01 ?4次下載
    主站蜘蛛池模板: 国内啪啪| 88影视在线观看污污| 97人摸人人澡人人人超一碰| 日日操夜夜骑| 色综合色狠狠天天综合色hd| 狼人综合色| 又大又粗进出白浆直流动态图| 成年人网站黄色| 天天躁狠狠躁| 精品视频在线观看视频免费视频| 天天做天天爱天天操| 台湾久久| 午夜毛片网站| 日日久| h视频日本| 亚洲4区| 亚洲伦理一区二区| 四虎精品影院4hutv四虎| 欧美一区二区三区免费高| 亚洲精品成人a在线观看| 婷婷免费视频| 两性色午夜视频自由成熟的性| 沟沟人体一区二区| 色橹橹| 欧美五月| 黄色在线观看网址| 久久99热精品这里久久精品| 亚州免费一级毛片| 欧美日韩高清一本大道免费| 丁香婷婷视频| 免费国产午夜高清在线视频| h免费在线观看| 五月婷婷综合基地| 久久久国产精品网站| 午夜精品免费| 五月丁五月丁开行停停乱| 99热都是精品| 嫩草影院地址一地址二| 综合天天色| 正在播放欧美| 亚洲一卡2卡3卡4卡5卡乱码|