线程同步的几种方式(2)

发布于:2022-12-13 ⋅ 阅读:(286) ⋅ 点赞:(0)

文章目录

1、互斥锁

1.1 互斥锁的API

1.2 互斥锁使用 

1.3死锁

2、读写锁

2.1读写锁的应用

3、条件变量

 3.1条件变量的API

3.2条件变量示例

4、信号量

4.1信号量示例


1、互斥锁

互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块,被锁定的这个代码块,所有的线程只能顺序执行 (不能并行处理),这样多线程访问共享资源数据混乱的问题就可以被解决了,需要付出的代价就是执行效率的降低,因为默认临界区多个线程是可以并行处理的,现在只能串行处理。

在 Linux 中互斥锁的类型为 pthread_mutex_t,创建一个这种类型的变量就得到了一把互斥锁:

pthread_mutex_t  mutex;

在创建的锁对象中保存了当前这把锁的状态信息:锁定还是打开,如果是锁定状态还记录了给这把锁加锁的线程信息(线程 ID)。一个互斥锁变量只能被一个线程锁定,被锁定之后其他线程再对互斥锁变量加锁就会被阻塞,直到这把互斥锁被解锁,被阻塞的线程才能被解除阻塞。一般情况下,每一个共享资源对应一个把互斥锁,锁的个数和线程的个数无关。

1.1 互斥锁的API

Linux 提供的互斥锁操作函数如下,如果函数调用成功会返回 0,调用失败会返回相应的错误号:

// 初始化互斥锁
// restrict: 是一个关键字, 用来修饰指针, 只有这个关键字修饰的指针可以访问指向的内存地址, 其他指针是不行的
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
           const pthread_mutexattr_t *restrict attr);
attr:锁的性质 不需要传空即可
// 释放互斥锁资源            
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:
mutex: 互斥锁变量的地址
attr: 互斥锁的属性,一般使用默认属性即可,这个参数指定为 NULL
 


// 修改互斥锁的状态, 将其设定为锁定状态, 这个状态被写入到参数 mutex 中
int pthread_mutex_lock(pthread_mutex_t *mutex);

这个函数被调用,首先会判断参数 mutex 互斥锁中的状态是不是锁定状态:

  1. 没有被锁定,是打开的,这个线程可以加锁成功,这个这个锁中会记录是哪个线程加锁成功了
  2. 如果被锁定了,其他线程加锁就失败了,这些线程都会阻塞在这把锁上
  3. 当这把锁被解开之后,这些阻塞在锁上的线程就解除阻塞了,并且这些线程是通过竞争的方式对这把锁加锁,没抢到锁的线程继续阻塞

// 尝试加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);

调用这个函数对互斥锁变量加锁还是有两种情况:

  1. 如果这把锁没有被锁定是打开的,线程加锁成功
  2. 如果锁变量被锁住了,调用这个函数加锁的线程,不会被阻塞,加锁失败直接返回错误号

// 对互斥锁解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);

不是所有的线程都可以对互斥锁解锁,哪个线程加的锁,哪个线程才能解锁成功。

1.2 互斥锁使用 

我们可以将上一节https://blog.csdn.net/weixin_51609435/article/details/126809083多线程交替数数的例子修改一下,使用互斥锁进行线程同步。两个线程一共操作了同一个全局变量,因此需要添加一互斥锁,来控制这两个线程。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>

#define MAX 100
// 全局变量
int number;

// 创建一把互斥锁
// 全局变量, 多个线程共享
pthread_mutex_t mutex;

// 线程处理函数
void* funcA_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        // 如果线程A加锁成功, 不阻塞
        // 如果B加锁成功, 线程A阻塞
        pthread_mutex_lock(&mutex);
        int cur = number;
        cur++;
        usleep(10);
        number = cur;
        pthread_mutex_unlock(&mutex);
        printf("Thread A, id = %lu, number = %d\n", pthread_self(), number);
    }

    return NULL;
}

void* funcB_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        // a加锁成功, b线程访问这把锁的时候是锁定的
        // 线程B先阻塞, a线程解锁之后阻塞解除
        // 线程B加锁成功了
        pthread_mutex_lock(&mutex);
        int cur = number;
        cur++;
        number = cur;
        pthread_mutex_unlock(&mutex);
        printf("Thread B, id = %lu, number = %d\n", pthread_self(), number);
        usleep(5);
    }

    return NULL;
}

int main(int argc, const char* argv[])
{
    pthread_t p1, p2;

    // 初始化互斥锁
    pthread_mutex_init(&mutex, NULL);

    // 创建两个子线程
    pthread_create(&p1, NULL, funcA_num, NULL);
    pthread_create(&p2, NULL, funcB_num, NULL);

    // 阻塞,资源回收
    pthread_join(p1, NULL);
    pthread_join(p2, NULL);

    // 销毁互斥锁
    // 线程销毁之后, 再去释放互斥锁
    pthread_mutex_destroy(&mutex);

    return 0;
}

1.3死锁

在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。

那么,当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁

举个例子,小林拿了小美房间的钥匙,而小林在自己的房间里,小美拿了小林房间的钥匙,而小美也在自己的房间里。如果小林要从自己的房间里出去,必须拿到小美手中的钥匙,但是小美要出去,又必须拿到小林手中的钥匙,这就形成了死锁。

死锁只有同时满足以下四个条件才会发生:

  • 互斥条件;
  • 持有并等待条件;
  • 不可剥夺条件;
  • 环路等待条件

造成死锁的场景有如下几种:

加锁之后忘记解锁

void func()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功, 当前循环完毕没有解锁, 在下一轮循环的时候自己被阻塞了
        // 其余的线程也被阻塞
    	pthread_mutex_lock(&mutex);
    	....
    	.....
        // 忘记解锁
    }
}

// 场景2
void func()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功
        // 其余的线程被阻塞
    	pthread_mutex_lock(&mutex);
    	....
    	.....
        if(xxx)
        {
            // 函数退出, 没有解锁(解锁函数无法被执行了)
            return ;
        }
        
        pthread_mutex_lock(&mutex);
    }
}

 重复加锁,造成死锁

void func()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功
        // 其余的线程阻塞
    	pthread_mutex_lock(&mutex);
        // 锁被锁住了, A线程阻塞
        pthread_mutex_lock(&mutex);
    	....
    	.....
        pthread_mutex_unlock(&mutex);
    }
}

// 隐藏的比较深的情况
void funcA()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功
        // 其余的线程阻塞
    	pthread_mutex_lock(&mutex);
    	....
    	.....
        pthread_mutex_unlock(&mutex);
    }
}

void funcB()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功
        // 其余的线程阻塞
    	pthread_mutex_lock(&mutex);
        funcA();		// 重复加锁
    	....
    	.....
        pthread_mutex_unlock(&mutex);
    }
}

在程序中有多个共享资源,因此有很多把锁,随意加锁,导致相互被阻塞

场景描述:
  1. 有两个共享资源:X, Y,X对应锁A, Y对应锁B
     - 线程A访问资源X, 加锁A
     - 线程B访问资源Y, 加锁B
  2. 线程A要访问资源Y, 线程B要访问资源X,因为资源X和Y已经被对应的锁锁住了,因此这个两个线程被阻塞
     - 线程A被锁B阻塞了, 无法打开A锁
     - 线程B被锁A阻塞了, 无法打开B锁

2、读写锁

读写锁是互斥锁的升级版,在做读操作的时候可以提高程序的执行效率,如果所有的线程都是做读操作, 那么读是并行的,但是使用互斥锁,读操作也是串行的。

读写锁是一把锁,锁的类型为 pthread_rwlock_t,有了类型之后就可以创建一把读写锁了

pthread_rwlock_t rwlock;

之所以称其为读写锁,是因为这把锁既可以锁定读操作,也可以锁定写操作。为了方便理解,可以大致认为在这把锁中记录了这些信息:

  • 锁的状态:锁定 / 打开
  • 锁定的是什么操作:读操作 / 写操作,使用读写锁锁定了读操作,需要先解锁才能去锁定写操作,反之亦然。
  • 哪个线程将这把锁锁上了

读写锁的使用方式也互斥锁的使用方式是完全相同的:找共享资源,确定临界区,在临界区的开始位置加锁(读锁 / 写锁),临界区的结束位置解锁。

如果说程序中所有的线程都对共享资源做写操作,使用读写锁没有优势,和互斥锁是一样的,如果说程序中所有的线程都对共享资源有写也有读操作,并且对共享资源读的操作越多,读写锁更有优势。

Linux 提供的读写锁操作函数原型如下,如果函数调用成功返回 0,失败返回对应的错误号:

#include <pthread.h>
pthread_rwlock_t rwlock;
// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
           const pthread_rwlockattr_t *restrict attr);
// 释放读写锁占用的系统资源
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

参数:
rwlock: 读写锁的地址,传出参数
attr: 读写锁属性,一般使用默认属性,指定为 NULL


// 在程序中对读写锁加读锁, 锁定的是读操作
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数的线程会被阻塞。


// 这个函数可以有效的避免死锁
// 如果加读锁失败, 不会阻塞当前线程, 直接返回错误号
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数加锁失败,对应的线程不会被阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。


// 在程序中对读写锁加写锁, 锁定的是写操作
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数的线程会被阻塞。


// 这个函数可以有效的避免死锁
// 如果加写锁失败, 不会阻塞当前线程, 直接返回错误号
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数加锁失败,但是线程不会阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。


// 解锁, 不管锁定了读还是写都可用解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

2.1读写锁的应用

题目要求:8 个线程操作同一个全局变量,3 个线程不定时写同一全局资源,5 个线程不定时读同一全局资源。

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

// 全局变量
int number = 0;

// 定义读写锁
pthread_rwlock_t rwlock;

// 写的线程的处理函数
void* writeNum(void* arg)
{
    while(1)
    {
        pthread_rwlock_wrlock(&rwlock);
        int cur = number;
        cur ++;
        number = cur;
        printf("++写操作完毕, number : %d, tid = %ld\n", number, pthread_self());
        pthread_rwlock_unlock(&rwlock);
        // 添加sleep目的是要看到多个线程交替工作
        usleep(rand() % 100);
    }

    return NULL;
}

// 读线程的处理函数
// 多个线程可以如果处理动作相同, 可以使用相同的处理函数
// 每个线程中的栈资源是独享
void* readNum(void* arg)
{
    while(1)
    {
        pthread_rwlock_rdlock(&rwlock);
        printf("--全局变量number = %d, tid = %ld\n", number, pthread_self());
        pthread_rwlock_unlock(&rwlock);
        usleep(rand() % 100);
    }
    return NULL;
}

int main()
{
    // 初始化读写锁
    pthread_rwlock_init(&rwlock, NULL);

    // 3个写线程, 5个读的线程
    pthread_t wtid[3];
    pthread_t rtid[5];
    for(int i=0; i<3; ++i)
    {
        pthread_create(&wtid[i], NULL, writeNum, NULL);
    }

    for(int i=0; i<5; ++i)
    {
        pthread_create(&rtid[i], NULL, readNum, NULL);
    }

    // 释放资源
    for(int i=0; i<3; ++i)
    {
        pthread_join(wtid[i], NULL);
    }

    for(int i=0; i<5; ++i)
    {
        pthread_join(rtid[i], NULL);
    }

    // 销毁读写锁
    pthread_rwlock_destroy(&rwlock);

    return 0;
}

3、条件变量

严格意义上来说,条件变量的主要作用不是处理线程同步,而是进行线程的阻塞。如果在多线程程序中只使用条件变量无法实现线程的同步,必须要配合互斥锁来使用。虽然条件变量和互斥锁都能阻塞线程,但是二者的效果是不一样的,二者的区别如下:

  1. 假设有 A-Z 26 个线程,这 26 个线程共同访问同一把互斥锁,如果线程 A 加锁成功,那么其余 B-Z 线程访问互斥锁都阻塞,所有的线程只能顺序访问临界区
  2. 条件变量只有在满足指定条件下才会阻塞线程,如果条件不满足,多个线程可以同时进入临界区,同时读写临界资源,这种情况下还是会出现共享资源中数据的混乱。

可以理解为:一个线程修改条件,另一个线程等待条件,一旦等到自己需要的条件,就去运行。

条件变量的特点:

  • 需要和互斥锁一起使用,才可以起到线程间同步的作用。

  • 允许线程以无竞争方式等待特定的条件发生,即条件改变这个信号会发送到所有等待这个条件发生的线程,不是说一个线程接收到这个消息其他线程就接收不到了。

 3.1条件变量的API

include <pthread.h> 

int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *attr);
//attr一般为NULL

// 线程阻塞函数, 哪个线程调用这个函数, 哪个线程就会被阻塞 
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); 

//将条件变量添加到等待队列中,阻塞,等待被唤醒;第一个参数是条件变量的地址,第二个参数是互斥锁; 

//也就是说条件变量往往伴随着互斥锁的使用; 

int pthread_cond_signal(pthread_cond_t *cond); //唤醒单个线程 

int pthread_cond_broadcast(pthread_cond_t *cond); //唤醒所有等待的线程

int pthread_cond_destroy(pthread_cond_t *cond);//销毁条件变量 

3.2条件变量示例

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

 pthread_cond_t cond; 
 pthread_mutex_t mutex; 
 char buff[128]={0}; 
 
 void *work_thread(void *arg) 
 { 
     char *thread_name=(char *)arg; 
     while(1) 
{ 
     pthread_mutex_lock(&mutex); 
     pthread_cond_wait(&cond,&mutex); 
     pthread_mutex_unlock(&mutex); 
     if(strncmp(buff,"end",3)==0) 
 { 
     break; 
 } 
     printf("%s %s",thread_name,buff); 
  } 
 } 

 int main() 
 { 
     pthread_cond_init(&cond,NULL); 
     pthread_mutex_init(&mutex,NULL); 
     pthread_t id1,id2; 
     pthread_create(&id1,NULL,work_thread,"thread:1"); 
     pthread_create(&id2,NULL,work_thread,"thread:2"); 
 while(1) 
 { 
     fgets(buff,128,stdin); 
     if(strncmp(buff,"end",3)==0) 
 { 
     pthread_cond_broadcast(&cond); 
     break; 
 } 
 else 
 {
     pthread_cond_signal(&cond); 
 } 
 } 
     pthread_join(id1,NULL); 
     pthread_join(id2,NULL); 
     pthread_mutex_destroy(&mutex); 
     pthread_cond_destroy(&cond); 
     printf("main over!\n"); 
     exit(0); 
 } 

4、信号量

信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。信号量不一定是锁定某一个资源,而是流程上的概念,比如:有 A,B 两个线程,B 线程要等 A 线程完成某一任务以后再进行自己下面的步骤,这个任务并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。

信号量(信号灯)与互斥锁和条件变量的主要不同在于” 灯” 的概念,灯亮则意味着资源可用,灯灭则意味着不可用。信号量主要阻塞线程,不能完全保证线程安全,如果要保证线程安全,需要信号量和互斥锁一起使用。

信号量和条件变量一样用于处理生产者和消费者模型,用于阻塞生产者线程或者消费者线程的运行。信号的类型为 sem_t 对应的头文件为 <semaphore.h>:

Linux 提供的信号量操作函数原型如下:

#include <semaphore.h>
// 初始化信号量/信号灯
int sem_init(sem_t *sem, int pshared, unsigned int value);
// 资源释放, 线程销毁之后调用这个函数即可
// 参数 sem 就是 sem_init() 的第一个参数            
int sem_destroy(sem_t *sem);

参数:

sem:信号量变量地址

pshared:

  1. 0:线程同步
  2. 非 0:进程同步

value:初始化当前信号量拥有的资源数(>=0),如果资源数为 0,线程就会被阻塞了。

// 参数 sem 就是 sem_init() 的第一个参数  
// 函数被调用sem中的资源就会被消耗1个, 资源数-1
int sem_wait(sem_t *sem);

当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,因此线程也就被阻塞了。相当于P操作。

// 调用该函数给sem中的资源数+1
int sem_post(sem_t *sem);

调用该函数会将 sem 中的资源数 +1,如果有线程在调用 sem_wait、sem_trywait、sem_timedwait 时因为 sem 中的资源数为 0 被阻塞了,这时这些线程会解除阻塞,获取到资源之后继续向下运行。相当于V操作。

4.1信号量示例

主线程获取用户输入,函数线程将用户输入的数据存储到文件中;

#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<string.h>
#include<unistd.h>
#include<pthread.h>
#include<semaphore.h>
#include<fcntl.h>

char buff[128]={0};
sem_t sem1;
sem_t sem2;
void *pthreadfun(void *arg)
{
    int fd=open("a.txt",O_RDWR|O_CREAT,0664);
    assert(fd!=-1);
    while(1)
    {
        sem_wait(&sem2);
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        write(fd,buff,strlen(buff));
        memset(buff,0,128);
        sem_post(&sem1);
    }
    sem_destroy(&sem1);
    sem_destroy(&sem2);
}
int main()
{
    sem_init(&sem1,0,1);
    sem_init(&sem2,0,0);
    pthread_t id;
    int red=pthread_create(&id,NULL,pthreadfun,NULL);
    assert(red==0);
    while(1)
    {
        sem_wait(&sem1);//p操作sem1
        printf("please input data:");
        fflush(stdout);//刷新
        fgets(buff,128,stdin);
        buff[strlen(buff)-1]=0;
        sem_post(&sem2);//操作
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
    }
        pthread_exit(NULL);

}


循环打印ABC...  

初始化三个信号量 1 0 0进行pv操作,同步进行,信号量1没有操作完,其他两个等待中,1操作完了,2开始..

 代码演示:

 #include <stdio.h> 

 #include <stdlib.h> 

 #include <unistd.h> 

 #include <assert.h> 

 #include <pthread.h> 

#include <semaphore.h> 



 sem_t sema; 

 sem_t semb; 

 sem_t semc; 

 void * funa(void *arg) 

 { 

 for(int i=0;i<5;i++) 

 { 

	 sem_wait(&sema); 

	 printf("A"); 

	 fflush(stdout); 

 	sem_post(&semb); 

 	sleep(1); 

 } 

} 



 void * funb(void *arg) 

 { 

 	for(int i=0;i<5;i++) 

 { 

	 sem_wait(&semb); 

 	printf("B"); 

	 fflush(stdout); 

	 sem_post(&semc); 

	 sleep(1); 

 } 

 } 
 void * func(void *arg) 

 { 

	 for(int i=0;i<5;i++) 

 { 

 	sem_wait(&semc); 

	 printf("C"); 

 	fflush(stdout); 

	 sem_post(&sema); 

	 sleep(1); 

 } 

 }
 int main() 

 { 

 	sem_init(&sema,0,1); 

	 sem_init(&semb,0,0); 

	 sem_init(&semc,0,0); 

 	pthread_t id[3]; 

	 pthread_create(&id[0],NULL,funa,NULL); 

 	pthread_create(&id[1],NULL,funb,NULL); 

	 pthread_create(&id[2],NULL,func,NULL); 

 	for(int i=0;i<3;i++) 

 { 

 	pthread_join(id[i],NULL); 

 } 

	sem_destroy(&sema); 

 	sem_destroy(&semb); 

 	sem_destroy(&semc); 

 	exit(0); 

 } 

顺序打印五次ABC


网站公告

今日签到

点亮在社区的每一天
去签到