代码改变世界

从如此简单的代码谈起

2014-09-19 19:40 Franz 阅读(...) 评论(...) 编辑 收藏

从如此简单的代码谈起

事情缘起, 前一段时间在公司的技术群里讨论以下方法那个更快.

public class Demo{
    private int count;
    const int maxNum = int.MaxValue;
    
    public void Run1()
    {
        for(int i =0 ; i < maxNum; i++)
        {
            this.count++;
        }
        
        Console.WriteLine(this.count);
        
        Console.ReadKey();
    }
    
    
    public void Run2()
    {
        int temp = 0;
        
        for(int i = 0 ; i < maxNum; i++)
        {
            temp ++;
        }
        
        this.count = temp;
        
        
        Console.WriteLine(this.count);  
        Console.ReadKey();  
    }

这个很多人都能正确的感知到是Run2方法更快一点, 但是为何呢?
我们先看一下两个方法的IL指令吧。

Demo.Run1:
IL_0000:  nop         
IL_0001:  ldc.i4.0    
IL_0002:  stloc.0     // i
IL_0003:  br.s        IL_0019
IL_0005:  nop         
IL_0006:  ldarg.0     
IL_0007:  dup         
IL_0008:  ldfld       UserQuery+Demo.count
IL_000D:  ldc.i4.1    
IL_000E:  add         
IL_000F:  stfld       UserQuery+Demo.count
IL_0014:  nop         
IL_0015:  ldloc.0     // i
IL_0016:  ldc.i4.1    
IL_0017:  add         
IL_0018:  stloc.0     // i
IL_0019:  ldloc.0     // i
IL_001A:  ldc.i4      FF FF FF 7F 
IL_001F:  clt         
IL_0021:  stloc.1     // CS$4$0000
IL_0022:  ldloc.1     // CS$4$0000
IL_0023:  brtrue.s    IL_0005
IL_0025:  ldarg.0     
IL_0026:  ldfld       UserQuery+Demo.count
IL_002B:  call        System.Console.WriteLine
IL_0030:  nop         
IL_0031:  call        System.Console.ReadKey
IL_0036:  pop         
IL_0037:  ret         

Demo.Run2:
IL_0000:  nop         
IL_0001:  ldc.i4.0    
IL_0002:  stloc.0     // temp
IL_0003:  ldc.i4.0    
IL_0004:  stloc.1     // i
IL_0005:  br.s        IL_0011
IL_0007:  nop         
IL_0008:  ldloc.0     // temp
IL_0009:  ldc.i4.1    
IL_000A:  add         
IL_000B:  stloc.0     // temp
IL_000C:  nop         
IL_000D:  ldloc.1     // i
IL_000E:  ldc.i4.1    
IL_000F:  add         
IL_0010:  stloc.1     // i
IL_0011:  ldloc.1     // i
IL_0012:  ldc.i4      FF FF FF 7F 
IL_0017:  clt         
IL_0019:  stloc.2     // CS$4$0000
IL_001A:  ldloc.2     // CS$4$0000
IL_001B:  brtrue.s    IL_0007
IL_001D:  ldarg.0     
IL_001E:  ldloc.0     // temp
IL_001F:  stfld       UserQuery+Demo.count
IL_0024:  ldarg.0     
IL_0025:  ldfld       UserQuery+Demo.count
IL_002A:  call        System.Console.WriteLine
IL_002F:  nop         
IL_0030:  call        System.Console.ReadKey
IL_0035:  pop         
IL_0036:  ret         

Demo..ctor:
IL_0000:  ldarg.0     
IL_0001:  call        System.Object..ctor
IL_0006:  ret         

可以看到大致是ldfld指令跟ldloc指令的差异。 因为这些都是il指令, 所以不好衡量那个更快一点。

il指令在运行时才会被转换到机器码, 让我们看一下他们的从汇编的角度是如何看到的

注, 用Windbg加载程序运行完毕, 并使用!U命令可以查看JIT generated code。 当然u命令依然可以使用. 这里使用的是!U的好处是可以清楚还原一下call指令。

0:003> !U 00007ff963450380
Normal JIT generated code
Demo.Run2()
Begin 00007ff963450380, size 4d
>>> 00007ff9`63450380 53              push    rbx
00007ff9`63450381 4883ec30        sub     rsp,30h
00007ff9`63450385 488bd9          mov     rbx,rcx
00007ff9`63450388 33c0            xor     eax,eax
00007ff9`6345038a 8bc8            mov     ecx,eax
00007ff9`6345038c 0f1f4000        nop     dword ptr [rax]
00007ff9`63450390 83c101          add     ecx,1
00007ff9`63450393 83c001          add     eax,1
00007ff9`63450396 3dffffff7f      cmp     eax,7FFFFFFFh
00007ff9`6345039b 7cf3            jl      00007ff9`63450390
00007ff9`6345039d 894b08          mov     dword ptr [rbx+8],ecx
00007ff9`634503a0 8b5b08          mov     ebx,dword ptr [rbx+8]
00007ff9`634503a3 e8b816aa5e      call    mscorlib_ni+0x391a60 (00007ff9`c1ef1a60) (System.Console.get_Out(), mdToken: 06000776)
00007ff9`634503a8 4c8bd8          mov     r11,rax
00007ff9`634503ab 498b03          mov     rax,qword ptr [r11]
00007ff9`634503ae 8bd3            mov     edx,ebx
00007ff9`634503b0 498bcb          mov     rcx,r11
00007ff9`634503b3 ff9068010000    call    qword ptr [rax+168h]
00007ff9`634503b9 33d2            xor     edx,edx
00007ff9`634503bb 488d4c2420      lea     rcx,[rsp+20h]
00007ff9`634503c0 e8bbd5035f      call    mscorlib_ni+0x92d980 (00007ff9`c248d980) (System.Console.ReadKey(Boolean), mdToken: 060007aa)
00007ff9`634503c5 90              nop
00007ff9`634503c6 4883c430        add     rsp,30h
00007ff9`634503ca 5b              pop     rbx
00007ff9`634503cb f3c3            rep ret
0:003> !U 00007ff963450310
Normal JIT generated code
Demo.Run1()
Begin 00007ff963450310, size 51
>>> 00007ff9`63450310 53              push    rbx
00007ff9`63450311 4883ec30        sub     rsp,30h
00007ff9`63450315 33d2            xor     edx,edx
00007ff9`63450317 660f1f840000000000 nop   word ptr [rax+rax]
00007ff9`63450320 8b4108          mov     eax,dword ptr [rcx+8]
00007ff9`63450323 83c001          add     eax,1
00007ff9`63450326 894108          mov     dword ptr [rcx+8],eax
00007ff9`63450329 83c201          add     edx,1
00007ff9`6345032c 81faffffff7f    cmp     edx,7FFFFFFFh
00007ff9`63450332 7cec            jl      00007ff9`63450320
00007ff9`63450334 8b5908          mov     ebx,dword ptr [rcx+8]
00007ff9`63450337 e82417aa5e      call    mscorlib_ni+0x391a60 (00007ff9`c1ef1a60) (System.Console.get_Out(), mdToken: 06000776)
00007ff9`6345033c 4c8bd8          mov     r11,rax
00007ff9`6345033f 498b03          mov     rax,qword ptr [r11]
00007ff9`63450342 8bd3            mov     edx,ebx
00007ff9`63450344 498bcb          mov     rcx,r11
00007ff9`63450347 ff9068010000    call    qword ptr [rax+168h]
00007ff9`6345034d 33d2            xor     edx,edx
00007ff9`6345034f 488d4c2420      lea     rcx,[rsp+20h]
00007ff9`63450354 e827d6035f      call    mscorlib_ni+0x92d980 (00007ff9`c248d980) (System.Console.ReadKey(Boolean), mdToken: 060007aa)
00007ff9`63450359 90              nop
00007ff9`6345035a 4883c430        add     rsp,30h
00007ff9`6345035e 5b              pop     rbx
00007ff9`6345035f f3c3            rep ret

我们只关心Console调用前的指令就可以了。 细细对比一下差异可以看到。
Run2 做加法操作主要是考的这两个add指令, 分别操作了两个寄存器, 一个eax是i++, 一个ecx是temp++;

00007ff9`63450390 83c101          add     ecx,1
00007ff9`63450393 83c001          add     eax,1

Run1做加法的主要的指令如下, 可以看到多了两条mov操作eax寄存器.

00007ff9`63450320 8b4108          mov     eax,dword ptr [rcx+8]
00007ff9`63450323 83c001          add     eax,1
00007ff9`63450326 894108          mov     dword ptr [rcx+8],eax
00007ff9`63450329 83c201          add     edx,1

备注: http://zh.wikipedia.org/wiki/X86调用约定 可以看具体在寄存器的约定.

mov操作从rcx寄存器+8的位置上取了个数(这个是托管堆上对象所在地方). 第一步从存储中取数到eax寄存器, 第二步, 对寄存器加1, 第三步, 将寄存器的值写回到存储器. 第四句是i++操作先忽略.

为了满足大家的好奇心, 我将对象的布局打一下.可以看到offset 8 的地方存放着我们要的count值.

0:003> !do 000000000253baf0 
Name: Demo
MethodTable: 00007ff9632f3c20
EEClass: 00007ff9634422d8
Size: 24(0x18) bytes
 (C:\Users\cuiweifu\AppData\Local\Temp\SnippetCompilerTemp\8d61e6aa-2c9c-41b4-b8a5-174f83e44e0a\output.exe)
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ff9c1f9f060  4000002        8         System.Int32  1 instance       2147483647 count

好了,我们知道以上操作细节了, 让我们回顾一下计算机组成原理中的一些基本常识.
离CPU的执行速率越高, L1 > L2 > L3 > 内存 > 硬盘 > 其他外设.
寄存器是CPU的一部分 (忘记的参看这里 http://zh.wikipedia.org/wiki/寄存器 ), 所以寄存器的操作是最快的. 而对缓存的访问取决在哪一个层次上命中.

就本例来说,这是寄存器 PK L1的测试结果.

这里需要提到的一点是这个加操作是在L1做的, 所以内存中真正的值是靠CPU刷新到内存中的. 如果是在多线程的环境下同时的调用Run1方法, 那么输出值是不确定的.
我们.NET中为了避免这件事情, 使用的方法是

System.Threading.Interlocked.Increment(ref this.count);

这个最终转换成为CPU的一个原子指令 (我记得不同的CPU上指令名称不一样,所以这里就不提指令的名字了). 这个指令能保证原子性的更新这个值.

可以测试这种方法虽然原子,但是比Run1方法还会慢一点. 具体的原因感兴趣的同学自己研究吧.

题外话: 首先感谢杨杰同学的催促,我才努力挤出点时间写这篇文章, 虽然文章是粗枝大叶的描述了一下希望对大家的理解有所帮助.
我本想从编译原理的角度来谈一些事情, 从CPU指令以及操作系统的视角上看这个问题, 但是我发现以我拙笨的描述能力很难在一篇blog中说清, 而且需要提及的东西太多, 有太多相关概念需要了解,所以也只能浅尝辄止了. 如果大家喜欢一些原理细节的东西欢迎跟我探讨.

ps: 公司正在招聘.NET程序员, 前端工程师, 数据库工程师,如果有兴趣请私信我