前言
對於現在很多程式語言來說,多執行緒已經得到了很好的支援,
以至於我們寫多執行緒程式簡單,但是一旦遇到併發產生的問題就會各種嘗試。
因為不是明白為什麼會產生併發問題,併發問題的根本原因是什麼。
接下來就讓我們來走近一點併發產生的那些問題。
猜猜是多少?
public class ThreadTest_V0
{
public int count = 0;
public void Add1()
{
int index = 0;
while (index++ < 1000000)//100萬次
{
++count;
}
}
public void Add2()
{
int index = 0;
while (index++ < 1000000)//100萬次
{
count++;
}
}
}
結果是多少?
static void V0()
{
ThreadTest_V0 testV0 = new ThreadTest_V0();
Thread th1 = new Thread(testV0.Add1);
Thread th2 = new Thread(testV0.Add2);
th1.Start();
th2.Start();
th1.Join();
th2.Join();
Console.WriteLine($"V0:count = {testV0.count}");
}
答案:100萬 到 200萬之間的隨機數。
為什麼?
接下來我們去深入瞭解一下為什麼會這樣?
一、可見性
首先我們來到 “可見性” 這個陌生的詞彙身邊。
通過一番交談了解到:
對可見性進行一下總結就是我改的東西你能同時看到。
1.1 背景
解讀一下呢,就像下面這樣:
CPU 記憶體 硬碟 ,處理速度上存在很大的差距,為了彌補這種差距,也是為了利用CPU強大計算能力。
CPU 和記憶體之前加入了快取,就是我們經常聽說的 暫存器快取、L1、2、3級快取。
應該的處理流程是這樣的:讀取記憶體資料,快取到CPU快取中,CPU進行計算後,從CPU快取中寫回記憶體。
1.2 執行緒切換
還有一點 我們都知道多執行緒其實是通過切換時間片來達到 “同時” 處理問題的假象。
1.3 單核時代
你也發現了,對於單核來說,程式其實還是序列開發的。
就像是 “一個人” ,東干點,西乾點,如果切換頻率上再快點速度,比我們的眨眼時間還短呢?那……
接下來,我們進入了多核時代。
1.4多核時代
顧名思義,多個CPU,也就是每個CPU核心都有自己的快取體系,但是記憶體只有一份。
比如CPU就是我麼們的本地快取,而記憶體相當於資料庫。
我們每個人的本地快取極有可能是不一樣的,如果我們拿著這些快取直接做一些業務計算,
結果可想而知,多核時代,多執行緒併發也會有這樣的問題 — CPU快取的資料不一樣咋辦?
1.5 volatile
這是CLR 為我們提出的解決方案,就是在遇到可見性引發的併發問題時,使用 volatile 關鍵字。
就是告訴 CPU,我不想用你的快取,所有的請求都直接讀寫記憶體。
一句話,就是禁用快取。
看上去這樣就能解決併發問題了吧?也不全是,還有下面這種槍情況。
二、有序性
字面意義就是有順序,那麼是什麼有順序呢?-- 程式碼
程式碼其實並不是我們所寫的那樣一五一十地執行,以C# 為例:
程式碼 --> IL --> Jit --> cpu 指令
程式碼 通過編譯器的優化生成了IL
CPU也會根據自己的優化重新排列指令順序
至少兩個點會有存在調整 程式碼順序/指令順序的可能。
2.1 猜猜 Debug和Release 執行結果各是多少
public class VolatileTest
{
public int falg = 0;
}
static void VolatileTest()
{
VolatileTest volatiler = new VolatileTest();
new Thread(
p =>
{
Thread.Sleep(1000);
volatiler.falg = 255;
}).Start();
while (true)
{
if (volatiler.falg == 255)
{
break;
}
};
Console.WriteLine("OK");
}
主執行緒一直自旋,直到子執行緒將值改變就退出,顯示 “OK”
Debug 版本,執行結果:
Release 版本,執行結果:
為什麼會這樣,因為我們的程式碼會經過編譯器優化,CPU指令優化,
語句的順序會發生改變,但是這樣也是這種離奇bug產生的一種方式。
怎麼避免它?
2.2 volatile
沒錯,依然是它,不僅僅是禁用cpu快取,而且還能禁止指令和編譯優化。
至少上面的那個例子我們可以再試試:
public class VolatileTest
{
public volatile int falg = 0;
}
到這裡應該就可以了吧,volatile 真好用,一個關鍵字就搞定。
正如你所想,依然沒有結束。
三、原子性
我們平時經常遇到要給一段程式碼區域加上鎖,比如這樣:
lock (lockObj)
{
count++;
}
我麼們為什麼要加鎖呢?你說為了執行緒同步,為什麼加鎖就能保證執行緒同步而不是其他方式?
3.1count++
說到這裡,我們需要再瞭解一個問題:count++
我們經常寫這樣的程式碼,那麼count++ 最終轉換成cpu指令會是什麼樣子呢?
指令1: 從記憶體中讀取 count
指令2:將 count +1
指令3:將新計算的count值,寫回記憶體
我們將這個count++ 操作和執行緒切換進行結合
這裡才是真正解答了最開始為什麼是 100萬到200之間的隨機數。
解決 原子性問題的方法有很多,比如鎖
3.2 lock
加鎖這個程式碼我就暫且忽略,因為lock我們並不陌生。
但是需要明白一點,lock() 是微軟提供給我們的語法糖,其實最終使用的是 Monitor,並且做了異常和資源處理。
CLR 鎖原理
多個執行緒訪問同一個例項下的共享變數,同時將同步塊索引從 -1 改成CLR維護的同步塊陣列,
用完就會將例項的同步快變成-1
3.3 Monitor
上面提到了隱姓埋名的Monitor,其實我們也可以拋頭露面地使用Monitor
這裡也不具體細說。具體使用可以參照上面圖片。
3.4 System.Threading.Interlocked
官方定義:原子性的簡單操作,累加值,改變值等
區區 count++ 使用lock 有點浪費,我們使用更加輕量級的 Interlocked,
為我們的 count ++ 保駕護航。
public class ThreadTest_V3
{
public volatile int count = 0;
public void Add1()
{
int index = 0;
while (index++ < 1000000)//100萬次
{
Interlocked.Add(ref count, 1);
}
}
public void Add2()
{
int index = 0;
while (index++ < 1000000)//100萬次
{
Interlocked.Add(ref count, 1);
}
}
}
結果不多說,依然穩穩的 200萬。
3.5 System.Threading.SpinLock結構
自旋鎖結構,可以這樣理解。
多執行緒訪問共享資源時,只有一個執行緒可以拿到鎖,其他執行緒都在原地等待,
直到這個鎖被釋放,原地等待的資源又一次進行搶佔,以此類推。
在具體使用 System.Threading.SpinLock結構 之前,我們根據剛剛講過的 System.Threading.Interlocked,進行一下改造:
public struct Spin
{
private int m_lock;//0=unlock ,1=lock
public void Enter()
{
while (System.Threading.Interlocked.Exchange(ref m_lock, 1) != 0)
{
//可以限制自旋次數和時間,自動斷開退出
}
}
public void Exit()
{
System.Threading.Interlocked.Exchange(ref m_lock, 0);
}
}
public class ThreadTest_V4
{
private Spin spin = new Spin();
public volatile int count = 0;
public void Add1()
{
int index = 0;
while (index++ < 1000000)//100萬次
{
spin.Enter();
count++;
spin.Exit();
}
}
public void Add2()
{
int index = 0;
while (index++ < 1000000)//100萬次
{
spin.Enter();
count++;
spin.Exit();
}
}
}
Enter() , m_lock 從0到1,就是加鎖;
鎖的是共享資源 count;
其他執行緒原地自旋等待(迴圈)
Exit(),m_lock 從1到0,就是解鎖;
System.Threading.SpinLock 結構和以上實現思想類似。
後面的內容就簡單提一下定義和應用場景,有必要的就可以單獨細查。
3.6 System.Threading.SpinWait結構
提供了基於自旋等待支援。
線上程必須等待發出事件訊號或滿足條件時方可使用.
3.7 System.Threading.ReaderWriterLockSlim類
授予獨佔訪問共享資源的寫作,
並允許多個執行緒同時訪問資源進行讀取。
3.8 CAS
cas 核心思想:
將 count 從記憶體讀取出來並賦值給一個區域性變數,叫做 originalData;
然後這個區域性變數 +1 並賦值給新值,叫做 newData;
再次從記憶體中將count讀取出來,如果originalData ==count,
說明沒有執行緒修改記憶體中count值,可以將新值儲存到記憶體中。
反之則可以選擇自旋或者其他策略。
當然還有程式之間的同步,這裡就不一一展開說了。
總結一下:
併發三要素 可見性、有序性、原子性
幾種鎖原理和CAS操作