Rust 併發編程 - Memory Ordering

在併發編程領域,Rust 提供了完善的機制來保證併發編程的安全,我們可以非常方便的使用 Mutex,Arc,Channel 等庫來處理我們的併發邏輯。 但在有些時候,爲了更高效的性能,我們可能會去寫一些 lock-free 的數據結構,而 Rust 自身也提供了 atomic 的支持。

對於每個 atomic 操作,都需要顯示的指定 Ordering,Rust 提供了 Relaxed,Release,Acquire,AcqRel,以及 SeqCst 這些 Ordering 的支持,使用不同的 Ordering 會讓編譯器或者 CPU 對某些指令進行重新排序執行,所以爲了更正確的寫出 lock-free 的代碼,瞭解這些 Ordering 是如何工作的,就顯得非常重要了。

Keywords

在開始介紹 Rust 的 memory ordering 之前,我們需要知道兩個常用的用來描述atomic 操作之間關係的概念,synchronizes-with 和 happens-before:

Synchronizes-with - 簡單來說,兩個線程 A 和 B,以及一個支持原子操作的變量 x,如果 A 線程對 x 寫入了一個新的值(store),而 B 線程在 x 上面讀取到了這個新的值(load),我們就可以認爲,A 的 store 就是 synchronizes-with B 的 load 的。

Happens-before - 這應該算是一個更基礎的概念。如果一個操作 B 能看到之前操作 A 產生的結果,那麼 A 就是 happens-before B 的。譬如在單線程裏面,如果一個操作 A 的語句在操作 B 的前面執行,通常叫做 sequenced-before,那麼 A 就是 happens-before B 的,譬如這樣:

vec.push(1);  // A
ready = true; // B

而對於跨線程(inter-thread) 的情況,要判斷 happens-before,就需要藉助於前面提到的 synchronizes-with 了。如果操作 A 是 synchronizes-with 另一個線程的操作 B 的,那麼 A 就是 happens-before B 的。Happens-before 也具有傳遞性,如果 B 是 happens-before C 的,那麼 A 也是 happens-before C。

如果 A 是 sequenced-before B,而 B 是 inter-thread happens-before C 的,那麼 A 也是 inter-thread happens-before C。同理,如果 A inter-thread happens-before B,而 B sequenced-before C,那麼 A 也是 inter-thread happens-before C。

可以看到,要寫出正確的 atomic 代碼,尤其是在多線程環境下面,關鍵就是要弄清楚兩個 atomic 操作的 syncrhonizes-with 關係。而這個不同的 Ordering 是不一樣的。

Relaxed ordering

Relaxed ordering 只能保證原子操作,在同一個線程裏面,對同一個變量的操作會滿足 happens-before 關係,但對於 inter-thread 來說,它不能提供 synchronizes-with 支持,並不保證任何順序。

下面是一個簡單的例子:

fn write_x_then_y() {
    X.store(true, Ordering::Relaxed);
    Y.store(true, Ordering::Relaxed);
}

fn read_y_then_x() {
    while !Y.load(Ordering::Relaxed) {}
    if X.load(Ordering::Relaxed) {
        Z.fetch_add(1, Ordering::SeqCst);
    }
}

fn main() {
    let t1 = thread::spawn(move || {
        write_x_then_y();
    });

    let t2 = thread::spawn(move || {
        read_y_then_x();
    });

    t1.join().unwrap();
    t2.join().unwrap();

    assert_ne!(Z.load(Ordering::SeqCst), 0);
}

上面 assert 可能會失敗,也就是 Z 的值在最後可能爲 1。在函數 write_x_then_y 裏面即使 store X happens-before store Y,即使在 read_y_then_x 裏面 load Y 返回了 true,X 的值仍然可能是 false。因爲對 X 和 Y 的兩個操作都是 relaxed 的,雖然對於不同的線程,兩個 load 或者兩個 store 都可能滿足 happens-before,但在 store 和 load 之間,並沒有相關的約束,也就是意味着 load 可能看到亂序的 store。

通常來說,relaxed 適用的場景就是需要對某個變量進行原子操作,而且不需要考慮多個線程同步的情況,譬如 reference counter,其它時候需要考慮有更強約束的其他 ordering。

Acquire-Release ordering

Acquire 和 Release 通常都是需要成對使用的,當對 store 使用 Release ordering 之後,後續任何的 Acquire ordering 的 load 操作,都會看到之前 store 的值。也就是說,通過 Acquire-Release,我們能支持 synchronizes-with。

fn write_x_then_y() {
    X.store(true, Ordering::Relaxed);
    Y.store(true, Ordering::Release);
}

fn read_y_then_x() {
    while !Y.load(Ordering::Acquire) {}
    if X.load(Ordering::Relaxed) {
        Z.fetch_add(1, Ordering::SeqCst);
    }
}

fn main() {
    let t1 = thread::spawn(move || {
        write_x_then_y();
    });

    let t2 = thread::spawn(move || {
        read_y_then_x();
    });

    t1.join().unwrap();
    t2.join().unwrap();

    assert_ne!(Z.load(Ordering::SeqCst), 0);
}

不同於之前的 relaxed,這裏我們對 Y 使用了 Acquire 和 Release,那麼最後 Z 就一定不可能爲 0 了。主要是因爲 store Y 是 synchronizes-with load Y 的,也就是 store Y happens before load Y,因爲 store X 是 sequenced-before store Y,那麼 store X 就是 happens-before load X 的。

通常,我們還可以使用 AcqRel ordering,它其實就是組合了 Acquire 和 Release,對於 load 使用 Acquire,而對於 store 則是使用 Release。

Sequence ordering

Sequence ordering 不光提供了 Acquire,Release 的 ordering 支持,同時也確保所有線程會看到完全一致的原子操作順序。

fn write_x() {
    X.store(true, Ordering::SeqCst);    // 1
}

fn write_y() {
    Y.store(true, Ordering::SeqCst);    // 2
}

fn read_x_then_y() {
    while !X.load(Ordering::SeqCst) {}
    if Y.load(Ordering::SeqCst) {       // 3
        Z.fetch_add(1, Ordering::SeqCst);  
    }
}

fn read_y_then_x() {
    while !Y.load(Ordering::SeqCst) {}
    if X.load(Ordering::SeqCst) {       // 4
        Z.fetch_add(1, Ordering::SeqCst); 
    }
}

fn main() {
        let t1 = thread::spawn(move || {
        write_x();
    });

    let t2 = thread::spawn(move || {
        write_y();
    });

    let t3 = thread::spawn(move || {
        read_x_then_y();
    });

    let t4 = thread::spawn(move || {
        read_y_then_x();
    });

    t1.join().unwrap();
    t2.join().unwrap();
    t3.join().unwrap();
    t4.join().unwrap();

    assert_ne!(Z.load(Ordering::SeqCst), 0);
}

上面的例子,只有使用 SeqCst ordering,才能保證 Z 最後的值不爲 0,任何其他的 ordering,都不能保證,我們來具體分析一下。因爲兩個 read 函數都是有 while 循環,退出之前一定能確保 write 函數被調用了。因爲使用 SeqCst 能保證所有線程看到一致的操作順序,假設 3 返回了 false,表明 X 爲 true,而 Y 爲 false,這時候一定能保證 store Y 還沒調用,一定能保證 store X 在 store Y 之前發生,4 就一定會返回 true。

如果這裏我們對 load 使用 Acquire,而對 store 使用 Release,read_x_then_yread_y_then_x 可能看到完全相反的對 X 和 Y 的操作順序。

SeqCst 在有些時候,可能會有性能瓶頸,因爲它需要確保操作在所有線程之前全局同步,但是它其實又是最直觀的一種使用方式, 所以通常,當我們不知道用什麼 ordering 的時候,用 SeqCst 就對了。

Memory fence

出了使用不同的 ordering,我們還可以使用 memory fence 來支持 synchronizes-with,如下:

fn write_x_then_y() {
    X.store(true, Ordering::Relaxed); // 1
    fence(Ordering::Release);         // 2
    Y.store(true, Ordering::Relaxed); // 3   
}

fn read_y_then_x() {
    while !Y.load(Ordering::Relaxed) {}  // 4
    fence(Ordering::Acquire);            // 5
    if X.load(Ordering::Relaxed) {       // 6
        Z.fetch_add(1, Ordering::SeqCst);
    }
}

fn main() {
    let t1 = thread::spawn(move || {
        write_x_then_y();
    });

    let t2 = thread::spawn(move || {
        read_y_then_x();
    });

    t1.join().unwrap();
    t2.join().unwrap();

    assert_ne!(Z.load(Ordering::SeqCst), 0);
}

在上面的例子中,2 Release fence 是 synchronizes-with 5 Acquire fence 的,而4 load Y 的時候一定會讀取到 3 store Y 的值,加上 1 store X 是 sequenced-before 3 的,那麼自然能確定 1 是 happens-before 6 的。也就是 Z 一定不會等於 0。

Epilogue

可以看到,要弄清楚 memory ordering,其實並不是一件容易的事情,不過多數時候,爲了不出錯,使用 SeqCst 就成。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章