Android SMP 入門

Android 3.0 以上版本的平台經過最佳化,可支援多處理器架構。本文介紹在 C、C++ 和 Java 程式設計語言 (以下簡稱「Java」) 中,為對稱多處理器系統編寫多執行緒程式碼時可能發生的問題。這份指南旨在為 Android 應用程式開發人員提供入門指引,而非對該主題進行完整討論。

簡介

SMP 是「對稱多處理器」的縮寫,這種設計可讓兩個以上的相同 CPU 核心共用主記憶體。直到幾年前,所有 Android 裝置都是 UP (單一處理器)。

大多數 (如果不是全部) Android 裝置一律有多個 CPU,但過去只有其中一個 CPU 用於執行應用程式,其他 CPU 則用於管理裝置硬體的各種位元 (例如無線電)。CPU 可能採用不同的架構,且在 CPU 上執行的程式無法使用主記憶體互相通訊。

目前市面上販售的大多數 Android 裝置都是以 SMP 設計為基礎,這讓軟體開發人員的工作變得更複雜。多執行緒程式中的競爭狀況可能不會在單一處理器上造成明顯問題,但如果兩個以上的執行緒同時在不同核心上執行,就可能經常失敗。此外,在不同的處理器架構上執行程式碼,甚至在相同架構的不同實作上執行,程式碼可能或多或少會發生錯誤。在 x86 上經過完整測試的程式碼,在 ARM 上可能會嚴重損毀。使用較新的編譯器重新編譯時,程式碼可能會開始失敗。

本文其餘部分將說明原因,並告訴您需要採取哪些措施,確保程式碼正常運作。

記憶體一致性模型:為什麼 SMP 略有不同

這份文件只是對複雜主題的快速概略介紹。部分領域可能不完整,但絕不應有誤導或錯誤。如您在下一節所見,這裡的詳細資料通常不重要。

請參閱文件結尾的「延伸閱讀」一節,進一步瞭解這個主題。

記憶體一致性模型 (通常簡稱為「記憶體模型」) 描述程式設計語言或硬體架構對記憶體存取的保證。舉例來說,如果您將值寫入位址 A,然後將值寫入位址 B,模型可能會保證每個 CPU 核心都會看到這些寫入作業依序發生。

大多數程式設計師習慣的模型是「依序一致性」,其說明如下 (Adve & Gharachorloo):

  • 所有記憶體作業似乎都是逐一執行
  • 單一執行緒中的所有作業,似乎都會按照該處理器程式描述的順序執行。

假設我們有一個非常簡單的編譯器或解譯器,不會產生任何意外:它會將原始碼中的指派作業轉譯為載入和儲存指令,並按照對應的順序執行,每次存取一個指令。為求簡化,我們也假設每個執行緒都在各自的處理器上執行。

如果查看一段程式碼,發現它會從記憶體中讀取和寫入一些資料,在循序一致的 CPU 架構上,您會知道程式碼會按照預期順序執行這些讀取和寫入作業。CPU 實際上可能會重新排序指令,並延遲讀取和寫入作業,但裝置上執行的程式碼無法判斷 CPU 是否正在執行任何操作,只能以簡單的方式執行指令。(我們會忽略記憶體對應裝置驅動程式 I/O)。

為了說明這些重點,建議您考慮使用一小段程式碼,通常稱為「試金石測試」。

以下是簡單範例,程式碼在兩個執行緒上執行:

Thread 1 Thread 2
A = 3
B = 5
reg0 = B
reg1 = A

在本範例和日後的所有石蕊範例中,記憶體位置會以大寫字母 (A、B、C) 表示,CPU 暫存器則以「reg」開頭。所有記憶體一開始都是零。指令會由上而下執行。在這裡,執行緒 1 將值 3 儲存在位置 A,然後將值 5 儲存在位置 B。執行緒 2 會將位置 B 的值載入 reg0,然後將位置 A 的值載入 reg1。(請注意,我們是以一種順序寫入,並以另一種順序讀取)。

假設執行緒 1 和執行緒 2 在不同的 CPU 核心上執行。在考慮多執行緒程式碼時,您應「一律」做出這項假設。

循序一致性可保證,在兩個執行緒都完成執行後,暫存器會處於下列其中一種狀態:

暫存器 州
reg0=5, reg1=3 (執行緒 1 先執行)
reg0=0, reg1=0 可能 (執行緒 2 先執行)
reg0=0, reg1=3 (並行執行)
reg0=5, reg1=0 永不

如果要在看到 A 的儲存作業之前看到 B=5,則讀取或寫入作業必須不按順序進行。在循序一致的機器上,這種情況不會發生。

單一處理器 (包括 x86 和 ARM) 通常具有循序一致性。執行緒會以交錯方式執行,因為 OS 核心會在執行緒之間切換。大多數 SMP 系統 (包括 x86 和 ARM) 都不具備循序一致性。舉例來說,硬體通常會在儲存至記憶體的過程中緩衝儲存,因此不會立即到達記憶體,也不會顯示在其他核心中。

細節則大不相同。舉例來說,x86 雖然不具備循序一致性,但仍保證 reg0 = 5 和 reg1 = 0 仍不可能發生。Store 會經過緩衝處理,但順序會維持不變。ARM 則不支援。緩衝儲存區的順序不會保留,且儲存區可能不會同時到達所有其他核心。這些差異對組合語言程式設計師來說很重要。不過,正如我們在下文中所見,C、C++ 或 Java 程式設計師可以且應該以隱藏這類架構差異的方式進行程式設計。

到目前為止,我們不切實際地假設只有硬體會重新排序指令。實際上,編譯器也會重新排序指令,以提升效能。在我們的範例中,編譯器可能會決定執行緒 2 中某些後續程式碼需要 reg1 的值,然後才需要 reg0,因此會先載入 reg1。或者,某些先前的程式碼可能已經載入 A,而編譯器可能會決定重複使用該值,而不是再次載入 A。無論是哪種情況,reg0 和 reg1 的載入作業都可能重新排序。

無論是在硬體或編譯器中,都允許重新排序對不同記憶體位置的存取,因為這不會影響單一執行緒的執行,而且可以大幅提升效能。如您所見,只要稍加留意,我們也可以防止它影響多執行緒程式的結果。

由於編譯器也可以重新排序記憶體存取,因此這個問題實際上並非 SMP 獨有。即使在單一處理器上,編譯器也可以重新排序範例中 reg0 和 reg1 的載入作業,而執行緒 1 則可排定在重新排序的指令之間。但如果編譯器剛好沒有重新排序,我們可能永遠不會發現這個問題。在大多數 ARM SMP 上,即使沒有編譯器重新排序,也可能會看到重新排序,可能是在大量成功執行之後。除非您使用組合語言進行程式設計,否則 SMP 通常只會讓您更有可能看到一直存在的問題。

無資料競爭程式設計

幸好,通常有簡單的方法可以避免考慮這些細節。只要遵循一些簡單的規則,通常可以放心地忽略上一節的所有內容,除了「循序一致性」部分。如果不小心違反這些規則,其他小工具可能會顯示。

現代程式設計語言鼓勵採用「無資料競爭」的程式設計風格。只要您承諾不引入「資料競爭」,並避免使用少數會向編譯器傳達其他指示的建構,編譯器和硬體就會承諾提供依序一致的結果。這並不代表他們會避免記憶體存取重新排序。這表示只要遵守規則,就無法判斷記憶體存取是否重新排序。這就像是告訴你香腸是美味可口的食物,只要你保證不參觀香腸工廠。資料競爭會揭露記憶體重新排序的醜陋真相。

什麼是「資料競爭」?

當至少有兩個執行緒同時存取相同的一般資料,且其中至少有一個執行緒修改該資料時,就會發生資料競爭。「一般資料」是指並非專門用於執行緒通訊的同步處理物件。互斥鎖、條件變數、Java 揮發性變數或 C++ 原子物件並非一般資料,其存取權允許競爭。事實上,這類參照是用來防止其他物件發生資料競爭。

為了判斷兩個執行緒是否同時存取同一個記憶體位置,我們可以忽略上述的記憶體重新排序討論,並假設順序一致性。如果 A 和 B 是初始值為 false 的一般布林變數,則下列程式不會發生資料競爭:

Thread 1 Thread 2
if (A) B = true if (B) A = true

由於作業不會重新排序,因此兩個條件都會評估為 false,且兩個變數都不會更新。因此不會發生資料競爭。您不必考慮如果執行緒 1 中從 A 載入和儲存至 B 的順序有所變更,會發生什麼情況。編譯器不得將執行緒 1 重新編寫為「B = true; if (!A) B = false」來重新排序。這就像是在光天化日之下,在市中心製作香腸。

資料競爭的正式定義適用於基本內建類型,例如整數和參照或指標。在另一個執行緒中讀取 的同時,將其指派給 int,顯然是資料競爭。不過,C++ 標準程式庫和 Java 集合程式庫的編寫方式,都可讓您在程式庫層級推論資料競爭。除非同時存取同一個容器,且至少其中一個存取作業會更新容器,否則不會發生資料競爭。在一個執行緒中更新 set<T>,同時在另一個執行緒中讀取,會導致程式庫發生資料競爭,因此可以非正式地視為「程式庫層級資料競爭」。相反地,在一個執行緒中更新一個 set<T>,同時在另一個執行緒中讀取 不同的,不會導致資料競爭,因為 程式庫承諾在這種情況下不會引入 (低階) 資料競爭。

一般來說,同時存取資料結構中的不同欄位,不會造成資料競爭。不過,這項規則有一個重要例外狀況:C 或 C++ 中的連續位元欄位序列會視為單一「記憶體位置」。存取這類序列中的任何位元欄位,都會視為存取所有位元欄位,以判斷是否存在資料競爭。這反映出一般硬體無法在不讀取及重新寫入相鄰位元的情況下,更新個別位元。Java 程式設計人員則沒有類似的疑慮。

避免資料競爭

現代程式設計語言提供多種同步機制,可避免資料競爭。最基本的工具如下:

鎖定或互斥鎖
互斥鎖 (C++11 std::mutex 或 pthread_mutex_t) 或 Java 中的 synchronized 區塊可用來確保特定程式碼區段不會與存取相同資料的其他程式碼區段同時執行。我們將這些和其他類似設施統稱為「鎖」。在存取共用資料結構之前,一律先取得特定鎖定,並在存取後釋放鎖定,可避免在存取資料結構時發生資料競爭。此外,這項機制也能確保更新和存取作業不可分割,也就是說,資料結構在更新期間不會執行其他更新。這是目前最常用來防止資料競爭的工具,使用 Java synchronized 區塊或 C++ lock_guard 或 unique_lock,確保在發生例外狀況時正確釋放鎖定。
Volatile/Atomic 變數
Java 提供 volatile 欄位,支援並行存取,不會造成資料競爭。自 2011 年起,C 和 C++ 支援具有類似語意的 atomic 變數和欄位。這類方法通常比鎖定更難使用,因為只能確保對單一變數的個別存取是不可分割的。(在 C++ 中,這通常會延伸到簡單的讀取-修改-寫入作業,例如遞增。Java 則需要特殊的方法呼叫。與鎖定不同,volatile 或 atomic 變數無法直接用於防止其他執行緒干擾較長的程式碼序列。

請注意,volatile 在 C++ 和 Java 中的意義截然不同。在 C++ 中,volatile 無法防止資料競爭,但舊版程式碼通常會使用它來解決缺少 atomic 物件的問題。我們不再建議使用此方法;在 C++ 中,請針對可由多個執行緒同時存取的變數使用 atomic<T>。C++ volatile 適用於裝置暫存器等。

C/C++ atomic 變數或 Java volatile 變數可用於防止其他變數發生資料競爭。如果 flag 宣告為 atomic<bool> 或 atomic_bool (C/C++) 或 volatile boolean (Java) 類型,且初始值為 false,則下列程式碼片段不會發生資料競爭:

Thread 1 Thread 2
A = ...
  flag = true
while (!flag) {}
... = A

由於執行緒 2 會等待 flag 設定,因此執行緒 2 中對 A 的存取必須在執行緒 1 中對 A 的指派之後發生,而不是同時發生。因此,A 上沒有資料競爭。flag 上的競爭不算是資料競爭,因為 volatile/atomic 存取並非「一般記憶體存取」。

實作時必須充分防止或隱藏記憶體重新排序,確保程式碼能像上述石蕊測試一樣正常運作。這通常會使 volatile/atomic 記憶體存取比一般存取更耗費資源。

雖然上述範例沒有資料競爭,但 Java 中的鎖定和 Object.wait(),或 C/C++ 中的條件變數,通常能提供更好的解決方案,不會在消耗電池電力的同時在迴圈中等待。

回憶集錦重新排序功能上線時

無資料競爭程式設計通常可讓我們不必明確處理記憶體存取重新排序問題。不過,在某些情況下,重新排序會變得可見:
  1. 如果程式有錯誤,導致非預期的資料競爭,編譯器和硬體轉換可能會變得可見,且程式的行為可能會令人驚訝。舉例來說,如果我們在上述範例中忘記宣告 flag 變動,執行緒 2 可能會看到未初始化的 A。或者,編譯器可能會判定旗標在執行緒 2 的迴圈期間不可能變更,並將程式轉換為
    Thread 1 Thread 2
    A = ...
      flag = true
    reg0 = flag; while (!reg0) {}
    ... = A
    偵錯時,即使 flag 為 true,您仍可能看到迴圈持續執行。
  2. 即使沒有競爭情形,C++ 仍提供明確放寬循序一致性的功能。不可分割作業可採用明確的 memory_order_... 引數。同樣地,java.util.concurrent.atomic 套件提供一組更受限的類似功能,尤其是 lazySet()。Java 程式設計師有時也會刻意使用資料競爭,達到類似效果。這些方法都能提升效能,但會大幅增加程式設計的複雜度。我們只在下方簡要討論。
  3. 部分 C 和 C++ 程式碼是以舊式風格編寫,與目前的語言標準不完全一致,其中使用 volatile 變數而非 atomic 變數,且透過插入所謂的「柵欄」或「屏障」明確禁止記憶體排序。這需要明確推論存取重新排序,並瞭解硬體記憶體模型。Linux 核心仍會使用類似的程式碼編寫樣式。新的 Android 應用程式不應使用此方法,此處也不會進一步討論。

練習

對記憶體一致性問題進行偵錯可能非常困難。如果缺少鎖定、atomic 或 volatile 宣告,導致部分程式碼讀取過時資料,您可能無法透過偵錯工具檢查記憶體傾印來找出原因。當您發出偵錯工具查詢時,CPU 核心可能已觀察到整組存取作業,而記憶體和 CPU 暫存器的內容會顯示為「不可能」狀態。

C 語言的禁止事項

以下列舉一些錯誤程式碼範例,並提供簡單的修正方法。在此之前,我們需要討論基本語言功能的使用。

C/C++ 和「volatile」

C 和 C++ volatile 宣告是特殊用途的工具。可防止編譯器重新排序或移除揮發性存取。這項功能適用於存取硬體裝置暫存器的程式碼、對應至多個位置的記憶體,或與 setjmp 相關的程式碼。但 C 和 C++ volatile 與 Java volatile 不同,並非專為執行緒通訊而設計。

在 C 和 C++ 中,對 volatile 資料的存取可能會與對非揮發性資料的存取重新排序,且不保證原子性。因此,即使在單一處理器上,volatile 也無法用於在可攜式程式碼中的執行緒之間共用資料。C volatile 通常不會防止硬體重新排序存取作業,因此在多執行緒 SMP 環境中,其本身的作用甚至更小。這就是 C11 和 C++11 支援 atomic 物件的原因。請改用這些。

許多舊版 C 和 C++ 程式碼仍會濫用 volatile 進行執行緒通訊。如果資料適合機器暫存器,且與明確柵欄搭配使用,或記憶體排序不重要,這種做法通常能正確運作。但無法保證在未來的編譯器中能正常運作。

範例

在大多數情況下,建議您使用鎖定 (例如 pthread_mutex_t 或 C++11 std::mutex),而非原子作業,但我們會採用後者,說明如何在實際情況中使用。

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

這裡的概念是,我們分配一個結構,初始化其欄位,最後透過將其儲存在全域變數中來「發布」它。此時,任何其他執行緒都可以看到它,但這沒關係,因為它已完全初始化,對吧?

問題在於,在欄位初始化之前,可能會觀察到 gGlobalThing 的儲存,這通常是因為編譯器或處理器重新排序了 gGlobalThing 和 thing->x 的儲存。另一個從 thing->x 讀取的執行緒可能會看到 5、0,甚至是未初始化的資料。

這裡的核心問題是 gGlobalThing 上的資料競爭。如果執行緒 1 呼叫 initGlobalThing(),而執行緒 2 呼叫 useGlobalThing(),則 gGlobalThing 可以在寫入時讀取。

只要將 gGlobalThing 宣告為原子,即可修正這個問題。在 C++11 中:

atomic<MyThing*> gGlobalThing(NULL);

這可確保寫入作業會以正確的順序顯示在其他執行緒中。此外,這項機制也能保證防止其他故障模式,這些模式雖然允許,但不太可能在實際的 Android 硬體上發生。舉例來說,這可確保我們不會看到僅部分寫入的 gGlobalThing 指標。

Java 禁止事項

我們尚未討論一些相關的 Java 語言功能,因此先快速瀏覽這些功能。

嚴格來說,Java 不要求程式碼必須沒有資料競爭。此外,還有少量經過精心編寫的 Java 程式碼,可在發生資料競爭時正確運作。不過,編寫這類程式碼非常棘手,因此我們只在下方簡短討論。更糟的是,當初指定這類程式碼含義的專家,現在也不認為該規格正確。(規格適用於無資料競爭的程式碼)。

目前,我們將遵循無資料競爭模型,Java 提供的保證基本上與 C 和 C++ 相同。同樣地,該語言提供了一些明確放寬循序一致性的原始類型,特別是 java.util.concurrent.atomic 中的 lazySet() 和 weakCompareAndSet() 呼叫。與 C 和 C++ 一樣,我們暫時忽略這些。

Java 的「synchronized」和「volatile」關鍵字

「synchronized」關鍵字提供 Java 語言內建的鎖定機制。每個物件都有相關聯的「監控器」,可用於提供互斥存取權。如果兩個執行緒嘗試在同一個物件上「同步處理」,其中一個執行緒會等待另一個執行緒完成。

如上所述,Java 的 volatile T 類似於 C++11 的 atomic<T>。系統允許同時存取 volatile 欄位,不會導致資料競爭。忽略 lazySet() 等和資料競爭,Java VM 的工作是確保結果仍顯示為循序一致。

具體來說,如果執行緒 1 寫入 volatile 欄位,而執行緒 2 隨後從該欄位讀取並看到新寫入的值,則執行緒 2 也保證會看到執行緒 1 先前執行的所有寫入作業。就記憶體效應而言,寫入 volatile 類似於釋出監控器,而讀取 volatile 則類似於取得監控器。

與 C++ 的 atomic 有一個顯著差異:如果我們在 Java 中寫入 volatile int x;,則 x++ 與 x = x + 1 相同;它會執行原子載入、遞增結果,然後執行原子儲存。與 C++ 不同,遞增作業整體並非不可分割。不可分割式遞增作業則由 java.util.concurrent.atomic 提供。

範例

以下是單調計數器的簡單錯誤實作:(Java 理論與實務:管理波動性)。

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

假設 get() 和 incr() 是從多個執行緒呼叫,我們希望確保每個執行緒在呼叫 get() 時都能看到目前的計數。最明顯的問題是,mValue++ 實際上是三項作業:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

如果兩個執行緒同時在 incr() 中執行,其中一項更新可能會遺失。如要讓遞增作業成為不可分割,我們需要宣告 incr()「synchronized」。

不過,這項功能仍有問題,尤其是在 SMP 上。get() 仍可與 incr() 同時存取 mValue,因此仍有資料競爭問題。根據 Java 規則,get() 呼叫可能會與其他程式碼重新排序。舉例來說,如果我們連續讀取兩個計數器,結果可能會不一致,因為 get() 呼叫是由硬體或編譯器重新排序。我們可以將 get() 宣告為同步,藉此修正問題。經過這項變更後,程式碼顯然正確。

不過,這可能會導致鎖定爭用,進而影響效能。我們可以使用「volatile」宣告 mValue,而不是宣告 get() 進行同步處理。(請注意,incr() 仍須使用 synchronize,因為 mValue++ 不是單一不可分割作業)。這也能避免所有資料競爭,因此可保留依序一致性。incr() 會稍微慢一些,因為會產生監控器進入/退出負擔,以及與 volatile 儲存區相關的負擔,但 get() 會更快,因此即使沒有爭用,如果讀取次數遠多於寫入次數,這也是一項優勢。(另請參閱 AtomicInteger,瞭解如何完全移除同步處理區塊)。

以下是另一個範例,形式與先前的 C 範例類似:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

這與 C 程式碼有相同的問題,也就是 sGoodies 發生資料競爭。因此,系統可能會在 goods 中的欄位初始化之前,觀察到指派 sGoodies = goods。如果您使用 volatile 關鍵字宣告 sGoodies,系統會還原循序一致性,並按照預期運作。

請注意,只有 sGoodies 參照本身是可變動的。但其中欄位的存取權則不在此限。當 sGoodies 為 volatile,且記憶體排序正確保留時,就無法同時存取欄位。陳述式 z = sGoodies.x 會先執行 MyClass.sGoodies 的揮發性載入,接著執行 sGoodies.x 的非揮發性載入。如果您建立本機參照 MyGoodies localGoods = sGoodies,則後續的 z = localGoods.x 不會執行任何 volatile 載入。

Java 程式設計中更常見的慣用語是惡名昭彰的「雙重檢查鎖定」:

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

我們的想法是,希望有一個 Helper 物件的單一執行個體與 MyClass 的執行個體相關聯。我們只能建立一次,因此會透過專屬的 getHelper() 函式建立並傳回。為避免兩個執行緒建立執行個體時發生競爭,我們需要同步處理物件建立作業。不過,我們不希望每次呼叫都支付「同步」區塊的負擔,因此只有在 helper 目前為空值時,才會執行該部分。

這會導致 helper 欄位發生資料競爭。可與另一個執行緒中的 helper == null 同時設定。

如要瞭解這項操作為何會失敗,請考慮將相同的程式碼稍微改寫,就像編譯成 C 語言一樣 (我新增了幾個整數欄位來代表 Helper’s 建構函式活動):

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

硬體或編譯器都無法阻止將儲存區重新排序為 helper,並將其儲存至 x/y 欄位。另一個執行緒可能會發現 helper 非空值,但其欄位尚未設定且無法使用。如需更多詳細資料和失敗模式,請參閱附錄中的「『雙重檢查鎖定已損壞』宣告」連結,或 Josh Bloch 的「Effective Java, 2nd Edition」中的第 71 項 (「謹慎使用延遲初始化」)。

解決方法有兩種:

  1. 請採取簡單的做法,刪除外部檢查。這樣可確保我們不會在同步區塊外檢查 helper 的值。
  2. 將 helper 宣告為 volatile。只要進行這項小變更,範例 J-3 中的程式碼就能在 Java 1.5 以上版本正常運作。(您可能需要花點時間確認這是否屬實)。

以下是 volatile 行為的另一個範例:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

在 useValues() 中,如果執行緒 2 尚未觀察到 vol1 的更新,就無法得知是否已設定 data1 或 data2。當它看到 vol1 的更新時,就知道可以安全地存取 data1 並正確讀取,而不會引入資料競爭。不過,它無法對 data2 做出任何假設,因為該儲存作業是在 volatile 儲存作業之後執行。

請注意,volatile 無法用於防止其他記憶體存取作業重新排序,以免彼此競爭。不保證會產生機器記憶體柵欄指令。這項功能可用於防止資料競爭,方法是僅在另一個執行緒滿足特定條件時執行程式碼。

建議行動

在 C/C++ 中,建議使用 C++11 同步處理類別,例如 std::mutex。如果沒有,請使用對應的 pthread 作業。包括適當的記憶體柵欄,在所有 Android 平台版本上提供正確 (除非另有指定,否則依序一致) 且有效率的行為。請務必正確使用。舉例來說,請記住,條件變數等待可能會在沒有信號的情況下虛假返回,因此應出現在迴圈中。

除非您實作的資料結構非常簡單 (例如計數器),否則最好避免直接使用原子函式。鎖定和解鎖 pthread 互斥鎖各需要一項不可分割作業,如果沒有爭用,成本通常低於單一快取失敗,因此將互斥鎖呼叫替換為不可分割作業並不會節省太多成本。非一般資料結構的無鎖設計需要更謹慎,確保資料結構上的高階作業看起來是不可分割的 (整體,而不只是明確不可分割的部分)。

如果您使用原子作業,使用 memory_order... 或 lazySet() 放寬排序可能會帶來效能優勢,但需要比我們目前傳達的更深入的理解。我們發現,使用這些 API 的現有程式碼中,有很大一部分在事後發現錯誤。請盡量避免。 如果您的用途不完全符合下一節所述的任何一種,請務必確認您是專家,或已諮詢專家。

在 C/C++ 中,避免使用 volatile 進行執行緒通訊。

在 Java 中,並行問題通常最好使用 java.util.concurrent 套件中的適當公用程式類別來解決。程式碼編寫良好,且在 SMP 上經過充分測試。

最安全的做法可能是將物件設為不可變動。Java 的 String 和 Integer 等類別的物件,在建立後就無法變更資料,因此可避免這些物件發生資料競爭。《Effective Java, 2nd Ed.》一書在「Item 15: Minimize Mutability」中提供了具體說明。請特別注意,將 Java 欄位宣告為「final」(Bloch) 非常重要。

即使物件不可變更,請注意,在沒有任何同步處理的情況下,將物件傳送至其他執行緒仍會造成資料競爭。這在 Java 中有時可以接受 (請參閱下文),但需要特別小心,且可能導致程式碼不穩定。如果不是極為重要的效能,請新增 volatile 宣告。在 C++ 中,如果未適當同步處理,就傳遞指向不可變動物件的指標或參照,則與任何資料競爭一樣,都是錯誤。在這種情況下,很可能會導致間歇性當機,例如接收執行緒可能會因為儲存重新排序而看到未初始化的方法資料表指標。

如果現有的程式庫類別和不可變動類別都不適用,則應使用 Java synchronized 陳述式或 C++ lock_guard/unique_lock,防止多個執行緒存取任何欄位。如果互斥鎖不適用於您的情況,請宣告共用欄位 volatile 或 atomic,但請務必謹慎瞭解執行緒之間的互動。這些宣告無法避免常見的並行程式設計錯誤,但有助於避免與最佳化編譯器和 SMP 錯誤相關的神祕失敗。

請避免在建構函式中「發布」物件參照,也就是讓其他執行緒可以使用該物件。在 C++ 中,或是在 Java 中遵循「不發生資料競爭」建議時,這項操作較不重要。不過,這項建議一向實用,如果 Java 程式碼是在 Java 安全性模型重要的其他情境中執行,這項建議就更為重要,因為不受信任的程式碼可能會透過存取「外洩」的物件參照,導致資料競爭。如果您選擇忽略我們的警告,並使用下一節中的某些技術,這點也至關重要。詳情請參閱「Java 中的安全建構技術」

進一步瞭解弱記憶體排序

C++11 和更新版本提供明確機制,可放寬無資料競爭程式的依序一致性保證。原子作業的明確 memory_order_relaxed、memory_order_acquire (僅限載入) 和 memory_order_release (僅限儲存) 引數,提供的保證都比預設 (通常是隱含) 的 memory_order_seq_cst 嚴格。memory_order_acq_rel 為不可部分完成的讀取-修改-寫入作業提供 memory_order_acquire 和 memory_order_release 保證。memory_order_consume 尚未充分指定或實作,因此目前應忽略。

Java.util.concurrent.atomic 中的 lazySet 方法與 C++ memory_order_release 儲存區類似。Java 的一般變數有時會用來取代 memory_order_relaxed 存取,但實際上更弱。與 C++ 不同的是,沒有實際機制可以無序存取宣告為 volatile 的變數。

一般來說,除非有迫切的效能考量,否則應避免使用這些方法。在 ARM 等弱排序機器架構上,使用這些函式通常可為每個原子作業節省數十個機器週期。在 x86 上,效能提升僅限於儲存,且可能較不明顯。不過,隨著核心數量增加,記憶體系統會成為限制因素,因此效益可能會降低。

弱排序原子作業的完整語意相當複雜。一般來說,這類方法需要精確瞭解語言規則,在此不深入探討。例如:

  • 編譯器或硬體可以將 memory_order_relaxed 存取權移入 (但不能移出) 由鎖定取得和釋放所限制的重要區段。這表示即使兩個 memory_order_relaxed 儲存區之間有重要區段,也可能不按順序顯示。
  • 如果將一般 Java 變數濫用為共用計數器,即使該變數只由另一個執行緒遞增,在其他執行緒看來仍可能減少。但 C++ 原子 memory_order_relaxed 則不適用。

在提出這項警告後,我們在此提供一些慣用語,這些慣用語似乎涵蓋了弱排序原子的大多數用途。其中許多僅適用於 C++。

非競爭存取

變數是不可分割的,因為它「有時」會與寫入作業同時讀取,但並非所有存取作業都有這個問題。舉例來說,變數可能需要是不可分割的,因為它是在關鍵區段之外讀取,但所有更新都受到鎖定保護。在這種情況下,受相同鎖定保護的讀取作業不會發生競爭,因為不會有並行寫入作業。在這種情況下,非競爭存取 (在本例中為載入) 可以加上 memory_order_relaxed 註解,而不會影響 C++ 程式碼的正確性。鎖定實作已強制執行必要的記憶體排序,以供其他執行緒存取,而 memory_order_relaxed 指定基本上不需要對原子存取強制執行額外的排序限制。

Java 中沒有與此類似的功能。

結果不保證正確

如果我們只使用競爭載入來產生提示,通常也不需要對載入強制執行任何記憶體排序。如果值不可靠,我們也無法可靠地使用結果來推論其他變數。因此,即使無法保證記憶體排序,也沒關係,且載入作業會提供 memory_order_relaxed 引數。

常見的做法是使用 C++ compare_exchange,以不可分割的方式將 x 替換為 f(x)。x 的初始載入作業 (用於計算 f(x)) 不需要可靠。如果我們判斷錯誤,compare_exchange 就會失敗,我們會重試。x 的初始載入可以使用 memory_order_relaxed 引數,只有實際 compare_exchange 的記憶體排序才重要。

已自動修改但未讀取的資料

有時資料會由多個執行緒平行修改,但直到平行運算完成後才會檢查。舉例來說,計數器由多個執行緒平行自動遞增 (例如在 C++ 中使用 fetch_add(),或在 C 中使用 atomic_fetch_add_explicit()),但這些呼叫的結果一律會遭到忽略。只有在所有更新完成後,系統才會在最後讀取產生的值。

在這種情況下,無法判斷是否已重新排序對這項資料的存取,因此 C++ 程式碼可能會使用 memory_order_relaxed 引數。

簡單的事件計數器就是常見的例子。由於這種情況很常見,因此值得對此進行一些觀察:

  • 使用 memory_order_relaxed 可提升效能,但可能無法解決最重要的效能問題:每次更新都需要獨占存取權,才能存取保留計數器的快取行。每次有新執行緒存取計數器時,都會導致快取失敗。如果更新頻率很高,且在執行緒之間交替,則避免每次都更新共用計數器,例如使用執行緒本機計數器並在最後加總,速度會快得多。
  • 這項技術可與上一節結合:在更新時,可以同時讀取近似值和不可靠的值,所有作業都使用 memory_order_relaxed。但請務必將結果值視為完全不可靠。即使計數似乎已遞增一次,也不代表另一個執行緒可以計入已執行遞增的點。遞增作業可能已與先前的程式碼重新排序。(至於我們稍早提到的類似案例,C++ 確實保證這類計數器的第二次載入不會傳回小於同一執行緒中先前載入的值。(除非計數器溢位)。
  • 我們經常會看到程式碼嘗試透過執行個別不可分割 (或非不可分割) 的讀取和寫入作業,來計算近似的計數器值,但不會將遞增作業視為不可分割。通常的論點是,這對於效能計數器等來說「足夠接近」。通常不會。 如果更新頻率夠高 (您可能關心的情況),通常會遺失大部分的計數。在四核心裝置上,通常會遺失超過一半的計數。(簡單練習:建構一個雙執行緒情境,其中計數器更新了一百萬次,但最終計數器值為一)。

簡單的旗標通訊

memory_order_release 儲存 (或讀取-修改-寫入作業) 可確保如果後續 memory_order_acquire 載入 (或讀取-修改-寫入作業) 讀取寫入的值,也會觀察到 memory_order_release 儲存之前的任何儲存 (一般或不可分割)。相反地,在 memory_order_release 之前的任何載入作業,都不會觀察到 memory_order_acquire 載入作業之後的任何儲存作業。與 memory_order_relaxed 不同,這可讓這類不可拆分的作業用於將一個執行緒的進度傳達給另一個執行緒。

舉例來說,我們可以將上述 C++ 的雙重檢查鎖定範例改寫為

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

取得載入和釋放儲存空間可確保如果我們看到非空值 helper,也會看到其欄位正確初始化。我們也納入先前的觀察結果,即非競速負載可以使用 memory_order_relaxed。

Java 程式設計人員可以將 helper 表示為 java.util.concurrent.atomic.AtomicReference<Helper>,並使用 lazySet() 做為發布商店。載入作業會繼續使用一般的 get() 呼叫。

在這兩種情況下,我們都著重於調整初始化路徑的效能,但這不太可能對效能造成重大影響。較易讀的折衷做法可能是:

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

這會提供相同的快速路徑,但會採用預設、循序一致的操作,在非效能關鍵的慢速路徑上執行。

即使在這種情況下,helper.load(memory_order_acquire) 仍可能在目前支援 Android 的架構上產生相同的程式碼,做為 helper 的一般 (循序一致) 參照。這裡最有效的最佳化方式可能是引入 myHelper,以消除第二次載入,不過未來的編譯器可能會自動執行這項操作。

取得/釋出排序無法防止儲存作業明顯延遲,也無法確保儲存作業以一致的順序向其他執行緒顯示。因此,它不支援 Dekker 互斥演算法所舉例的棘手但相當常見的編碼模式:所有執行緒首先設定一個旗標,表示它們想要執行某些操作;如果執行緒 t 隨後注意到沒有其他執行緒正在嘗試執行某些操作,則它可以安全地繼續進行,因為它知道不會有任何干擾。由於 t 的旗標仍處於設定狀態,因此其他執行緒都無法繼續執行。如果使用取得/釋出排序存取旗標,這項操作就會失敗,因為這無法防止執行緒的旗標在其他執行緒錯誤執行後,才向其他執行緒顯示。預設 memory_order_seq_cst 會防止這種情況。

不可變更的欄位

如果物件欄位在首次使用時初始化,之後就再也不會變更,則可以使用弱排序存取權進行初始化,然後讀取該欄位。在 C++ 中,可以宣告為 atomic,並使用 memory_order_relaxed 存取;在 Java 中,則可以宣告為不含 volatile,並在不採取特殊措施的情況下存取。這需要滿足以下所有條件:

  • 您應該可以從欄位本身的值判斷欄位是否已初始化。如要存取欄位,快速路徑測試和傳回值應只讀取欄位一次。在 Java 中,後者是不可或缺的。即使欄位測試顯示已初始化,第二次載入仍可能讀取到先前未初始化的值。在 C++ 中,「讀取一次」規則只是最佳做法。
  • 初始化和後續載入都必須是不可分割的,也就是說,不應顯示部分更新。如果是 Java,欄位不應為 long 或 double。如果是 C++,則需要原子指派;就地建構無法運作,因為 atomic 的建構並非原子。
  • 重複初始化必須安全,因為多個執行緒可能會同時讀取未初始化的值。在 C++ 中,這通常遵循所有原子類型所施加的「可簡單複製」要求;具有巢狀擁有指標的類型需要在複製建構函式中取消分配,且不會是可簡單複製的。如果是 Java,則可接受特定參照類型:
  • Java 參照僅限於只包含 final 欄位的不可變動類型。不可變類型的建構函式不應發布物件的參照。在這種情況下,Java final 欄位規則可確保讀取器看到參照時,也會看到初始化的 final 欄位。C++ 沒有與這些規則類似的規則,因此也不接受指向擁有物件的指標 (除了違反「可簡單複製」規定之外)。

結語

雖然這份文件不只是淺談,但也不會深入探討。這個主題非常廣泛且深入。以下是可進一步探索的領域:

  • 實際的 Java 和 C++ 記憶體模型是以「發生前」關係表示,指定保證兩個動作會以特定順序發生的時間。定義資料競爭時,我們曾非正式地提到兩次記憶體存取「同時」發生。正式定義為兩者不分先後。建議您瞭解 Java 或 C++ 記憶體模型中「發生在之前」和「與...同步」的實際定義。雖然「同時」的直覺概念通常就足夠,但這些定義具有指導意義,特別是如果您考慮在 C++ 中使用弱排序原子作業。(目前的 Java 規格僅以非常非正式的方式定義 lazySet())。
  • 瞭解編譯器在重新排序程式碼時,可以和不可以執行的操作。(JSR-133 規格提供了一些合法轉換的絕佳範例,可產生非預期的結果)。
  • 瞭解如何在 Java 和 C++ 中編寫不可變類別 (除了「建構後不變更任何內容」之外,還有更多內容)。
  • 請詳閱《Effective Java, 2nd Edition》中「Concurrency」一節的最佳化建議。(例如,您應避免在同步區塊內呼叫要覆寫的方法)。
  • 請詳閱 java.util.concurrent 和 java.util.concurrent.atomic API,瞭解可用的功能。建議使用並行註解,例如 @ThreadSafe 和 @GuardedBy (來自 net.jcip.annotations)。

附錄中的「延伸閱讀」部分提供文件和網站連結,可協助您進一步瞭解這些主題。

附錄

實作同步處理儲存庫

(雖然大多數程式設計師不會實作這項功能,但這項討論很有啟發性。)

對於 int 等小型內建類型,以及 Android 支援的硬體,一般載入和儲存指令可確保儲存作業會完整顯示,或完全不顯示在載入相同位置的其他處理器中。因此,系統會免費提供一些「原子性」的基本概念。

如先前所述,這還不夠。為確保依序一致性,我們也需要防止作業重新排序,並確保記憶體作業以一致的順序向其他程序顯示。事實證明,只要我們明智地選擇強制執行前者,後者就會在 Android 支援的硬體上自動執行,因此我們在這裡基本上會忽略它。

記憶體作業的順序會保留,避免編譯器和硬體重新排序。本文著重於後者。

ARMv7、x86 和 MIPS 的記憶體排序是透過「fence」指令強制執行,這類指令大致上可防止 fence 後面的指令在 fence 前面的指令之前顯示。(這類指令通常也稱為「屏障」指令,但可能會與 pthread_barrier 樣式的屏障混淆,後者的功能遠不只如此)。Fence 指令的精確含義是相當複雜的主題,必須探討多種不同 Fence 提供的保證如何互動,以及這些保證如何與硬體通常提供的其他排序保證結合。這只是概略說明,因此我們會略過這些細節。

最基本的排序保證是由 C++ memory_order_acquire 和 memory_order_release 原子作業提供:在釋放儲存空間之前的記憶體作業,應在取得載入後顯示。在 ARMv7 上,強制執行方式如下:

  • 在儲存指令前加上適當的柵欄指令。這可防止所有先前的記憶體存取作業與儲存指令重新排序。(這也會不必要地防止與後續儲存指令重新排序)。
  • 在載入指令後加上適當的柵欄指令,防止載入指令與後續存取指令重新排序。(再次提供不必要的排序,至少是較早的載入)。

兩者結合後,就足以滿足 C++ 取得/釋出排序。這些是 Java volatile 或 C++ 循序一致 atomic 的必要條件,但並非充分條件。

如要瞭解我們還需要什麼,請考慮我們稍早簡短提及的 Dekker 演算法片段。flag1 和 flag2 是 C++ atomic 或 Java volatile 變數,兩者最初都是 false。

Thread 1 Thread 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

依序一致性表示必須先執行對 flagn 的其中一項指派,並由另一個執行緒中的測試查看。因此,我們永遠不會看到這些執行緒同時執行「critical-stuff」。

不過,取得-釋放排序所需的柵欄只會在每個執行緒的開頭和結尾加上柵欄,這對此處沒有幫助。此外,我們也必須確保如果 volatile/atomic 儲存區後面接著 volatile/atomic 載入,兩者不會重新排序。這通常是透過在循序一致的儲存作業前後加上柵欄來強制執行。(這同樣比所需更強,因為這個柵欄通常會依據所有後續記憶體存取,排序所有先前的記憶體存取)。

我們也可以將額外的柵欄與依序一致的載入作業建立關聯。由於儲存作業較不頻繁,我們所述的慣例較為常見,且用於 Android。

如先前章節所述,我們需要在這兩項作業之間插入儲存/載入屏障。在 VM 中執行的 volatile 存取程式碼如下所示:

不穩定負荷 volatile 儲存區
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

實際的機器架構通常會提供多種類型的柵欄,這些柵欄會排序不同類型的存取,且可能會有不同的成本。這兩者之間的選擇很微妙,且會受到以下因素影響:必須確保儲存區以一致的順序向其他核心顯示,以及多個柵欄組合所施加的記憶體排序正確。詳情請參閱劍橋大學網頁,其中收集了原子與實際處理器的對應關係。

在某些架構 (尤其是 x86) 上,不需要「acquire」和「release」屏障,因為硬體一律會隱含強制執行足夠的排序。因此在 x86 上,系統只會產生最後一個柵欄 (3)。同樣地,在 x86 上,原子讀取-修改-寫入作業會隱含強大的柵欄。因此,這些作業不需要任何柵欄。在 ARMv7 上,上述所有柵欄都是必要的。

ARMv8 提供 LDAR 和 STLR 指令,可直接強制執行 Java volatile 或 C++ 依序一致的載入和儲存需求。這樣可以避免上述不必要的重新排序限制。ARM 上的 64 位元 Android 程式碼會使用這些指令;我們選擇在這裡著重於 ARMv7 柵欄放置,因為這能更清楚說明實際需求。

其他資訊

提供更深入或更廣泛資訊的網頁和文件。一般來說,越實用的文章越靠近清單頂端。

Shared Memory Consistency Models: A Tutorial
這份文件由 Adve 和 Gharachorloo 於 1995 年撰寫,如果您想深入瞭解記憶體一致性模型,這份文件是不錯的起點。
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
記憶體屏障
這篇簡短的文章總結了相關問題。
https://en.wikipedia.org/wiki/Memory_barrier
Threads 基本概念
Hans Boehm 撰寫的 C++ 和 Java 多執行緒程式設計簡介。討論資料競爭和基本同步處理方法。
http://www.hboehm.info/c++mm/threadsintro.html
Java Concurrency In Practice
這本書於 2006 年出版,詳細涵蓋了廣泛的主題。強烈建議所有在 Java 中編寫多執行緒程式碼的人閱讀。
http://www.javaconcurrencyinpractice.com
JSR-133 (Java 記憶體模型) 常見問題
Java 記憶體模型的簡介,包括同步處理、volatile 變數和最終欄位建構的說明。 (有點過時,特別是討論其他語言時。)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
Java 記憶體模型中程式轉換的有效性
Java 記憶體模型仍存在問題,這篇文章從技術角度進行了詳細說明。這些問題不適用於無資料競爭的程式。
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
套件 java.util.concurrent 總覽
java.util.concurrent 套件的說明文件。頁面底部附近有一個名為「記憶體一致性屬性」的區段,說明各種類別提供的保證。
java.util.concurrent 套件摘要
Java Theory and Practice: Safe Construction Techniques in Java
本文詳細探討在物件建構期間參照逸出的危險,並提供執行緒安全建構函式的指南。
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
Java 理論與實務:管理波動性
這篇文章詳細說明瞭 Java 中 volatile 欄位的功能和限制。
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
「雙重檢查鎖定機制失效」宣告
Bill Pugh 詳細說明在沒有 volatile 或 atomic 的情況下,雙重檢查鎖定機制會以各種方式中斷。包括 C/C++ 和 Java。
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] Barrier Litmus Tests and Cookbook
討論 ARM SMP 問題,並提供簡短的 ARM 程式碼片段。如果您覺得本頁的範例不夠具體,或想閱讀 DMB 指令的正式說明,請參閱這篇文章。此外,也說明瞭在可執行程式碼上用於記憶體屏障的指令 (如果您要即時產生程式碼,這可能很有用)。請注意,這是在 ARMv8 之前,ARMv8 也支援其他記憶體排序指令,並改用更強大的記憶體模型。(詳情請參閱「ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile」)。
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
Linux 核心記憶體屏障
Linux 核心記憶體屏障說明文件。其中包含一些實用範例和 ASCII 藝術。
http://www.kernel.org/doc/Documentation/memory-barriers.txt
ISO/IEC JTC1 SC22 WG21 (C++ 標準) 14882 (C++ 程式設計語言) 第 1.10 節和第 29 條 (「原子作業程式庫」)
C++ atomic 作業功能的草案標準。這個版本接近 C++14 標準,其中包含 C++11 在這個領域的小幅變更。
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(簡介:http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf)
ISO/IEC JTC1 SC22 WG14 (C 標準) 9899 (C 程式設計語言) 第 7.16 章 (「Atomics <stdatomic.h>」)
ISO/IEC 9899-201x C atomic 作業功能的草案標準。如需詳細資訊,請參閱後續的瑕疵報告。
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
C/C++11 處理器對應 (劍橋大學)
Jaroslav Sevcik 和 Peter Sewell 收集的 C++ 原子翻譯,適用於各種常見處理器指令集。
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
Dekker 演算法
「第一個已知正確解決並行程式設計中互斥問題的演算法」。維基百科文章中有完整的演算法,並討論如何更新演算法,以便與現代最佳化編譯器和 SMP 硬體搭配使用。
https://en.wikipedia.org/wiki/Dekker's_algorithm
ARM 與 Alpha 的比較,以及處理依附關係
Catalin Marinas 在 arm-kernel 郵寄清單中發布的電子郵件。包含位址和控制依附元件的完整摘要。
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
What Every Programmer Should Know About Memory
Ulrich Drepper 撰寫的超長詳細文章,介紹不同類型的記憶體,尤其是 CPU 快取。
http://www.akkadia.org/drepper/cpumemory.pdf
Reasoning about the ARM weakly consistent memory model
這份文件由 ARM, Ltd. 的 Chong 和 Ishtiaq 撰寫,旨在以嚴謹但易於理解的方式說明 ARM SMP 記憶體模型。這裡使用的「觀測能力」定義來自這篇論文。同樣地,這是在 ARMv8 之前。
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
編譯器編寫者的 JSR-133 食譜
Doug Lea 撰寫這份文件,做為 JSR-133 (Java 記憶體模型) 說明文件的補充資料。其中包含 Java 記憶體模型的初始實作指南,許多編譯器編寫者都曾使用過,且仍廣泛引用,可能提供深入分析。可惜的是,這裡討論的四種柵欄不適合 Android 支援的架構,而上述 C++11 對應現在是更精確的配方來源,即使是 Java 也是如此。
http://g.oswego.edu/dl/jmm/cookbook.html
x86-TSO:x86 多處理器的嚴謹實用程式設計師模型
x86 記憶體模型的精確說明。不過,ARM 記憶體模型的精確描述相當複雜。
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf