多核、多处理器环境下多线程同步技巧

多核、多处理器环境下多线程同步技巧,来自CocoaChina。

我们这里大部分人应该已经熟悉了在单核单处理器的环境下对多线程进行同步的方法。传统的方法有:Mutex(互斥体)、Semaphore(信号 量)、Event(事件)、MailBox(邮箱)、Message(消息)等。这些方法都有个共同的特性——即在使用这些方法的前、后会分别加 上关中断、开中断操作(或是任务调度的禁止、开启)。
而在多核情况下单单开关中断是不起作用的。因为当一个线程在核心0中运行时,它无法一直去关 心核心1中的线程运行状态,它甚至不知道核心1到底在运行哪个线程。
因此需要通过另一种方法来解决线程同步问题。
这里比较早的方法是通过原子操作做成旋锁进行同步。
什么是原子操作?
原子操作是指当对一个指定的存储单元进行这样的操作时,该操作不会被任何事件打断; 另一方面,当对一个指定的存储单元进行这样的时,其它外部的访问都无法对该存储单元进行访问,直到该操作完成。

Intel在x86架构下 提供xchg指令来实现对存储器的原子操作:
XCHG    [memory],     register
其功能是将register 的值与指定存储器单元的值进行叫唤。
下面贴出代码示例:

  1. .text
  2. .align 2
  3. .globl _cmpxchg_64
  4. .globl _xchg_64
  5.  
  6. // extern long xchg_64(register volatile long *pMem, register long reg);
  7. _xchg_64:
  8.  
  9.     xchg    %rsi, (%rdi)
  10.     mov     %rsi, %rax
  11.     ret


上面代码是一份AT&T的汇编源代码,通过.s文件进行保存。
下面贴出如何在C源文件中进行调用该函数:

  1. #include <stdio.h>
  2.  
  3. // pMem:rdi     reg:rsi
  4. extern long xchg_64(register volatile long *pMem, register long reg);
  5.  
  6. int main (int argc, const char * argv[])
  7. {
  8.     // insert code here...
  9.     volatile long a = 20;
  10.     register long b = 10;
  11.     
  12.     b = xchg_64(&a, b);
  13.     
  14.     printf("a = %ld, b = %ld\r\n", a, b);    
  15.     return 0;
  16. }

好了。我们有了原子操作后就能做出旋锁机制来解决多核环境的多线程同步问题。
下面通过一个简单的代码来说明问题:

 
extern long xchg_64(register volatile long *pMem, register long reg);
 
int try_get_lock(volatile long *pLock)
{
return !xchg_64(pLock, 1);
}
 
void release_lock(volatile long *pLock)
{
xchg_64(pLock, 0);
}
 
static volatile int counter = 0;
 
static void ThreadProc(void)
{
static volatile long myLock = 0;
while (!try_get_lock(&myLock));
counter++;
release_lock(&myLock);
}


这里先自己规定,锁的初始值是0,当有线程加锁时,对其置为 1,释放锁时再将它置为0。
这里我们有一个线程例程ThreadProc,它将会被多个线程调用。这个例程的功能就是将counter值递增。当 一个线程进行counter++时,它先取出counter的值,放到寄存器中,然后对该寄存器做加1操作;最后再写回counter。


如果这里 没有旋锁会发生啥情况呢?
呵呵,将可能会有一个戏剧性的一幕:比如counter的值为0,然后,当线程A将counter值读入寄存器时,线程 B恰恰刚好完成了一次加1操作,此时counter已经变成1了;但是这时,线程A中的寄存器值还是原来的0,因此再当线程A将counter的值递增到 1时,它的加1操作等于是失败了。
而使用旋锁就不会出问题。因为在将counter的值取出前已经有了锁保护,当线程A获得锁之后,myLock 的值会变成1,此时,其它线程再调用try_get_lock都会返回0,而进行无休止的轮询。由于这个操作非常快,因此对整体性能而言不会有大的影响。 counter++操作完成后就会立即释放锁。
但是为了防止本核由于外部事件而被切出,在必要的场合下还可以添加开关中断;另外使用旋锁还有一个 非常重要的注意事项——当一个线程获得旋锁后,在其将锁释放之前不能够将该线程销毁,否则会使其它线程处于死锁状态。因为锁得不到释放,因此其它线程都会 陷入无止境的轮询,呵呵。因此加入开关中断操作能够同时也能避免这种情况发生。

上面讲述了如何通过旋锁解决多核、多处理器环境下的多线程同步问题。
旋锁有一些很大的弊端:首先就是可能造成死锁,除非用开关中断;另一方面,当 线程数量猛增时,锁的数量也会同时增加,这对于锁的管理而言会成为很大的软件管理的负担。因此这里将引入一种Lock-Free的方法来使得一些简单的操 作不需要通过锁进行同步。
Intel在i386架构下提供了CMPXCHG原子操作。该指令的原型如下:
CMPXCHG    memory,    register
功能:将EAX(在64位模式下:RAX)与memory中的值进行比较,如果相同,ZF标志置1,并且register中的值被加载到memory中;否 则ZF标志位清0,并且将memory中的值加载到register中。

下面将该原子操作封装成一个函数,以便C/C++以及 Objective-C/C++能够调用:

  1. .text
  2. .align 2
  3. .globl _cmpxchg_64
  4.  
  5. // cmpData:rdi      loadReg:rsi     pMem:rdx
  6. // extern long cmpxchg_64(register long cmpData, register long *loadReg, register volatile long *pMem);
  7. _cmpxchg_64:
  8.     push    %rcx
  9.     push    %rbx
  10.  
  11.     mov     %rdi, %rax
  12.     mov     (%rsi), %rcx
  13.     xor     %rbx, %rbx
  14.     cmpxchg %rcx, (%rdx)
  15.     cmovnz  %rax, %rcx
  16.     mov     $1, %rax
  17.     cmovz   %rax, %rbx
  18.     mov     %rcx, (%rsi)
  19.     mov     %rbx, %rax
  20.     
  21.     pop     %rbx
  22.     pop     %rcx
  23.  
  24.     ret

下面我们将用Lock-Free的方法来改写ThreadProc:

  1. #include <stdio.h>
  2.  
  3. extern long cmpxchg_64(register long cmpData, register long *loadReg, register volatile long *pMem);
  4.  
  5. static volatile long counter = 0;
  6.  
  7. static void ThreadProc(void)
  8. {
  9.     register long oldValue = counter;
  10.     long newValue = oldValue + 1;
  11.     
  12.     while(!cmpxchg_64(oldValue, &newValue, &counter))
  13.     {
  14.         oldValue = newValue;
  15.         newValue = oldValue + 1;
  16.     }
  17. }
  18.  
  19.  
  20. int main (int argc, const char * argv[])
  21. {
  22.     // insert code here...
  23.     ThreadProc();
  24.     printf("The counter is: %ld\r\n", counter);
  25.     
  26.     return 0;
  27. }

这里稍微讲解一下。
在ThreadProc中,我们的目的仍然是要将counter加1。那么我们先读取counter的值,然后将该值加1后的 值存储到一边。然后我们就可以用自己打造的cmpxchg_64做原子操作了。如果返回值为1,说明原来的值与counter在比较时的值是完全一样的。 比如,初始值为0,那么在交换前counter的值没有被其它线程改写,因此仍然为0,这时就可以将加1后的值写入counter。由于cmpxchg是 一个原子操作,因此整个比较与交换的过程是原子的,外部不会打断此操作,并且在此操作期间,总线会将counter锁住,使得其它线程要访问 counter时,其所在的核都会被阻塞。
如果返回0,说明counter被读取后交换前被修改过,那么这里就要重新获得counter的值并且 在此基础上做加1操作,然后再执行cmpxchg操作,直到最后成功。

我们可以清楚地看到,这段代码中没有添加任何锁,而且,如果正在 ThreadProc的一个线程被销毁也不会影响其它线程的执行,从而我们根本就不需要添加开关中断的形式来保证避免其它线程死锁的问题。
另外, 大家可以在3楼代码中第11行插入counter++;在 看看结果。

最后再提供一份关于Lock-Free比较好的资料:Non-blocking synchronization

其中,cmpxchg是属于CAS机制;而在ARMv7架构中的LDREX/STREX是属于 LL/SC机制。它们都可以用来实现Lock-Free的方法。然而LL/SC机制使用起来必须更加谨慎,因为LL和SC往往必须成对出现,中间也不该允 许被中止。所以这个机制大多架构中用的比较少。现在ARMv7以及Power架构有这个指令集,而ARMv7只有这一种方法能实现 Lock-Free。而ARMv7之前,只能使用SWP指令来实现旋锁,SWP与XCHG功能完全一样。

阅读更多

没有更多推荐了,返回首页