.NET 大小事
虛偽共用
Stephen Toub、Igor Ostrovsky 以及 Huseyin Yildiz

目錄
除非您隱居在深山野外,否則一定聽說過「多重核心架構所帶來的轉變」。處理器製造商 (例如 Intel 和 AMD) 紛紛以擴充處理器的核心數量來提高硬體的運算動力,而不再嘗試以倍增的方式提高時脈速度。這樣的轉變使得軟體開發人員在撰寫應用程式時,必須注意並行處理的運作方式,才能充分發揮大幅提升的電腦運算動力。
為了因應這個趨勢,有許多並行程式庫和語言已陸續出現,包括 Microsoft .NET Framework 的並列擴充性 (Parallel Extensions)、平行模式程式庫 (Parallel Pattern Library,PPL)、並行與協調執行階段 (Concurrency & Coordination Runtime,CCR)、Intel 的執行緒建置組塊 (Threading Building Blocks,TBB)...等等。這些程式庫的目標,都是要減少撰寫高效率平行應用程式所需的未定案程式碼,方法是藉由提供像是 Parallel.For 和 AsParallel 的建構。可惜的是,雖然這些建構代表著邁向平行處理時代的一大步,卻仍然無法解除開發人員必須清楚知道程式碼一舉一動的負擔,包括執行的作業、採用的結構,以及硬體將如何影響應用程式的效能。
雖然軟體產業已經積極地在開發全新的並行程式設計模型,但目前尚未出現可完全免除所有並行處理相關問題的程式設計模型。因此,就短期而言,若要撰寫效率優良的平行程式,就仍然必須了解記憶體和快取的運作方式。
重點在於硬體
需要了解應用程式在較低階的層次如何運作,並不是新的需求或概念。為了要最佳化效能,開發人員必須清楚知道記憶體的存取將如何影響應用程式的效能。
當我們談論從記憶體讀取和寫入記憶體時,通常會忽略在現代的硬體架構中,已經很少會直接針對電腦的記憶體模組進行讀取和寫入。這是因為記憶體的存取速度很慢,級距性地比數學計算更慢,但是又級距性地比存取硬碟和網路資源更快。
為了因應記憶體存取速度的不足,當下大部分的處理器,都會運用記憶體的快取來提升應用程式效能。快取分為多重層級,當下的一般使用者電腦都會有至少兩個層級,稱為 L1 和 L2,有些電腦甚至會有更多層級。L1 的速度最快,但是最為昂貴,所以電腦通常只會有少量的 L1 快取 (我們用以撰寫此專欄的膝上型電腦有 128KB 的 L1 快取)。L2 的速度比較慢,但是沒有那麼昂貴,所以電腦通常會配置比較多的 L2 快取 (上述膝上型電腦有 2MB 的 L2 快取)。
從記憶體讀取資料時,要求的資料以及鄰近的資料 (稱為快取行) 都會從記憶體載入至快取,然後再從快取提供資料給程式。一次載入整個快取行而非個別的位元組,可以大幅提升應用程式效能。我們的膝上型電腦的 L1 和 L2 快取行都是 64 個位元組。由於應用程式經常會循序讀取記憶體中的位元組 (在存取像是陣列的資料時最常見),因此藉由在快取行中載入一連串的資料,應用程式就可以避免為每一個要求存取一次主記憶體,因為即將讀取的資料很可能已經載入至快取中。但是,這代表開發人員必須對應用程式如何存取記憶體有相當的認知,才能充分發揮快取的優勢。
請看 [圖 1] 中的 C# 程式。我們建立了一個二維陣列,而應用程式會以兩種不同的方法進行寫入。在第一個程式碼區段 (以註解標示為 Faster),應用程式會針對每個資料列進行迴圈,然後針對資料列中的每個資料行進行迴圈。在第二個程式碼區段 (標示為 Slower),應用程式會針對每個資料行進行迴圈,然後針對資料行中的每個資料列進行迴圈。註解的標示已經明顯透露出兩者的差異性,但是當我們在膝上型電腦上測試此程式碼時,Faster 版本的執行速度比 Slower 版本快了幾乎兩倍,雖然它們之間的差異只是 for 迴圈的順序顛倒而已。

圖 1 記憶體存取模式很重要
using System;
using System.Diagnostics;
class Program {
public static void Main() {
const int SIZE = 10000;
int[,] matrix = new int[SIZE, SIZE];
while (true) {
// Faster
Stopwatch sw = Stopwatch.StartNew();
for (int row = 0; row < SIZE; row++) {
for (int column = 0; column < SIZE; column++) {
matrix[row, column] = (row * SIZE) + column;
}
}
Console.WriteLine(sw.Elapsed);
// Slower
sw = Stopwatch.StartNew();
for (int column = 0; column < SIZE; column++) {
for (int row = 0; row < SIZE; row++) {
matrix[row, column] = (row * SIZE) + column;
}
}
Console.WriteLine(sw.Elapsed);
Console.WriteLine("=================");
Console.ReadLine();
}
}
}
會發生這樣的效能差異,是因為矩陣陣列之記憶體的組織方式。陣列會以連續性的方式儲存在記憶體中,且會採用以資料列為主的順序,亦即矩陣的每個資料列都會以直線的方式,一一排列在記憶體中。當應用程式存取陣列的值時,相關的快取行就會包含要求的值以及其他鄰近的值,而這些值很可能就是相同資料列,但緊接在所存取值之前或之後的值。因此,要存取該資料列的下一個值時,就會明顯地比存取另一個資料列的值時快出許多。在 Slower 版本中,我們會一直存取位於不同資料列的值,因為我們會從一個資料列跳至另一個資料列,以存取各個資料列的相同資料行位置。若要看看此現象的實際範例,請參閱 Kenny Kerr 在本期
MSDN Magazine 中所撰寫的專欄:
msdn.microsoft.com/magazine/cc850829。
顛覆幼稚園時的教育,共用的美德可能會造成負面的影響
平行處理更會彰顯這個問題。我們不但需要注意處理器如何存取記憶體中的資料以及如何運用其快取,我們還要注意多執行緒/處理器的系統將如何存取資料。
在當下的許多系統中,有些快取不會在核心之間共用。由於快取會維護來自主記憶體的資料副本,所以處理器需要實作同步機制,以確保快取內容與主記憶體和其他快取之間會維持一致性;這樣的機制稱為「快取一致性 (Cache Coherency)」或「記憶體一致性 (Memory Coherency)」的通訊協定。
當下大部分 Intel 處理器所使用的通訊協定稱為 MESI,此名稱是快取行的四個可能狀態的縮寫:已修改 (Modified)、獨佔 (Exclusive)、共用 (Shared) 及無效 (Invalid)。通訊協定會確保快取將監視系統中的其他快取,並採取適當的動作來維持一致性,例如將已修改資料清除至主記憶體,好讓另一個需要該資料的快取進行讀取作業。這樣的作業至少需要進行記憶體的讀取/寫入,而如上所述與展示,這樣的作業很耗資源。
麻煩的是,此問題還有另一個較隱匿的層面,就是資料是否正在共用,並不會有明顯的跡象。真正的共用會在兩個執行緒同時需要存取特定記憶體位置,而該位置的一致性是由快取一致性通訊協定負責維護。然而,一般開發人員所不知道的,就是有時候其實會發生其他的共用情況,亦即當兩個執行緒都要存取特定的資料集,但由於硬體架構的關係 (例如快取行的大小),快取一致性通訊協定會認定這些資料集正在共用。此現象稱為「虛偽共用」(如 [圖 2] 所示),且在實際的平行應用程式中,可能會造成嚴重的效能問題。
圖 2 快取一致性和快取行 (按一下影像以放大圖片)
請參考 [圖 3] 中的程式碼。在此程式碼片段中,我們正在配置 System.Random 的執行個體,每一個處理器配置一個。然後再以平行處理的方式 (使用屬於 Parallel Extensions 之 Task Parallel Library 的 Parallel.For 建構) 執行所有的反覆項目。每一個反覆項目都會使用一個 Random 執行個體,並針對它執行作業 (在此案例中會持續針對 Random 執行個體呼叫 Next)。

圖 3 虛偽共用的範例
static void WithFalseSharing() {
int iterations = Environment.ProcessorCount;
Random[] rands = new Random[iterations];
for (int i = 0; i < iterations; i++) rands[i] = new Random();
Parallel.For(0, iterations, i => {
Random r = rands[i];
DoWork(r);
});
}
static void DoWork(Random rand) {
for (int i = 0; i < WORKAMOUNT; i++) rand.Next();
}
在此範例中,可能不易察覺進行共用的地方。因為應用程式只是一直在讀取 rands 陣列,而且同一個快取行在兩個不同的快取中進行讀取也很正常 (就 MESI 通訊協定而言,兩個快取行可能都會標記為 Shared)。問題其實是在於 Random 執行個體本身。每個 Random 執行個體都會包含某個狀態,以產生假隨機值序列中的下一個值,所以每當呼叫 Next 以運算下一個值時,就會更新該內部狀態。我們配置這些 Random 執行個體的方式,會導致它們在記憶體中變得愈來愈鄰近,以致於造成執行個體之間的虛偽共用,雖然從原始程式碼的觀點而言,迴圈中的所有執行緒都是獨立的。
有數個方法可以解決虛偽共用的問題,但都會涉及 Random 執行個體的配置,亦即在記憶體中的配置距離要夠遠,使它們不會出現在同一個快取行上才行。在 .NET 中,由於記憶體配置的細節都是執行階段在處理,所以此問題需要另類的解決方法。
為了提供範例,請參考 [圖 4] 中的程式碼,此版本就不會發生相同的問題。此程式碼會在我們所關注的執行個體中間,配置額外的 Random 執行個體,以確保我們所關注的 Random 執行個體之間的距離夠遠,所以不會造成虛偽共用 (至少在這部電腦上不會)。當我們在這部雙核心的電腦上進行測試之後,發現此更新的版本所產生的結果改善了很多,其執行速度比 [圖 3] 的程式碼快了高達六倍。

圖 4 修正虛偽共用的範例
static void WithoutFalseSharing() {
const int BUFFER = 64;
int iterations = Environment.ProcessorCount * BUFFER;
Random[] rands = new Random[iterations];
for (int i = 0; i < iterations; i++) rands[i] = new Random();
Parallel.For(0, iterations, BUFFER, i => {
Random r = rands[i];
DoWork(r);
});
}
static void DoWork(Random rand) {
for (int i = 0; i < WORKAMOUNT; i++) rand.Next();
}
偵測虛偽共用的方法
不幸的是,虛偽共用很不容易發現,因為發生效能問題時,並沒有單一獨立的指標可指出這樣的問題。還好,可以藉由比對某些剖析數據,來確認是否發生虛偽共用並找出肇事的程式碼。重要的是,您必須知道何時該懷疑虛偽共用,且在追蹤肇事的程式碼時,需要了解並行程式碼的記憶體存取模式。
讓我們假設您已經識別了造成效能瓶頸的並行程式碼區段。如果程式碼的下列條件成立,則您需要注意的應該是共用快取行的問題:
- 程式碼屬於 CPU 和記憶體運算密集的程式碼,亦即沒有太多的 I/O 或封鎖的 OS 呼叫。
- 即使在運算動力更強悍的硬體上執行來提升並行處理等級,延展性也無法提高。
- 如果使用不同的輸入資料但採取不同的周遊模式,效能就會大幅降低,即使處理的負荷和記憶體存取數目一模一樣。
我們發現,指出虛偽共用的最佳提示,來自於 CPU 效能計數器。這些都是 CPU 本身可提供的硬體層次統計資料,其中會提供 CPU 的各個子系統的效能資訊。範例包括 L2 快取區遺漏數以及錯估分支數。每一個處理器型號所提供的計數器可能會有所不同,但是大部分剖析工具所揭露的資料,都有一定的共同子集。我們將以這些資料進行示範,其中包括 L2 快取區遺漏、停用的指令,以及未暫止週期。
L2 快取區遺漏會指出,嘗試從 L2 快取讀取卻造成從主記憶體載入的次數。發生 L2 快取區遺漏的原因包括:資料不存在於 L2 快取中,或是相對的快取行因另一個處理器的更新而被標記為無效。後者在虛偽共用時經常會發生,因此,若要診斷此問題,我們就需要注意 L2 快取區遺漏 (L2 Cache Misses) 計數器異常暴增的情況。
指令的週期數 (Cycles Per Instruction,CPI) 是描述演算法基準測試的整體效能時,最常用到的統計資料。此數據代表演算法在給定期間,CPU 用來執行每個指令所耗費的平均時脈週期。雖然這個計數器無法揭露單一 CPU 的效能,但是此數據可以從停用的指令 (Instructions Retired) 和未暫止週期 (Non Halted Cycles) 等計數器衍生。
此數據顯然會取決於硬體 (CPU 型號、時脈速度、快取架構、記憶體速度...等等),所以 CPI 的數據通常會有硬體組態的資料伴隨,以說明演算法在特定硬體上的效率。
在診斷虛偽共用時,我們知道除了 CPU 用於執行指令的實際時脈週期以外,CPI 測量的數據還會受到記憶體存取延遲時間以及跨處理器快取同步等的影響 (這兩者都會造成 CPU 的閒置而浪費珍貴的時脈週期)。這似乎符合我們想要得知的資料,因為在其他因素不變的情況下,虛偽共用就是會加重跨處理器通訊的負荷以及增加存取主記憶體的次數。因此,發生虛偽共用時,相同的作業就會產生更高的 CPI 數據。
另外值得一提的是,CPI 的數據與執行時間無關,這點與 L2 快取區遺漏 (L2 Cache Misses) 計數器不同。因此這可用來比較執行時間長短不同的執行作業。
使用 Visual Studio 剖析工具
在 Visual Studio 中,L2 遺漏可由取樣和檢測等兩種剖析模式來追蹤,但是 CPI 需要使用以檢測為基礎的剖析模式,因為其中涉及兩個 CPU 計數器。
若要以取樣模式剖析 L2 遺漏,請先在 Visual Studio 中,為您的專案建立一個取樣為基礎的效能工作階段。然後使用工作階段的屬性,將取樣事件變更為 [效能計數器 (Performance Counter)],然後從 [可移植事件 (Portable Events)] > [記憶體事件 (Memory Events)] 類別目錄選取 [L2 遺漏 (L2 Misses)],或是從 [平台事件 (Platform Events)] > [L2 快取 (L2 Cache)] 類別目錄選取 [內傳的 L2 快取行 (L2 Lines In)] (在某些處理器上,L2 遺漏 (L2 Misses) 會標記為不支援)。
接下來您就可以剖析應用程式。報告的數據是根據每 1M L2 遺漏事件進行剖析取樣的結果。這代表當以排除在外的取樣方式排序時,造成最多 L2 遺漏的函式將會在最頂端。
若要以檢測模式剖析 L2 遺漏和 CPI,請啟動以檢測模式為基礎的效能工作階段。在這個工作階段的屬性中,啟用 CPU 計數器的集合,並選取想要使用的 CPU 計數器:先前所描述的 [L2 快取區遺漏 (L2 Cache Misses)] 事件,以及 [可移植事件 (Portable Events)] > [一般 (General)] 類別目錄中的 [停用的指令 (Instructions Retired)] 和 [未暫止週期 (Non Halted Cycles)]。
剖析工具所產生的報告會包含一些新的資料行,其中會顯示每個 CPU 計數器的包含在內與排除在外的測量數據,還有一般已耗用時間相關的測量數據。就我們的目的而言,最有用的資訊就是每個計數器之各函式的排除在外值。
如果函式會存取廣泛的記憶體,則 L2 遺漏就會比較多,特別是當它大於或接近 L2 快取的大小。但是,如果某個僅會存取少量記憶體範圍的函式也造成大量的 L2 遺漏,就很可能是共用快取行所觸發的遺漏。此簡單的測試同時適用於檢測和取樣的剖析資料。然而,由於沒有一個簡單的判斷方法,所以最好是進行比較,例如針對相同演算法進行不同的設定和並行處理等級的執行測試,再進行交互比對。
您也可以嘗試更進階的方法。在檢測剖析模式中,函式的準確 L2 遺漏數據會出現在 [L2 遺漏排除在外 (L2 Misses Exclusive)] 資料行中。如果您大概知道此函式所參考的記憶體數量,就可以使用此數據來預估 L2 遺漏的數據與總預期記憶體讀取數據的差異。但是,使用此技巧時,需要非常注意架構的細節,例如快取行的大小以及所使用之 CPU 計數器的實際行為。
為了說明這一點,請參考一個測試剖析工作階段的 L2 遺漏統計資料,如 [圖 5] 所示。受測試的應用程式會執行四個執行緒,且它們會重複地周遊並更新結構的陣列,其記憶體總大小大約是 100KB。測試的電腦有四個 CPU (雙插槽、雙核心),總共有 4MB 的 L2 快取。

圖 5 測試工作階段的 L2 遺漏統計資料
| 測試案例 |
記憶體讀取總數 |
所參考之唯一記憶體位址的大小 |
作用點函式的「排除在外的內傳 L2 快取行 (Exclusive L2 Lines In)」 |
| 有虛偽共用 (存取距離為 4 個位元組) |
~1GB |
100KB |
310,000,000 |
| 較少的虛偽共用 (存取距離為 16 個位元組) |
~1GB |
100KB |
95,000,000 |
| 沒有虛偽共用 (理想的存取距離) |
~1GB |
100KB |
240,000 |
在此範例中,我們可以透過 L2 的統計資料,輕易地辨識出有虛偽共用的案例,甚至不需要考慮到架構的細節或進行更精確的計算。很明顯可以看出,大量的讀取作業都會造成 L2 遺漏。由於應用程式所參考的唯一記憶體只有 100KB,這要放入 L2 快取中應該綽綽有餘,但是報告中的 [內傳的 L2 快取行 (L2 Lines In)] 卻逼近記憶體參考總數。
有時候,頻繁且短暫的函式呼叫作業也可能會造成檢測剖析模式的額外負荷,而造成執行階段行為的偏差並擾亂剖析結果。在這樣的案例中,應該使用取樣的剖析模式來收集 L2 的統計資料。[圖 3] 和 [圖 4] 中顯示的範例就是這樣,因為其中涉及大量的 Random.Next 呼叫。當我們針對此範例使用取樣剖析模式時,結果如 [圖 6] 所示。雖然此結果沒有像 [圖 5] 那麼極端,但是此處可指出虛偽共用的提示,在於 L2 遺漏的計數明顯指出 Random.Next 的呼叫 (3.5M / 40M) 大部分都造成快取的重新載入,雖然從各個執行緒的觀點來看,所參考的資料在記憶體中完全是靜態的。修正過之案例的取樣結果可以證實這一點,如 [圖 6] 所示。

圖 6 Random.Next 範例的 L2 遺漏統計資料
| 測試案例 |
呼叫 Random.Next 的總數 |
「排除在外的內傳 L2 快取行 (Exclusive L2 Lines In)」的排行榜 (設定為每 1000 個 L2 事件的取樣命中率) |
| 有虛偽共用 (圖 3) |
40M (4x10M) |
System.Random.InternalSample -- 3,525 Program.DoWork -- 1,828 System.Random.Next -- 271 [mscorwks.dll] -- 161 [ntdll.dll] -- 78
|
| 沒有虛偽共用 (圖 4) |
40M (4x10M) |
[mscorwks.dll] -- 101 [ntdll.dll] -- 76 System.Random.InternalSample -- 53
|
若要取得特定函式的 CPI 數據,可藉由計算兩個效能計數器的比率:排除在外的未暫止週期 (Exclusive Non Halted Cycles) 以及排除在外之停用的指令 (Exclusive Instructions Retired)。這需要使用兩個不同的取樣剖析模式的工作階段,因為每一次執行時只能剖析其中一個計數器。
如前所述,特定演算法的 CPI 會明顯受到硬體組態的影響。因此,進行比較時,只能使用相似的硬體組態。利用此數據的方法之一,就是在不同的並行處理等級的硬體上執行相同的演算法,然後再進行比較。這對於在並行處理等級較低的環境中發生執行時間不一致而無法比較 L2 統計資料的情況會很有幫助,因為 CPI 與執行時間無關。
使用相同的輸入資料時,如果相同的演算法在較少 CPU 的電腦上得到明顯較小的 CPI 值,則意味著演算法在有較多 CPU (較高的並行處理等級) 時,無法有效運用硬體資源。如果 CPI 數據的焦點是在執行運算的程式碼上,亦即軟體同步處理的因素已經排除,則這就代表硬體效率的下降可能是由快取因素所造成。
虛偽共用是否僅為理論?
虛偽共用的問題似乎很難理解,或許您會認為實際執行時不會碰到這樣的問題。但事實上,此問題經常會在實際執行的環境中發生,甚至到令人難以置信的頻率。
雖然您在撰寫平行應用程式時,可以完全不理會虛偽共用的問題,因為您可能不會進行效能測試來偵測這些問題,但是相關的概念絕對要謹記在心,特別是在應用程式開發時的設計和實作階段。畢竟您竟然想要撰寫平行應用程式,就代表您很注重效能。如果您努力地讓應用程式可以在各種多核心的系統上最佳化延展性,但是最後卻被虛偽共用把平行處理的效益完全吞噬,那就太冤枉了。
另一項要注意的重點是,虛偽共用的問題不一定都能夠透過測試來發覺。這是因為其行為屬非決定性的、可能跨越不同的硬體組態 (快取行的大小可能會有差異),且即使是少許的程式碼變更也可能造成激烈的變化。事實上,您通常在單一執行緒應用程式中為了最佳化應用程式效能而實作的方案,其實可能會在多執行緒應用程式中造成更嚴重的虛偽共用問題。如果您正在開發需要有高效能的應用程式,只要盡早考慮到虛偽共用的問題,最後一定會獲益良多。
可能發生虛偽共用的記憶體位置
虛偽共用只有在會導致運算時間變得緩慢時造成問題。如果快取的值只有偶爾會失效,則這對於整體執行時間的影響就可以忽略。但是,如果執行緒會耗費太多時間讀取和寫入特定記憶體位置,且該記憶體位置的快取副本一直因為另一個執行緒的虛偽共用而失效,那麼只要解決虛偽共用的問題,應該就可以大幅提升應用程式的效能。
基於區域性的原則 (Principle of Locality),程式通常會耗費許多時間在存取鄰近的記憶體位置上。例如,試想一下常見的平行運算案例:同時會有數個執行緒共同合作地運算結果。每個執行緒都會計算一個中繼結果,再結合各執行緒的中繼結果來產生最終的結果。如果各個執行緒在讀取和寫入中繼結果時耗費太多時間,且來自不同執行緒的中繼結果在記憶體中因為鄰近而可以納入相同的快取行上,那麼虛偽共用就可能會拖累演算法的效能。
以平行處理的方式為陣列中的整數計算總和時,就是符合此模式的範例。每一個執行緒都會負責陣列的一個區段,並將該區段的項目加總到累計值中。如果來自不同執行緒的累計值位於相同的快取行上,效能就會降低,因為執行緒會不斷地覆蓋各自的快取而耗費時間,亦即在快取階段導致應用程式的執行序列化。
我們可以拿 Parallel LINQ (PLINQ) 專案發生虛偽共用問題的情況,來當做較複雜的實際執行案例。PLINQ 是一個 LINQ 提供者,與 LINQ-to-Objects 類似,但它會將評估查詢的工作分散給電腦上所有可用的核心。簡單而言,PLINQ 可代表使用一組列舉值的平行計算 (實作 IEnumerator<T> 介面的類別)。重複執行時,每一個列舉值都會處理輸入的一部分,並計算相對的輸出。不同的列舉值會由不同的執行緒處理,因而使計算作業分散到多重執行緒。如果電腦上有多重核心可用來為執行緒進行排程,就可能會實現平行處理的效益。
然而,這樣的案例很容易導致虛偽共用。我們有數個執行緒,每個執行緒都會針對本身的列舉值讀取和修改欄位。如果列舉值在記憶體中變得太鄰近,虛偽共用就會大幅吞噬平行處理的效益,若沒有注意而在實作時直接建立並傳遞列舉值給執行緒 (有如在先前的 Random 範例一樣),就很可能發生這樣的情形。
避免連續性的記憶體配置
雖然 .NET 記憶體配置器會隱藏許多記憶體配置的細節,但是在大多數的情況下,我們還是可以猜出配置的某些屬性,甚至確定某些配置屬性。我們可以假設:
- 在相同類別執行個體中的兩個執行個體欄位,其記憶體位置會很鄰近。
- 在相同型別中的兩個靜態欄位,其記憶體位置會很鄰近。
- 在陣列中,兩個索引相鄰的項目,其記憶體位置會相鄰。
- 連續配置的物件,其記憶體位置可能會很鄰近,且隨著時間會更鄰近。
- 在同一個解析 (Closure) 中使用的本機變數,通常會在執行個體欄位中擷取,因此,根據上述第一項假設,它們的記憶體位置可能會很鄰近。
在其他的案例中,我們知道兩者的記憶體位置會比較疏遠。例如,由不同執行緒所配置的物件,其記憶體位置通常會比較疏遠。
如果某個執行緒配置了一個物件,然後一些中型或大型物件,最後再配置另一個物件,則第一個物件和最後一個物件的距離,就會是它們之間所配置物件的總和。雖然我們無法保證此行為,但是在實務上通常是如此 (但是,如果它們之間的物件過大,可能會因而進入另一個堆積,這樣可能就會導致第一個物件和最後一個物件的記憶體位置很鄰近)。
如果兩個物件之間以未使用欄位填補,且總大小至少等於快取行的大小,則該兩個物件就不會出現在同一個快取行上。填補欄位應該會在記憶體的最後。如果我們知道物件會在記憶體中出現的順序,就可以只填補第一個物件。
根據上述假設,我們可以在設計平行應用程式時,盡量避免虛偽共用的問題,以省略未來需要偵錯的麻煩。
未來的程式設計模型,應該會向使用者隱藏這些複雜的問題。然而,在這之前,亦即短期內,我們還是要了解記憶體和快取的運作方式,才能夠撰寫出效率優異的平行程式。
Stephen Toub 是 Microsoft Parallel Computing Platform 團隊的資深專案經理。他同時也是 MSDN Magazine 的專欄主筆。
Igor Ostrovsky 是 Microsoft Parallel Computing Platform 團隊的軟體開發工程師。他是 Parallel LINQ (PLINQ) 的主要開發人員。
Huseyin Yildiz 是 Microsoft Parallel Computing Platform 團隊的軟體開發工程師。他目前正在參與 Task Parallel Library (TPL) 的工作。