C#中的原子操作Interlocked,你真的了解吗?
阅读目录
一、背景
这个标题起的有点标题党的嫌疑[捂脸],这个事情的原委是这样的,有个Web API的站点在本地使用Release模式Run的时候出现问题,但是使用Debug模式则不会。通过打日志定位到问题在如下的这个代码这里:
private static int _flag; public void ExactlyOnceMethod() { var original = Interlocked.Exchange(ref _flag, 1); if (original == _flag) { // 1.重复进入 } else { // 2.第一次进入 } }
理论上,会有一次请求进入到2中,但是实际问题是全部都进入到了1中。
二、代码描述
这个代码很简单,就做了2个事情,1是使用Interlocked.Exchange将_flag变量进行赋值。2是将Interlocked.Exchange操作后返回的原始值与_flag变量进行对比,如果相等说明这个变量已经被修改过了,表示这里是重入了。如果不是则说明第一次进入此方法。
关于Interlocked.Exchange的解释,见微软官网文档,传送门在此:https://msdn.microsoft.com/zh-cn/library/d3fxt78a.aspx
三、越分析越黑暗
好了,咋一看了好几分钟,也没看出有什么不妥的地方,那么首先就往多线程问题上考虑了。但是这里唯一的共享变量就是_flag,走的又是CAS操作,在这里不存在多线程问题。而且结合日志输出,的确这个方法就是只执行了一次。仔细的再看了一遍官方文档中的内容,见下图1。我发现示例代码中的写法和我上面贴的代码是不一样的,这里并没有重用变量usingResource,而且直接将比较的对象变成了一个常量0。
【图1】
带着好奇,我去翻阅了下.Net Framework的源码。传送门在此 http://referencesource.microsoft.com/#mscorlib/system/threading/interlocked.cs,52be0cc9b3954ae9 。但是它直接是个extern方法,见下图2:
【图2】
这里又陷入了一个困境,现在线索断了。查阅了一些资料,MethodImplOptions.InternalCall 表明这个方法的实现在微软开源的sscli中可以找到答案(原文地址 http://bbs.csdn.net/topics/330019064 中的5楼回复)。但是经各方查找,目前已经找不到源码所在地了,据说是.Net Framework 2.0时代的产物。
OK,那我就想看下汇编代码试试。下面是反编译出的汇编代码:
1 var original = Interlocked.Exchange(ref _flag, 1); 2 00DC35EF mov ecx,5F2DFCCh //将5F2DFCCh地址上的数据放入寄存器ecx 3 00DC35F4 mov edx,1 //将1放入寄存器edx 4 00DC35F9 call 70B95330 //调用70B95330地址上的方法 5 00DC35FE mov dword ptr [ebp-48h],eax //将寄存器eax的数据 保存到地址ebp-48h的双字型指针上 6 00DC3601 mov eax,dword ptr [ebp-48h] //将地址ebp-48h的双字型指针上的数据放入寄存器eax(可以理解上上一步的反向操作) 7 00DC3604 mov dword ptr [ebp-40h],eax //将寄存器eax的数据 保存到地址ebp-40h的双字型指针上 8 if (original == _flag) 9 00DC3607 mov eax,dword ptr [ebp-40h] //将地址ebp-40h的双字型指针上的数据放入寄存器eax 10 00DC360A cmp eax,dword ptr ds:[5F2DFCCh] //比较地址ds:[5F2DFCCh]的双字型指针上的数据和寄存器eax中的数据。 这里开始下面的代码不是我们的讨论点了,就不翻译了 11 00DC3610 setne al 12 00DC3613 movzx eax,al 13 00DC3616 mov dword ptr [ebp-44h],eax 14 00DC3619 cmp dword ptr [ebp-44h],0 15 00DC361D jne 00DC3624
这里的5F2DFCCh其实就是_flag。我们可以看到在真正做这个Interlocked.Exchange操作的时候,并没有直接去修改5F2DFCCh地址上的数据,但是在做cmp操作的时候由于我们比较的对象是_flag变量,所以还是继续使用了5F2DFCCh地址上的数据。也就是说:CPU运算在寄存器中操作数据,但是我们用于判断的变量是个静态全局变量,持有的是这个引用地址。那么是不是可以这么来理解:【如果说Interlocked的内部操作与当前上下文使用的并不是同一个CPU核心】,那么这个“判断依据”并不是像代码上写的这样,因为我们预期是肯定一样的(变量都是同一个)。理由是做Interlocked的时候在CPU1的高速缓存中,另一个在CPU2上操作加载的数据还是内存中的。其中CPU1往内存同步数据(将寄存器中的值赋值给_flag这个全局变量)有一个非常短的时间差。如果是这样的话,也就能解释为什么会有下面的3种情况出现:
1.在有的机器上是没问题的,在有的机器上是有问题的。
2.在Debug模式下是没问题的,在Release模式下是有问题的。
3.在if语句之前增加一条日志记录到物理文件中也是没问题的。
依据这个推测的话,原因就是因为这个时间差的耗时和所在机器的硬件配置环境都有关系。只要这个“赋值”操作所用时间 < 代码执行到if所需的时间,那么就不会出现问题。根据这个结论也能得出解决方案,就是让这个表达式成立即可,哪怕就是简单粗暴的Sleep1毫秒都行。笔者建议的解决方案有2种:
方案1:是给这个全局变量增加volatile关键字即可,关键字的说明请看这里(https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/volatile)。
方案2:参照官方的示例写法,将_flag替换为常量来做比较,比如这里可以更改成original == 0 即可。
四、结语
总结一下:
使用Interlocked做的CAS本身是一个CPU操作。数据是放在CPU的寄存器中做的交换。但是我们判断的变量是个静态全局变量,持有的是这个引用地址。
也就是出现问题的流程是:
1.从传入的ref引用地址加载数据到CPU寄存器
2.寄存器中做交换并且返回原始值,但是更新引用地址的操作并不是在这个上下文中的同步操作。
3.然后我们比较的时候,左侧原始值肯定为0,但是流程1中的变量在非常短的时间内也是原始值为0(如图3)。导致了这个问题的产生。
【图3】
强调一下,这个结论也是建立在【如果说Interlocked的内部操作与当前上下文使用的并不是同一个CPU核心】的猜测下,这方面资料实在是找不到也无法进一步验证,所以我也不是敢100%确定是否正确。如果哪位小伙伴能够来个明确的解惑欢迎在下面留言~
在分析该问题的过程中,参考了以下几位小伙伴的思想成果,感谢分享:
http://286.iteye.com/blog/2295165
http://www.cnblogs.com/5iedu/p/4719625.html
http://blog.csdn.net/hsuxu/article/details/9467651
作者:Zachary
出处:https://zacharyfan.com/archives/262.html
▶关于作者:张帆(Zachary,个人微信号:Zachary-ZF)。坚持用心打磨每一篇高质量原创。欢迎扫描右侧的二维码~。
定期发表原创内容:架构设计丨分布式系统丨产品丨运营丨一些思考。
如果你是初级程序员,想提升但不知道如何下手。又或者做程序员多年,陷入了一些瓶颈想拓宽一下视野。欢迎关注我的公众号「跨界架构师」,回复「技术」,送你一份我长期收集和整理的思维导图。
如果你是运营,面对不断变化的市场束手无策。又或者想了解主流的运营策略,以丰富自己的“仓库”。欢迎关注我的公众号「跨界架构师」,回复「运营」,送你一份我长期收集和整理的思维导图。