一:背景
1. 講故事
最近同事在寫一段業務邏輯的時候,程式跑起來總是報:集合已修改;可能無法執行列舉操作
,硬是沒有找到什麼情況下會導致這個異常產生,就讓我來找一下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中到底是怎麼限制的。
二:原始碼探索
1. 從IL中尋找答案
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
來生成字典的迭代類,這思路就非常清晰了,然後我們看一下類索引器都做了些什麼。
從圖中可以看到,每一次的索引器操作,這裡都執行了version++,所以字典初始化完成之後,這裡的 version=3
,沒有問題吧,然後繼續看程式碼,尋找 Dictionary.GetEnumerator
方法啟動迭代類。
上面程式碼的 _version = dictionary._version;
一定要看仔細了,在啟動迭代類的時候記錄了當時字典的版本號,也就是_version=3
,然後繼續探索moveNext方法幹了什麼,如下圖:
從圖中可以看到,當每次執行moveNext的過程中,都會判斷一下字典的 version 和 當初初始化迭代類中的version 版本號是否一致,如果不一致就丟擲異常,所以這行程式碼就是點睛之筆了,當在foreach體中執行了 dict[userid] = dict[userid] + 1;
語句,相當於又執行了一次類索引器操作,這時候字典的version就變成 4 了,而當初初始化迭代類的時候還是3,自然下一次執行 moveNext 就是 3 != 4
丟擲異常了。
如果你非要讓我證明給你看,這裡可以使用dnspy直接除錯原始碼,在異常那裡下一個斷點再檢視兩個version版本號不就知道啦。。。
2. 面對疾風
有些朋友可能要說,碼農今天分享的這篇一點水準都沒有,我18年前就知道字典是不能動態修改的,還分析的頭頭是勁???。
但是我有話要說,這個還確實是我的一個盲區,平時在迭代字典的時候value一般都是引用型別,動態修改引用型別的值自然是沒有問題的,這是因為你不管怎麼修改都不會改變 _version
版本號,<font color="red">但質疑我的也不要把話說的太滿,因為這種操作是非常語義化非常大眾的需求,你能保證後面net版本不支援這個嗎???</font> 如果你說不可能,那恭喜你,被我帶到坑裡面去啦。???
下面我用原封不動的程式碼在 .net 5
下跑一次,睜大眼睛好好看哦~~~
驚訝吧, 居然在 .Net 5
中可以的,接下來用ILSpy去查查底層原始碼,.netcore 3.1 和 net5 中分別對 類索引器 都做了啥修改。
- netcore 3.1
Path: C:Program FilesdotnetsharedMicrosoft.NETCore.App3.1.2System.Private.CoreLib.dll
- net5
Path: C:Program FilesdotnetsharedMicrosoft.NETCore.App5.0.0-preview.5.20278.1System.Private.CoreLib.dll
對比兩張圖你會發現 .Net5
中並沒有做 _version++
操作,這就??了,如果你再細讀程式碼,你還發現 .Net5 對字典進行了較大幅度的優化,哈哈,當初在 .Net5
之前產生的錯誤,在 .Net5
中居然沒有啦!
四: 總結
原始碼面前,不談隱私,沒事多翻翻原始碼,有可能還有意外收穫,比如在 .Net 5
下的這點新發現,<font color="red">可能還是全網第一個哦</font>,這要是兩個大牛爭吵,讓小白去相信誰呢,嘿嘿,原始碼才是真正的專家~