-
foreach 集合又抛经典异常了,这次一定要刨根问底
一:背景
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年前就知道字典是不能动态修改的,还分析的头头是劲