JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

快取一致性問題

當我們把好幾顆核心圍在同一塊共享記憶體旁,又各自配上私有快取的那一刻,一個安靜的矛盾就冒了出來:兩顆核心可以各自握著同一條記憶體列的影本,卻對它寫了什麼各執一詞。這篇導覽要正面迎擊快取一致性問題——「一致」究竟是什麼意思、為什麼寫入會打破它、那個會懲罰無辜程式碼的偽共享陷阱,以及讓所有人保持誠實的協定的第一張草圖。

好幾張桌子,一座圖書館

在前一篇導覽裡,我們看到晶片產業為何轉向多核心:功率牆讓單一核心再也快不了多少,於是唯一的出路就是把好幾顆核心放在同一塊晶片上,平行地跑執行緒。最常見的安排是共享記憶體多處理器:每顆核心都看到同一塊主記憶體、同一組位址,彷彿大家都在同一本共享筆記本上書寫。正是這種共享的視野,讓執行緒級平行寫起來舒服——傳一個指標過去,任何一顆核心都能跟著它走。

但別忘了快取那一級的教訓:一顆每次存取都走到主記憶體的核心,會慢得像在爬。因此每顆核心都保留自己的私有快取——一張又小又快的桌子,擺著它最常用的快取列,好讓它幾乎不必走到圖書館去。對一顆孤立的核心而言,這妙極了。可是一旦核心變多,它就悄悄埋下一個矛盾:同一個位址,如今可以同時躺在好幾張私有桌上。只要大家都只是讀,那無傷大雅——同一頁的數張一模一樣的影本。麻煩在有人拿起筆的那一刻才開始。

當影本各執一詞

想像一個共享變數 `x`,目前是 0,躺在主記憶體裡。核心 A 讀了它,於是 `x = 0` 的一份副本如今住在 A 的快取裡。核心 B 也讀了它,所以 `x = 0` 同樣住在 B 的快取裡——兩份影本,都正確。現在核心 A 執行 `x = 1`。如果 A 採用寫回策略,它就把新值塗在自己的桌上,而不會立刻走到圖書館去。A 的快取現在說 `x = 1`。但 B 的快取仍說 `x = 0`,主記憶體也是。同一個位址有三份副本,而它們不再一致。下一次 B 讀 `x` 時,它會自信滿滿地交回一個過時的 0——一個此刻已經根本錯了的值。

一句話講完快取一致性問題:在共享記憶體之上配私有快取,一顆核心的寫入會讓其他核心握著過時的副本,於是不同核心對同一個位址觀察到不同的值。請注意,這不是任何人程式裡的臭蟲——兩顆核心跑的都是再尋常不過的載入與儲存。是硬體悄悄打破了那本共享筆記本的錯覺,而正是這個錯覺一開始讓程式模型變得合理。如果機器要守住它的承諾,硬體自己就得把這修好。

一個念頭就能修:通知其他人

這個修補的核心,是溝通。在一顆核心被允許修改一條列之前,它必須確保那條列的過時副本不在任何其他地方留存。主流的策略是寫入時作廢:當核心 A 想寫 `x`,它先昭告天下「我要寫這條列了——其他人,把你們的副本丟掉。」每一顆持有這條列的核心都把它作廢。現在 A 是唯一的持有者,可以放心地塗寫。等 B 下次讀 `x` 時,它的桌上空無一物,吃到一次快取未命中,於是被交給最新的值(從 A 的快取或從記憶體而來)。那個過時的 0 永遠不會被交回,因為它已經不存在了。

那則昭告怎麼傳到每個人耳裡?兩大協定家族給出不同的答案。在窺探協定裡,核心共享一條廣播媒介——經典做法是一條匯流排——每個快取控制器都在旁傾聽每一筆交易,就像鄰居偶然聽見一句喊出來的話。當 A 廣播「作廢 `x`」,B 的快取直接聽見並動作。窺探美妙地簡單,但假設了每個人都聽得見每一聲喊叫,一旦核心一多就無以為繼。另一條路,目錄協定,保留一份目錄,記下每一條列究竟被哪些核心持有;A 的寫入只把作廢訊息精準寄給那些核心,不需廣播。目錄要付出更多帳務成本,卻能擴展到大型機器,在那裡光靠喊叫只會把所有人淹沒。

為了讓這一切都能追蹤,快取裡的每條列都帶著一小撮狀態,控制器在它上頭跑一台小小的有限狀態機。最有名的方案就是MESI,它的四個狀態——Modified(已修改)、Exclusive(獨占)、Shared(共享)、Invalid(無效)——讓一個快取知道它的副本是不是唯一的、是不是髒的、能不能默默地讀。我們會在下一篇導覽裡完整追蹤 MESI 的狀態之舞;目前只要知道「記住每條列的狀態,寫之前先打聲招呼」就是整場遊戲的全部,便已足夠。

偽共享:懲罰無辜者的陷阱

這裡有個會咬到真實程式設計師的微妙之處。一致性不是逐位元組追蹤,而是以一整條快取列為粒度——典型是 64 個位元組。硬體只能作廢或共享整條列,從來不能只動半條。所以兩個在邏輯上毫不相干、卻碰巧落在同一條列裡的變數,在一致性機制眼中,和同一個變數無從分辨。碰其中一個,你就擾動了另一個所在的列。這就是偽共享,是平行程式裡最殘忍的效能陷阱之一,因為程式是正確的——它只是莫名其妙地慢成一團。

想想那個經典案例:一個陣列 `counts[2]`,核心 0 在一個緊湊迴圈裡猛打 `counts[0]`,核心 1 猛打 `counts[1]`。邏輯上這兩者各自獨立——沒有共享資料,不需要鎖。但兩個整數坐在同一條 64 位元組的列裡。每當核心 0 寫 `counts[0]`,寫入時作廢就逼著核心 1 的整條列副本被丟掉;核心 1 下一次寫入又把這條列拽回來,作廢核心 0 的。這條列在每一次更新時都在兩個快取之間乒乓來回,每一次都是一次一致性未命中,耗掉數十個週期。兩顆其實毫無真正共享的核心,最後卻被序列化,比一顆核心還慢。

Address line (64 bytes), bytes 0..63:
  [ counts[0] | counts[1] | ... unused ... ]   <- ONE cache line
      ^Core0       ^Core1

  Core0: write counts[0]  -> invalidate the line in Core1
  Core1: write counts[1]  -> invalidate the line in Core0
  ... line ping-pongs every write (a coherence miss each time)

Fix: pad so each counter sits in its own line:
  struct { long v; char pad[56]; } counts[2];   // 64-byte aligned
兩個各自獨立的計數器困在同一條 64 位元組的列裡,永無止盡地乒乓;把每一個填補到自己的列裡就能根治。

一致性是必要的,但不免費——也不是萬能

誠實面對一致性買到什麼、又付出什麼。它買到正確性:有一個能運作的協定,對共享資料的尋常載入與儲存就會回傳合理、彼此認可的值,那本共享筆記本的錯覺得以維持。但它的代價是流量與延遲。每一次對共享列的寫入都得廣播或寄出作廢;另一顆核心隨後的每一次讀取都得付一次一致性未命中去重新取回。一條在快取之間彈跳的列——無論來自真共享還是偽共享——都可能比一次尋常的對記憶體的未命中慢得多。這還是那個一再出現的架構教訓:快取的好處是靠局部性掙來的,而對一致性不友善的共享模式,可以讓平行程式在算出一模一樣的答案時慢上好幾倍。

現在有兩個界線值得在心裡釘牢。第一,一致性保證對一個位址的寫入會變得可見且有序——它本身並不告訴你對不同位址的寫入在其他核心眼中如何交錯。那條更鬆、更微妙的保證是記憶體一致性模型,而鬆弛的模型會嚇到你;我們在本級稍後再談。第二,在大型機器上,記憶體本身並不均勻:在 NUMA 之下,每顆核心碰它附近的記憶體庫很快,碰遙遠的庫很慢,所以一條共享列實際住在哪裡,即使一致性已經完成了它的工作,也會改變代價。一致性讓值保持正確;它並不讓每一次存取都同樣便宜。

退一步,問題的形狀就清楚了。私有快取給了每顆核心速度;共享記憶體給了我們一個好寫的程式模型;而兩者合在一起,就要求一位硬體裁判——一致性協定——在多核心晶片上每一次記憶體存取底下安靜地運轉。下一篇導覽我們會細細跟著這位裁判,追蹤一條列在 Modified、Exclusive、Shared、Invalid 之間移動時的 MESI 狀態,看清每一次轉移究竟送出什麼訊息。