原子類型(Atomics)

Wu Yu Wei published on
39 min, 7621 words

瞭解原子類型(Atomics)與記憶體順序(Memory Ordering)可以幫助我們更瞭解多執行緒程式設計,但實際上要理解的話可能就會和筆者一樣在網路上找隨機的文章或是官方文件來啃,可能就找個當下適合的選項來用,不再繼續往下理解,直到下一次又遇到更深入的問題。這篇文章算是一個總結,希望從最頭到尾說清楚,並且瞭解 Rust 的很多類型爲何那樣設計,以至於能讓所有人都能寫出安全又高效的多執行緒/並行程式。

以下會先介紹多處理器程式設計,再來才會說明各個記憶體順序的意義,最後講解原子類型怎麼使用這些順序的,結束之前會再補充其他具有內部可變性的 Rust 類型爲什麼也是合理的。

多處理器程式設計

當今幾乎所有程式都已經是跑在多核心 CPU 的機器上了,而這也產生了不少奇妙的問題讓我們得常常處理。在只有一個執行緒的程式是沒有什麼好擔心的,但如果是多核心的話很多麻煩就跟着來了,我們知道編譯器在編譯時會幫忙重新排列你的程式來做最佳化,事實上連 CPU 也會做這樣的事。對於編譯器的重新排列,我們可以檢查產生出來的組合語言知道,但如果是多核心的 CPU 的話我們就沒辦法這樣做了。

當執行緒跑在不同的 CPU,CPU 會去重新排列指令,而這樣的行爲是很難去 debug 的,我們往往只能觀察它們的行爲、執行結果、pipeline 和 cache 等等才能知道。而這就是原子類型(Atomics)想要解決的,這些類型會確保在共用記憶體時它們的指令不會被重新排列。

強記憶體排列與弱記憶體排列

接下來會有很多關於 CPU 的引用都會參考這份文件:Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3A。但實際上不同的 CPU 對於記憶體會提供不同的保障,我們通常可以區分爲強記憶體排列(Strong Memory Ordering)和弱記憶體排列(Weak Memory Ordering),但這也只是簡單區隔而已,很多甚至是介於這兩者之間。爲了歸納這些不同,C++ 有一套 Abstraction Machine,而 Rust 也是借鑑於此。這套模型便是希望讓我們有辦法寫出能跑在任何機器上的程式,無論是強記憶體排列或是弱記憶體排列。你會看到 C++ 的 abstraction model 指定了很多存取記憶體的方式,而這幾乎也是必須的,考量到我們得用相同的語法跑在強與弱記憶體排列的處理器上。

強記憶體排列的 CPU 可以給與我們一些非常重要的保障,這些保障可以讓我們的指令縮減到 no-op。No-op 是指 CPU 可以讓整個語義不發生,不會有任何花費與成本的損失。他做的事情其實也沒啥酷炫的。他就只是提醒編譯器,我們寫的程式哪些的地方是不可以讓記憶體重新排列的;而弱記憶體排列的 CPU 就沒那麼好了,我們得親自設置記憶體柵欄(Memory Fence)以及寫些特殊的指令來防止同步問題。當然要完全明白最好的方式就是親自在弱記憶體排列的 CPU 試試看,不過現在的人所使用的 CPU 幾乎都是強記憶體排列了,所以寫的時候可能就觀察不到差別了,下面在解釋時會特別說明兩者之間分別發生了什麼事情。

上面說到現在所有的 CPU 幾乎都是強模型了,無論 AMD 或 Intel 都是,它們能夠對特定的指令給與不重新排列的保障,這些保障我們可以在 Intel 開發者手冊的第 8.2.2 章看到:

  • 讀取不會和其他的讀取重新排列
  • 寫入不會和之前的讀取重新排列
  • 寫入不會和其他的寫入重新排列(有其他例外)

還有最重要的這一條,特別指明不提供保障:

  • 讀取可能會和之前其他位置的寫入重新分配,但不會和之前相同位置的寫入重新分配

這一條要特別注意的原因是因爲和後面的排列選項 SeqCst 有關。先在此注意到就好。接下來的章節會以弱模型爲基準,再補充強模型的說明。

快取記憶體

通常 CPU 有三層快取記憶體:L1、L2 和 L3。L2 和 L3 會在 CPU之間共享。而每個核心會有自己的 L1 快取記憶體,我們所有要解決的問題就是從這裏開始。L1 快取記憶體用的是 MESI 協議,聽起來好像很複雜,其實就只是字面上的意思。他是指快取可以有四種狀態的縮寫(Intel 開發者手冊第 11.4 章有做更詳細的介紹):

已修改 Modified (M) - 快取行是髒的(dirty),要寫回主記憶體
獨占 Exclusive (E) - 快取行只在當前快取中,但是乾淨的(clean)
共享 Shared (S) - 快取行也存在於其它快取中且是乾淨的,快取行可以在任意時刻拋棄
無效 Invalid (I) - 快取行是無效的,其他快取修改過了

處理器之間的通訊

既然我們真的得存取並修改 Shared 記憶體,那麼其他核心是怎麼知道它們的 L1 已經無效了。一個快取行被視爲無效的規則就是有其他核心的 L1 修改過了,這時雖然自己的 L1 還是在 Shared 的狀態,但在那邊的卻已經是 Modified 了。所以記憶體之間得有個通訊的方法來通知大家,我們就暫且想像每個核心都有個信箱吧。

每個信箱都可以存一些訊息,以避免 CPU 一直被傳過來的訊息給中斷,到某個時間點 CPU 會檢查信箱然後依據收到的訊息來更新快取。所以舉個例子的話就是,有個 CPU 修改了某個快取行,傳送了訊息給其他核心。核心檢查信箱收到訊息後,更新他們的快取行。最後 L1 從 主記憶體(或 L2、L3)取得正確的值,讓狀態再次回到 Shared

在強模型的 CPU 則會有點不同。如果有個 CPU 要修改 Shared 的快取行,它得先通知所有的核心讓該行失效,才能真的進行修改。這類的 CPU 通常會有快取一致性來修改個核心的快取。

記憶體順序(Memory Ordering)

現在我們終於對 CPU 的設計以及它們怎麼溝通開始有點概念了。那麼也是時候來看看這些記憶體順序還有它們代表什麼意思了。無論是 C++ 的 std::memory_order 或是 Rust 的 std::sync::atomic::Ordering,都有五個值。接下來在講解時,我們很難真的用程式範例來說明發生什麼事情,不過我們可以想像一個觀察者的 CPU 在一旁觀看發生什麼事情。

Relaxed

在目前的 CPU:

Relaxed 的記憶體順序會防止編譯器重新排列這些指令,但在弱模型的 CPU,它可能還是會重新排列其他所有的記憶體存取。所以如果只是要作爲計數器的話不會有啥問題,但如果是一個要實作在 lock 裡的 flag 的話可能就會出問題了。因爲這樣會無法確保其他一般的記憶體存取有沒有在 flag 之前或之後遭到重新排列。

在觀察者的 CPU:

編譯器和 CPU 可以任意重新排列記憶體順序,但是不能夠變更 Relaxed 讀寫之間的順序。也就是說觀察者的 CPU 可能會看到與我們寫出來的程式順序不同,但是它們永遠會看到 Relaxed 操作 A 一定會發生在 Relaxed 操作 B 之前。所以說 Relaxed 是最弱的記憶體順序,它不會爲其他 CPU 做同步的事情。

在強模型的話,所有的記憶體操作預設都是 Acquire/Release 語義的。因此 Relaxed 只會是用來告訴編譯器這些指令不能被重新排列的。在強模型用這個順序的原因有助於編譯器去重新排列其他記憶體存取。這也是爲啥你用 Relaxed 的效果會和 Acquire/Release 一樣,不過要注意這當然指存在強模型系統下,面對不同的 CPU 平時還是依照歸納模型說明的爲準,不然程式是會在不同 CPU 下出錯的。可以參考這篇文章看看爲何同個程式碼跑在強記憶體排列與弱記憶體排列會不一樣。

Aquire

在目前的 CPU:

任何在 Acquire 存取之後的記憶體操作都會維持在它之後,它是與 Release 順序一對的,組織成一個「記憶體三明治」。所有在它們之間的記憶體存取會與其他 CPU 進行同步。在弱模型的系統下,這代表在 Aquire 操作之前會需要用到一些特殊的 CPU 指令,強迫目前的核心消化完信箱內的所有訊息,很多 CPU 都有這種序列化和記憶體排序的指令。在 Acquire 讀取之前可能還會在加個記憶體柵欄來防止 CPU 重新排列記憶體存取。這樣 Acquire 的操作就會與其他 CPU 確保修改記憶體是同步的。

記憶體柵欄簡單來說就是個硬體的概念,它會強迫 CPU 在柵欄前完成所有的記憶體讀寫,以防止指令重新排列。這樣一來就能確保沒有任何在柵欄前的操作會與柵欄後的操作重新排列。爲了區分不同操作像是讀寫或者全都有,它們都會有不同的名字。全部讀寫都擋的就叫做 full fence。

你可以在[Intel 開發者手冊]的第 8.2,8.3,8.5 章看到它們的介紹,像是 MFENCE 就是會序列化所有讀寫的柵欄。不過在強模型系統中我們通常看不太到,因爲它們沒有需求。在弱模型的化就不一樣了,它們是實作 Acquire/Release 幾乎必備的指令。

在觀察者的 CPU:

因爲 Acquire 是個讀取操作,不會修改記憶體,所以沒有觀察的必要。不過有點要注意的是如果觀察者核心做了 Acquire 的讀取操作時,它就能夠看到所以從 Acquire 發生的記憶體操作,一直到 Release 儲存。這代表有個全域的同步事件正在發生,我們會在後面的 Release 繼續說明。

Acquire 通常用來寫鎖的行爲,讓一些操作可以維持在成功取得鎖之後。所以說 Acquire 只會用在讀取的操作上。在 Rust 的話許多儲存的原子操作要是你用 Acquire 的話會直接 panic。

在強模型系統的話,這個會是 no-op 也就是不會有任何花費,也不會有效能的損失。不過它還會防止編譯器去重新排列那些在 Acquire 之後的記憶體操作被排列到 Acquire 之前。

Release

在目前的 CPU:

相對應於 Acquire,任何在 Release 記憶體順序之前的記憶體操作都會在它之前。它是與 Aquire 順序一對的。在弱模型系統中,編譯器可能會再加個記憶體柵欄來確保 CPU 沒有重新排列 Release 之前的記憶體操作到它之後。這邊還有一項保障是所有其他核心做到 Acquire 時一定要看到所有在 AcquireRelease 之間的記憶體操作。所以不光是所有操作必須在本地端排序正確,還要確保在此時所有的更動都是可以被其他觀察核心看到的。這就代表在 Acquire 開始之前以及 Release 之後都要進行某種全域同步操作。所以我們有兩種選項:

  1. Acquire 讀取必須要消化所有的訊息,如果有任何其他核心讓任和我們要讀得記憶失效的話,它必須要讀到正確的值才行。
  2. Release 儲存必須是原子的並且讓它曾修改的值在其他所有快取都被失效。

只進行其中一個操作就足夠了,所以這比後面要講的 SeqCst 弱一些,但是也比較有效率。

在觀察者的 CPU:

觀察者的 CPU 可能不會看到任何特定順序的變更,除非他有用到 Acquire 讀取記憶。如果有的話,它將會看到 AcquireRelease 的所有記憶體,包含 Release 本身。

Release 通常會和 Acquire 一起用於鎖的實作。對於一個鎖的函式,有些操作通常需要維持在成功獲得鎖之後,一直到鎖被釋放爲止。所以說與 Acquire 相反,在 Rust 許多讀取的原子操作要是用 Release 的話會直接 panic。

在強模型的 CPU,所有的 Shared 值一旦被修改的話,在所有的 L1 快取都會失效。這代表 Acquire 讀取時就會取得更新過的相關記憶體資訊,而 Release 會立即讓在其他核心擁有該資訊的快取行失效。這就是爲何這些語義在這類的系統不會有任何效能花費。

AcqRel

這是用在需要同時讀取並儲存值的操作,像是 AtomicBool::compare_and_swap 就是這種。因爲這樣的操作需要同時讀取並儲存,這在弱模型系統下就會有影響,僅使用 Relaxed 的話是不夠的。我們也可以把這個操作想成是某種柵欄,在它之前的記憶體操作不會被重新排列到這之後,而在之後的操作也不會被排列到之前。剩下做的事情就和上面的 Acquire/Release 一樣了。

SeqCst

在目前的 CPU:

到這邊強與弱模型的行爲就一樣了,SeqCst 的意思是 Sequential Consistency,我想就翻作順序一致性吧。它和 Acquire/Release 擁有同樣地保障,但還保證能建立「唯一的總修改順序」。

雖然被視爲最強保障,但其實 SeqCst 還是有被質疑是不是該作爲最推薦的順序,也有些人反對使用它。 很多時候似乎還是很難有個很好的理由說一定得用 SeqCst,甚至有些論文有指出它可能是有瑕疵的。總之,這邊只是提一下不是所有人的看法都相同,很多時候說不定 Acquire`/`Release 就能涵蓋所有問題了。

無論如何,讓我們繼續介紹 SeqCst 吧!我們會需要看個範例和產生的組合語言,以下是 playground 連結: https://godbolt.org/z/EFK-qU

use std::sync::atomic::{AtomicBool, Ordering};
static X: AtomicBool = AtomicBool::new(true);
static Y: AtomicBool = AtomicBool::new(true);

pub fn example(val: bool) -> bool {
    let x = X.load(Ordering::`Acquire`);
    X.store(val | x, Ordering::`Release`);
    let y = Y.load(Ordering::`Acquire`);
    x || y
}
movb    example::X.0.0(%rip), %al # load(Acquire)
testb   %al, %al
setne   %al
orb     %dil, %al
movb    %al, example::X.0.0(%rip) # store(Release)
movb    $1, %al                   # load(Acquire)
retq

不同CPU可能產生的指令會有點不同,但結果都是一樣的。store 會用到 Release 記憶體順序也就是 movb %al, example::X.0.0(%rip)。我們知道這樣就能確保有該值得快取行會立即失效。

那問題在哪呢?是時候提起之前其中一項不保證的事情了,Intel 開發者手冊的第 8.2.3.4 章有在更加詳述:

8.2.3.4 讀取可能會和更早之前不同位置的寫入重新排列 The Intel-64 記憶體排序模型允許讀取能和之前不同位置的寫入重新排列。但是讀取不會和相同位置的寫入重新排列。

所以也就是說剛剛的範例可能會被改成:

let x = X.load(Ordering::Acquire);
X.store(val | x, Ordering::Release); # 之前不同位置的寫入
let y = Y.load(Ordering::Acquire);   # 讀取

// 可能會被 CPU 變更成

let x = X.load(Ordering::Acquire);
let y = Y.load(Ordering::Acquire);
X.store(val | x, Ordering::Release);

如果我們現在改成 SeqCst 的話就可以看到生成的組合語言變成:

use std::sync::atomic::{AtomicBool, Ordering};
static X: AtomicBool = AtomicBool::new(true);
static Y: AtomicBool = AtomicBool::new(true);

pub fn example(val: bool) -> bool {
    let x = X.load(Ordering::SeqCst);
    X.store(val | x, Ordering::SeqCst);
    let y = Y.load(Ordering::SeqCst);
    x || y
}
movb    example::X.0.0(%rip), %al
testb   %al, %al
setne   %al
orb     %dil, %al
xchgb   %al, example::X.0.0(%rip)
movb    $1, %al
retq

我們可以看到讀取的指令變成了 xchgb %al, example::X.0.0(%rip),這是一個原子的操作,[xchgb 有個 lock 的前綴],lock 相關的指令會確保其他所有核心的快取行存取到的記憶體在獲取時會鎖住,然後在修改時變成無效。除此之外它還可以作爲 full fence,Intel 開發者手冊的第 8.2.3.9 章就如此說道:

8.2.3.9 Lock 指令的讀取和寫入不會被重新排列 記憶體排序模型會阻止 lock 指令的讀取和寫入無論是之前或之後都不會被重新排列。此區塊的範例僅展示 lock 指令在讀取或寫入之前的狀況。讀者請注意到 lock 指令後的讀取或寫入是不會被重新排列的。

在觀察者的 CPU:

對於觀察者來說,這項變更非常地顯著。首先就是順序一致性,如果我們要求一個讀取一定得在某個 flag 釋放之後,理論上我們是可以觀察到 Acquire/Release 語義下,它卻在 Release 操作之前就讀取了。使用 lock 可以防止這樣的情況,所以除了擁有 Acquire/Release 的保證以外,它還確保覺不會有其他記憶體的操作會在之間發生,無論是讀取還是寫入。

再來就是唯一的總修改順序,SeqCst 在弱模型的 CPU 可以提供一些我們在強模型 CPU 就有的保障,最重要的就是這個唯一總修改順序。如果我們有兩個觀察者核心,他們將會在 SeqCst 操作下看到相同的順序,Acquire/Release 則沒辦法提供這項保證。一號觀察者可能會看到兩個變動但順序和二號觀察者不同。假如有個一號核心用 Aquire 拿到了 flag X ,而二號核心也一樣以此方式拿到了 Y。兩者再做相同的操作最後使用 Release 將 flag 改回去。這樣不會阻止一號觀察者先看到 flag X 變回去再來看到 Y 變回去,但二號觀察者卻看到相反地順序。SeqCst 可以防止這發生。在強模型下,甚至連儲存都會馬上被其他核心看到,所以修改的順序在這不會是問題。

**所以說 SeqCst 是最強的記憶體順序,但同時花費也比其他順序高一些。**你可以看到上面的每個原子指令都會有些開銷,因爲這會用到 CPU 的快取一致機制以及鎖定其他快取的記憶體位置等等。

原子操作(Atomic Operation)

除了上述講到的記憶體柵欄,使用 std::sync::atomic 提供的原子類型還可以使用到一些 CPU 重要的指令,這些是我們平常在 Rust 看不到得:

在 [Implementing Scalable Atomic Locks for Multi-Core Intel® EM64T and IA32 Architectures] 一文中有提到:

User level locks involve utilizing the atomic instructions of processor to atomically update a memory space. The atomic instructions involve utilizing a lock prefix on the instruction and having the destination operand assigned to a memory address. The following instructions can run atomically with a lock prefix on current Intel processors: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG...

也就是說當我們使用原子類型的方法,像是 AtomicUsizefetch_add 的話,編譯器實際上會變更原本指令的行爲,在 CPU 除了加兩個數字以外還會做其他事。用組合語言來看的話就會像是 lock addq ... 而不僅僅是我們平常預期的 addq ...

一個原子操作其實是一系列的執行操作組合成一個不可分割的操作單位。任何觀察者是沒辦法看到底下的細部操作或者嘗試在操作執行時獲得相同的資料的。任何在其他核心且會造成的的 B 操作都得等擁有相同資料的操作 A 先完成才行。我們用計數器來舉個例子,總共有三個步驟:讀取資料、修改資料、儲存資料。每一步之間,其他核心都可以跟着採取想要的操作進行讀寫。通常我們希望可以在我們讀取資料一直到儲存回去時,不被其他人干擾到,確定操作正確無誤,這就是原子操作想要解決的。

原子類型常見的用途之一就是 spinlock,而最簡單的範例大概長得像這樣:https://godbolt.org/z/GxS_bp

static LOCKED: AtomicBool = AtomicBool::new(false);
static mut COUNTER: usize = 0;

pub fn spinlock(inc: usize) {
    while LOCKED.compare_and_swap(false, true, Ordering::Acquire) {}
    unsafe { COUNTER += inc };
    LOCKED.store(false, Ordering::Release);
}
xorl    %eax, %eax
lock    cmpxchgb %cl, example::LOCKED(%rip)
jne     .LBB0_1
movb    $0, example::LOCKED(%rip)
retq

lock cmpxchgb %cl, example::LOCKED(%rip) 就是我們的 compare_and_swaplock cmpxchgb 就是我們的 lock 操作,它會讀取 flag 的值然後如果條件符合的話就變更它。我們可以看到Intel 開發者手冊的第 8.2.5 章的說明:

多處理器的同步機制可能會需要依賴強記憶體排列的模型。在這裏一個程式可以用 lock 指令像是 XCHG 指令或是 LOCK 前綴來確保記憶體的讀取、修改、寫入都是原子的。Lock 指令通常就像 I/O 操作一樣,它們等待之前所有的指令完成以及所有緩充區的寫入都寫進記憶體了。

接下來要釐清的就是 lock 指令前綴是在做什麼了。用最簡短的方式說明的話就是當有記憶體讀取時,它就馬上將該快取行設置爲已修改(Modified)。這樣一來從記憶一被讀取到核心的 L1 快取開始就是 Modified 的了。接着就算每個核心都還沒開始處理他們信箱的訊息,處理器就會用[快取一致性機制]讓所有其他核心的狀態都直接變爲無效(Invalid)了。

如果說訊息傳遞是一般的同步方式的話,那 lock 操作以及其他記憶體順序或序列化操作則是包含更昂貴但強大的機制可以繞過訊息傳遞,直接鎖定其他核心的快取行,讓任何讀寫都沒辦法在這過程中實現,然後將它們設爲無效(Invalid),進而引導快取去索取更新的值。

快取行通常在 64 位元的系統都是 64 bytes,不同 CPU 可能還是會有差。不過會特別提起的原因是如果鎖定機制在鎖定時會橫跨兩行快取行的話,花費會變得更昂貴,因爲這會需要再用到一些硬體的技術像是 bus locking 以及更多等等。而且對於會橫跨快取行的原子操作在不同架構擁有支援也大相徑庭。

Rust 的引用型態

到這邊就算是講完整個原子類型了,希望這些說明能幫到想瞭解的人,至少能夠深入去理解整個原子操作的心智模型我覺得非常有利於往後實際的使用。而在 Rust 也是如此,所以差不多也是時候來深入理解一下 Rust 的引用形態了。我想熟悉 Rust 的人讀完上面應該就會發現這和 Rust 的引用 &&mut 有點像,尤其是整個 MESI 協議定義的狀態。當然我們在學的時候,大家是稱 & 爲 不可變引用(Immutable Reference);稱 &mut 爲 不可變引用(Mutable Reference)。書上會說對於那些可以違反 Rust 的型態模型的型態,像是我們本文的重點 Atomics 以及其他諸如 Rc/RefCell 等等,是因爲它們有內部可變性(interior mutability),才能在不可變引用做改變。現在你可以換個角度來看了,& 就是共享引用(Shared Reference),而 &mut 則是獨佔引用(Exclusive Reference)。這剛剛好可以對應到 MESI 的 E 和 S,擁有這樣的模型可以作出其他語言做不到的優化。在 Rust 裡,也確實只有被視爲 Exclusive 記憶體能夠被修改。

這代表只要我們不違反修改 Shared 記憶體的規則的話,所有的 Rust 程式可以將所有核心上的 L1 快取視爲是有更新到最新,而且無需進行同步的。

當然我們是需要在不同核心間共享記憶體的,但是顯式地表達出來,並特別注意的話是可以寫出品質更好地程式碼的。

內部可變性

既然我們可以更改我們觀看得角度,那麼我們也可以一一來解釋哪些具有內部可變性的型態是說的通的了。標準函式庫裡的 UnsafeCell<T> 唯一 可以擁有可變資料的共享引用,這是一個底層的 unsafe 元件平時這個型態我們是不會直接使用的。其他所有具有內部可變性的型態都是以此打造出安全的界面和不同的功能需求,畢竟 Rust 的初衷就一款能寫出安全程式的語言,這些型態本來必須達到這樣的條件。所以除了原子類型以外,標準函式庫內中含有內部可變性的類型有:

Cell<T>

我們可以改變 Cell<T> 的值,就算有其他的引用存在。但這是安全的因爲它的 API 有保障:

  • 不同的執行緒絕對不可能引用同個 Cell<T>,這是因爲 Cell<T> 本來就沒有 Sync trait,也就是說 Cell<T> 只能存在單一執行緒。
  • 沒有辦法取得 Cell<T> 裏面內容的引用,因爲要是能這樣拿到引用的話,就有可能去改變內部的值違反規則了。它所有的存取動作都是將 Cell<T> 裡的資料複製出去。

RefCell<T>

我們可以改變 RefCell<T> 的值,就算有其他的引用存在。但這是安全的因爲它的 API 有保障:

  • RefCell<T> 一樣也只能存在單一執行緒,它和 Cell<T> 一樣都不能在多執行緒間引用。
  • 而在單一執行緒時,動態借用檢查規則會去檢測並防止取得 RefCell<T> 引用並拿到內部資料的人改變其值。

Mutex<T>

我們可以改變 Mutex<T> 的值,就算有其他的引用存在。但這是安全的因爲它的 API 有保障:

  • 只有一個引用可以對內部的 T 進行操作,無論是讀取或寫入。其他的存取都會被擋住直到當前的人將鎖釋放。

RwLock<T>

我們可以改變 RwLock<T> 的值,就算有其他的引用存在。但這是安全的因爲它的 API 有保障:

  • 只有一個引用可以對內部的 T 改變其值,而且必須在沒有任何引用用來讀取的情況下。要是這條件成立的話,其他的存取都會被擋住。也就是同時只能有一個寫入,或者多個讀取。

總結

這篇原本是想寫個簡單概述,不過看着像是 dtolnaycfsamson 的文章,覺得還是都寫下來好了,以上的資訊大部分都來自於此。回過頭來看,我也一樣不覺得一開始在學習時用可變與不可變是錯的,畢竟 Rust 的學習曲線在一開始就算蠻斗的了。不過要是能夠轉換觀看這些類型的心智模型的話,我自己覺得很多事反而更說的通了,不必特地去煩惱哪些型態有內部可變性而哪些沒有,又或者得想很久爲什麼這些 API 得這樣設計。這篇文章的確主軸仍然是原子類型,但我想順便偷渡一下這些觀點,可以發現 Rust 的整個設計模型的確可以直接的對應到底曾的記憶體管理設計。在寫程式時,變能夠特地注意到這些特點,進而寫出品質非常好的程式出來。