🚫 Ad Blocker Detected

Please disable your AD blocker to continue using this site. Ads help us keep the content free! please press keyboard F5 to refresh page after disabled AD blocker

請關閉廣告攔截器以繼續使用本網站。廣告有助於我們保證內容免費。謝謝! 關閉後請按 F5 刷新頁面

0%

多執行緒系統中不得不知-volatile

前言

假如你寫過多執行緒系統一定會看過volatile,但你對他的了解有多少?

MSDN對於volatile關鍵字解釋如下.

volatile 關鍵字指出某個欄位可能是由同時執行的多個執行緒所修改。 編譯器、執行階段系統,甚至硬體都有可能基於效能因素,而重新排列對記憶體位置的讀取和寫入。 宣告為 volatile 的欄位不受限於這些最佳化考量。 加入 volatile 修飾詞可確保所有的執行緒都會依執行寫入的順序,觀察任何其他執行緒所執行的暫時性寫入。

MSDN上寫一堆文謅謅的解釋,如果沒有相對應OS或底層概念會很難理解上面敘述

volatile 三大特性

這裡我先總結volatile三大特性

  1. volatile修飾的變數具有可見性
  2. volatile避免指令優化重排
  3. volatile不保證Atomic

本文會針對這三大特性來一一解釋

注意在執行本文程式碼時要把Build Mode改成Release.

volatile令變數具有可見性 & volatile避免指令優化重排

下面有段程式碼.

有一個member物件初始數值Balance=100,建立一個Thread裡面會把member物件的餘額成0

在Main Thread中while (member.balance > 0)有一段程式會等待member.balance=0跳出迴圈.

預期在程式最後印出執行結束!

但如果您使用Release Mode來跑會發現

最後一行執行結束!不會如預期印出來..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Program
{
static void Main(string[] args)
{

Member member = new Member();
new Thread(()=>{

System.Console.WriteLine($"Sleep 前~ 餘額剩下:{member.balance}");
member.UpdateBalance();
System.Console.WriteLine($"Sleep 結束! 餘額剩下:{member.balance}");
}).Start();

while (member.balance > 0)
{
//等待sub thread把balance改成0跳出迴圈
}

Thread.Sleep(50);
Console.WriteLine("執行結束!");
Console.ReadKey();
}
}

public class Member
{
public int balance = 100;
public void UpdateBalance()
{
balance = 0;
}
}

解釋volatile可見性 & 指令優化重排

每個Thread對於變數操作,會先把Memory記憶體中的變數Copy一份到記憶體中並執行操作,操作完畢再重新寫入Memory中.

概念大致如下圖

所以這就會導致一個問題,假如ThreadA對於變數做異動,但ThreadB不會被通知

一般來說Release Mode,會把程式語言優化(包含一些CPU指令),所以在multiple-Thread 系統中可能就會遇到一些不預期問題(如此範例)

所以依照上面特性我來總結解釋一下

  • 一開始Main Thread跟sub Thread在取得Balance都是100
  • sub Thread更新餘額成為0,但Main Thread沒有更新(還是100)
  • Main Thread進入無限迴圈導致出不來.

使用 volatile 解決問題

解決上面問題我們只需要在balance上加一個volatile就好!

1
2
3
4
5
6
7
8
9
public class Member
{
public volatile int balance = 100;
public void UpdateBalance()
{
// sub thread update balance to 0
balance = 0;
}
}

執行結果如上,能看到程式可以正常結束了

還記得我一開始的總結前兩條嗎

  1. volatile修飾的變數具有可見性
  2. volatile避免指令優化重排

使用volatile會告訴編譯器別嘗試優化,使用此變數程式碼,無論是讀取還是寫入,都在主記憶體操作。

所以當呼叫UpdateBalance(balance = 0;) 方法時此異動在Main Thread就會看到Memory balance = 0所以就跳出迴圈.

volatile 不保證Atomic

雖然volatile讓變數具有可見性,但不保證Atomic(原子性),這是甚麼意思?

我一樣用下圖來解釋來解釋.

在每個Thread要異動變數都會將數值Copy進Thread中進行修改在異動,就算目前有可見性,但我們不能保證修改指令具有原子性

所以可能造成兩個Thread剛好對於一個數值或物件異動造成Data Racing.

如果要解決此問題可以參閱 高併發系統系列-使用lock & Interlocked CAS(compare and swap)

下面的範例來演示我說的問題

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Program
{
static void Main(string[] args)
{

NoAtomicMember m = new NoAtomicMember();
List<Task> tasks = new List<Task>();

for (var i = 0; i < 10; i++)
{
tasks.Add(Task.Run(()=>{
for (var i = 0; i < 10000; i++)
{
m.AddBalance();
}
}));
}
Task.WaitAll(tasks.ToArray());

System.Console.WriteLine(m.balance);
Console.ReadKey();
}
}
public class NoAtomicMember{
public volatile int balance = 0;
public void AddBalance(){
balance+=10;
}
}

我這個例子使用10個Task來模擬高併發動作,對於同一個數值做新增餘額10000次

理論上我們預期Balance要是10w,但每次執行的結果都不是10w且數值都不一樣,這個問題在正式環境很嚴重.

我們可以使用lock來避免同一時間會有多個Thread對於同一個物件修改

1
2
3
4
5
6
7
8
9
public class NoAtomicMember{
public int balance = 0;
object _sync = new object();
public void AddBalance(){
lock(_sync){
balance+=10;
}
}
}

修改後結果如下

區域變數或參考型別使用volatile

如果是區域變數或參考型別volatile關鍵字就無法使用,這時候我們可以使用下面兩個method來替代使用.

  • Thread.VolatileRead
  • Thread.VolatileWrite

下面是VolatileRead,VolatileWrite原始碼

能發現在裡面都有呼叫MemoryBarrier方法.

MemoryBarrier保證我們程式可見性,概念跟volatile一樣清除cache直接讀取主要Memory資料.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static Object VolatileRead(ref Object address)
{
Object ret = address;
//呼叫組語load 禁止指令重排 從Memory拿到最新資料
MemoryBarrier(); // Call MemoryBarrier to ensure the proper semantic in a portable way.
return ret;
}

public static void VolatileWrite(ref Object address, Object value)
{
//呼叫組語store 禁止指令重排
MemoryBarrier(); // Call MemoryBarrier to ensure the proper semantic in a portable way.
address = value;
}

MSDN Thread.MemoryBarrier其中有段說明

同步處理記憶體存取,如下所示:執行目前執行緒的處理器無法以下列方式重新排列指示

IL中使用volatile差異

我們知道C#是透過CLR來運行IL中繼語言,我們可以透過ILSpy來查看上面使用volatile和沒使用volatile差異

沒使用volatile

Main function:

Member Class:

使用volatile

Main function:

Member Class:

上面IL能發現有使用volatile差異在一個指令OpCodes.Volatile

指定目前在評估Heap頂端的位址可能是volatile,並且無法快取讀取該位置的結果,或者無法隱藏存放該位置的多個存放區。

上面是MSDN解釋 主要是說使用volatile在Thread中就無法cache該變數資料每次讀取都必須回Memory中讀.

小結

在多執行緒系統中我建議常異動的變數要使用volatile來保證每個Thread讀,寫資料是正確

發現在我們常用的Console類別,運用許多volatile來達到Thread互相資料可見性.

但也要注意volatile不保證Atomic,所以如果有Atomic需求記得要使用CAS或Lock來處理.

另外volatile也不是萬靈丹,既然可以提高可見性想必對於系統會有多一些負擔,所以還是要看情況來使用.

__此文作者__:Daniel Shih(石頭)
__此文地址__: https://isdaniel.github.io/volatile-introduce/
__版權聲明__:本博客所有文章除特別聲明外,均採用 CC BY-NC-SA 3.0 TW 許可協議。轉載請註明出處!

如果本文對您幫助很大,可街口支付斗內鼓勵石頭^^