MIT 6.S081 聊聊xv6中的檔案系統(上)

KatyuMarisa發表於2021-02-03

前言

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中的nloglogstart。這個區域儲存著落盤的日誌,每條日誌對應一個block。實際上,日誌本身是某個block經過一次完整的寫操作之後的狀態。在xv6系統中,為了維護好磁碟狀態的一致性,對盤塊的寫操作暫時不會覆蓋原來的盤塊,而是將這個盤塊作為一條日誌儲存在日誌區,等待日誌提交時再將日誌區的盤塊回寫到相應的區域。這一部分的原理和內容將會在後面的日誌層中介紹。

磁碟的第四個區域為inodes區,其對應的後設資料為superblock中的inodestartnblocks。每個檔案都會對應著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 dinodestruct dirent無縫填滿等。

我們在kernel/param.h中定義了一系列檔案系統相關的引數,其中包括魔數字、檔案系統大小、inode最大數量等,並且可以以此推算出superblock中其他的後設資料的值,即各個區域在磁碟上的範圍。這些後設資料被記錄到superblock中。隨後通過wsect方法,將磁碟(檔案)的所有部分全部置零。

在mkfs/mkfs.c中還定義了xintxshort方法,這些方法的目的是改變位元組序(即我們常說的大端儲存小端儲存),這部分的內容不再贅述,如果不瞭解可以參照一下相應的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 sectorsuper blockloginodesbitmapdata等區域,為檔案系統實現檔案組織、空間的分配與回收提供著重要的支援。

裝置驅動層

作業系統並不會直接操作相應的裝置,而是要通過相應的裝置驅動程式來操縱對應的裝置。我們在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的成員,devblockno標識著這個緩衝塊對應的裝置和該裝置的盤塊號,data中存放的是對應盤塊上的資料內容。
然後我們再閱讀binitbcache來了解一下緩衝塊的組織形式,可以得知,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提供了bpinbunpin兩個方法,當完成buf的寫操作後,會呼叫bpin方法,使refcnt增一,這樣可以避免在呼叫brelse時該buf被回收。值得注意的是,如果一個程式呼叫了brelse,並不意味著這個緩衝塊會被很快寫入到磁碟上;一方面講,可能還有其他的程式將這個緩衝塊給pin住,阻止緩衝塊的回寫操作;另一方面,將緩衝塊在系統中多存放一段時間,以減少總的I/O次數,對於系統來說是件好事。這些緩衝塊回寫的時機,我們將會在日誌層進行討論。

最後是breadbwrite方法。bread從磁碟上讀取(dev, blockno)對應的磁碟塊到buf->data中,bwritebuf->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中的其他成員(typevalid、連結串列指標等);這一點可能讓我們覺得比較詭異,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會討論檔案系統的剩餘部分。

相關文章