前言
我本想把上篇中沒講完的剩餘層全部在本篇中講完,但沒想到越寫越多。日誌層的程式碼不多,其思想和解決問題的手段也不算難以理解,但其背後涉及的原理和思想還是非常值得回味的,因此我打算用一整篇完整的blog來講解日誌層,並對其作出一點擴充套件。
本篇內容應該也會幫你對事務的擁有一個更好地理解。
聊聊xv6的檔案系統(上篇):https://www.cnblogs.com/KatyuMarisaBlog/p/14366115.html
淺談一致性
警告:
本節內容我越寫越覺得自己是個民科,整出了一套四不像的東西來強行糊弄自己,如果覺得這部分內容辣眼睛的話就請跳過吧(留下了蔡雞的淚水)
《xv6 book》中告訴我們,檔案系統必須支援crash recovery
,即檔案系統在訪問檔案的過程中如果經歷斷電等情況,經過重啟仍然能夠正常工作。更加嚴謹的說法是,檔案系統在面臨崩潰等情況後,仍然能夠保證一致性。這裡的一致性可能與我們所預想的定義有所出入。為了能夠正確的理解日誌層為檔案系統提供的作用,我們必須首先正確理解本文中所談及的一致性的具體含義。
首先我們給出wikipedia上關於一致性的嚴謹定義:
The semantic definition states that a theory is consistent if it has a model, i.e., there exists an interpretation under which all formulas in the theory are true.
The syntactic definition states a theory T is consistent if there is no formula φ such that both φ and its negation ?φ are elements of the set of consequences of T.
https://en.wikipedia.org/wiki/Consistency
將這個定義遷移到程式設計上,我個人給出我自己關於一致性
的論述:對於一個系統M,定義其合法的操作集合P,並假設其當前狀態為S,且一切的一致性約束條件
都成立,如果經過操作p(p∈P)後狀態轉換為S’,且S’內部的一切約束性條件仍然成立,那麼則稱這個系統在操作集合P下能保持一致性
。
現在將這個論述套用到檔案系統上,設磁碟為我們的系統
M,並設定對這個系統的操作集合P
為xv6中的事務操作,設定一致性約束條件
為磁碟的後設資料能正確反映磁碟的狀態,檔案的後設資料能正確反映檔案的狀態,這即是xv6檔案系統的一致性模型。
這種定義可能比較拗口,既不像數學定義那樣高度抽象且嚴謹,也沒能做到讓人容易理解。為了讓這些定義更加形象一些,我們首先腦補一些可能會產生不一致狀態
的情景。
情景1:
假設釋放盤塊的操作的先後順序為先置點陣圖位為0,再將盤塊的內容置零,且分配盤塊時不再重置盤塊的內容(因為認為釋放盤塊時空間已被置空了,無需重複操作),且沒有日誌層的支援。
考慮刪除檔案的操作。刪除檔案時,會將改檔案所涉及的所有盤塊一一釋放。假設斷電發生在置盤塊bn的點陣圖位為0後,將bn中的內容清零之前,然後系統重啟,注意此時盤塊bn的內容並沒有被置空。如果這個盤塊被分配了出去,則獲得這個盤塊的檔案中就包含了前面那個檔案被刪除的資料,即髒資料,這一點便違背了一致性約束條件
:檔案的後設資料必須能正確反映檔案的狀態。
當然我們有很多的辦法可以避免這種情況,例如說盤塊被分配時再執行一次清空操作、記錄檔案偏移值等等;
情景2:
仍然考慮刪除檔案的操作。閱讀xv6的相關程式碼程式碼可知,檔案系統通過struct inode
實現對檔案內容的讀寫,其中inode->addr
記錄著檔案的內容所佔據的盤塊的盤塊號(這將在下篇的inode層進行介紹)。如果刪除操作是先釋放掉存放檔案內容的盤塊,再將inode
回寫到inode區其對應的盤塊上,且斷電發生在盤塊內容被刪除後,inode
被回寫前,那麼inode
便索引到了一塊內容已經被清空的盤塊,這也違背了我們的一致性約束條件
:檔案的後設資料必須正確反映檔案的狀態;
情景3:
點陣圖在磁碟空間的分配和回收中擔任著重要的角色,因此其可以被看做是磁碟的後設資料。我們在魔改作業系統程式碼時,也可能會使得點陣圖將一個已經被釋放掉的盤塊標註為已分配,或者將一個已經被分配出去的盤塊標註為未分配。這種情況同樣違背了我們的一致性約束條件
:磁碟的後設資料必須正確反映磁碟的狀態。
瞭解前文中所提到的磁碟的一致性約束條件
的具體內容後,我們接下來思考下檔案的寫操作的情景。一次寫操作可能涉及到對inode的修改、對bitmap的修改、對data區盤塊的修改等。原則上講,我們必須保證檔案的寫操作p滿足 p ∈ P,即完成檔案的寫操作後,所有其涉及到的盤塊都需要被正確的修改(否則我們寫的程式碼就是有bug的程式碼),這樣磁碟的一致性狀態可以在執行p後仍能保持。但檔案系統仍然有一個大敵:崩潰。崩潰會使得操作p在執行的中間被打斷,而不完整的p無法保證磁碟的一致性。
我們接下來通過幾張圖片來了解一下系統是如何進入不一致狀態的。
系統為什麼會進入不一致的狀態?
導致系統進入不一致狀態的原因可以分為三種:
1)對於一個現狀態為不一致的系統執行任何操作p,無法預期操作完成後該系統的狀態;
2)對於一個現狀態為一致的系統,執行操作p(p ∉ P,即操作p無法保證執行後系統能保持一致性),系統可能會進入不一致的狀態;
3)對於一個現狀態為一致的系統,執行操作p(p ∈ P )的中間被打斷,p中斷執行;一個未完全執行的操作p很可能不屬於P,這也可能導致系統進入不一致的狀態;
第一種情況不難理解;第二種情況可以這樣設想:我們希望設計一個屬於P的操作p,但由於我們的設計存在bug,導致我們實現的p’無法完成預期的功能,這樣導致系統進入了不一致的狀態,而不一致狀態的系統的執行結果是無法保證的;第三種情況即對應著我們的斷電重啟情景。
唯一能夠保證一致性的情景是,在系統處於一致狀態時,完整的執行屬於p的操作:
一致性協議的基本要求
理解以上情形後,我們就可以思考如何通過設計來保證我們的系統的一致性了,而使得系統能夠保證一致性的設計與實現即為該系統的一致性協議
。我們現在已經可以提出一致性協議
的基本要求了:
1)必須保證我們系統的初始狀態滿足所有的一致性約束條件
。這點是顯然成立的;
2)必須保證該系統的所有操作op均滿足 op ∈ P。xv6的程式碼組織可以保證這一點;
3)必須保證操作p的原子性
,即p要麼全部被執行,要麼沒有被執行;
根據前文中我們給出的一致性模型可知,只要實現了上述三點要求,即可保證我們的系統(磁碟)能夠在保持一致性。xv6的日誌層提供了事務(transaction)的抽象,其要求我們將對檔案的寫操作組織到事務中來完成。xv6的設計保證,事務的序列執行和併發執行都是符合我們的一致性協議的,即只要我們將檔案的寫操作組織到事務中,磁碟的狀態必定能保持一致(磁碟的後設資料能正確反映磁碟的狀態,檔案的後設資料能正確反映檔案的狀態)。xv6關於事務的抽象與DBMS的事務十分相似,但其具體實現並不相同(DBMS要求事務能夠實現回滾操作,而xv6的事務不需要實現回滾)。接下來我們通過程式碼來了解xv6的事務抽象與一致性協議的實現。
日誌與事務
xv6的事務
是通過日誌的機制來支援實現的。一份日誌
就是一個髒塊,這些髒塊既包括檔案的內容
所對應的盤塊,也包括一系列檔案相關、磁碟相關的後設資料
盤塊(例如說bitmap
的盤塊、inode
的盤塊等)。我們知道,一次對檔案的寫操作,可能會涉及到bitmap
的改動、inode
的改動等等,這些資料塊都有可能變為“髒塊”,xv6並不會直接將髒塊回寫到對應的磁碟上,而是會先將這些髒塊的副本寫入到磁碟的日誌區。當一組事務相關的所有日誌均已成功儲存到日誌區後,xv6會將logheader
回寫到磁碟的日誌區,標明這批日誌已經可以成功提交。
一批完整的、狀態為可提交的日誌完全囊括了一組檔案寫操作所涉及到的所有盤塊的全部修改,因此如果在執行這組寫操作前磁碟的狀態是一致
的,那麼將這批日誌中的塊全部回寫後,磁碟的狀態必定仍會保持一致。概括的來講,即事務的操作屬於 P ,這樣我們的一致性協議的前兩條需求都可以保證。下面我們上手原始碼,來了解xv6中事務和日誌的設計與實現。
首先我們來看一下xv6中日誌的資料結構:
struct logheader {
int n; // the num of logs (mark the length of array)
int block[LOGSIZE]; // the conresponding subscript of buf in buf array
};
struct log {
struct spinlock lock;
int start; // the log start at this block
int size; // the max log num
int outstanding; // how many FS sys calls are executing.
int committing; // in commit(), please wait.
int dev; // the dev num
struct logheader lh;
};
struct log log[NDISK];
struct log
中有兩個成員十分值得我們注意:outstanding
和commiting
。commiting
標記著一批日誌是否在進行提交操作,而outstanding
標記著當前等待寫日誌的檔案系統相關的系統呼叫個數。為了簡化設計,xv6對日誌的操作進行了一定的限制:1)當一批日誌正在被提交時(log.commiting
== 1),不接受新的日誌;2)日誌的提交被推遲到沒有FS相關的系統呼叫時,即某個時刻如果有FS相關的系統呼叫在使用日誌的話,則推遲提交這批日誌,這個時刻對應的log.outstanding
!= 0。這也同時意味著,所有FS相關的系統呼叫產生的日誌不會被分散提交,而是必定一次性全部提交。
下圖可能能夠幫你更好地觀察到日誌與緩衝區之間的關係:
mysql給我們提供了關鍵字COMMIT和END來標註一個事務的開始與結束。與之類似的,xv6提供了begin_op
和end_op
兩個api來標註一個事務的開始與結束,並要求一些FS相關的api,都必須夾在這兩個api間執行:
begin_op();
...
bp = bread(...);
bp->data[...] = ...;
log_write(bp);
...
end_op();
這套程式設計模型告訴我們,我們對盤塊進行寫操作時,首先要呼叫begin_op
標註事務的開始,然後通過bget
拿到盤塊對應的緩衝塊,並在bp->data
上寫入資料。寫操作執行完成後,需要呼叫log_write
,將這個buf新增到日誌中,最後呼叫end_op
標註事務的結束。
void
begin_op(int dev)
{
acquire(&log[dev].lock);
while(1){
if(log[dev].committing){
sleep(&log, &log[dev].lock);
} else if(log[dev].lh.n + (log[dev].outstanding+1)*MAXOPBLOCKS > LOGSIZE){
// this op might exhaust log space; wait for commit.
sleep(&log, &log[dev].lock);
} else {
log[dev].outstanding += 1;
release(&log[dev].lock);
break;
}
}
}
先看一下begin_op
。根據前面的討論,當一批日誌正處於提交階段時(logp[dev].commiting == 1)不應當建立新的日誌;
另外,xv6限制了一個事務中修改盤塊的數量(MAXOPBLOCKS),以避免日誌溢位。對於裝置dev來說,之前的事務已經產生了log[dev].lh.n
條尚未提交的日誌,同時現在尚有log.[dev].outstanding
條事務尚未完成全部操作,再加上本事務最多需要寫MAXOPBLOCKS條日誌,因此最終的越界檢查條件為 log[dev].lh.n + (log[dev].outstanding+1)*MAXOPBLOCKS > LOGSIZE
,如果可能存在越界的情況,則睡眠等待。經過這兩個判別條件後即允許這個事務執行,這裡簡單的將log[dev].outstanding
+= 1,為這個事務的日誌預留好空間,然後返回。
void
log_write(struct buf *b)
{
int i;
int dev = b->dev;
if (log[dev].lh.n >= LOGSIZE || log[dev].lh.n >= log[dev].size - 1)
panic("too big a transaction");
if (log[dev].outstanding < 1)
panic("log_write outside of trans");
acquire(&log[dev].lock);
for (i = 0; i < log[dev].lh.n; i++) {
if (log[dev].lh.block[i] == b->blockno) // log absorbtion
break;
}
log[dev].lh.block[i] = b->blockno;
if (i == log[dev].lh.n) { // Add new block to log?
bpin(b);
log[dev].lh.n++;
}
release(&log[dev].lock);
}
log_write
將日誌的buf下標標註到log[dev].lh[block]
中。如果之前這個buf已經被新增到過日誌中了,則無需多此一舉(即log absorbtion
),否則,需要呼叫bpin
讓這個buf的引用計數增一,避免這個髒塊在回寫前被bget
給回收掉。
void
end_op(int dev)
{
int do_commit = 0;
acquire(&log[dev].lock);
log[dev].outstanding -= 1;
if(log[dev].committing)
panic("log[dev].committing");
if(log[dev].outstanding == 0){
do_commit = 1;
log[dev].committing = 1;
} else {
// begin_op() may be waiting for log space,
// and decrementing log[dev].outstanding has decreased
// the amount of reserved space.
wakeup(&log);
}
release(&log[dev].lock);
if(do_commit){
// call commit w/o holding locks, since not allowed
// to sleep with locks.
commit(dev);
acquire(&log[dev].lock);
log[dev].committing = 0;
wakeup(&log);
release(&log[dev].lock);
}
}
end_op
標記著一次事務的結束。首先使記錄著當前未執行完事務數量的標記log[dev].outstanding
遞減(此時記錄著buf數量的log[dev].lh.n
已經被正確修改了)。如果此時已經沒有尚未完成的FS操作,則標註log[dev].commiting
為1,並呼叫commit
提交日誌,將這批日誌(髒塊)寫入到磁碟上的日誌區。接下來我們閱讀一下commit
以及其相關的程式碼:
static void
commit(int dev)
{
if (log[dev].lh.n > 0) {
write_log(dev); // Write modified blocks from cache to log
write_head(dev); // Write header to disk -- the real commit
install_trans(dev); // Now install writes to home locations
log[dev].lh.n = 0;
write_head(dev); // Erase the transaction from the log
}
}
static void
write_log(int dev)
{
int tail;
for (tail = 0; tail < log[dev].lh.n; tail++) {
struct buf *to = bread(dev, log[dev].start+tail+1); // log block
struct buf *from = bread(dev, log[dev].lh.block[tail]); // cache block
memmove(to->data, from->data, BSIZE);
bwrite(to); // write the log
brelse(from);
brelse(to);
}
}
static void
write_head(int dev)
{
struct buf *buf = bread(dev, log[dev].start);
struct logheader *hb = (struct logheader *) (buf->data);
int i;
hb->n = log[dev].lh.n;
for (i = 0; i < log[dev].lh.n; i++) {
hb->block[i] = log[dev].lh.block[i];
}
bwrite(buf);
brelse(buf);
}
static void
install_trans(int dev)
{
int tail;
for (tail = 0; tail < log[dev].lh.n; tail++) {
struct buf *lbuf = bread(dev, log[dev].start+tail+1); // read log block
struct buf *dbuf = bread(dev, log[dev].lh.block[tail]); // read dst
memmove(dbuf->data, lbuf->data, BSIZE); // copy block to dst
bwrite(dbuf); // write dst to disk
bunpin(dbuf);
brelse(lbuf);
brelse(dbuf);
}
}
write_log
負責將日誌寫入到磁碟的日誌區,緩衝塊指標to對應著日誌區上放置這條日誌的盤塊,from對應著一條處於提交狀態的日誌。mommove
將日誌的內容從from複製到to之後,對to呼叫bwrite
,實現日誌塊的回寫操作。
write_head
修改磁碟日誌區的後設資料區,即將logheader
回寫,而logheader
的成員blocks
標註了事務中涉及到的日誌的位置。這一方法必須在write_log
執行完成之後才能被呼叫。否則如果系統在write_head
之後,write_log
之前發生崩潰,則會產生日誌缺失的情況,使得系統在重啟時無法通過日誌還原系統的狀態。
install_trans
負責使用日誌覆蓋掉對應的盤塊。該方法讀取logheader.blocks
陣列,將每個對應的日誌區的盤塊安裝到檔案區。
當成功用日誌覆蓋掉對應的盤塊後,系統已經無需再繼續持有這些日誌,因此會再次呼叫write_head
修改日誌區的logheader
來清除日誌,並設定log[dev].lh.n
= 0。
重做日誌
崩潰不僅僅可能發生在檔案讀寫時,也可能發生在系統恢復時,如果發生在系統恢復時,那麼不完整的日誌安裝操作也可能使系統進入不一致狀態。xv6的日誌的實現方式可以巧妙的解決這一情景,因為xv6的日誌實現方式是重做日誌。我個人簡單的將重做日誌定義如下:
假設當前系統的狀態為s1(s1是一致的狀態,因為日誌只會在一致狀態下生成),一組事務的操作會使系統從狀態s1進入s2(根據事務的定義,s2也是一致狀態),這組事務在執行過程中被中斷時,系統所有的可能狀態構成集合SI(即系統從s1過渡到s2之間的全部過渡態,包含很多的不一致狀態)。可重做日誌需要保證,對於任意狀態 s’ ∈ SI,安裝完畢所有日誌後,系統必定能進入狀態s2。
很容易證明,xv6的日誌是重做日誌,因為xv6日誌的本質就是經過完整訪問操作之後的髒塊。
事實上,日誌可以有很多種實現,像xv6這樣以髒塊作為日誌就是一種可行的方法。另一種可行的實現方法是將操作作為日誌(以下簡稱為操作日誌),而不是像xv6一樣,將操作後的結果(髒塊)作為日誌。操作日誌的採用還是比較普遍的,例如很多為銀行業務而設計的DBMS會採用操作日誌。在6.824的Lab中,也是採用了操作日誌,使用raft演算法在叢集上同步操作日誌,並在系統崩潰後通過回放操作日誌中的操作來恢復狀態機的狀態。
系統崩潰的情景
在小節日誌與事務中我提到,xv6的api設計可以保證一切事務 p ∈ P,這樣我們一致性協議
三個條件中的前兩個都可以得到滿足。下面我們來分析一致性協議的第三條要求(即原子性)是如何滿足的,或者說我們來分析,xv6是如何在系統崩潰的情況下,仍然能保證磁碟狀態的一致性的。
你應該很早就注意到了日誌層中的這段程式碼,這段程式碼要求xv6系統啟動之初,將日誌區現存的全部可以安裝的日誌,安裝到其對應的盤塊上:
static void
recover_from_log(int dev)
{
read_head(dev);
install_trans(dev); // if committed, copy from log to disk
log[dev].lh.n = 0;
write_head(dev); // clear the log
}
如果你無法接受之前採用的接近數學語言的說法,那我們來條分縷析,考察崩潰發生的全部情景。一個事務的操作可以被分為以下幾個部分,如圖所示:
記憶體中的操作,例如說log_write
、file_write
等
將髒塊寫入到磁碟的日誌區(write_log
)
日誌全部落盤,接下來要修改logheader,標註好哪些日誌盤塊中有日誌(write_head
)
利用日誌覆蓋其對應的盤塊(install_trans
)
所有日誌安裝成功,修改logheader記錄已經沒有日誌可寫
崩潰的情景可能有6個,已經在圖上標出。現在我們僅考慮單個事務的情況,後面我們再推廣到多個事務併發的情況;
情景1下是系統在沒有事務執行時發生崩潰,情景2是系統正在處理事務,但事務尚未執行寫磁碟操作。由於執行事務前磁碟的狀態是一致的,而當前的事務尚未對磁碟的狀態進行修改(沒有執行寫盤塊的操作),因此崩潰重啟後,磁碟的狀態仍然是一致的。
情景3表示崩潰發生在髒塊寫入到磁碟的日誌區時,注意此時雖然日誌區發生了狀態變化,但日誌還沒有被安裝,也就是說inode區、data區仍然是一致的。重啟時,由於logheader
還沒來得及被回寫,因此logheader
的成員n
為0,因此這批日誌不會被安裝,且看起來好像日誌區沒有日誌一樣。這樣,磁碟的狀態仍然是一致的,只是那些落盤的日誌永無執行之日而已。
情景4表示崩潰發生在寫logheader
時。為了方便分析起見,我們不妨認為崩潰不會發生在寫盤塊的過程中,而只會發生在寫盤塊的間隙中間,即能保證寫盤塊的操作必定是原子的。這種情形下,如果logheader
沒有被回寫,那麼情況4等價於情況3,如果logheader
成功被回寫,那麼情況4等價於情況5;但無論哪種情況,最終都可以使系統進入一致性狀態。
情景5發生在磁碟將日誌區的日誌安裝到對應的盤塊期間。前文中已經討論過,不完整的事務操作可能使磁碟進入不一致狀態,因此這種崩潰情景會導致磁碟進入不一致的狀態。但系統在重啟後會呼叫recover_from_log
方法來重做日誌。由於情景5發生時這批事務的所有日誌必定都在日誌區,因此重做這批日誌必定能使系統進入一直狀態。
情景6發生在日誌已經全部安裝之後、重置logheader
之前。這種情景等價於情景5,只不過是將日誌重新執行一次而已。
接下來考慮併發的情景,根據前面的情景討論,崩潰發生在記憶體操作時是不會引起不一致的現象的,而磁碟的操作又通過鎖來保證了序列性。因此在併發情景下,仍然可以保證磁碟狀態的一致性。
一種可能會導致不一致的情景 —— 盤塊寫操作的非原子性
前文中花費了海量的筆墨來討論xv6日誌層下一致性的實現原理,現在我們來討論一個非常極端的corner case,這種情景可能會導致磁碟進入不一致的狀態。
回顧一下write_log
方法,它要將記憶體中的logheader
回寫到日誌區對應的logheader
上,而log[dev].lh.blocks
標註著事務中所涉及的全部日誌的日誌塊編號。一個logheader
正好佔據一個盤塊的大小,只需要呼叫一次bwrite
即可完成回寫。但我們考慮一種非常極端的情景:如果程式的崩潰發生在logheader
回寫的過程中會怎樣?
這種情景可能會產生不完整的資料,一種情況是log[dev].lh.blocks
陣列僅僅只寫了一部分,另一部分還沒被持久化到磁碟上。如果重啟系統,recover_from_log
會讀取logheader
區,根據log[dev].lh.blocks
陣列的內容來索引日誌,將日誌安裝到對應的data區;而由於log[dev].lh.blocks
的資料是不完整的,那麼就無法保證一個事務中的全部日誌均能被安裝,也就破壞了一致性協議
的第三條目:操作必須保證原子性。更為嚴重的情景是那些佔據了多個位元組的成員,如果系統崩潰發生時這些成員的對應位元組並沒有被完整寫完,那麼這個成員的值就是無效的了,這可能會誘發更大程度的損害。
歸根結底的原因,是我們的一切操作都要基於盤塊的寫操作,而盤塊的寫操作無法保證原子性,這種原子性的缺失僅會在上述情形下使磁碟有進入不一致狀態的可能,避免方法其實也不難想到 —— 雖然對盤塊的寫操作無法保證原子性,但對盤塊的任意一個bit應該是可以保證原子性的。由此,我們可以在磁碟的log區再設立一個標誌bit,當logheader
塊回寫完畢後再置這個bit為1。每次系統重啟前需要先檢查這個bit,如果為1的話,說明logheader
已經成功回寫完畢,因此可以安裝這些日誌。
你咋整出這套民科理論來講一致性的?
這個想法的源頭在於我讀日誌層的程式碼時的一個突發奇想,假設我在xv6中執行下面的程式:
int main(int argc, char **argv) {
int fd = open("test.txt", O_CREATE|O_RDWR, 0666);
int ret = write(fd, "abcde", 5);
if (ret == 5) {
printf("nice!");
}
exit(0);
}
在執行程式碼的時候如果程式輸出了“nice”,然後系統崩潰掉了,我把系統重啟去讀取這個檔案,能從這個檔案裡看到我寫入的“abcde”嗎?更加嚴謹的說,如果一個write
系統呼叫成功返回了n個位元組,那麼這n個位元組能不能保證成功的落盤了呢?
經過閱讀xv6的原始碼,我發現這是不能保證的,因為write
中,只需要將buf->refcnt
增1後,就可以返回,即write
返回時,我們不能保證具體的資料已經被回寫到磁碟上。這一點給了我很大的衝擊,雖然我知道呼叫flush
重新整理緩衝區可以保證內容順利回寫,但這也意味著僅依靠write
的成功返回完全無法保證資料的持久化,這種情況是不是一種常見的情況,還是隻出現在了xv6裡面?為此我查閱了一些資料,最終在write
的 manpage 裡面找到了如下的描述:
A successful return from write() does not make any guarantee that data has been committed to disk. On some filesystems, including NFS, it does not even guarantee that space has successfully been reserved for the data. In this case, some errors might be delayed until a future write(), fsync(2), or even close(2). The only way to be sure is to call fsync(2) after you are done writing all your data.
原來大家都一樣(不靠譜)啊....(不過如果大家都不靠譜,那隻能說明自己的想法不靠譜了orz)
這個時候我繼續思考,這種情形會不會導致錯誤,即會不會造成磁碟資料不一致的情形,然後發現這是不會的,雖然這五個位元組沒被寫入,但對這個檔案的讀寫操作,並不會出現bitmap標註錯誤、讀寫不完整的盤塊等惡劣情況。這個時候我意識到,產生上面的想法是因為我對一致性
的理解產生了誤差。日誌層所提供的一致性
到底是一種什麼樣的一致性,對應的一致性約束條件
到底是什麼?根據這些一致性約束條件
,我能夠推理出哪些必定能保持一致性
的情景?這些都是我在讀這些原始碼的時候沒有仔細思考的問題。再次重讀了一下程式碼後,我就給出了前文中我所認為的一致性約束的具體內容。
最後的最後,既想利用嚴謹的數學語言來實現對這些問題的高度抽象,發現自己根本沒那種程度的數學能力和洞察力,又希望能通過各種簡單的示例來抓住重點,但又發現這些例子不能完全說服自己,整出了一套四不像的理論,也把自己完成了民科,以後還是得多看書多寫碼多思考吧。
回顧總結
本篇blog主要討論了xv6的日誌層的實現以及其相關的原理。關於文章中提到的一致性、事務的數學定義都是我個人的胡言亂語,如果希望學習相關概念請抄起大部頭書來進行學習。
日誌層的作用是保證磁碟系統的一致性,為了能夠理解日誌層的功能,我們需要對一致性
的概念首先進行了解。我們在很多的地方都能看到一致性
這個概念,例如說資料庫中資料的一致性、分散式系統共識演算法達成的一致性,以及xv6中磁碟狀態的一致性等。寬泛的來說,系統的一致性指系統內部的一致性約束條件均能得到滿足。對於磁碟系統來說,一致性約束條件為磁碟的後設資料能正確反映磁碟的狀態,檔案的後設資料能正確反映檔案的狀態。
磁碟在通過mkfs/mkfs生成時是滿足所有的一致性約束條件
的,在與xv6連線後,xv6會通過檔案讀寫操作改變磁碟的狀態。一次完整的檔案讀寫操作可能會涉及到data區、inode區、bitmap區的修改。xv6的檔案系統程式碼我們可以認為是沒有bug的,即一次完整的檔案讀寫操作是磁碟系統的一個合法操作,因此完整的執行完這些操作後,磁碟系統的狀態仍然是一致的。但系統崩潰、斷電等情況會導致一個完整的操作被中斷,此時系統很可能進入不一致的狀態。為了應對這種問題,xv6引入了日誌和事務的概念。
事務是xv6對日誌層的上層(inode層
)提供的一種輔助抽象,其要求將一切對檔案的訪問操作組織在begin_op
和end_op
之間。檔案的讀寫操作都是在緩衝區上進行的,而不會立即落盤,也無法保證最終會落盤(但磁碟的一致性狀態仍然是得到保持的)。xv6會在當前沒有等待的FS系統呼叫的時候,將一組事務所訪問的所有盤塊寫入到磁碟的日誌區,日誌區的這些盤塊即為日誌
,其本質就是一塊對應於data區
的髒塊。只有當事務所涉及的所有日誌都順利持久化到日誌區後,檔案系統才會將日誌區的日誌安裝到data區
,這樣可以保證一組事務的全部操作都會被寫入到磁碟上。
當崩潰發生時,系統可能會進入不一致狀態。根據前文討論,系統唯一可能進入不一致狀態的情景為日誌的安裝過程或logheader回寫的過程被打斷。在這種情景下,日誌必定已經全部落盤,且日誌是一種重做日誌,因此係統重啟後,通過重做這些日誌,必定能使系統進入一致的狀態。
根據日誌層所保證的一致性約束條件
(磁碟的後設資料能正確反映磁碟的狀態,檔案的後設資料能正確反映檔案的狀態)容易推論,我們無法保證一次write系統呼叫的成功返回後所寫的內容能夠落盤。