一直以来对锁比较感兴趣。因为在多核编程中,锁是一个可恨有可爱的东西。说它可恨,是因为锁的使用,降低了并发性,也就降低了性能。可爱之处呢,因为锁的使用有时是无法避免的。那么如何实现一个高性能的锁又是一个很有意思的问题。以后有机会,再跟大家交流一下锁的实现部分。


今天是我在看spinlock的时候,突然想到的问题。这个问题不局限于spinlock,下面的示例我使用mutex来代替。


#include <stdlib.h>

#include <stdio.h>

#include <pthread.h>


extern int counter;

extern pthread_mutex_t counter_mutex;


void add_counter(void)

{

    pthread_mutex_lock(&counter_mutex);

    ++counter;

    pthread_mutex_unlock(&counter_mutex);

}

counter是由counter_mutex保护的。在更新counter的时候,必须要先持有counter_mutex,这样才能保证正确更新counter。另外锁的实现中,一般需要有内存barrier的指令,来禁止CPU的乱序执行。如果没有barrier的话,在CPU的指令执行过程中,counter的更新很可能发生在unlock之后。这些问题也不是今天的重点。


内存barrier指令只能保证CPU在barrier前的内存指令必须完成。但是如果在编译器将counter放到了寄存器中怎么办?比如在持有counter_mutex之前,对counter有读取的操作。那么编译器很可能会将counter在lock之前,就存到寄存器中。这样,在持有lock之后,counter因为之前已经读取到寄存器中了,这个++counter是否会直接对寄存器操作呢?如下面的代码:


#include <stdlib.h>

#include <stdio.h>

#include <pthread.h>


extern int counter;

extern pthread_mutex_t counter_mutex;


#define ASM_SEPERATOR __asm__ __volatile__ ("nop")


void add_counter(void)

{

    /* 下面的代码对counter进行了读取操作,那么counter会被放入到寄存器中 */

    int t = counter;

    ASM_SEPERATOR;

    printf("counter is %d\n", counter);

    ASM_SEPERATOR;


     /* 在前面的代码中,counter已经被放入到了寄存器中,那么下面的更新是否会直接更新该寄存器呢? */

    pthread_mutex_lock(&counter_mutex);

    ASM_SEPERATOR;

    ++counter;

    ASM_SEPERATOR;

    pthread_mutex_unlock(&counter_mutex);

}


当想到这个问题的时候,心里升起一阵寒意。因为这样的代码肯定会存在于我们的工程中。在持有锁之前,对保护的资源进行读取的动作,这是一个很平常的行为。如果前面的读取动作导致该资源被放到寄存器中,岂不是导致锁失效了?难道在这种情况下,即使是读取动作也要加锁保护吗?如果假设为真的话,那么有bug的代码就太多了,那么早就报出很多问题了。所以这种使用方法应该是没有问题的。


还是让我们看一下反汇编吧:


00000000 :

extern pthread_mutex_t counter_mutex;

#define ASM_SEPERATOR __asm__ __volatile__ ("nop")

void add_counter(void)

{

0: 55 push %ebp

1: 89 e5 mov %esp,%ebp

3: 83 ec 28 sub $0x28,%esp

int t = counter;

6: a1 00 00 00 00 mov 0x0,%eax

b: 89 45 f4 mov %eax,-0xc(%ebp)

ASM_SEPERATOR;

e: 90 nop

printf("counter is %d\n", counter);

f: 8b 15 00 00 00 00 mov 0x0,%edx

15: b8 00 00 00 00 mov $0x0,%eax

1a: 89 54 24 04 mov %edx,0x4(%esp)

1e: 89 04 24 mov %eax,(%esp)

21: e8 fc ff ff ff call 22

ASM_SEPERATOR;

26: 90 nop

27: c7 04 24 00 00 00 00 movl $0x0,(%esp)

2e: e8 fc ff ff ff call 2f

pthread_mutex_lock(&counter_mutex);

33: 90 nop

ASM_SEPERATOR;

34: a1 00 00 00 00 mov 0x0,%eax

39: 83 c0 01 add $0x1,%eax

3c: a3 00 00 00 00 mov %eax,0x0

++counter;

41: 90 nop

ASM_SEPERATOR;

42: c7 04 24 00 00 00 00 movl $0x0,(%esp)

49: e8 fc ff ff ff call 4a

pthread_mutex_unlock(&counter_mutex);

4e: c9 leave

4f: c3 ret

红色部分的代码是将counter赋给t,这时counter已经被存入到eax中。而蓝色的代码是++counter。这里显示在counter进行自加的时候,是重新读取counter到寄存器中,然后再做自加,并没有直接利用前面的寄存器eax。


上面的汇编是没有使用优化选项的输出,下面是使用-O2优化的汇编结果:


Disassembly of section .text:

00000000 :

0: 55 push %ebp

1: 89 e5 mov %esp,%ebp

3: 83 ec 18 sub $0x18,%esp

6: 90 nop

7: a1 00 00 00 00 mov 0x0,%eax

c: c7 04 24 00 00 00 00 movl $0x0,(%esp)

13: 89 44 24 04 mov %eax,0x4(%esp)

17: e8 fc ff ff ff call 18

1c: 90 nop

1d: c7 04 24 00 00 00 00 movl $0x0,(%esp)

24: e8 fc ff ff ff call 25

29: 90 nop

2a: 83 05 00 00 00 00 01 addl $0x1,0x0

31: 90 nop

32: c7 04 24 00 00 00 00 movl $0x0,(%esp)

39: e8 fc ff ff ff call 3a

3e: c9 leave

3f: c3 ret

在t=counter时,依然是将counter放入到eax中,然后在将eax的值赋给t。而++counter的时候,干脆不用寄存器了,直接对内存进行加1的操作(x86支持对内存的加法操作)。


从汇编的结果上看,我之前的想到的问题有些杞人忧天了。即使counter在lock之前被存入某个寄存器,在自加的时候,仍然会重新读取,而不是直接使用那个寄存器。那么为什么编译器会产生这样的结果呢?因为使用了lock?比如lock的API中会有某个指令导致编译器生成这样的代码?我认为不可能。因为这样对编译器提出了非常过分的要求。因为编译的时候,编译器根本不会去检查调用的函数。在本例中,这个函数是pthread库函数,但是很多时候,这个函数甚至可以不存在。所以这个猜想肯定不对的。那么只有一个合理的解释了,因为counter是一个外部变量(非本函数内部定义)。编译器会假设该变量可能随时都会被外部更改,所以在任何时候,都需要重新读取到寄存器再使用。


这次我们干脆不是用全局变量,而是使用传入的参数:

#include <stdlib.h>

#include <stdio.h>

#include <pthread.h>


#define ASM_SEPERATOR __asm__ __volatile__ ("nop")


void add_counter(int *counter)

{

    int t = *counter;

    ASM_SEPERATOR;

    printf("counter is %d %d\n", t, *counter);

    ASM_SEPERATOR;


    ASM_SEPERATOR;

    ++*counter;

    ASM_SEPERATOR;

    printf("counter is %d\n", *counter);


}

反汇编输出:


00000000 :

0: 55 push %ebp

1: 89 e5 mov %esp,%ebp

3: 53 push %ebx

4: 83 ec 14 sub $0x14,%esp

7: 8b 5d 08 mov 0x8(%ebp),%ebx

a: 8b 03 mov (%ebx),%eax

c: 90 nop

d: 89 44 24 08 mov %eax,0x8(%esp)

11: 89 44 24 04 mov %eax,0x4(%esp)

15: c7 04 24 00 00 00 00 movl $0x0,(%esp)

1c: e8 fc ff ff ff call 1d

21: 90 nop

22: 90 nop

23: 8b 03 mov (%ebx),%eax

25: 83 c0 01 add $0x1,%eax

28: 89 03 mov %eax,(%ebx)

2a: 90 nop

2b: 89 44 24 04 mov %eax,0x4(%esp)

2f: c7 04 24 12 00 00 00 movl $0x12,(%esp)

36: e8 fc ff ff ff call 37

3b: 83 c4 14 add $0x14,%esp

3e: 5b pop %ebx

3f: 5d pop %ebp

40: c3 ret

蓝色部分仍然是自加的代码++*counter,和全局变量的counter一样,都是需要将外部变量的值读入到寄存器中,然后进行运算,再存入到寄存器中。


至此,我们得出结论,编译器在处理外部变量的时候,每次都需要重新读取到寄存器中,然后再使用。