【前言】
在日常開發作業中,我們經常要對變數進行操作,例如對一個int變數遞增++,在單執行緒環境下是沒有問題的,但是如果一個變數被多個執行緒操作,那就有可能出現結果和預期不一致的問題,
例如:
static void Main(string[] args)
{
var j = 0;
for (int i = 0; i < 100; i++)
{
j++;
}
Console.WriteLine(j);
//100
}
在單執行緒情況下執行,結果一定為100,那么在多執行緒情況下呢?
static void Main(string[] args)
{
var j = 0;
var t1 = Task.Run(() =>
{
for (int i = 0; i < 50000; i++)
{
j++;
}
});
var t2 = Task.Run(() =>
{
for (int i = 0; i < 50000; i++)
{
j++;
}
});
Task.WaitAll(t1, t2);
Console.WriteLine(j);
//82869 這個結果是隨機的,和每個執行緒執行情況有關
}
我們可以看到,多執行緒情況下并不能保證執行正確,我們也將這種情況稱為 “非執行緒安全”
這種情況下我們可以通過加鎖來達到執行緒安全的目的
static void Main(string[] args)
{
var locker = new object();
var j = 0;
var t1 = Task.Run(() =>
{
for (int i = 0; i < 50000; i++)
{
lock (locker)
{
j++;
}
}
});
var t2 = Task.Run(() =>
{
for (int i = 0; i < 50000; i++)
{
lock (locker)
{
j++;
}
}
});
Task.WaitAll(t1, t2);
Console.WriteLine(j);
//100000 這里是一定的
}
加鎖的確能解決上述問題,那么有沒有一種更加輕量級,更加簡潔的寫法呢?
那么,今天我們就來認識一下 Interlocked 類
【Interlocked 類下的方法】
Increment(ref int location)
Increment 方法可以輕松實作執行緒安全的變數自增
/// <summary>
/// thread safe increament
/// </summary>
public static void Increament()
{
var j = 0;
Task.WaitAll(
Enumerable.Range(0, 50)
.Select(t =>
Task.Run(() =>
{
for (int i = 0; i < 2000; i++)
{
Interlocked.Increment(ref j);
}
}
))
.ToArray()
);
Console.WriteLine($"multi thread increament result={j}");
//result=100000
}
看到這里,我們一定好奇這個方法底層是怎么實作的?
我們通過ILSpy反編譯查看原始碼:
首先看到 Increment
方法其實是通過呼叫 Add
方法來實作自增的
再往下看,Add
方法是通過 ExchangeAdd
方法來實作原子性的自增,因為該方法回傳值是增加前的原值,因此回傳時增加了本次新增的,結果便是相加的結果,當然 location1
變數已經遞增成功了,這里只是為了友好地回傳增加后的結果,
我們再往下看
這個方法用 [MethodImpl(MethodImplOptions.InternalCall)]
修飾,表明這里呼叫的是 CLR 內部代碼,我們只能通過查看原始碼來繼續學習,
我們打開 dotnetcore 原始碼:https://github.com/dotnet/corefx
找到 Interlocked
中的 ExchangeAdd
方法
可以看到,該方法用回圈不斷自旋賦值并檢查是否賦值成功(CompareExchange回傳的是修改前的值,如果回傳結果和修改前結果是一致,則說明修改成功)
我們繼續看內部實作
內部呼叫 InterlockedCompareExchange
函式,再往下就是直接呼叫的C++原始碼了
在這里將變數添加 volatile
修飾符,阻止暫存器快取變數值(關于volatile不在此贅述),然后直接呼叫了C++底層內部函式 __sync_val_compare_and_swap
實作原子性的比較交換操作,這里直接用的是 CPU 指令進行原子性操作,性能非常高,
相同機制函式
和 Increment
函式機制類似,Interlocked
類下的大部分方法都是通過 CompareExchange
底層函式來操作的,因此這里不再贅述
- Add 添加值
- CompareExchange 比較交換
- Decrement 自減
- Exchange 交換
- And 按位與
- Or 按位或
- Read 讀64位數值
public static long Read(ref long location)
Read 這個函式著重提一下
可以看到這個函式沒有 32 位(int)型別的多載,為什么要單獨為 64 位的 long/ulong 型別單獨提供原子性讀取運算子呢?
這是因為CPU有 32 位處理器和 64 位處理器,在 64 位處理器上,暫存器一次處理的資料寬度是 64 位,因此在 64 位處理器和 64 位作業系統上運行的程式,可以一次性讀取 64 位數值,
但是在 32 位處理器和 32 位作業系統情況下,long/ulong 這種數值,則要分成兩步操作來進行,分別讀取 32 位資料后,再合并在一起,那顯然就會出現多執行緒情況下的并發問題,
因此這里提供了原子性的方法來應對這種情況,
這里底層同樣用了 CompareExchange
操作來保證原子性,引數這里就給了兩個0,可以兼容如果原值是 0 則寫入 0 ,如果原值非 0 則不寫入,回傳原值,
__sync_val_compare_and_swap 函式
在寫入新值之前, 讀出舊值, 當且僅當舊值與存盤中的當前值一致時,才把新值寫入存盤
【關于性能】
多執行緒下實作原子性操作方式有很多種,我們一定會關心在不同場景下,不同方法間的性能問題,那么我們簡單來對比下 Interlocked
類提供的方法和 lock
關鍵字的性能對比
我們同樣用執行緒池調度50個Task(內部可能執行緒重用),分別執行 200000 次自增運算
public static void IncreamentPerformance()
{
//lock method
var locker = new object();
var stopwatch = new Stopwatch();
stopwatch.Start();
var j1 = 0;
Task.WaitAll(
Enumerable.Range(0, 50)
.Select(t =>
Task.Run(() =>
{
for (int i = 0; i < 200000; i++)
{
lock (locker)
{
j1++;
}
}
}
))
.ToArray()
);
Console.WriteLine($"Monitor lock,result={j1},elapsed={stopwatch.ElapsedMilliseconds}");
stopwatch.Restart();
//Increment method
var j2 = 0;
Task.WaitAll(
Enumerable.Range(0, 50)
.Select(t =>
Task.Run(() =>
{
for (int i = 0; i < 200000; i++)
{
Interlocked.Increment(ref j2);
}
}
))
.ToArray()
);
stopwatch.Stop();
Console.WriteLine($"Interlocked.Increment,result={j2},elapsed={stopwatch.ElapsedMilliseconds}");
}
運算結果
可以看到,采用 Interlocked
類中的自增函式,性能比 lock
方式要好一些
雖然這里看起來性能要好,但是不同的業務場景要針對性思考,采用恰當的編碼方式,不要一味追求性能
我們簡單分析下造成執行時間差異的原因
我們都知道,使用lock(底層是Monitor類),在上述代碼中會阻塞執行緒執行,保證同一時刻只能有一個執行緒執行 j1++
操作,因此能保證操作的原子性,那么在多核CPU下,也只能有一個CPU核心在執行這段邏輯,其他核心都會等待或執行其他事件,執行緒阻塞后,并不會一直在這里傻等,而是由作業系統調度執行其他任務,由此帶來的代價可能是頻繁的執行緒背景關系切換,并且CPU使用率不會太高,我們可以用分析工具來印證下,
Visual Studio 自帶的分析工具,查看執行緒使用率
使用 Process Explorer 工具查看代碼執行程序中背景關系切換數
可以大概估計出,采用 lock(Monitor)同步自增方式,背景關系切換 243
次
那么我們用同樣的方式看下底層用 CAS
函式執行自增的開銷
Visual Studio 自帶的分析工具,查看執行緒使用率
使用 Process Explorer 工具查看代碼執行程序中背景關系切換數
可以大概估計出,采用 CAS
自增方式,背景關系切換 220
次
可見,不論使用什么技術手段,執行緒創建太多都會帶來大量的執行緒背景關系切換
這個應該是和測驗的代碼相關
兩者比較大的區別在CPU的使用率上,因為 lock 方式會造成執行緒阻塞,因此不會所有的CPU核心同時參與運算,CPU在當前行程上使用率不會太高,但 cas 方式CPU在自己的時間分片內并沒有被阻塞或重新調度,而是不停地執行比較替換的動作(其實這種場景算是無用功,不必要的負開銷),造成CPU使用率非常高,
【總結】
簡單來說,Interlocked 類提供的方法給我們帶來了方便快捷操作欄位的方式,比起使用鎖同步的編程方式來說,要輕量不少,執行效率也大大提高,但是該技術并非銀彈,一定要考慮清楚使用的場景后再決定使用,比如服務器web應用下,多執行緒執行大量耗費CPU的運算,可能會嚴重影回應用吞吐量,雖然表面看起來執行這個單一的任務效率高一些(代價是CPU全部撲在這個任務上,無法回應其他任務),其實在我們的測驗中,總共執行了 10000000 次運算,這種場景應該是比較極端的,而且在web應用場景下,用 lock 的方式回應時間也沒有達到不能容忍的程度,但是用 lock 的好處是cpu可以處理其他用戶請求的任務,極大提高了吞吐量,
我們建議在競爭較少的場景,或者不需要很高吞吐量的場景下(簡單說是CPU時間不那么寶貴的場景下)我們可以用 Interlocked 類來保證操作的原子性,可以適當提升性能,而在競爭非常激烈的場景下,一定不要用 Interlocked 來處理原子性操作,改用 lock 方式會好很多,
【原始碼地址】
https://github.com/sevenTiny/CodeArts/blob/master/CSharp/ConsoleAppNet60/InterlockedTest.cs
【博主宣告】
本文為站主原創作品,轉載請注明出處:http://www.cnblogs.com/7tiny 且在文章頁面明顯位置給出原文鏈接,作者:
7tiny
Software Development
北京市海淀區 Haidian Area Beijing 100089,P.R.China
郵箱Email : [email protected]
網址Http: http://www.7tiny.com
WeChat: seven-tiny
更多聯系方式點我哦~
Best Regard ~
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/526738.html
標籤:C#