【Linux C++】线程安全-原子性、可见性、有序性

目录

一、为什么我们要使用多线程?

二、线程安全是什么?

三、线程安全的三个体现

      原子性

      可见性

      有序性

四、如何保证线程安全

      1、加锁

      2、原子操作-总线锁(原子操作函数、CAS、C++11atomic类)

       原子操作函数

       CAS指令(compare and swap)

       C11原子类型

      3、线程同步(本文先不讲解)

五、总结


一、为什么我们要使用多线程?

        1)为了解决负载均衡问题,充分利用CPU资源

        2)程序的运行效率可能会提高

二、线程安全是什么?

        线程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。

三、线程安全的三个体现

      原子性

        原子性的操作是不可被中断的一个或一系列操作。

        假设定义一个全局变量,我们需要实现var+1这个操作,这个操作分为三个部分,取到var,var+1,存放var。这三个动作分开来讲的话都是原子操作,如果合在一起而不受外界任何干扰,并且只能有一个线程操作这个变量的话,那么他也是原子性的。

        我们知道多线程和多进程不一样,多进程同时操作同名的全局变量时,他们操作的并不是同一个,也就是说,进程1对全局变量的更改不会影响到进程2。但是多线程不一样,他们操作的是同一个全局变量,线程1极有可能会影响线程2,这就导致了线程安全的问题

        为什么呢?

        我们说过var+1分为三个部分,取值、+1、存放,如果线程1在存放到内存之前,线程2去取这个全局变量,那么线程2取到的就是未+1的var,这个时候线程2对var的操作将没有任何意义。

        使用demo程序测试一下,源码及注释如下:

// 本程序用于"@是星星鸭"博客的测试程序
// 本程序创建了两个线程,让两个线程分别对var进行1000000次+1,那么理想状态下var最终值应该是2000000
// 但是结果中却有很多次并不是2000000,这就说明了该线程的操作不是原子性的,说明在线程1操作var的时候
// 线程2也操作了var。最终导致有很多次重复的结果,所以结果不是2000000
// 但是也有var成功达到2000000的结果,如果你循环10000000次,那么成功的几率就会越来越少。              

#include 
#include 
#include 
#include 
#include 

void *thmain1(void *arg);           // 线程1主函数
void *thmain2(void *arg);           // 线程2主函数
                                      
int var=0;                            // 被操作的全局变量

int main(int argc, char* argv[])
{   
    pthread_t thid1,thid2;          // 线程1、2的标识id

    // 创建线程
    if(pthread_create(&thid1,0,thmain1,0)!=0) { printf("create failed.\n"); return -1; }
    if(pthread_create(&thid2,0,thmain2,0)!=0) { printf("create failed.\n"); return -1; }

    // 等待线程退出
    pthread_join(thid1,NULL); pthread_join(thid2,NULL);

    // 打印出var经过两百次操作的最终值
    printf("var=%d\n",var);

    return 0;
}

// 线程1、2各自对全局变量进行1000000次加1
void *thmain1(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        var++;
    } 
}
 
void *thmain2(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        var++;
    }
} 

        makefile如下:

all: demo01

demo01:demo01.cpp
    g++ -g -o demo01 demo01.cpp -lpthread

clean:
    rm -rf demo01  

        运行结果如下:

【Linux C++】线程安全-原子性、可见性、有序性_第1张图片

 // 本程序创建了两个线程,让两个线程分别对var进行1000000次+1,那么理想状态下var最终值应该是2000000
// 但是结果中却有很多次并不是2000000,这就说明了该线程的操作不是原子性的,说明在线程1操作var的时候
// 线程2也操作了var。最终导致有很多次重复的结果,所以结果不是2000000
// 但是也有var成功达到2000000的结果,如果你循环10000000次,那么成功的几率就会越来越少。

      可见性

        你可能会好奇,我连原子性解决方案都没讲,为什么就开始将可见性了呢?因为这二者的联系很大,简单来说对于var操作的后半段就属于可见性的范围了。

        我们说过var+1分为取值、+1、赋值。那么从+1之后到存入内存之前,这一段就属于可见性了,线程变量的可见性问题,需要从操作系统的CPU、缓存、内存的矛盾开始说起。读写性能上 CPU>缓存>内存>I/O

        CPU和内存之间隔着缓存和CPU寄存器。缓存还分为一级、二级、三级缓存。CPU的读写性能上要大于内存,为了提高效率会将数据先取到缓存中,CPU处理完数据后会先放到缓存中,然后同步到内存中。

        这样就会导致一个问题,当我们线程1操作完var之后,他会先放入缓存,在缓存未同步到内存之前,线程2来取var的值,那么取得的就是未修改的值。

        什么是可见?

        如果能保证变量被修改之后会立马存入内存,也就是说如果变量被修改到存入内存中这是一瞬间完成的,那么就成为共享变量的可见性。因为你一修改,内存中的值立马就变了,这样对于其他线程就是可见的变化,但是如果你修改了,等一会内存才变化,那么这中间的过程其他线程是不可见的。

        可见性的解决方案

        使用volatile修饰共享变量,volatile修饰的共享变量在修改后会立即被更新到内存中,其他线程使用共享变量会去内存中读取

        当然还有一种方法是加锁,这里先不说,因为加锁可以解决整个原子性的问题,没必要放在这里解决可见性的问题。

        我们先来思考一下:

        var操作分为取值、+1、赋值,volatile关键字修饰的变量是可见的,也就是使用volatile修饰var的话,在+1之后到赋值这之间的动作就成了原子性操作了。也可以看成是一瞬间完成的,那么如果使用了volatile修饰变量之后,最终值还是达不到两百万,是不是就能说明从取值到+1这之间的操作不是原子性了?

        使用volatile修饰的代码如下:

// 本程序用于"@是星星鸭"博客的测试程序
// 本程序创建了两个线程,让两个线程分别对var进行1000000次+1,那么理想状态下var最终值应该是2000000
// 但是结果中却有很多次并不是2000000,这就说明了该线程的操作不是原子性的,说明在线程1操作var的时候
// 线程2也操作了var。最终导致有很多次重复的结果,所以结果不是2000000
// 但是也有var成功达到2000000的结果,如果你循环10000000次,那么成功的几率就会越来越少。              

#include 
#include 
#include 
#include 
#include 

void *thmain1(void *arg);           // 线程1主函数
void *thmain2(void *arg);           // 线程2主函数
                                      
volatile int var=0;                            // 被操作的全局变量

int main(int argc, char* argv[])
{   
    pthread_t thid1,thid2;          // 线程1、2的标识id

    // 创建线程
    if(pthread_create(&thid1,0,thmain1,0)!=0) { printf("create failed.\n"); return -1; }
    if(pthread_create(&thid2,0,thmain2,0)!=0) { printf("create failed.\n"); return -1; }

    // 等待线程退出
    pthread_join(thid1,NULL); pthread_join(thid2,NULL);

    // 打印出var经过两百次操作的最终值
    printf("var=%d\n",var);

    return 0;
}

// 线程1、2各自对全局变量进行1000000次加1
void *thmain1(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        var++;
    } 
}
 
void *thmain2(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        var++;
    }
} 

        makefile不变

        运行结果如下:

【Linux C++】线程安全-原子性、可见性、有序性_第2张图片

        这也就说明了,虽然var的取值、+1、赋值,后半部分设置为可见保证了后半部分的原子性,但是前半部分还是会触发线程安全问题。

      有序性

        我们先将有序性再讲解决方案

        编译器和处理器为了优化程序性能而对指令序列进行重排序,也就是你编写的代码顺序和最终执行的指令顺序是不一致的,重排序可能会导致多线程程序出现内存可见性问题。在实际开发中,关于有序性的场景非常少,可以说是不用考虑。

四、如何保证线程安全

      1、加锁

        加锁是万能的,这里我先使用互斥锁操作一下,互斥锁并不是最优解决方案,本文章不讲解锁,以后会出有关互斥锁、自旋锁、读写锁、条件变量、信号量等锁的文章

        互斥锁操作代码如下:

// 本程序用于"@是星星鸭"博客的测试程序
// 本程序创建了两个线程,让两个线程分别对var进行1000000次+1,那么理想状态下var最终值应该是2000000
// 但是结果中却有很多次并不是2000000,这就说明了该线程的操作不是原子性的,说明在线程1操作var的时候
// 线程2也操作了var。最终导致有很多次重复的结果,所以结果不是2000000
// 但是也有var成功达到2000000的结果,如果你循环10000000次,那么成功的几率就会越来越少。              

#include 
#include 
#include 
#include 
#include 

void *thmain1(void *arg);           // 线程1主函数
void *thmain2(void *arg);           // 线程2主函数

// 声明并初始化互斥锁,PTHREAD_MUTEX_INITIALIZER该宏用来初始化互斥锁。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
                                      
int var=0;                            // 被操作的全局变量

int main(int argc, char* argv[])
{   
    pthread_t thid1,thid2;          // 线程1、2的标识id

    // 创建线程
    if(pthread_create(&thid1,0,thmain1,0)!=0) { printf("create failed.\n"); return -1; }
    if(pthread_create(&thid2,0,thmain2,0)!=0) { printf("create failed.\n"); return -1; }

    // 等待线程退出
    pthread_join(thid1,NULL); pthread_join(thid2,NULL);

    // 打印出var经过两百次操作的最终值
    printf("var=%d\n",var);

    return 0;
}

// 线程1、2各自对全局变量进行1000000次加1
void *thmain1(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        pthread_mutex_lock(&mutex);    // 操作变量前加锁
        var++;                         // 中间进行取值、+1、赋值操作
        pthread_mutex_unlock(&mutex);  // 操作变量完成后解锁
    } 
}
 
void *thmain2(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        pthread_mutex_lock(&mutex);
        var++;
        pthread_mutex_unlock(&mutex);
    }
} 

        makefile不变。

        运行结果如下:

【Linux C++】线程安全-原子性、可见性、有序性_第3张图片

        可以看到使用了互斥锁之后保证了整个线程操作的原子性,也保证了线程安全。 

      2、原子操作-总线锁(原子操作函数、CAS、C++11atomic类)

        原子操作本质上就是总线锁

        CPU与内存通过总线进行数据交换,在操作之前锁住总线然后执行如下三条汇编指令,操作完成后释放锁

        xadd、cmpxchg或xchg

        这里不讲汇编,了解一下即可。

        总线锁是硬件级别的锁,他的效率非常高,比线程库提供的锁快十倍左右。

       原子操作函数

        C语言提供了对整型变量进行原子操作的系列函数如下:

        返回原值并操作:

        type __sync_fetch_and_add(type* ptr, type value);        // 相当于i++

        type __sync_fetch_and_sub(type* ptr, type value);        // 相当于i--

        type __sync_fetch_and_or(type* ptr, type value);           // 相当于or

        type __sync_fetch_and_and(type* ptr, type value);        // 相当于and

        type __sync_fetch_and_nadd(type* ptr, type value);      // 相当于not and,(not a or b)

        type __sync_fetch_and_xor(type* ptr, type value);         // 相当于xor

        这里我演示一下  __sync_fetch_and_add:

// 本程序用于"@是星星鸭"博客的测试程序
// 本程序创建了两个线程,让两个线程分别对var进行1000000次+1,那么理想状态下var最终值应该是2000000
// 但是结果中却有很多次并不是2000000,这就说明了该线程的操作不是原子性的,说明在线程1操作var的时候
// 线程2也操作了var。最终导致有很多次重复的结果,所以结果不是2000000
// 但是也有var成功达到2000000的结果,如果你循环10000000次,那么成功的几率就会越来越少。              

#include 
#include 
#include 
#include 
#include 

void *thmain1(void *arg);           // 线程1主函数
void *thmain2(void *arg);           // 线程2主函数

// 声明并初始化互斥锁,PTHREAD_MUTEX_INITIALIZER该宏用来初始化互斥锁。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
                                      
int var=0;                            // 被操作的全局变量

int main(int argc, char* argv[])
{   
    pthread_t thid1,thid2;          // 线程1、2的标识id

    // 创建线程
    if(pthread_create(&thid1,0,thmain1,0)!=0) { printf("create failed.\n"); return -1; }
    if(pthread_create(&thid2,0,thmain2,0)!=0) { printf("create failed.\n"); return -1; }

    // 等待线程退出
    pthread_join(thid1,NULL); pthread_join(thid2,NULL);

    // 打印出var经过两百次操作的最终值
    printf("var=%d\n",var);

    return 0;
}

// 线程1、2各自对全局变量进行1000000次加1
void *thmain1(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        __sync_fetch_and_add(&var,1);       // 这意思是对&var地址中的值进行加1原子操作
    } 
}
 
void *thmain2(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        __sync_fetch_and_add(&var,1);       // 这意思是对&var地址中的值进行加1原子操作
    }
} 

        makefile不变

        运行结果如下:

【Linux C++】线程安全-原子性、可见性、有序性_第4张图片

        其他的函数就不演示了,可以自己多做demo进行测试。

        操作并返回操作后的值:

        type __sync_add_and_fetch(type* ptr, type value);        // 相当于++i

        type __sync_sub_and_fetch(type* ptr, type value);        // 相当于--i

        type __sync_or_and_fetch(type* ptr, type value);           // 相当于or

        type __sync_and_and_fetch(type* ptr, type value);        // 相当于and

        type __sync_nand_and_fetch(type* ptr, type value);      // 相当于not and

        type __sync_xor_and_fetch(type* ptr, type value);         // 相当于xor

        这里就不演示函数的使用了。

       CAS指令(compare and swap)

        bool __sync_bool_compare_and_swap(type *ptr,type oldval,type newval);

        type __sync_val_compare_and_swap(type* ptr,type oldval,type newval);

        这两个函数的操作也是原子性的。

        读出*ptr的值,如果*ptr == oldval,就将newval写入*ptr。

        第一个函数在相等并写入的情况下返回true。

        第二个函数返回操作之前的值。

        演示:(只演示函数怎么用,不讲解原子性操作,你要知道这两个函数操作是原子性的)

#include                                                                                    
#include 
#include 

int var1=1;
int var2=2;
int var3=3;

int main()
{
    bool bret = __sync_bool_compare_and_swap(&var1,1,3);    // var1如果等于1,把3赋值给var1
                                                           
    printf("var1: %d\n",var1);
    while(bret) { printf("如果输出这句话说明iret==true\n"); break; }

    int iret = __sync_val_compare_and_swap(&var2,2,var3);  // 如果var==2,将var3赋值给var2

    printf("var2: %d\t操作之前的值:%d\n",var2,iret);

    return 0;
}

        编译:g++ -g -o demo02 demo02.cpp

        运行:

【Linux C++】线程安全-原子性、可见性、有序性_第5张图片

         

       C11原子类型

        std::atomic模板类封装了原子操作,支持布尔、整数和字符类型,说是支持三种类型,实质上布尔和字符也是整数。

        测试:

// 本程序用于"@是星星鸭"博客的测试程序
// 本程序创建了两个线程,让两个线程分别对var进行1000000次+1,那么理想状态下var最终值应该是2000000
// 但是结果中却有很多次并不是2000000,这就说明了该线程的操作不是原子性的,说明在线程1操作var的时候
// 线程2也操作了var。最终导致有很多次重复的结果,所以结果不是2000000
// 但是也有var成功达到2000000的结果,如果你循环10000000次,那么成功的几率就会越来越少。              

#include 
#include 
#include 
#include 
#include 
#include         // atomic类
#include       // cin cout
using namespace std;

void *thmain1(void *arg);           // 线程1主函数
void *thmain2(void *arg);           // 线程2主函数
                                      
std::atomic var;    // std::可写可不写。var这里不能赋值进行初始化,因为这里var是类的对象

int main(int argc, char* argv[])
{   
    pthread_t thid1,thid2;          // 线程1、2的标识id

    // 创建线程
    if(pthread_create(&thid1,0,thmain1,0)!=0) { printf("create failed.\n"); return -1; }
    if(pthread_create(&thid2,0,thmain2,0)!=0) { printf("create failed.\n"); return -1; }

    // 等待线程退出
    pthread_join(thid1,NULL); pthread_join(thid2,NULL);

    // 打印出var经过两百次操作的最终值
    cout << "var: " << var << endl;

    return 0;
}

// 线程1、2各自对全局变量进行1000000次加1
void *thmain1(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        var++;        // atomic重载了++运算符
    } 
}
 
void *thmain2(void *arg)
{
    for(int ii=0;ii<1000000;ii++)
    {
        var++;
    }
} 

        makefile有所改动

        g++ -g -o demo01 demo01.cpp -lpthread -std=c++11       

        运行结果如下:

【Linux C++】线程安全-原子性、可见性、有序性_第6张图片

      3、线程同步(本文先不讲解)

        关于线程同步的知识,我先不讲解,之后我会与锁的讲解放在一起。 

五、总结

        通过以上文章我们大致了解了线程安全,以及保护线程操作的一些方案,之后我会发布线程同步的文章以及一些锁的知识。由于我的vim下载了一些插件的原因,导致我从demo程序拷贝到这里的代码还需要删删改改,所以也难免会有错误,希望发现错误的大佬帮忙指正。之后的日子大家一起进步!

        凑个封面๑乛◡乛๑ 

 

你可能感兴趣的:(C,C++,Linux,linux,c++)