前言
Lab一做一晚上,blog一寫能寫兩天,比做Lab的時間還長(
這篇博文是半夜才寫完的,本來打算寫完后立刻發出來,但由於今天發現白天發博點擊量會高點,就睡了一覺后才發(幾十的點擊量也是點擊量啊T_T)....
我個人計划采用bottom-up的方式,用兩篇blog配合源碼講解xv6的文件系統。
xv6對文件系統的架構做出了如下的分層:
我個人傾向於將設備驅動程序
也加入到文件系統的架構中,因此最后形成的架構圖如下:
本篇blog講解首先談了談我個人對文件系統的見解,隨后講解xv6的存儲介質層、設備驅動層和緩沖層。內容很可能有各種紕繆,如果dalao發現其中的錯誤,還請在評論區指正。
關於日志層、inode層、命名目錄層、文件描述符層,會在下一篇blog中討論。
我眼中的文件系統
文件管理是操作系統的幾大基礎功能之一(內存管理、進程管理、設備管理、文件管理)。一般來說,文件是持久化在磁盤上的一組二進制數據,具體類型包括ELF文件、圖片文件、音樂文件、視頻文件、文本文件等;不同類型的文件有着不同的信息和存儲格式,而操作系統無需關心文件的具體內部結構和文件的具體應用,即操作系統不應當關心文件中信息的解釋,這是具體的用戶程序應當關注的事情。特定格式的文件通常與其對應的用戶程序協定好了上下文,以便用戶程序可以識別和讀取文件內部的信息。一個不算很恰當的例子就是ELF格式的文件,其前32字節(對應ELF32文件)被稱作ELF頭部,記錄着這個文件的類別(可重定位、可執行、Core、共享目標)、版本、對應的段的偏移值等;如果這個文件是可執行文件,在執行改文件時,exec系統調用將按照ELF格式文件的布局讀取相應的段,最終將這個ELF文件加載成用戶進程。這里雖然操作系統擔當了解釋文件(exec按照用戶程序解釋該文件)的角色,但exec調用是知道這個文件的格式的,並依照這個格式對文件的上下文進行了解釋。
我們從上文可知,操作系統/文件系統不應當關心文件的具體格式和具體內容。文件系統所負責的任務,應當是向下(存儲層)操控對應的設備管理器(一般是磁盤的驅動系統),將對文件的操作轉換為對某個設備的操作;向上(面向用戶)為用戶提供一套所有文件通用的接口(創建、查找、打開、讀取、修改、刪除,目錄等),對用戶隱藏底層存儲與文件操作的繁瑣細節(如並發訪問、崩潰恢復等)。
一般來說,講解文件系統時,總是離不開講解磁盤等存儲設備。既然我們采用了bottom-up的講解方法,那我們自然要首先從具體的存儲介質
開始講起。
存儲介質層
xv6欽點的存儲介質為磁盤
,因此本節我們討論file system對磁盤做了哪些要求和行為。
磁盤的區域划分
如果你之前學過《操作系統》這門課程的話你應該知道,剛剛拿到手的裸盤是不能直接使用的,必須經過物理格式化
和邏輯格式化
之后才能被使用。邏輯格式化
即在磁盤上寫入一系列支持文件系統所需的數據,這些數據將磁盤的空間進行了划分,為實現文件的組織、存儲空間的分配與回收提供着相應的支持。xv6對磁盤空間的划分方法如下圖所示:
磁盤上的第一個盤塊為boot sector
,即引導區
。一般來說每個磁盤可以划分成多個分區
,而每個分區都會有一個引導區
,如果在這個分區上安裝有操作系統的話,需要將操作系統的引導入口寫入到boot sector
中。xv6默認磁盤只有一個分區,因此自然也就只有一個引導區。boot sector怎么將內核代碼加載到內存中就又是另一個話題了,這里不再贅述。
磁盤的第二個盤塊為super block
,記錄着管理這個磁盤的一系列元數據
,我們可以在kernel/fs.h中看到super block
的數據結構:
// Disk layout:
// [ boot block | super block | log | inode blocks |
// free bit map | data blocks]
//
// mkfs computes the super block and builds an initial file system. The
// super block describes the disk layout:
struct superblock {
uint magic; // Must be FSMAGIC
uint size; // Size of file system image (blocks)
uint nblocks; // Number of data blocks
uint ninodes; // Number of inodes.
uint nlog; // Number of log blocks
uint logstart; // Block number of first log block
uint inodestart; // Block number of first inode block
uint bmapstart; // Block number of first free map block
};
磁盤的第三個區域為log區
,其對應的元數據為superblock
中的nlog
和logstart
。這個區域存儲着落盤的日志,每條日志對應一個block。實際上,日志本身是某個block經過一次完整的寫操作之后的狀態。在xv6系統中,為了維護好磁盤狀態的一致性,對盤塊的寫操作暫時不會覆蓋原來的盤塊,而是將這個盤塊作為一條日志存儲在日志區,等待日志提交時再將日志區的盤塊回寫到相應的區域。這一部分的原理和內容將會在后面的日志層
中介紹。
磁盤的第四個區域為inodes區
,其對應的元數據為superblock
中的inodestart
和nblocks
。每個文件都會對應着inode區
的一個inode,每個inode記錄着文件的元數據,例如文件類型、文件大小、盤塊占據情況等。
磁盤的第五個區域為bitmap
,其對應的元數據為superblock
中的bmapstart
。在xv6中使用位圖
來記錄盤塊的使用情況,相應的位圖即被存放在了這個區域,為文件系統中盤塊的分配與回收提供相應的支持。
磁盤的第六個區域(也就是最后一個區域)即文件盤塊,用於存儲文件的具體內容或者間接索引塊
(見Lab8 File System)。
磁盤的邏輯格式化
前文中已經提到,一塊裸盤需要經過物理格式化
和邏輯格式化
后才能被使用,其中邏輯格式化
的任務就是將磁盤按照預先設定好的格式進行划分。在本課程我們使用的是硬件模擬器qemu,需要將一個文件作為磁盤。查看一下對應的makefile命令:
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 1 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
注意到一個命令行參數 file=fs.img
,這說明在qemu中將我們的fs.img當做了磁盤。fs.img是通過項目目錄下的程序mkfs/mkfs來生成的,我們需要查看一下mkfs/mkfs.c下的代碼,這部分的代碼正好對應着磁盤的邏輯格式化
過程。比較有趣的是,我們可以查看一下mkfs的include情況,可以發現其使用了C標准庫文件。這很正常,畢竟我們只需要生成一個符合前文所述的格式的文件即可,無需關心到底使用了哪些庫。
// mkfs/mkfs.c
int
main(int argc, char *argv[])
{
int i, cc, fd;
uint rootino, inum, off;
struct dirent de;
char buf[BSIZE];
struct dinode din;
static_assert(sizeof(int) == 4, "Integers must be 4 bytes!");
if(argc < 2){
fprintf(stderr, "Usage: mkfs fs.img files...\n");
exit(1);
}
assert((BSIZE % sizeof(struct dinode)) == 0);
assert((BSIZE % sizeof(struct dirent)) == 0);
fsfd = open(argv[1], O_RDWR|O_CREAT|O_TRUNC, 0666); // create file fs.img. Notice that we used C standard library.
if(fsfd < 0){
perror(argv[1]);
exit(1);
}
這一部分代碼對編譯環境和某些數據結構進行了檢查,例如說int
必須占據4字節大小、一個盤塊必須能被struct dinode
和struct dirent
無縫填滿等。
我們在kernel/param.h中定義了一系列文件系統相關的參數,其中包括魔數字、文件系統大小、inode最大數量等,並且可以以此推算出superblock中其他的元數據的值,即各個區域在磁盤上的范圍。這些元數據被記錄到superblock中。隨后通過wsect方法,將磁盤(文件)的所有部分全部置零。
在mkfs/mkfs.c中還定義了xint
、xshort
方法,這些方法的目的是改變字節序(即我們常說的大端存儲
和小端存儲
),這部分的內容不再贅述,如果不了解可以參照一下相應的wikipedia:https://zh.wikipedia.org/wiki/字節序。
memset(buf, 0, sizeof(buf));
memmove(buf, &sb, sizeof(sb));
wsect(1, buf); // write super block which contains meta data of file system.
將superblock寫入到磁盤的第一個盤塊上。
rootino = ialloc(T_DIR);
assert(rootino == ROOTINO);
bzero(&de, sizeof(de));
de.inum = xshort(rootino);
strcpy(de.name, ".");
iappend(rootino, &de, sizeof(de));
bzero(&de, sizeof(de));
de.inum = xshort(rootino);
strcpy(de.name, "..");
iappend(rootino, &de, sizeof(de));
根目錄是磁盤上的第一個文件。首先調用ialloc
分配一個inode。由於它是一個目錄文件,因此要擁有默認的兩個表項“.”和“..”,iappend
將這兩個表項寫入到磁盤的盤塊上。
for(i = 2; i < argc; i++){
// get rid of "user/"
char *shortname;
if(strncmp(argv[i], "user/", 5) == 0)
hortname = argv[i] + 5;
else
shortname = argv[i];
assert(index(shortname, '/') == 0);
if((fd = open(argv[i], 0)) < 0){
perror(argv[i]);
exit(1);
}
// Skip leading _ in name when writing to file system.
// The binaries are named _rm, _cat, etc. to keep the
// build operating system from trying to execute them
// in place of system binaries like rm and cat.
if(shortname[0] == '_')
shortname += 1;
inum = ialloc(T_FILE); // allocate each file a inode
bzero(&de, sizeof(de));
de.inum = xshort(inum);
strncpy(de.name, shortname, DIRSIZ);
iappend(rootino, &de, sizeof(de));
while((cc = read(fd, buf, sizeof(buf))) > 0)
iappend(inum, buf, cc); // write into disk
close(fd);
}
mkfs/mkfs接收了一系列的命令行參數,這些參數正好對應着makefile中的 $UPROGS
,即我們添加到磁盤中的用戶程序。這部分代碼為每一個用戶程序分配一個inode
,並將內容寫入到對應的盤塊上,置其占據的位圖位為1。這樣我們也可以理解為什么我們啟動了內核之后,能夠看到這些程序了。
// fix size of root inode dir
rinode(rootino, &din);
off = xint(din.size);
off = ((off/BSIZE) + 1) * BSIZE;
din.size = xint(off);
winode(rootino, &din);
balloc(freeblock);
exit(0);
}
最后修改一下根目錄的大小,mkfs/mkfs執行就結束了。
我們可以看到,mkfs/mkfs擔當了磁盤的邏輯格式化的角色,在根據我們的設計在磁盤上實現了對區域的划分,並將一些用戶程序寫入到了文件系統中。如果感興趣的話,你也可以考慮將360全家桶和百度全家桶也寫入到文件系統里(捌要命啦
值得注意的是,上述代碼中都沒涉及到對0號盤塊,即boot sector的寫操作,而且內核也沒被寫入到磁盤中。內核應該是通過其他的方式被加載到內存中的,這里我也沒有做進一步的探究。
總結
xv6采用磁盤作為文件存儲的介質。磁盤最初一般是一塊裸盤,需要經過物理格式化
和邏輯格式化
后才能被使用。其中的邏輯格式化
即根據預先設計好的格式,對磁盤進行划分。xv6系統中的mkfs/mkfs承擔了磁盤邏輯格式化
的任務,將磁盤空間划分成了boot sector
、super block
、log
、inodes
、bitmap
、data
等區域,為文件系統實現文件組織、空間的分配與回收提供着重要的支持。
設備驅動層
操作系統並不會直接操作相應的設備,而是要通過相應的設備驅動程序
來操縱對應的設備。我們在linux中經常使用mount
命令來“掛載”一個塊設備。當塊設備通過usb等接口插入到主板上時,操作系統即可識別到有一個塊設備已經與主板建立了連接,但此時並不能對這個設備進行操作,而必須通過mount
命令后,才能實現對塊設備的讀寫,而mount
命令的功能之一就是尋找到該塊設備對應的設備驅動程序
。經過mount
之后,操作系統即可通過設備驅動程序
來操作設備了。
在make qemu
的命令行輸出中,我們可以看到 -device virtio-blk-device
這個命令行參數,即要求qemu模擬virtio-blk-device這個硬件,根據這個名字來看,這是一個塊設備,其對應的設備驅動程序
的代碼在kernel/virtio_disk.c中。這部分的代碼看一眼就頭大(I/O設備的代碼應該是最復雜的代碼了,我舍友寫藍牙代碼的時候頭都快炸了),我們沒精力也沒必要去認真閱讀這些代碼。我們只需要知道以下內容:
1)xv6采用了MMIO(內存映射I/O),即不通過I/O指令訪問設備,而是在內存中划分出一塊特殊的區域,並映射到對應的設備上,對這部分區域的讀寫操作將被映射到對設備的操作;
2)virtio_disk.c的代碼承擔的是設備驅動程序的角色,文件系統通過調用virtio_disk_rw
,實現對設備的寫操作;
3)在設備驅動程序的輔助下,一切對磁盤的操作,被統一為對磁盤上盤塊的讀/寫操作;
最后做個總結,設備驅動層應當包含着一系列的設備驅動程序。操作系統不能也不應該直接操作相應的設備,而是需要通過設備對應的設備驅動程序
來實現對設備的操作。在xv6中,qemu將會模擬設備virtio-blk-device,其對應的設備驅動程序的代碼在kernel/virtio_disk.c下,這個設備驅動程序向上層提供了一個重要的接口virtio_disk_rw
,通過這個接口,操作系統與磁盤的一切交互(文件系統的請求、數據的讀寫等),全部通過對特定盤塊的讀寫操作來實現。
緩沖層
我們知道,I/O操作和內存操作的速度差距是非常大的,這導致很多程序的速度瓶頸源自於I/O的效率。目前一個廣泛采用的方式是將文件的部分塊讀取到內存中作為緩沖,將對磁盤的訪問操作轉換為對內存的操作,並通過一致性協議維持內存中文件塊與磁盤文件塊的一致性。除此以外,緩沖區還要承擔另一個任務:同步對磁盤塊的並發訪問。接下來我們仔細分析xv6的源碼,來了解xv6是如何實現這些需求的。
api簡介
xv6的緩沖層代碼在kernel/bio.c下。我們首先看一下buf
的數據結構以及binit
的代碼:
struct buf {
int valid; // has data been read from disk?
int disk; // does disk "own" buf?
uint dev;
uint blockno;
struct sleeplock lock;
uint refcnt;
struct buf *prev; // LRU cache list
struct buf *next;
uchar data[BSIZE];
};
struct {
struct spinlock lock;
struct buf buf[NBUF];
// Linked list of all buffers, through prev/next.
// head.next is most recently used.
struct buf head;
} bcache;
void
binit(void)
{
struct buf *b;
initlock(&bcache.lock, "bcache");
// Create linked list of buffers
bcache.head.prev = &bcache.head;
bcache.head.next = &bcache.head;
for(b = bcache.buf; b < bcache.buf+NBUF; b++){
b->next = bcache.head.next;
b->prev = &bcache.head;
initsleeplock(&b->lock, "buffer");
bcache.head.next->prev = b;
bcache.head.next = b;
}
}
首先看一下struct buf
的成員,dev
和blockno
標識着這個緩沖塊對應的設備和該設備的盤塊號,data
中存放的是對應盤塊上的數據內容。
然后我們再閱讀binit
和bcache
來了解一下緩沖塊的組織形式,可以得知,xv6預先准備了NBUF個緩沖塊,雖然這些緩沖塊是被存放在數組里面的,但我們訪問緩沖塊的時候並不希望通過數組下標訪問,而希望使用鏈表的方式來訪問(這樣可以為LRU提供良好的支持)。在binit
被調用后,所有的緩沖塊被串接成一條鏈表。
接下來我們來了解一下緩沖塊的分配與回收的相關代碼:
static struct buf*
bget(uint dev, uint blockno)
{
struct buf *b;
acquire(&bcache.lock);
// Is the block already cached?
for(b = bcache.head.next; b != &bcache.head; b = b->next){
if(b->dev == dev && b->blockno == blockno){
b->refcnt++;
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
// Not cached; recycle an unused buffer.
// Notice that we travel the list reversely.
for(b = bcache.head.prev; b != &bcache.head; b = b->prev){
if(b->refcnt == 0) {
b->dev = dev;
b->blockno = blockno;
b->valid = 0;
b->refcnt = 1;
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
panic("bget: no buffers");
}
void
brelse(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("brelse");
releasesleep(&b->lock);
acquire(&bcache.lock);
b->refcnt--;
if (b->refcnt == 0) {
// no one is waiting for it.
b->next->prev = b->prev;
b->prev->next = b->next;
b->next = bcache.head.next;
b->prev = &bcache.head;
bcache.head.next->prev = b;
bcache.head.next = b;
}
release(&bcache.lock);
}
void
bpin(struct buf *b) {
acquire(&bcache.lock);
b->refcnt++;
release(&bcache.lock);
}
void
bunpin(struct buf *b) {
acquire(&bcache.lock);
b->refcnt--;
release(&bcache.lock);
}
bget
方法獲取一個特定的buf。注意bget
方法接受的兩個參數:dev和blockno,這兩個參數可以索引到所指定的設備的所指定的盤塊。這個方法首先遍歷鏈表,查看(dev, blockno)
所對應的的盤塊是否已經被分配了一個buf,如果已經分配了,則返回這個buf;否則,從空閑的buf中選擇一個buf,標注buf與(設備, 盤塊)的映射關系(b->dev = dev,b->blockno = blockno),並將這個buf返回,此時這個buf尚未讀取入對應的磁盤盤塊(b->valid == 0, b->data沒有意義),對盤塊的讀取操作被推遲到調用bread
時再進行。
brelse
方法釋放一個特定的buf。每個buf都有一個refcnt
成員,該成員也承擔着引用計數
的作用,不過其所計的數比較特殊:當不同的進程調用bget
獲取同一個盤塊的buf時,bget
方法會返回這個buf對應的指針,同時將buf的引用計數增1,表明某個進程還在使用着這個buf;當這個buf->data內容已經被更新時,文件系統需要尋找一個時機,將這個buf回寫到磁盤上,為此,必須保證回寫前這個buf不能被回收掉。對於這種情形,xv6提供了bpin
和bunpin
兩個方法,當完成buf的寫操作后,會調用bpin
方法,使refcnt
增一,這樣可以避免在調用brelse
時該buf被回收。值得注意的是,如果一個進程調用了brelse
,並不意味着這個緩沖塊會被很快寫入到磁盤上;一方面講,可能還有其他的進程將這個緩沖塊給pin住,阻止緩沖塊的回寫操作;另一方面,將緩沖塊在系統中多存放一段時間,以減少總的I/O次數,對於系統來說是件好事。這些緩沖塊回寫的時機,我們將會在日志層進行討論。
最后是bread
和bwrite
方法。bread
從磁盤上讀取(dev, blockno)對應的磁盤塊到buf->data
中,bwrite
將buf->data
的內容回寫到(dev, blockno)上,它們對設備的讀寫操作都是通過調用設備驅動代碼所提供的virtio_disk_rw
來實現的,這也印證了我們在設備驅動層所提出的結論:操作系統與磁盤的一切交互(文件系統的請求、數據的讀寫等),全部通過對特定盤塊的讀寫操作來實現。
LRU置換算法
buf的數量明顯小於盤塊的數量,因此緩沖池必須要有相應的置換算法,當已經沒有未分配的緩沖塊時,要選擇重復利用一個已經沒有進程引用的緩沖塊。為了提高緩沖區的命中率,xv6選擇了LRU置換算法,即優先淘汰掉那些最長時間未使用的緩沖塊。
我們重溫一下brelse
的代碼,當一個緩沖塊的refcnt遞減至0時,說明所有進程都已不再引用這個緩沖塊,且xv6的代碼組織可以保證,當refcnt為0時,該緩沖塊一定不是臟塊,即buf->data必定與(buf->dev, buf->blockno)對應的盤塊內容一致;這種情況下,這個buf已經可以被回收利用了;此時,brelse
代碼會將這個緩沖塊放置在鏈表的最前端。然后我們重溫一下bget
的代碼;當第一個for循環結束時,表明(dev,blockno)對應的盤塊並沒有被分配相應的緩沖區,因此我們需要找到一個空閑的buf;注意到第二個循環是逆序遍歷鏈表的,因此最新被釋放的buf必定會最晚被選中淘汰,由此實現了LRU置換算法。
Lock
緩沖層共涉及到了兩類鎖,第一類鎖是struct bcache
中的自旋鎖bcache.lock
,第二類鎖是struct buf
中的睡眠鎖。我接下來會講解一下這兩類鎖的功能。
當我們設計數據結構的時候,如果用到了鎖,就一定要十分明確我們希望用鎖保護那些成員。xv6的緩沖層中,對鎖的設計和鎖的作用的邊界划分的非常清晰,並充分發揮了睡眠鎖和自旋鎖各自的優勢,十分值得我們學習(當然如果你在項目里用了自旋鎖就活該被整 →_→ )
簡單的說,struct buf
中的睡眠鎖的作用是同步多進程對盤塊的讀寫操作,即每個盤塊(buf)只允許一個進程訪問它的data
字段,而bcache.lock
負責保護所有的buf中的其他成員(type
、valid
、鏈表指針等);這一點可能讓我們覺得比較詭異,struct buf
中的鎖居然不保護struct buf
中的全部成員,這些成員反而要交給另一把鎖來保護,而且這把鎖還是一把自旋鎖。
實際上,xv6的代碼中廣泛使用着自旋鎖。我們知道,自旋鎖的實現原理是在一個死循環中不斷通過CAS指令來檢查狀態變化,這個過程相當於cpu一直在空等,也正是因此給了我們一種自旋鎖浪費了cpu的利用率的感覺;但如果希望提升cpu利用率而選擇了睡眠鎖反而有副作用,因為睡眠鎖如果獲取失敗,進程會進入睡眠狀態等待下一次被調度,這浪費的一輪cpu反而延長了等待時間,南轅北轍。因此對於內核代碼來說,更適合使用自旋鎖。當然,我們要在不需要自旋鎖的時候盡早釋放掉它。
經過上述的解釋后,我想你應該也可以理解為什么要用一把自旋鎖來保護幾十個buf的成員變量了,因為這些操作都是對內存的操作,所需要花費的時間不會太長,而內核代碼是十分注重效率的,自旋鎖雖然會讓cpu空等,但拿到鎖所需要的時間相比睡眠鎖來說更短。在遍歷buf的鏈表、訪問buf除了data段的成員時,都需要持有着bcache.lock
。
對buf->data
成員的讀寫,要通過睡眠鎖來保護,因為這其中會涉及到I/O操作,其等待的時間會很長。此外,當調用bget
成功獲取到buf時,也要獲取buf的睡眠鎖,以防止其他進程對這個buf進行讀寫操作,這樣就達到了同步多進程對盤塊的讀寫操作的目的。
關於緩沖區置換算法的一些思考
在內存之上、寄存器之下引入cache對程序執行速度的提升效率有目共睹,程序的局域性原理也因此深入人心。類似的,我們可能希望將程序的局域性原理推廣到文件的訪問上,例如說,我曾經分析認為,brelse
釋放掉的buf,很可能在不久的將來再次被bget
到,即一個磁盤塊在現在被訪問后,在不就的將來很可能會被再次訪問。基於這種情形,LRU算法是一種很適合緩沖池的置換算法,因為每次被relse
的會被放在鏈表的最前端,這樣它更容易被bget
到,對程序的執行效率也有一定幫助。
這種想法是站不住腳的,且不說一次cache miss操作所造成的時間損失遠高於遍歷鏈表造成的時間損失,而且LRU算法的初衷,並不包含“一個磁盤塊在現在被訪問后,在不就的將來很可能會被再次訪問”這一想法;xv6選擇LRU作為置換算法,每次將brelse
的盤塊放在鏈表的最前端,也並不是認為“brelse
釋放掉的buf,很可能在不久的將來再次被bget
到”,而是僅僅簡單的希望淘汰掉最久沒使用的buf而已。
再深入想想的話,“一個磁盤塊在現在被訪問后,在不就的將來很可能會被再次訪問” 這一想法,很可能是不成立的。為此,讓我們首先來復習一下程序的局域性原理所包含的內部含義:
1)時間局部性:如果一個地址被訪問,那么在不久的將來,這個地址很可能會再次被訪問;
2)空間局部性:如果一個地址被訪問,那么在不久的將來,與這個地址所臨近的地址很可能會被訪問;
空間局域性原理成立的條件在於,程序的指令是順序連續存放的,程序的執行流一般也是順序的(bne指令會打斷順序執行,不過也僅僅是在bne指令處跳轉,跳轉后直到下一跳bne指令之前,程序仍然是順序執行的),此外,順序遍歷數據也是程序系統中最常見的訪問數據方式,這些特性都完美契合空間局域性原理。時間局域性原理的分析就比較復雜了,但不難找到一些例子,例如說頻繁調用的子程序、頻繁訪問的常量,以及廣泛存在的循環代碼等。
回顧了程序局域性原理后,我們拿着這兩條原理來考察文件的訪問操作,看看文件的訪問是否能與這兩條原理有所契合。但這並不是一個容易思考的問題,程序的局域性原理之所以成立,是因為程序的數據和代碼在組織上連續、程序代碼的順序執行、占據大量cpu時間的循環代碼、數據訪問一般情況下是連續的等等原因共同作用的結果,而文件訪問的情景是非常豐富的,而“一個磁盤塊在現在被訪問后,在不就的將來很可能會被再次訪問” ,也僅僅是其中一個可能存在的情景而已。我這里考慮三類訪問情景(順序訪問、大范圍隨機訪問、重復掃描訪問),這三類情景應當是比較常見的文件訪問情景,並考慮基於LRU和基於MRU的緩沖池置換算法的優劣性。
1)首先考慮以順序訪問為主的情景,即對文件只順序進行一次掃描。這種情景下LRU和MRU的置換情形是一模一樣的,即假設緩沖池的容量為N,那么從第 N + 1 個盤塊開始,每讀取一個文件盤塊都要發生一次緩沖頁置換,只是被淘汰出的緩沖頁不一樣而已。
2)然后再考慮重復掃描訪問的情景,即對文件重復進行多次順序掃描。仍然假設緩沖池的容量為N,對於LRU置換算法來說,仍然是從第 N + 1 個盤塊開始,每讀取一個文件盤塊都會發生一次緩沖頁置換;但對於MRU算法來說,從第二次掃描開始,必定會有N + 1塊盤塊命中緩存,相比於全部是Miss的LRU置換算法來說,MRU置換算法顯然能獲得更高的效率。
3)最后考慮大范圍隨機訪問的情景,即每次訪問時,文件的每一個盤塊都有相等的概率被訪問到;這種情形下MRU算法和LRU算法的行為都會非常復雜,我個人沒有能力對這種情形做出推斷。在wikipedia中查詢MRU的相應條目后可以看到如下的資料:
In findings presented at the 11th VLDB conference, Chou and DeWitt noted that "When a file is being repeatedly scanned in a [Looping Sequential] reference pattern, MRU is the best replacement algorithm."[7] Subsequently, other researchers presenting at the 22nd VLDB conference noted that for random access patterns and repeated scans over large datasets (sometimes known as cyclic access patterns) MRU cache algorithms have more hits than LRU due to their tendency to retain older data.[8]
https://en.wikipedia.org/wiki/Cache_replacement_policies#Most_recently_used_(MRU)
即由於基於MRU的置換算法有保留比較老的數據的傾向,在重復掃描文件和隨機訪問大數據集的情形下,其cache的命中率相比基於LRU置換算法的緩沖池更高,因此訪問效率也更高。
目前為止我所思考的情形,僅限於單線程訪問,在多線程的情形下可能會更加復雜,但這些思考已經足夠了。通過上述情形我們可以知道,將局域性原理推廣到文件訪問上存在着諸多邏輯和事實上的沖突,緩沖區的置換算法應當依據其對應系統最常見的I/O情景來選擇,而不能依賴經驗進行推論。其實回顧一下,很多書本上告訴我們,緩沖區的設計時為了緩和I/O效率與內存訪問效率差距帶來的問題,並沒有將局域性原理擴展到文件訪問的情形下,因此我的這種觀點,可以說是既找不到來源,也找不到依據了。
本篇blog就到此結束了,下一篇blog會討論文件系統的剩余部分。