同步机制(一)

  什么是同步机制?

    同步机制 :在并发程序设计中,各进程对公共变量的访问必须加以制约,这种制约称为同步。

  为什么需要同步机制?

    当计算机只运行一个线程的时候,自然不需要同步。所有的资源都是这个线程独享。那么就不会有任何竞争。

    但是当计算机出现了多个线程的时候,那么就出现了各种麻烦,为了处理这些麻烦我们就需要使用一些办法来解决这些麻烦。

    多线程引出的麻烦(对资源的竞争导致的出错) : 

      设想存在A,B两个线程。对同一数据C进行修改。首先A读取数据C,得知C=13。这时候发生了线程切换。切换为B,然后B读取C,得知C=13。然后修改C,将C减少1,最后保存C。这时候C为12。执行结束后,又切换回A,A将C增加1,但是A得知的C是13,于是C=13+1=14。保存C。这时候C=14。

      将上述例子代入生活中来说 :首先A本来是从存钱罐里塞进去了一块钱,B拿出了一块钱。但是最终结果确实C从13元变成14元了。莫名其妙多了一块钱了!

  如何实现同步机制?

    原子操作 : 原子操作的意思就是不可切割,不可打断的操作。

      CPU层面的原子操作 : 对于CPU而言,一条机器指令(机器指令和汇编指令是一一对应的关系,所以后面的例子我会采取汇编指令代替机器指令)就是一个原子操作。因为中断随时有可能发生,但只会发生在两句机器指令之间,而不会在一句机器指令执行到一半就发生,这是不被允许的(被动的进程切换就是通过时钟中断处理程序来实现的)。

      CPU是如何实现原子操作的?

        多种情况分析:

        例1) 多核CPU对自己的寄存器进行修改不存在公共资源的竞争,因为寄存器是CPU私有的(每个CPU都有自己的寄存器),其它的CPU无法直接访问。

        例2) 单核CPU对自己的寄存器进行修改 : 同上

        例3) 单核CPU对内存(公共变量)进行修改 : 因为只有一个CPU,一个内存,也就可以理解为内存是CPU私有的。

        例4) 多核CPU对同一地址的物理内存(公共变量)进行修改 :

            这时候会存在公共资源的竞争,我们可以简单地把CPU修改内存的内容分为三步 : 

              1) 读取对应内存上的内容

              2) 用内容进行计算

              3) 将结果写入对应内存

        cpu0的任务是将内存1111的值+1:inc word ptr ds:[1111]

        cpu1的任务是将内存1111的值-1 : dec word ptr ds:[1111]

        那么就回到了我们之前提到的例子 : 多线程引出的麻烦(对资源的竞争导致的出错)

        什么是总线锁 : 就是使用处理器提供的一个LOCK信号,当一个处理器在总线上输此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。      

        解决策略 : 在进行操作 1)之前,先使用总线锁锁定这段内存(例如 : 1111 ~ 1113)。使得其他CPU无法对该内存操作,当操作 3) 结束,就释放这段内存的总线锁。

      那么CPU层面的原子操作的支持也就形成了 : 一条机器指令就是一个原子操作

    软件层面的原子操作 : 

      原子整数 : 既然机器指令允许直接对内存进行算术运算,那么直接设置一个宏或函数,使用汇编操作该变量即可达成原子整数了。

       原子整数的加法代码实现(任意平台都能运行,这个本来就不需要头文件):

int add (int * pval, int num){
    int old;
    __asm__  volatile( // volatile : 修饰内嵌汇编时表示不要优化指令
        "lock; xaddl %2,%1;" // %1 += %2
        : "=a" (old) // =表示是输出参数,a表示rax寄存器
        : "m" (*pval) , "a"(num) // m 表示内存变量, a表示rax寄存器
        : "cc", "memory"
    );
    return old;
}

    虽然看上去这并不是一句汇编,但实际上只有 : lock xaddl %2,%1,这句汇编是临界区,也就是说,这句话锁上了内存,然后将pval的虚拟地址对应的物理地址上的数据增加了num。后面的 return old,只不过把最后结果返回,但是实际上返回值为Null也完全不会影响,因为早已经通过指针修改了那块地址的内容了。

    原子操作的使用(任意平台都能运行,这个本来就不需要头文件):

int add (int * pval, int num){
    int old;
    __asm__  volatile( // volatile : 修饰内嵌汇编时表示不要优化指令
        "lock; xaddl %2,%1;" // %1 += %2
        : "=a" (old) // =表示是输出参数,a表示rax寄存器
        : "m" (*pval) , "a"(num) // m 表示内存变量, a表示rax寄存器
        : "cc", "memory"
    );
    return old;
}

int main (){
    int c = 1;
    add(&c, 100);
    // printf("c : %d
",c);
    return 0;
}

    在这里仅仅提供了单线程的使用,实际上多线程也是这么使用的。

    在多线程模式下的运行(在Linux平台下可运行):

# include<stdio.h>
# include<unistd.h>
# include<pthread.h>

#define THREAD_COUNT 10
/*
关于
volatile的本意是“易变的” 因为访问寄存器要比访问内存单元快的多,所以编译器一般都会作减少存取内存的优化,但有可能会读脏数据。
当要求使用volatile声明变量值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。
精确地说就是,遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问;
如果不使用valatile,则编译器将对所声明的语句进行优化。

与 register 相反。
*/

/*
内嵌汇编语法如下 :
       __asm__(汇编语句模板: 输出部分: 输入部分: 破坏描述部分)
共四个部分:汇编语句模板,输出部分,输入部分,破坏描述部分,各部分使用":"格开,汇编语句模板必不可少,其他三部分可选,'
如果使用了后面的部分,而前面部分为空,也需要用":"格开,相应部分内容为空。
例如:
             __asm__ __volatile__("cli": : :"memory")
*/
int add (int * pval, int num){
    int old;
    __asm__  volatile( // volatile : 修饰内嵌汇编时表示不要优化指令
        "lock; xaddl %2,%1;" // %1 += %2
        : "=a" (old) // =表示是输出参数,a表示rax寄存器
        : "m" (*pval) , "a"(num) // m 表示内存变量, a表示rax寄存器
        : "cc", "memory"
    );
    return old;
}

void* thread_callback(void *arg){
    int * pcount = (int *)arg;  
    int i = 0;

    while(i++ < 100000){
        // (*pcount)++;
        add(pcount,1);
        usleep(1);
    }
}

int main (){
    pthread_t threadid[THREAD_COUNT] = {0}; // 初始化
    int i = 0;
    int count = 0;

    // 创建线程
    for (i = 0; i < THREAD_COUNT; i++){
        pthread_create(&threadid[i], NULL, thread_callback, &count);// thread_callback, &count : 函数与其参数
    }

    for (i = 0; i < 10; i++){
        printf("count : %d
", count);
        sleep(1);
    }
    return 0;
}

 

 

原文地址:https://www.cnblogs.com/vizdl/p/12247200.html