最近同事在寫一段業務邏輯的時候,程序跑起來老是報:集合已修改;可能沒法執行枚舉操做
,硬是沒有找到什麼狀況下會致使這個異常產生,就讓我來找一下bug,其實這個異常在座的每一個程序員幾乎都遇到過,誰也不是一輩子下就是大牛,簡單看了下代碼,確實是多線程操做foreach,但並無對foreach進行Add,Remove操做,掃完代碼其實我也是有點懵,沒撤只能調試了,在foreach裏套一層trycatch,查看異常的線程堆棧從而找出了問題代碼,代碼簡化以下:程序員
static void Main(string[] args) { var dict = new Dictionary<int, int>() { [1001] = 1, [1002] = 10, [1003] = 20 }; foreach (var userid in dict.Keys) { dict[userid] = dict[userid] + 1; } }
先尋找點安慰,說實話,憑肉眼你以爲這段代碼會拋出異常嗎? 反正我是被騙過了,大寫的尷尬,結論以下,運行一下便知。多線程
從圖中看確實是異常,說明在foreach的過程當中連迭代集合的 value 都不能夠修改,這讓我激起了強烈的探索欲,看看FCL中究竟是怎麼限制的。oop
C#已發展到 9.0
了,處處都充斥着語法糖,有時候不看一下底層的IL都不知道究竟是轉化成了什麼,因此這個是必須的。優化
IL_000d: callvirt instance void class [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::set_Item(!0, !1) IL_001b: callvirt instance void class [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::set_Item(!0, !1) IL_0029: callvirt instance void class [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::set_Item(!0, !1) IL_0037: callvirt instance valuetype [System.Collections]System.Collections.Generic.Dictionary`2/KeyCollection/Enumerator<!0, !1> class [System.Collections]System.Collections.Generic.Dictionary`2/KeyCollection<int32, int32>::GetEnumerator() .try { IL_003d: br.s IL_005a // loop start (head: IL_005a) IL_003f: ldloca.s 1 IL_0041: call instance !0 valuetype [System.Collections]System.Collections.Generic.Dictionary`2/KeyCollection/Enumerator<int32, int32>::get_Current() IL_004c: callvirt instance !1 class [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::get_Item(!0) IL_0053: callvirt instance void class [System.Collections]System.Collections.Generic.Dictionary`2<int32, int32>::set_Item(!0, !1) IL_005a: ldloca.s 1 IL_005c: call instance bool valuetype [System.Collections]System.Collections.Generic.Dictionary`2/KeyCollection/Enumerator<int32, int32>::MoveNext() IL_0061: brtrue.s IL_003f // end loop IL_0063: leave.s IL_0074 } // end .try finally { } // end handler
從IL代碼中能夠看到,先執行了三次字典的索引器操做,而後調用了 Dictionary.GetEnumerator
來生成字典的迭代類,這思路就很是清晰了,而後咱們看一下類索引器都作了些什麼。.net
從圖中能夠看到,每一次的索引器操做,這裏都執行了version++,因此字典初始化完成以後,這裏的 version=3
,沒有問題吧,而後繼續看代碼,尋找 Dictionary.GetEnumerator
方法啓動迭代類。線程
上面代碼的 _version = dictionary._version;
必定要看仔細了,在啓動迭代類的時候記錄了當時字典的版本號,也就是_version=3
,而後繼續探索moveNext方法幹了什麼,以下圖:3d
從圖中能夠看到,當每次執行moveNext的過程當中,都會判斷一下字典的 version 和 當初初始化迭代類中的version 版本號是否一致,若是不一致就拋出異常,因此這行代碼就是點睛之筆了,當在foreach體中執行了 dict[userid] = dict[userid] + 1;
語句,至關於又執行了一次類索引器操做,這時候字典的version就變成 4 了,而當初初始化迭代類的時候仍是3,天然下一次執行 moveNext 就是 3 != 4
拋出異常了。調試
若是你非要讓我證實給你看,這裏可使用dnspy直接調試源碼,在異常那裏下一個斷點再查看兩個version版本號不就知道啦。。。code
有些朋友可能要說,碼農今天分享的這篇一點水準都沒有,我18年前就知道字典是不能動態修改的,還分析的頭頭是勁😁😁😁。blog
可是我有話要說,這個還確實是個人一個盲區,平時在迭代字典的時候value通常都是引用類型,動態修改引用類型的值天然是沒有問題的,這是由於你無論怎麼修改都不會改變 _version
版本號,但質疑個人也不要把話說的太滿,由於這種操做是很是語義化很是大衆的需求,你能保證後面net版本不支持這個嗎??? 若是你說不可能,那恭喜你,被我帶到坑裏面去啦。😄😄😄
下面我用原封不動的代碼在 .net 5
下跑一次,睜大眼睛好好看哦~~~
驚訝吧, 竟然在 .Net 5
中能夠的,接下來用ILSpy去查查底層源碼,.netcore 3.1 和 net5 中分別對 類索引器 都作了啥修改。
Path: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\3.1.2\System.Private.CoreLib.dll
Path: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.0-preview.5.20278.1\System.Private.CoreLib.dll
對比兩張圖你會發現 .Net5
中並無作 _version++
操做,這就🐮👃了,若是你再細讀代碼,你還發現 .Net5 對字典進行了較大幅度的優化,哈哈,當初在 .Net5
以前產生的錯誤,在 .Net5
中竟然沒有啦!
源碼面前,不談隱私,沒事多翻翻源碼,有可能還有意外收穫,好比在 .Net 5
下的這點新發現,可能仍是全網第一個哦,這要是兩個大牛爭吵,讓小白去相信誰呢,嘿嘿,源碼纔是真正的專家~