Foreach 集合又拋經(jīng)典異常了,這次一定要刨根問(wèn)底
一、背景
1. 講故事
最近同事在寫(xiě)一段業(yè)務(wù)邏輯的時(shí)候,程序跑起來(lái)總是報(bào):集合已修改;可能無(wú)法執(zhí)行枚舉操作,硬是沒(méi)有找到什么情況下會(huì)導(dǎo)致這個(gè)異常產(chǎn)生,就讓我來(lái)找一下bug,其實(shí)這個(gè)異常在座的每個(gè)程序員幾乎都遇到過(guò),誰(shuí)也不是一生下就是大牛,簡(jiǎn)單看了下代碼,確實(shí)是多線程操作foreach,但并沒(méi)有對(duì)foreach進(jìn)行Add,Remove操作,掃完代碼其實(shí)我也是有點(diǎn)懵,沒(méi)撤只能調(diào)試了,在foreach里套一層trycatch,查看異常的線程堆棧從而找出了問(wèn)題代碼,代碼簡(jiǎn)化如下:
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;
}
}
先尋找點(diǎn)安慰,說(shuō)實(shí)話,憑肉眼你覺(jué)得這段代碼會(huì)拋出異常嗎?反正我是被騙過(guò)了,大寫(xiě)的尷尬,結(jié)論如下,運(yùn)行一下便知。
圖片
從圖中看確實(shí)是異常,說(shuō)明在foreach的過(guò)程中連迭代集合的 value 都不可以修改,這讓我激起了強(qiáng)烈的探索欲,看看FCL中到底是怎么限制的。
二、源碼探索
1. 從IL中尋找答案
C#已發(fā)展到 9.0 了,到處都充斥著語(yǔ)法糖,有時(shí)候不看一下底層的IL都不知道到底是轉(zhuǎn)化成了什么,所以這個(gè)是必須的。
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代碼中可以看到,先執(zhí)行了三次字典的索引器操作,然后調(diào)用了 Dictionary.GetEnumerator 來(lái)生成字典的迭代類(lèi),這思路就非常清晰了,然后我們看一下類(lèi)索引器都做了些什么。
圖片
從圖中可以看到,每一次的索引器操作,這里都執(zhí)行了version++,所以字典初始化完成之后,這里的 versinotallow=3,沒(méi)有問(wèn)題吧,然后繼續(xù)看代碼,尋找 Dictionary.GetEnumerator 方法啟動(dòng)迭代類(lèi)。
圖片
上面代碼的 _version = dictionary._version; 一定要看仔細(xì)了,在啟動(dòng)迭代類(lèi)的時(shí)候記錄了當(dāng)時(shí)字典的版本號(hào),也就是_versinotallow=3,然后繼續(xù)探索moveNext方法干了什么,如下圖:
圖片
從圖中可以看到,當(dāng)每次執(zhí)行moveNext的過(guò)程中,都會(huì)判斷一下字典的 version 和 當(dāng)初初始化迭代類(lèi)中的version 版本號(hào)是否一致,如果不一致就拋出異常,所以這行代碼就是點(diǎn)睛之筆了,當(dāng)在foreach體中執(zhí)行了 dict[userid] = dict[userid] + 1; 語(yǔ)句,相當(dāng)于又執(zhí)行了一次類(lèi)索引器操作,這時(shí)候字典的version就變成 4 了,而當(dāng)初初始化迭代類(lèi)的時(shí)候還是3,自然下一次執(zhí)行 moveNext 就是 3 != 4 拋出異常了。
如果你非要讓我證明給你看,這里可以使用dnspy直接調(diào)試源碼,在異常那里下一個(gè)斷點(diǎn)再查看兩個(gè)version版本號(hào)不就知道啦。。。
圖片
2. 面對(duì)疾風(fēng)
有些朋友可能要說(shuō),碼農(nóng)今天分享的這篇一點(diǎn)水準(zhǔn)都沒(méi)有,我18年前就知道字典是不能動(dòng)態(tài)修改的,還分析的頭頭是勁??????。
但是我有話要說(shuō),這個(gè)還確實(shí)是我的一個(gè)盲區(qū),平時(shí)在迭代字典的時(shí)候value一般都是引用類(lèi)型,動(dòng)態(tài)修改引用類(lèi)型的值自然是沒(méi)有問(wèn)題的,這是因?yàn)槟悴还茉趺葱薷亩疾粫?huì)改變 _version 版本號(hào),但質(zhì)疑我的也不要把話說(shuō)的太滿,因?yàn)檫@種操作是非常語(yǔ)義化非常大眾的需求,你能保證后面net版本不支持這個(gè)嗎??? 如果你說(shuō)不可能,那恭喜你,被我?guī)У娇永锩嫒ダ病??????
下面我用原封不動(dòng)的代碼在 .net 5 下跑一次,睜大眼睛好好看哦~~~
圖片
驚訝吧, 居然在 .Net 5 中可以的,接下來(lái)用ILSpy去查查底層源碼,.netcore 3.1 和 net5 中分別對(duì) 類(lèi)索引器 都做了啥修改。
- netcore 3.1
Path:C:\Program Files\dotnet\shared\Microsoft.NETCore.App\3.1.2\System.Private.CoreLib.dll
圖片
- net5
Path:C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.0-preview.5.20278.1\System.Private.CoreLib.dll
對(duì)比兩張圖你會(huì)發(fā)現(xiàn) .Net5 中并沒(méi)有做 _version++ 操作,這就????了,如果你再細(xì)讀代碼,你還發(fā)現(xiàn) .Net5 對(duì)字典進(jìn)行了較大幅度的優(yōu)化,哈哈,當(dāng)初在 .Net5 之前產(chǎn)生的錯(cuò)誤,在 .Net5 中居然沒(méi)有啦!
四、總結(jié)
源碼面前,不談隱私,沒(méi)事多翻翻源碼,有可能還有意外收獲,比如在 .Net 5下的這點(diǎn)新發(fā)現(xiàn),可能還是全網(wǎng)第一個(gè)哦,這要是兩個(gè)大牛爭(zhēng)吵,讓小白去相信誰(shuí)呢,嘿嘿,源碼才是真正的專家!