在日常開發(fā)工作中,我們經常要對變量進行操作,例如對一個int變量遞增++。在單線程環(huán)境下是沒有問題的,但是如果一個變量被多個線程操作,那就有可能出現結果和預期不一致的問題。
創(chuàng)新互聯(lián)專業(yè)為企業(yè)提供海曙網站建設、海曙做網站、海曙網站設計、海曙網站制作等企業(yè)網站建設、網頁設計與制作、海曙企業(yè)網站模板建站服務,十余年海曙做網站經驗,不只是建網站,更提供有價值的思路和整體網絡服務。
例如:
static void Main(string[] args)
{
var j = 0;
for (int i = 0; i < 100; i++)
{
j++;
}
Console.WriteLine(j);
//100
}
在單線程情況下執(zhí)行,結果一定為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 這個結果是隨機的,和每個線程執(zhí)行情況有關
}
我們可以看到,多線程情況下并不能保證執(zhí)行正確,我們也將這種情況稱為 “非線程安全”
這種情況下我們可以通過加鎖來達到線程安全的目的
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 類
Increment 方法可以輕松實現線程安全的變量自增
///
/// thread safe increament
///
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 內部代碼,我們只能通過查看源碼來繼續(xù)學習。
我們打開 dotnetcore 源碼:https://github.com/dotnet/corefx
找到 Interlocked
中的 ExchangeAdd
方法
可以看到,該方法用循環(huán)不斷自旋賦值并檢查是否賦值成功(CompareExchange返回的是修改前的值,如果返回結果和修改前結果是一致,則說明修改成功)
我們繼續(xù)看內部實現
內部調用 InterlockedCompareExchange
函數,再往下就是直接調用的C++源碼了
在這里將變量添加 volatile
修飾符,阻止寄存器緩存變量值(關于volatile不在此贅述),然后直接調用了C++底層內部函數 __sync_val_compare_and_swap
實現原子性的比較交換操作,這里直接用的是 CPU 指令進行原子性操作,性能非常高。
和 Increment
函數機制類似,Interlocked
類下的大部分方法都是通過 CompareExchange
底層函數來操作的,因此這里不再贅述
Read 這個函數著重提一下
可以看到這個函數沒有 32 位(int)類型的重載,為什么要單獨為 64 位的 long/ulong 類型單獨提供原子性讀取操作符呢?
這是因為CPU有 32 位處理器和 64 位處理器,在 64 位處理器上,寄存器一次處理的數據寬度是 64 位,因此在 64 位處理器和 64 位操作系統(tǒng)上運行的程序,可以一次性讀取 64 位數值。
但是在 32 位處理器和 32 位操作系統(tǒng)情況下,long/ulong 這種數值,則要分成兩步操作來進行,分別讀取 32 位數據后,再合并在一起,那顯然就會出現多線程情況下的并發(fā)問題。
因此這里提供了原子性的方法來應對這種情況。
這里底層同樣用了 CompareExchange
操作來保證原子性,參數這里就給了兩個0,可以兼容如果原值是 0 則寫入 0 ,如果原值非 0 則不寫入,返回原值。
__sync_val_compare_and_swap 函數
在寫入新值之前, 讀出舊值, 當且僅當舊值與存儲中的當前值一致時,才把新值寫入存儲
多線程下實現原子性操作方式有很多種,我們一定會關心在不同場景下,不同方法間的性能問題,那么我們簡單來對比下 Interlocked
類提供的方法和 lock
關鍵字的性能對比
我們同樣用線程池調度50個Task(內部可能線程重用),分別執(zhí)行 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
方式要好一些
雖然這里看起來性能要好,但是不同的業(yè)務場景要針對性思考,采用恰當的編碼方式,不要一味追求性能
我們簡單分析下造成執(zhí)行時間差異的原因
我們都知道,使用lock(底層是Monitor類),在上述代碼中會阻塞線程執(zhí)行,保證同一時刻只能有一個線程執(zhí)行 j1++
操作,因此能保證操作的原子性,那么在多核CPU下,也只能有一個CPU核心在執(zhí)行這段邏輯,其他核心都會等待或執(zhí)行其他事件,線程阻塞后,并不會一直在這里傻等,而是由操作系統(tǒng)調度執(zhí)行其他任務。由此帶來的代價可能是頻繁的線程上下文切換,并且CPU使用率不會太高,我們可以用分析工具來印證下。
Visual Studio 自帶的分析工具,查看線程使用率
使用 Process Explorer 工具查看代碼執(zhí)行過程中上下文切換數
可以大概估計出,采用 lock(Monitor)同步自增方式,上下文切換 243
次
那么我們用同樣的方式看下底層用 CAS
函數執(zhí)行自增的開銷
Visual Studio 自帶的分析工具,查看線程使用率
使用 Process Explorer 工具查看代碼執(zhí)行過程中上下文切換數
可以大概估計出,采用 CAS
自增方式,上下文切換 220
次
可見,不論使用什么技術手段,線程創(chuàng)建太多都會帶來大量的線程上下文切換
這個應該是和測試的代碼相關
兩者比較大的區(qū)別在CPU的使用率上,因為 lock 方式會造成線程阻塞,因此不會所有的CPU核心同時參與運算,CPU在當前進程上使用率不會太高,但 cas 方式CPU在自己的時間分片內并沒有被阻塞或重新調度,而是不停地執(zhí)行比較替換的動作(其實這種場景算是無用功,不必要的負開銷),造成CPU使用率非常高。
簡單來說,Interlocked 類提供的方法給我們帶來了方便快捷操作字段的方式,比起使用鎖同步的編程方式來說,要輕量不少,執(zhí)行效率也大大提高。但是該技術并非銀彈,一定要考慮清楚使用的場景后再決定使用,比如服務器web應用下,多線程執(zhí)行大量耗費CPU的運算,可能會嚴重影響應用吞吐量。雖然表面看起來執(zhí)行這個單一的任務效率高一些(代價是CPU全部撲在這個任務上,無法響應其他任務),其實在我們的測試中,總共執(zhí)行了 10000000 次運算,這種場景應該是比較極端的,而且在web應用場景下,用 lock 的方式響應時間也沒有達到不能容忍的程度,但是用 lock 的好處是cpu可以處理其他用戶請求的任務,極大提高了吞吐量。
我們建議在競爭較少的場景,或者不需要很高吞吐量的場景下(簡單說是CPU時間不那么寶貴的場景下)我們可以用 Interlocked 類來保證操作的原子性,可以適當提升性能。而在競爭非常激烈的場景下,一定不要用 Interlocked 來處理原子性操作,改用 lock 方式會好很多。
https://github.com/sevenTiny/CodeArts/blob/master/CSharp/ConsoleAppNet60/InterlockedTest.cs