併發編程重要知識點:內存模型

大家好,我是程序喵,最近事情太多,好久才能肝出一篇文章,大家見諒。

這篇文章簡單介紹下併發編程中一個重要的知識點:內存模型。

直接看這段代碼:

#include <iostream>
#include <thread>
int x = 0;
int y = 0;
void func1() {  
  x = 100;  
  y = 2;
}
void func2() {  
  while (y == 2) {    
    std::cout << x << std::endl;    
    break;  
  }
}
int main() {  
  std::thread t1(func1);  
  std::thread t2(func2);  
  t1.join();  
  t2.join();
}

大家猜一猜這段代碼會輸出什麼?100 嗎?絕大數時候會是 100,但是也有極小概率會是 0,理論上有輸出 0 的可能。

這裏涉及到內存序(memory order)的知識點,在這之前,需要先了解下什麼是改動序列。

在一個 C++ 程序中,每個對象都具有一個改動隊列,它由所有線程在對象上的全部寫操作構成,變量的值會隨着時間推移形成一個序列,不同線程觀察同一個變量的序列,正常情況下是一致的,如果出現不一致,就說明出現了數據競爭線程不安全的問題。

看圖,隨着時間的推移,一個變量可能做了圖中的改動,產生了一個改動序列,即(1,2,3,4,5,6,7,8,9,10),然而理論上來說,不同線程不能保證他們看見的是最新的值,比如同一時刻,線程 a 可能看見的是 5,線程 b 可能看見的是 4,線程 c 可能看見的是 3,d 是 4,e 是 2。然後過了一段時間,可能變成了 a(10),b(4),c(8),d(6),e(3)。

每個線程看到的只會是序列中上一次看到的之後的值,不可能是之前的,時光不能倒流。

同理,如圖:

如果有兩個變量,它們的改動序列如圖,然而同一時刻,理論上可能不同線程看到的值不同。

再回到上面那段代碼:

#include <iostream>
#include <thread>
int x = 0;
int y = 0;
void func1() {
  x = 100;
  y = 2;
}
void func2() {
  while (y == 2) {
    std::cout << x << std::endl;
    break;
  }
}
int main() {
  std::thread t1(func1);
  std::thread t2(func2);
  t1.join();
  t2.join();
}

t1 和 t2 線程看到的 x 和 y 值可能有(0,0)(0,2)(100,0)(100,2),所以上面的代碼運行時,正常會輸出 100,但是也有可能會輸出 0。

你可能會說,可得了吧,我測試了幾十萬次,輸出的都是 100,從沒出現過 0。是的,大概率都是 100,這種現象只有理論上會出現,而且估計意外只會在內存序相對寬鬆的 Arm 機器上會出現,正常的 X86 應該不會出現這種問題。但我們寫代碼還是要往標準了寫。

爲什麼會出現這種現象?

因爲編譯器有指令亂序的優化。

還是上面那段代碼:

int x = 0;
int y = 0;
void func() {
  x = 100;
  y = 2;
}

函數 func() 中,按順序來看可能是先執行 x = 100 再執行 y = 2,但實際情況可能不同,有可能編譯器會做一些指令重排序的優化,真正優化後的結果可能會是 y = 2,再 x = 100,重排序後再運行,結果和順序執行完全相同。

(你可能會問,爲什麼要做這種優化,先執行誰後執行誰都需要執行,有意義嗎?文中我只是舉一個比較簡單的例子,可能這裏沒有意義,但遇到真正複雜的代碼時指令重排序還是很有效的優化策略,其實如果你學過計算機體系結構就會知道,這種沒有任何依賴關係的指令是可以做並行優化的,具體是什麼術語我也記不起來了,好像是 SIMD。)

編譯器只會保證單線程環境下,優化執行的最終結果是一致的,所以這種優化就會導致多線程情況下的數據衝突問題,比如上面的代碼:

void func1() {
  x = 100;
  y = 2;
}
void func2() {
  while (y == 2) {
    std::cout << x << std::endl;
    break;
  }
}
int main() {
  std::thread t1(func1);
  std::thread t2(func2);
  t1.join();
  t2.join();
}

由於執行重排序的原因,無法保證另一個線程在執行 func2 的時候,x 和 y 的賦值順序,所以上面 x 的輸出,有可能是 0,也有可能是 100。

這也就是爲什麼會出現上面介紹的改動序列的原因。

那怎麼解決這種問題?

肯定是要在某些情況下,禁止這種指令重排序。

可以引入原子操作,我們可以把上面的 x 和 y 的定義改爲:

std::atomic<int> x = 0;
std::atomic<int> y = 0;

結果自然而然就會變得正常。

爲什麼?

因爲 C++ 的 atomic 不僅僅是原子操作,它很重要的一點是可以禁止這種指令重排序。

我們平時使用 atomic 可能都是這樣使用:

int value = x.load();
x.store(100);
但其實atomic的多數函數都是重載函數,它可以配置一些參數,這些參數就是內存序的類型參數:
x.store(100, std::memory_order_relaxed);

C++ 裏關於一共引入了 6 種內存序的類型:

儘管有 6 種內存序,但其實可簡單劃分爲 3 種模式

所以,我們看下這段使用 relaxed 模型的代碼會不會觸發 assert:

#include <assert.h>
#include <atomic>
#include <thread>
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x_then_y() {  
  x.store(true, std::memory_order_relaxed);  
  y.store(true, std::memory_order_relaxed);
 }
 void read_y_then_x() {  
   while (!y.load(std::memory_order_relaxed))    
     ;   
   if (x.load(std::memory_order_relaxed))    
     ++z;
  }
  int main() {  
    x = false;  
    y = false;  
    z = 0;  
    std::thread a(write_x_then_y);  
    std::thread b(read_y_then_x);  
    a.join();  
    b.join();  
    assert(z.load() != 0);
   }

再看下使用 acquire-release 模型的代碼會不會觸發 assert:

#include <assert.h>
#include <atomic>
#include <thread>
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x_then_y() {
  x.store(true, std::memory_order_relaxed);
  y.store(true, std::memory_order_release);
}
void read_y_then_x() {
  while (!y.load(std::memory_order_acquire))
    ;
  if (x.load(std::memory_order_relaxed)) ++z;
}
int main() {
  x = false;
  y = false;
  z = 0;
  std::thread a(write_x_then_y);
  std::thread b(read_y_then_x);
  a.join();
  b.join();
  assert(z.load() != 0);
}

使用先後一致次序模型的代碼這裏就不過多介紹了,atomic 的默認次序,肯定沒問題的。

所以在我們平時開發過程中,普通開發者不用管那麼多,使用默認的 atomic 次序就行,資深程序員可以自由選用,充分利用更加細分的次序關係來提升性能,比如寫一個高性能的無鎖隊列。

一般使用默認的 atomic 足以,我估計大多數人寫的代碼,性能瓶頸一般都在業務邏輯上,而不是這種內存模型上。

這裏還有個 memory fence 的概念,大體作用和上面介紹的類似,感興趣的可以自己瞭解一下哈。

寫到這裏,推薦大家看看這段無鎖隊列的代碼 https://github.com/taskflow/taskflow/blob/master/taskflow/core/tsq.hpp ,有助於理解 C++ 的內存模型。

參考資料:

  • https://en.cppreference.com/w/cpp/atomic/memory_order

  • https://mp.weixin.qq.com/s/t5_Up2YZEZt1NLbvgYz9FQ

  • 《C++ 併發編程第二版》

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/vFU8pfOYSEEw7u_sKCGjBQ