最近同事在写一段业务逻辑的时候,程序跑起来老是报:集合已修改;可能没法执行枚举操做
,硬是没有找到什么状况下会致使这个异常产生,就让我来找一下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
下的这点新发现,可能仍是全网第一个哦,这要是两个大牛争吵,让小白去相信谁呢,嘿嘿,源码才是真正的专家~