本隨筆續接:.NET同步與異步之相關背景知識(六)
在上一篇隨筆中已經提到、解決競爭條件的典型方式就是加鎖 ,那本篇隨筆就重點來說一說.NET提供的最常用的鎖 lock關鍵字 和 Monitor。
public object thisLock = new object(); private long index; public void AddIndex() { lock (this.thisLock) { this.index++; if (this.index > long.MaxValue / 2) { this.index = 0; }
// 和 index 無關的大量操作 } } public long GetIndex() { return this.index; }
這一組demo,代碼簡潔,邏輯簡單,一個 AddIndex 方法 保證字段 index 在 0到100之間,另外一個GetIndex方法用來獲取字段index的值。
但是,這一組Demo卻有不少問題,甚至可以說是錯誤,下面我將一一進行說明:
1、忘記同步——即讀寫操作都需要加鎖
GetIndex方法, 由於該方法沒有加鎖,所以通過該方法在任何時刻都可以訪問字段index的值,也就是說會恰好在某個時間點獲取到 101 這個值,這一點是和初衷相違背的。
2、讀寫撕裂
如果說讀寫撕裂這個問題,這個demo可能不是很直觀,但是Long類型確實存在讀寫撕裂。比如下面的例子:
/// <summary> /// 測試原子性 /// </summary> public void TestAtomicity() { long test = 0; long breakFlag = 0; int index = 0; Task.Run(() => { base.PrintInfo("開始循環 寫數據"); while (true) { test = (index % 2 == 0) ? 0x0 : 0x1234567890abcdef; index++; if (Interlocked.Read(ref breakFlag) > 0) { break; } } base.PrintInfo("退出循環 寫數據"); }); Task.Run(() => { base.PrintInfo("開始循環 讀數據"); while (true) { long temp = test; if (temp != 0 && temp != 0x1234567890abcdef) { Interlocked.Increment(ref breakFlag); base.PrintInfo($"讀寫撕裂: { Convert.ToString(temp, 16)}"); break; } } base.PrintInfo("退出循環 讀數據"); }); } 測試原子性操作64位的數據結構是需要兩個命令來實現讀寫操作的,也就是說、如果恰好在兩個寫命令中間發生了讀取操作,就有可能讀取到不完成的數據。故而要警惕讀寫撕裂。
3、粒度錯誤
AddIndex 方法中,和 index 無關的大量操作 ,放在鎖中是沒有必要的,雖然沒必要但是也不是錯的,只能說這個鎖的粒度過大,造成了沒必要的並發上的性能影響。
下面舉例一個錯誤的鎖粒度:
public class BankAccount { private long id; private decimal m_balance = 0.0M; private object m_balanceLock = new object(); public void Deposit(decimal delta) { lock (m_balanceLock) { m_balance += delta; } } public void Withdraw(decimal delta) { lock (m_balanceLock) { if (m_balance < delta) throw new Exception("Insufficient funds"); m_balance -= delta; } } public static void ErrorTransfer(BankAccount a, BankAccount b, decimal delta) { a.Withdraw(delta); b.Deposit(delta); } public static void Transfer(BankAccount a, BankAccount b, decimal delta) { lock (a.m_balanceLock) { lock (b.m_balanceLock) { a.Withdraw(delta); b.Deposit(delta); } } } public static void RightTransfer(BankAccount a, BankAccount b, decimal delta) { if (a.id < b.id) { Monitor.Enter(a.m_balanceLock); // A first Monitor.Enter(b.m_balanceLock); // ...and then B } else { Monitor.Enter(b.m_balanceLock); // B first Monitor.Enter(a.m_balanceLock); // ...and then A } try { a.Withdraw(delta); b.Deposit(delta); } finally { Monitor.Exit(a.m_balanceLock); Monitor.Exit(b.m_balanceLock); } } } 錯誤的鎖粒度在 ErrorTransfer 方法中,在轉賬的兩個方法中間的時間點上,轉賬金額屬於無主狀態,這時鎖的粒度就過小了 。
在 Transfer 方法中,雖然粒度正確了,但是此時容易死鎖。而比較恰當的方式可以是:RightTransfer 。
4、不合理的lock方式
鎖定非私有類型的對象是一種危險的行為,因為非私有類型被暴露給外界、外界也可以對被暴露的對象進行加鎖,這種情況下很容造成死鎖 或者 錯誤的鎖粒度。
較為合理的方式是 將 thislock 改為 private .
由上述進行類推:
1、lock(this):如果當前類型為外界可訪問的也會有類似問題。
2、lock(typeof(T)): 因為Type對象,是整個進程域中是唯一的。所以,如果T為外界可訪問的類型也會有類似問題。
3、lock("字符串"):因為String類型的特殊性(內存駐留機制),多個字符串其實有可能是同一把鎖,所以、一不小心就容易掉入陷阱、造成死鎖 或者錯誤的鎖粒度。
下面是 AddIndex 方法的全部il代碼 [使用 .NET 4.5類庫,VS2015 編譯]:
.method public hidebysig instance void AddIndex() cil managed { // 代碼大小 81 (0x51) .maxstack 3 .locals init ([0] object V_0, [1] bool V_1, [2] bool V_2) IL_0000: nop IL_0001: ldarg.0 IL_0002: ldfld object ParallelDemo.Demo.LockMonitorClass::thisLock IL_0007: stloc.0 IL_0008: ldc.i4.0 IL_0009: stloc.1 .try { IL_000a: ldloc.0 IL_000b: ldloca.s V_1 IL_000d: call void [mscorlib]System.Threading.Monitor::Enter(object, bool&) IL_0012: nop IL_0013: nop IL_0014: ldarg.0 IL_0015: ldarg.0 IL_0016: ldfld int64 ParallelDemo.Demo.LockMonitorClass::index IL_001b: ldc.i4.1 IL_001c: conv.i8 IL_001d: add IL_001e: stfld int64 ParallelDemo.Demo.LockMonitorClass::index IL_0023: ldarg.0 IL_0024: ldfld int64 ParallelDemo.Demo.LockMonitorClass::index IL_0029: ldc.i8 0x3fffffffffffffff IL_0032: cgt IL_0034: stloc.2 IL_0035: ldloc.2 IL_0036: brfalse.s IL_0042 IL_0038: nop IL_0039: ldarg.0 IL_003a: ldc.i4.0 IL_003b: conv.i8 IL_003c: stfld int64 ParallelDemo.Demo.LockMonitorClass::index IL_0041: nop IL_0042: nop IL_0043: leave.s IL_0050 } // end .try finally { IL_0045: ldloc.1 IL_0046: brfalse.s IL_004f IL_0048: ldloc.0 IL_0049: call void [mscorlib]System.Threading.Monitor::Exit(object) IL_004e: nop IL_004f: endfinally } // end handler IL_0050: ret } // end of method LockMonitorClass::AddIndex IL當然你沒必要完全看懂,你只需要注意到三個細節就可以了:
1、調用 [mscorlib]System.Threading.Monitor::Enter(object, bool&) 方法,其中第二個入參為 索引為1的local變量 [查類庫後發現該參數是 ref 傳遞引用]。
2、如果索引為1的local變量 不為 false,則 調用 [mscorlib]System.Threading.Monitor::Exit(object) 方法
3、try... finally 語句塊
換句話,也就是說 lock關鍵字其實本質上就是 Monitor 類的簡化實現方式,為了安全、進行了try...finally處理。
因為進入鎖(Enter)和離開鎖(Exit)都是有一定的性能損耗的,所以,當有頻繁的沒有必要的鎖操作的時候,性能影響更大。
比如:在生產者消費者模式中,如果沒有需要消費的數據時,對鎖的頻繁操作是沒有必要的(輪詢模式,不是推送)。
在這種情況下, wait方法就派上用場了。如下是MSDN中的一句備注:
當前擁有對指定對象的鎖的線程調用此方法以釋放該對象,以便另一個線程可以訪問它。 等待重新獲取鎖時阻止調用方。 當調用方需要等待另一個線程操作後將發生狀態更改時,調用此方法。
wait 和 pulse 方法一筆帶過,這對方法、筆者用的也不多。
隨筆暫告一段落、下一篇隨筆介紹: 鎖(ReaderWriterLockSlim)(預計1篇隨筆)
附,Demo : http://files.cnblogs.com/files/08shiyan/ParallelDemo.zip
參見更多:隨筆導讀:同步與異步
(未完待續...)