请稍侯

C语言多线程编程初探--MinGW+pthread

20 August 2014

  前两天看到何登成的博客中谢了一篇关于锁的文章[1],于是想在本地搭建环境试验下文章中的例子,自己的机器是windows,装了MinGW,想着如何能实现多线程,google了小下,发现pthread,pthread其实就是POSIX thread,定义了创建和操纵线程的一套API,一般在类Unix系统中都已经存在,在windows上也有移植,可以直接使用Windows API实现的第三方库pthreads-win32,这里就是介绍如何将pthreads-w32与MinGW一起使用,完成一个最简单的多线程例子,因为pthreads-win32是使用的windows API实现的,更底层的细节可能和在Linux下实现稍有区别,因为我在CentOS中执行代码时效果与在Windows下一些区别,当然大的方向是没问题的,只是一些小的方面,比如说创建完的线程的执行顺序上。
  首先在pthread win32官网上下载最新的版本,解压之后,我们需要的是其中Pre-built.2文件夹中的东西,里面有三个文件夹:dll、include和lib。

1 将dll\x86下的pthreadGC2.dll和pthreadGCE2.dll拷贝到MinGW的bin文件夹下;
2 将include文件夹下的pthread.h、sched.h和semaphore.h拷贝到MinGW的include文件夹下;
3 还有将lib\x86下的libpthreadGC2.a和libpthreadGCE2.a拷贝到MinGW的lib下面。

  这样我们就可以在MinGW中调用pthread库了。下面的代码是博客中的例子,用来表明有锁和无锁的区别的。

#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include "myspinlock.h"

// 是否使用spinlock包含全局变量,默认是不使用,改为1则为使用
#define USE_SPINLOCK 0
sem_t beginSemaArr[500];

sem_t endSema;

int global_count;
int add_loops = 10000;

spinlock slock = 0;

void *threadAddFunc(void *param)
{
    long thread_id = (long)param;

    for (;;)
    {
        sem_wait(&beginSemaArr[thread_id]);  // Wait for signal
     
        for (int i = 0; i < add_loops; i++)
        {
#if USE_SPINLOCK
         spin_lock(&slock);
            global_count++;
            spin_unlock(&slock);
#else
         global_count++;
#endif
     }

        sem_post(&endSema);  // Notify transaction complete
 }
    return NULL;  // Never returns
};

int main()
{
    // Initialize the semaphores
 for (int i = 0; i < 500; i++)
        sem_init(&beginSemaArr[i], 0, 0);
    sem_init(&endSema, 0, 0);
    // Spawn the threads
 pthread_t threadArr[500];
    for (int i = 0; i < 500; i++)
        pthread_create(&threadArr[i], NULL, threadAddFunc, (void *)i);
    // Repeat the experiment ad infinitum
 for (int iterations = 1; ; iterations++)
    {
        // 重设 global_count
     global_count = 0;
        // Signal threads
     for (int j = 0; j < 500; j++)
        sem_post(&beginSemaArr[j]);
        // Wait for threads
     for (int j = 0; j < 500; j++)
        sem_wait(&endSema);
        // threads all finish, print global_count
     if (global_count != 5000000)
        printf("iter %d: global_count = %d\n", iterations, global_count);
    }
    return 0;  // Never returns
}

  安装完使用gcc -o pthread.exe pthread.c -lpthreadGC2命令来编译(注意顺序,-lpthreadGC2要放在后面,放在前面会报错=。=!,这里之所以是-l(-l参数表明使用什么库)后面加的是pthreadGC2是因为我们之前拷贝的库的名字是libpthreadGC2.a,在使用的时候需要将前缀lib和后缀.a去掉使用):

C:\Users\Jeremy\AppData\Local\Temp/ccyEbaaa.o(.text+0xe4):globalcount.cpp: undefined reference to `_imp__pthread_create'
C:\Users\Jeremy\AppData\Local\Temp/ccyEbaaa.o(.text+0x118):globalcount.cpp: undefined reference to `_imp__sem_post'
C:\Users\Jeremy\AppData\Local\Temp/ccyEbaaa.o(.text+0x139):globalcount.cpp: undefined reference to `_imp__sem_wait'
collect2: ld returned 1 exit status

  然后./pthread.exe就可以看到执行的过程了。这个例子中main函数创建了500个线程,在每个线程中对一个全局变量global_count进行加1的操作,通过信号量保证每一次迭代到末尾时500个线程全部执行完毕,理论上来说此时global_count应该是等于5000000的,可是当我们执行这个程序的时候,发现并不是每次global_count都是等于5000000的。这就意味着并不是所有的加1操作都是被执行到了。原因在于:++操作并不是一个原子操作,原子操作是指在执行完毕之前不会被任何其它任务或事件中断的操作。而我们使用的++语句,其对应的汇编语句有3句:

    global_count++;
00A1163C  mov         eax,dword ptr [global_count]  
00A1163F  add         eax,1  
00A11642  mov         dword ptr [global_count],eax  

  反汇编之后我们看到,global_count++需要先把global_count的值加载到eax中,然后将eax加1之后,再将eax写回到global_count。这就会导致,有可能当线程A执行到第二条汇编指令时,线程B开始执行第一条,于是A刚刚将eax寄存器加1,又被线程B覆盖了,此时虽然执行了两次++,但是global_count的值只被加了1。所以我们希望在执行++的时候不要被打断,于是就产生了锁的概念,每次执行++之前先加上一把锁,执行完之后再解锁,执行代码期间,如果其他线程需要判断这段代码有没有加锁,如果加锁了就需要等待之前加锁的线程把锁解开之后才能进行操作。何登成在样例代码中实现了一个自己写的SpinLock(自旋锁),自旋锁这个名字翻译的很直接,当线程在等待的时候,并没有进入阻塞状态,而是一直在“自旋”(通过一个while一直在查询锁的状态)。加上锁之后(也就是将USE_SPINLOCK定义为1),再次执行代码,没有一条语句打印出来。
  这里的SpinLock实现的就是我们熟悉的互斥的功能,互斥是表明线程对于资源的独占性,同一时间段中只有一个线程能够访问该资源。看下SpinLock的实现:

...
#define barrier() asm volatile("": : :"memory")
...
static inline unsigned xchg_32(void *ptr, unsigned x)
{
    __asm__ __volatile__("xchgl %0,%1"
                :"=r" ((unsigned) x)
                :"m" (*(volatile unsigned *)ptr), "0" (x)
                :"memory");

    return x;
}

#define EBUSY 1
typedef unsigned spinlock;

static void spin_lock(spinlock *lock)
{
    while (xchg_32(lock, EBUSY));
}
...

  使用内联汇编进行编写,选择其中的spin_lock函数稍微解释下。在spin_lock中是一个while循环,这个循环也是就是自旋的意思所在。xchg_32是一个内联汇编函数,内联汇编的格式大体如下:

    __asm__ __volatile__("<汇编语句模板> %0,%1...%9"
                :"=<限制字符>" <操作数>
                :"<限制字符>" <操作数>, "<限制字符>" <操作数>...
                :"<clobber列表>");

  函数中第一行是汇编语句模板[2],xchgl是汇编的交换指令,该指令需要两个操作数,分别使用%0和%1来表示,与函数的参数ptr和x对应;
  第二行的“=r”表明是这部分是输出,=表示输出,r表示将输出变量放入通用寄存器;
  第三行是输入部分,用于描述输入操作数,不同的操作数描述符之间使用逗号格开。操作数的描述部分前面都有一段双引号来进行修饰,这个双引号和其里面的部分称为限制字符,就像"m"、"0"等等,"m"表示该擦作数为内存变量,"0"表示它限制的操作数与第0个操作数匹配,也就意味着x与第0个操作数ptr一样,也是内存变量;
  最后一行是“Clobber列表”,意为被破坏的列表,如果指令连续使用一些寄存器。我们必须把这么寄存器列在clobber列表之中,也则是内联汇编程序里第三个冒号后的范围。这样是为了告诉GCC我们自己将会使用并且修改它们。这样GCC将不会认为这些寄存器里的值是可用的。我们没有必要列出用于输入和输出的寄存器。因为GCC知道asm程序使用到它们(因为它们在约束中明显地指出来)。如果指令使用任何其它的寄存器,无论是显式还是隐式的指出(同是这样的寄存器也没有在输入或者输出约束列表中出现),那么这些寄存器必须在clobber列表中出现。如果我们的指令会改变寄存器的值,我们必须加上"cc"到clobber列表上。如果我们的指令在不可预知的情况修改了内存,则要增加这个到clobber 列表增加”memory”。这样的话GCC在执行汇编指令时就不会在寄存器中保存这个内存的值。如果对内存的影响并没有列在input或者output中,那么我们还要增加volatile这个关键字[3]。
  该函数的功能就是当线程A使用spin_lock之后,EBUSY(1)就会与slock(一开始为0)发生交换,此时slock便等于1,程序返回0,继续执行,当其他线程B执行到spin_lock时,无论如何交换,slock和EBUSY都是1,所以函数返回1,此时该线程就会一直在while里面自旋,直到线程A使用spin_unlock将slock置为0。
  spin_unlock中再将slock置为0之前调用了barrier(),这句话什么都没做,只是clobber列表中写了"memory",说明下面要修改内存了,于是编译器不会在寄存器中保存slock的值而是直接写入到内存中。spin_lock中的slock(参数ptr)使用了"m"限制以及"volatile"的修饰,每次都会从内存中读取,所以一旦spin_unlock将slock置为0,spin_lock中的while就会退出while循环。
  更多的细节在何登成的博客中有说明,还有更深入的讨论,比如说Lock Acquire和Unlock Release语义的说明。这里只是自己实验了下,理解了下SpinLock的实现。

参考文献

  [1] 并发编程系列之一:锁的意义
  [2] GCC 内联汇编(GCC内嵌ARM汇编规则)
  [3] (转)GCC内联汇编入门