惊!线程同步居然讲的这么详细:信号量、互斥锁、条件变量、读写锁

目录

1.为什么要进行线程的同步操作?

2 线程同步

2.1 信号量

2.2 互斥锁

2.3 条件变量

2.4 读写锁


1.为什么要进行线程的同步操作?

我们以一个程序引出这个问题:如下:

#include 
#include
#include
#include
#include
int val=0;
void*  thread_fun(void *arg)
{
      int i = 0;
      for(;i<1000;++i)
      {   
          ++val;
          printf("val = %d\n",val);                                                                    
      }   
}
int main()
{
     pthread_t id[5];
     int i = 0;
     for(;i<5;++i)
     {   
         pthread_create(&id[i],NULL,thread_fun,NULL);
     }   
     for(int i=0;i<5;++i)
     {   
         pthread_join(id[i],NULL);
     }   
     return 0;
 }
  

上述代码按道理来讲最后一次打印出val的值应该为5000,但是在实际情况中却是一个变值(在多核处理器下),可能打出4999,也可能打出4994和4996,也可能打出5000。这是为什么呢?

首先我们知道计算机为了不浪费CPU资源会给各个需要使用CPU的任务分配时间片,时间片完成后,就进行下一个任务。如上程序,在多核处理器的linux系统下运行的时候,多个线程在多个处理器下同时运行。我们假设线程1运行在处理器A上,线程2运行在处理器B上。我们知道每个处理器在数据处理的时候是需要讲数据拷贝进自己的缓存中。我们假设现在val的值为3000,这时线程1和线程2都同时进入线程函数中的for循环,那么在处理器A和B的缓存中,val的值为3000,正当线程1准备执行val++操作的时候(val++不是原子操作,可以分割:第一步读取val,第二步++,第三步写回内存),这时线程1在处理器A上的时间片结束,线程1阻塞。这时线程2还在处理器B上继续运行,执行一次for循环后,val的值更新为3001,处理器B将3001写回val的内存,这时线程因为某个条件又开始运行起来了,但是处理器A中val的值还是3000,执行++操作之后值为3001,现在又将3001写回内存,那么这就出现了覆盖,即3001被写回内存两次。这就解释了上述程序为什么会打印出一个不确定值。

我们将程序在单核处理器中运行:

设置虚拟机处理器数量和每个处理器核心数都为1,继续运行上述程序。可以发现,每次打印结果都为5000,这时因为处理器只有一个,每次只允许一个线程运行。因此不存在上述描述的多线程运行的情况。

线程同步:即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态。

2 线程同步

在linux下保证线程同步的常有方法有信号量、互斥锁、条件变量和读写锁四种。使用起来都是非常简单,一般都是为线程函数中访问内存的代码添加互斥操作。

2.1 信号量

信号量的操作在进程同步中也经常用到,我在我的另一篇博文中已经详细介绍了信号量的用法(进程同步)。

文章链接:https://blog.csdn.net/qq_42214953/article/details/105636753

在进程通讯中我们使用的信号量、消息队列等都是在sys/下面的,都是SysV标准,已经很老了。因此在本程序中,信号量我们使用POSIX标准的

具体操作有以下:

int sem_init(sem_t *sem, int pshared, unsigned int value); 
//sem:sem_t类型的变量地址
//pshared:0表⽰线程间共享,非0表示进程间共享   
//value是信号量初值,大于0表示才可以访问    

int sem_destroy(sem_t *sem);
//摧毁 sem_t 信号量

 

//P和V操作
int sem_wait(sem_t *sem);//信号量等待,-1,P操作
int sem_post(sem_t *sem);//信号量发布,+1,V操作

接下来我们按照线程同步的概念:即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态。用信号量去控制线程对内存的访问。

#include                                                                                       
#include
#include
#include
#include
#include
//全局的变量
int val = 0;
//信号量声明
sem_t sem;
  
//线程函数
void *thread_fun(void *arg)
{
   int i = 0;
   for(;i<1000;++i)
   {
      //p操作
      sem_wait(&sem);
      val++;
      //v操作
      sem_post(&sem);
    }
 }
int main()
{
    //初始化信号量
    sem_init(&sem,0,1);
    pthread_t id[5];
    int i = 0;
    for(;i<5;++i)
    {
        pthread_create(&id[i],NULL,thread_fun,NULL);
    }
     for(i=0;i<5;++i)
     {
         pthread_join(id[i],NULL);
     }
     printf("last value = %d\n",val);
  
  
     //删除信号量
     sem_destroy(&sem);
     exit(0);
 }

程序运行结果如下所示:

惊!线程同步居然讲的这么详细:信号量、互斥锁、条件变量、读写锁_第1张图片

从执行结果可以看到,每次打印值都为5000,符合我们的预期。

2.2 互斥锁

互斥量也叫互斥锁,从本质上来说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成之后释放锁。在对互斥量进行加锁之后,任何其他试图对互斥量加锁的线程将会被阻塞直至当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞,那么所有在该互斥锁上阻塞的线程都会变成运行状态。第一个变成运行状态的线程可以对互斥锁重新进行加锁操作,其他线程将会看到锁依然被锁住,只能继续等待直至锁释放。在这样的情况下,每次只有一个线程能向前推进。

互斥锁的操作方式如下:

#include
头文件

pthread_mutext_t mutex;
//声明一个互斥锁

int pthread_mutext_init(pthread_mutext_t *mutex,const pthread_mutextattr_t* attr);
此函数用于初始化一个锁。
成功返回0。成功之后,锁为未锁状态。
参数attr一般设置为NULL,表示锁的默认属性为快速互斥锁。


int pthread_mutex_destory(pthread_mutex_t *mutex);
此函数用于摧毁一个互斥锁。
成功返回0。

int pthread_mutex_lock(pthread_mutex_t *mutex);
此函数用于互斥锁的上锁操作,如果互斥锁为锁住状态,那么调用此函数的线程阻塞在此处。直至锁释放。


int pthread_mutex_trylock(pthread_mutex_t *mutex);
此函数调用在参数mutex指定的mutex对象当前被锁住的时候立即返回,除此之外,
pthread_mutex_trylock()跟pthread_mutex_lock()功能完全一样。因此此函数要与循环去搭配使用。

int pthread_mutex_unlock(pthread_mutex_t *mutex);
此函数用于释放mutex互斥锁对象。即对锁进行解锁操作。
成功返回0。

在使用互斥锁的时候,在锁初始化之后,只需要将2.1节代码中信号量的p、v操作换成加锁、解锁操作即可,非常简单。这里不再给出示例代码。由于线程同步的另一种机制条件变量一般会搭配互斥锁一起使用,在介绍条件变量的时候会给出互斥锁的使用代码。

注意:在使用互斥锁的时候要避免产生死锁。

如果一个线程试图对一个状态为加锁的互斥锁执行锁操作,那么这个线程自身就会陷入死锁状态。还有例如:程序中使用多个互斥锁的时候,如果允许一个线程一直占有第一个锁,并且在试图锁住第二个互斥锁,但是第二个互斥锁的拥有者也试图去锁住第一个互斥锁,这样,双方都在向对方请求,产生死锁。

2.3 条件变量

条件变量可以使线程睡眠去等待某个条件的发生。条件变量是一个很难理解的部分,所以我想要用一个经典的生产者消费者代码去带领我们去理解条件变量。

首先我们先看关于条件变量的几个常用的函数:

#include

条件变量包含在pthread.h头文件里

 

pthread_cond con;

声明一个条件变量

 

int pthread_cond_init(pthread_cond_t *cv, const pthread_condattr_t *cattr);

初始化一个条件变量,成功返回0,cattr的含义和pthread_mutex_init里面的含义一样,通常设置为NULL

 

pthread_cond_wait(pthread_cond_t *con,pthread_mutex_t *mutex);

此函数首先会对互斥锁mutex进行解锁操作,然后陷入睡眠,直至被pthread_cond_signal或者pthread_cond_broadcast函数唤醒。唤醒之后,程序pthread_cond_wait会继续执行,不再睡眠,同时对mutex进行加锁。

 

pthread_cond_signal(pthread_cond_t * cond);

唤醒一个条件变量

pthread_cond_broadcast(pthread_cond_t * cond);

唤醒所有睡眠的条件变量

pthread_cond_destroy(pthread_cond_t *cond);

摧毁一个条件变量

看下面生产者消费者代码:

/*
 * 生产者消费者:
 * 主线程为生产者:产生数据
 * 子线程为消费者:消耗数据
 * */
#include
#include
#include
#include
#include
#include
//创建全局的互斥锁和条件变量
pthread_mutex_t mutex;
pthread_cond_t cond;
//消费者
void *custom_1(void *arg)
{
    char *s=(char *)arg;
    while(1)
    {
        pthread_mutex_lock(&mutex);
              
         pthread_cond_wait(&cond,&mutex);//对锁解锁,然后睡眠,直至被唤醒,再进行加锁
     
        //可以释放锁,因为只有在同意时刻,只有一个子线程能到达此处
        pthread_mutex_unlock(&mutex);
        if(strncmp(s,"end",3)==0)
            break;
        printf("消费者1:%s\n",s);
       
    }
    printf("消费者1结束\n");
}

void *custom_2(void *arg)
{
    char *s=(char *)arg;
    while(1)
    {
        pthread_mutex_lock(&mutex);
              
        pthread_cond_wait(&cond,&mutex);//对锁解锁,然后睡眠,直至被唤醒,再进行加锁
        //可以释放锁,因为只有在同意时刻,只有一个子线程能到达此处
        pthread_mutex_unlock(&mutex);
        if(strncmp(s,"end",3)==0)
            break;
        printf("消费者2:%s\n",s); 
    }
    printf("消费者2结束\n");
}

int main()
{
    //初始化锁和条件变量
    pthread_mutex_init(&mutex,NULL);
    pthread_cond_init(&cond,NULL);
    //buff存储数据
    char buff[128]={0};
    //创建两个消费者
    pthread_t id[2];
    pthread_create(&id[0],NULL,custom_1,(void*)buff);
    pthread_create(&id[1],NULL,custom_2,(void*)buff);
    //主线程生产数
    while(1)
    {
        char readLine[128]={0};
        fgets(readLine,128,stdin);
        strcpy(buff,readLine);
        if(strncmp(readLine,"end",3)==0)
            break;
        //唤醒一个线程
        pthread_mutex_lock(&mutex);
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(&mutex);
    }
    //唤醒所有子线程退出
    pthread_mutex_lock(&mutex);
    pthread_cond_broadcast(&cond);
    pthread_mutex_unlock(&mutex);

    pthread_join(id[0],NULL);
    pthread_join(id[1],NULL);
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
    exit(0);
}

执行结果:

惊!线程同步居然讲的这么详细:信号量、互斥锁、条件变量、读写锁_第2张图片

看完这个,我们来回答几个问题?

为什么有互斥锁还要使用条件变量?

首先,互斥锁体现的是一种竞争关系,我不需要资源了,轮到你来使用了。而条件变量体现的是一种协作关系,我将资源准备好了,你来使用吧。

从上一节介绍互斥量的时候,我们可以知道,互斥锁只有两种状态,lock或者unlock。而条件变量通过允许线程阻塞和等待另一个线程发送信号唤醒阻塞线程的方法弥补了互斥锁的不足。

为什么pthread_cond_wait之后执行pthread_mutex_unlock之后不再对共享资源s进行加锁操作?

由于主线程的pthread_cond_signal方法每次只有一个处于pthread_cond_wait的子线程被唤醒,所以每次只有一个子线程被随机唤醒,因此确保了只有一个线程去访问共享资源。

因此,不需要对s进行加锁,实际上将对共享资源的控制操作转移到了可以激活的条件变量上。

2.4 读写锁

读写锁和前面介绍的互斥锁相类似,不过读写锁允许更高的并行性。互斥锁只有lock和unlock两种状态,每次只有一个线程可以访问共享资源。但是,读写锁有三种状态:读模式下的加锁状态、写模式下的加锁状态以及不加锁状态。一次只有一个线程可以占有写模式下的锁,但是允许多个线程占有读模式下的锁。

读写锁实际上是一种特殊的互斥锁,它把对共享资源的访问划成读和写两种模式,读只对共享资源进行读操作,而写可以对共享资源进行编辑操作。

下面是一些关于读写锁的函数:

#include

头文件

pthread_rwlock_t rwloc;

声明一个读写锁

int pthread_rwlock_init(pthread_rwlock_t* rwloc,const pthread_rwlockattr_t *attr);

初始化一个读写锁,attr与互斥量和条件变量初始函数的attr的含义一样。

int pthread_rwlock_destroy(pthread_rwlock_t* rwloc);

摧毁一个读写锁

int pthread_rwlock_rdlock(pthread_rwlock_t *rwloc);

获取读模式下的锁

int pthread_rwlock_wrlock(pthread_rwlock_t *rwloc);

获取写模式下的锁

int pthread_rwlock_unlock(pthread_rwlock_t *rwloc);

释放一个写锁或者读锁

同时以下两个函数在读写锁中也经常用到

int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

非阻塞的获取锁操作, 如果可以获取则返回0, 否则返回错误的EBUSY.

关于读写锁的代码示例和注意问题可以参见下面博客。

 

参见博客

为了安全起见(万一他的博文被删了)我还是将他总结的读写锁的注意问题摘抄一下:

  1. 当没有锁的时候:读锁请求和写锁请求都可以满足。
  2. 当有读锁的时候,读锁请求可以满足,因为读操作是共享的。读的时候,写操作是阻塞的。
  3. 当有写锁的时候,读锁请求和写锁请求都不会满足,线程会阻塞在请求位置。

 

 

 

 

你可能感兴趣的:(Linux)