第 2 章 c语言多线程编程

这部分我也是现学现卖。C11标准引入了线程支持,但是直到glibc的2.28版本才实现了C11标准的线程。不幸服务器上的glibc版本是2.17。

其实在linux下,c语言早已有线程库pthread了。据说glibc里的线程库就是直接把pthread封装了一下
(~ ̄▽ ̄)~

其实pthread和thread我都不会用,为了拥抱新标准简单学习了一下C11的thread,这里就简单讲一下。

2.1 还是乘加运算

大切な人といつかまた巡り会えますように。——『Plastic Memories』

这里是4个线程进行运算的c代码

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <threads.h>

struct Input{
    double* a;
    double* b;
    double* c;
    double* d;
    unsigned long long N;
    unsigned long long R;
};

__attribute__ ((noinline))
int muladd_th(void* input){
    struct Input* in;
    unsigned long long i, j;
    double* va;
    double* vb;
    double* vc;
    double* vd;
    in = (struct Input*)input;
    va = in->a;
    vb = in->b;
    vc = in->c;
    vd = in->d;
    for(i = 0; i < in->R; i++){
        for(j = 0; j < in->N; j++){
            vd[j] = va[j] * vb[j] + vc[j];
        }
    }
    return 0;
}

__attribute__ ((noinline))
void muladd(double* a, double* b, double* c,
            double* d, unsigned long long N,
            unsigned long long R){
    thrd_t threads[4];
    struct Input inputs[4];
    unsigned long long i;
    for(i = 0; i < 4; i++){
        inputs[i].a = a + N/4*i;
        inputs[i].b = b + N/4*i;
        inputs[i].c = c + N/4*i;
        inputs[i].d = d + N/4*i;
        inputs[i].N = N/4;
        inputs[i].R = R;
        if(i == 3){
            inputs[i].N = N-N/4*3;
        }
        thrd_create(&(threads[i]), muladd_th, &(inputs[i]));
    }
    for(i = 0; i < 4; i++){
        thrd_join((threads[i]), NULL);
    }
    
}

int main(){
    double* a = (double*)(malloc(8192*sizeof(double)));
    double* b = (double*)(malloc(8192*sizeof(double)));
    double* c = (double*)(malloc(8192*sizeof(double)));
    double* d = (double*)(malloc(8192*sizeof(double)));
    
    //Prepare data
    unsigned long long i;
    for(i = 0; i < 8192; i++){
        a[i] = (double)(rand()%2000) / 200.0;
        b[i] = (double)(rand()%2000) / 200.0;
        c[i] = ((double)i)/10000.0;
    }
    
    struct timespec start, stop;
    double elapsed;
    clock_gettime(CLOCK_MONOTONIC, &start);

    muladd(a, b, c, d, 8192, 1000000);
    clock_gettime(CLOCK_MONOTONIC, &stop);
    elapsed = (double)(stop.tv_sec-start.tv_sec);
    elapsed += (double)(stop.tv_nsec-start.tv_nsec)/ 1000000000.0;
    printf("Elapsed time = %8.6f s\n", elapsed);
    for(i = 0; i < 8192; i++){
        if(i % 1001 == 0){
            printf("%5llu: %16.8f * %16.8f + %16.8f = %16.8f (%d)\n",
                   i, a[i], b[i], c[i], d[i], d[i]==a[i]*b[i]+c[i]);
        }
    }

    free(a);
    free(b);
    free(c);
    free(d);
}

简单说一下。需要#include <threads.h>。这里把4096维数组拆成4份,分别由四个线程完成计算。1,000,000次的循环也放到线程里面做。此外,这里还更换了计时函数。由于之前使用的计时函数在多线程的情况下会把每个线程的时间加起来,计算的时间就不对了。如果亲自试一试的话,会发现感受到的时间比输出的时间要短。

此外,上述程序需要glibc>=2.28. 要再x40上运行的话可以自己编译一个glibc。我尝试了编译glibc 2.30。glibc 2.30需要的gmake版本比x40带有的要高一些,所以又得自己编译一个gmake。简单说一下流程:

下载最新的gnu make的源代码,再里面直接

  $ ./configure
  $ make

得到一个make的可执行文件。
将这个make重命名为gmake并拷贝到一个地方,比如~/bin/
再把这个地方加入到$PATH环境变量里export PATH=~/bin/:$PATH.
然后下载glibc 2.30的源代码,解压。在其目录里面创建一个build文件夹并进入。

  $ ../configure --prefix=~/glibc230/
  $ gmake -j10 all
  $ gmake install

注意一定要指定--prefix,否则make install会尝试将glibc安装到系统目录。
这里假设你将glibc安装到~/glibc230.
安装好以后就可以编译muladd_mt.c了

gcc -L~/glibc230/lib -I~/glibc230/include \
-Wl,--rpath=~/glibc230/lib \
-Wl,--dynamic-linker=~/glibc230/lib/ld-linux-x86-64.so.2 \
-std=c11 \
-o muladd_mt muladd_mt.c -pthread

结果,用时6秒左右。

注意!后面的thrd_join((threads[i]), NULL);非常重要,一定不可省略。这句话保证了调用该函数的线程在这里等,直到threads[i]的线程结束。第二个参数如果不是NULL的话,threads[i]线程会把返回值写到第二参数指示的地址。如果不写thrd_join会导致主线程提前退出,于是threads[i]线程的运行结果就拿不到了。这是不好的。

thrd_create函数的三个参数:
第一个是一个指向thrd_t结构的指针,后面thrd_join等操作都需要这个thrd_t结构,相当于线程的编号。
第二个参数是一个函数指针(直接用函数名即可)。这个函数必须是接受一个void*参数并返回一个int的函数。void*参数用来向线程传递数据。
第三个参数就是要向上述函数传入的参数。由于参数需要是一个指针,因此在上面的程序里面设计了一个结构Input,里面包含了计算所需的信息。
创建完后线程就运行起来了。这时该干啥干啥,然后用thrd_join等结果就行了。

2.2 本章小结

又是这个环节!线程比SIMD灵活多了,每个线程可以做不同的事情(可以根据传入的参数判断一下)。线程们在多核心CPU上可以同时执行,可以看到同样的内存空间。用个循环把它们安排明白就可以开始等了😃

相信你的脑中又一次充满了问号,我再尝试自问自答一下。


问:这里也是muladd函数,为什么不直接把它执行1,000,000遍?
答:这是因为线程的创建和销毁是有代价的。频繁创建和销毁线程会占用过多的资源。最好是创建好线程后让它执行一整套任务。此外,有一种“线程池”的技术,可以先创建一个包含一些线程的池,然后向线程池发送任务。接到任务后线程池会自动唤醒一个线程取执行任务,这样就避免了频繁创建/销毁线程所需的开销。然而由于我学艺不精,并不知道具体如何实现一个线程池(但是想必存在开源的已经写好的线程池库)。当然,也可以在主线程中准备数据,每准备好一部分就开启一个线程进行处理,最后再等待所有线程结束后收集好计算结果。但是要注意,最大线程数量是有限制的,请合理划分数据。


问:你这多线程编程自己都不会,还出来写指南?
答:正常小朋友一般问不出来这种问题。