Linux线程安全同步和互斥

目录

一.Linux线程互斥

1.进程线程间的互斥相关背景概念

2.互斥量mutex引出

 3.互斥量接口

4.互斥量原理探究

二.可重入VS线程安全

1.基本概念

2.常见的线程不安全的情况

3.常见的线程安全的情况

4.常见的不可重入的情况

5.常见的可重入的情况

6.可重入与线程安全联系

7.可重入与线程安全区别

三.常见锁概念

1.死锁

2.死锁的四个必要条件 

3.避免死锁

四.Linux线程同步 

1.同步概念与竞态条件

2.条件变量

3.条件变量函数

4.为什么pthread_cond_wait需要互斥量


一.Linux线程互斥

1.进程线程间的互斥相关背景概念

  • 临界资源: 多线程执行流共享的资源叫做临界资源(全局变量count)。
  • 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。(对count的++,--操作的代码)
  • 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
  • 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

                

(1)临界资源和临界区 

①代码,主线程和新线程都对count++操作

#include 
#include 
#include 

int count = 0; //全局

void* Routine(void* arg)
{
	while (1){
		count++;
		sleep(1);
	}
	pthread_exit((void*)0);
}

int main()
{
	pthread_t tid;
	pthread_create(&tid, NULL, Routine, NULL);

	while (1){
		printf("count: %d\n", count);
		sleep(1);
	}

	pthread_join(tid, NULL);
	return 0;
}

②结果: 全局变量count就叫做临界资源,因为它被多个执行流共享,线程执行函数中printf和count++就叫做临界区,因为这些代码对临界资源进行了访问。

Linux线程安全同步和互斥_第1张图片

 Linux线程安全同步和互斥_第2张图片

                

(2)互斥和原子性 

多线程情况下,如果多个执行流都对同一份临界资源进行访问可能导致数据不一致的问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。

①代码: 模拟抢票系统

#include 
#include 
#include 

int tickets = 1000;

void* routine(void* arg) //抢票
{
	const char* name = (char*)arg;
	while (1){
		if (tickets > 0){
			usleep(1000);
			printf("[%s] get a ticket, left: %d\n", name, --tickets);
		}
		else{
			break;
		}
	}

	printf("%s quit!\n", name);
	pthread_exit((void*)0);
}

int main()
{
	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, routine, (void*)"thread 1");
	pthread_create(&t2, NULL, routine, (void*)"thread 2");
	pthread_create(&t3, NULL, routine, (void*)"thread 3");
	pthread_create(&t4, NULL, routine, (void*)"thread 4");
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);
	return 0;
}

 ②结果:没有对临界资源进行保护,出现了负数

Linux线程安全同步和互斥_第3张图片

                         

③出现负数原因分析 

  • if语句判断条件为真以后(判断也需要CPU参与),代码可以并发的切换到其他线程," 同一时刻" 有多个线程判断tickets时tickets的值是相同的。
  • usleep用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
  • --tickets作本身就不是一个原子操作

                 

--tickets实际的操作过程

 1.对一个变量进行++,--操作需要三个步骤

  • load: tickets变量本身是在内存中的,需要加载到寄存器中
  • update: CPU对寄存器的值进行-1
  • store: 将寄存器的值写回内存

                 

2.汇编代码

Linux线程安全同步和互斥_第4张图片

 3.--tickets的过程中有三条汇编指令,其中在执行任何一条指令时都有可能被切走

                                

2.互斥量mutex引出

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,就会带来一些问题。

                         

解决上述抢票系统的问题,需要做到三点:

  • 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥量 :
Linux线程安全同步和互斥_第5张图片

                 

 3.互斥量接口

(1) 初始化

函数:

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

参数说明:

  • mutex:需要初始化的互斥量。
  • attr:初始化互斥量的属性,一般设置为NULL即可。

返回值:

  • 互斥量初始化成功返回0,失败返回错误码。

调用pthread_mutex_init函数初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

                         

(2)销毁

函数 :  int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:mutex:需要销毁的互斥量。

返回值: 互斥量销毁成功返回0,失败返回错误码。


销毁互斥量需要注意:

  • 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
  • 不要销毁一个已经加锁的互斥量。
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

                         

(3)加锁

函数:int pthread_mutex_lock(pthread_mutex_t *mutex);

参数:mutex:需要加锁的互斥量。


返回值:互斥量加锁成功返回0,失败返回错误码。

调用pthread_mutex_lock时,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

                

(4)解锁 

函数:  int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数: mutex:需要解锁的互斥量。

返回值:互斥量解锁成功返回0,失败返回错误码。

                                 

(5)使用示例

①代码 : 上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。

#include 
#include 
#include 

pthread_mutex_t lock;
int tickets = 1000;

void* routine(void* arg) //
{
	const char* name = (char*)arg;
	while (1){
    pthread_mutex_lock(&lock); //加锁
		if (tickets > 0){
			usleep(100);
			printf("[%s] get a ticket, left: %d\n", name, --tickets);
            pthread_mutex_unlock(&lock); //解锁
		}
	  	else{
            pthread_mutex_unlock(&lock);//解锁
			break;
		}
	}

	printf("%s quit!\n", name);
	pthread_exit((void*)0);
}

int main()
{
    pthread_mutex_init(&lock,NULL);

	pthread_t t1, t2, t3, t4;
	pthread_create(&t1, NULL, routine, (void*)"thread 1");
	pthread_create(&t2, NULL, routine, (void*)"thread 2");
	pthread_create(&t3, NULL, routine, (void*)"thread 3");
	pthread_create(&t4, NULL, routine, (void*)"thread 4");
	
	pthread_join(t1, NULL);
	pthread_join(t2, NULL);
	pthread_join(t3, NULL);
	pthread_join(t4, NULL);

    pthread_mutex_destroy(&lock);
	return 0;
}

②结果 : 不再出现票为负数的情况 

Linux线程安全同步和互斥_第6张图片

                

(6)提示

  • 在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
  • 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
  • 进行临界资源的保护,是所有执行流都应该遵守的标准,这时程序员在编码时需要注意的。

                

4.互斥量原理探究

  • ①对临界区进行保护,所有的执行线程都必须遵守这个规则(编码规则,加锁) ;先加锁->访问临界区->再解锁

  • 所有的线程必须看到同一把锁,锁本身就是临界资源!锁本身先保证自身安全! 申请锁的过程,不能有中间状态,也就是两态。加锁和解锁必须是原子的。

  • ③lock ->访问临界区(花时间)->unlock,在特定进程/线程拥有锁的时候,期间有新线程过来申请锁,一定申请不到 ! 新线程如何?   阻塞,将进程/线程对应的PCB投入到等待队列,unlock后,进行进程/线程唤醒操作!

                         

(1)临界区内的线程可能进行线程切换

  • 临界区内的线程完全可能进行线程切换,但即便该线程被切走它也是带着锁走的,其他线程也无法进入临界区进行资源访问,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
  • 其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。

                 

(2)锁是否需要被保护?

  • 我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
  • 既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
  • 锁自带保护机制,加锁和解锁的过程是原子的

(3)加锁解锁原子性探究

  • 经过上面的例子,已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换
  • 由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一 个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期

         

OS运行的简单理解

  • OS一旦启动成功后就是一个死循环。
  • 时钟是计算机中的一个硬件,时钟每隔一段时间会向操作系统发起一个时钟中断,操作系统就会根据时钟中断去执行中断向量表。
  • 中断向量表本质上就是一个函数表,比如刷磁盘的函数、检测网卡的函数以及刷新数据的函数等等。
  • 计算机不断向操作系统发起时钟中断,操作系统就根据时钟中断,不断地去执行对应的代码。
  • CPU有多个,但总线只有一套。CPU和内存都是计算机中的硬件,这两个硬件之间要进行数据交互一定是用线连接起来的,其中我们把CPU和内存连接的线叫做系统总线,把内存和外设连接起来的线叫做IO总线。
  • 系统总线只有一套,有的时候CPU访问内存是想从内存中读取指令,有的时候是想从内存读取数据,所以总线是被不同的操作种类共享的。计算机是通过总线周期来区分此时总线当中传输的是哪种资源

                

①通过lock和unlock的伪代码理解

Linux线程安全同步和互斥_第7张图片

                 

②可以把mutex想象成一个变量,初始值为1在内存中,%al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:

  1. 先将%al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的%al寄存器清0。
  2. 然后交换%al寄存器和内存中mutex的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
  3. 最后判断%al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

Linux线程安全同步和互斥_第8张图片

                                

③理解上下文保护 : 上下文信息保存在内存里 , 上下文切换时将寄存器的值保存在这样的结构里

Linux线程安全同步和互斥_第9张图片

                

④ 现在假设有线程A和线程B两个线程,线程A先运行

 1)如果线程A执行完步骤1时被切换走,线程B开始执行步骤1

线程A执行步骤1时还未交换mutex的值,线程A有上下文保护,不受影响

2)如果线程A执行完步骤2时被切换走,线程B开始执行步骤2

线程A执行完步骤2,线程A保存自己的寄存器值1,上下文保护将寄存器值1带走了,此时的内存值为0. 线程B拿自己的上下文数据也要和内存的值进行交换,没意义, 交换完还是0和0,线程B继续向下执行就被挂起了;再次切换线程时线程B要进程上下文保存,%al是0,线程A上下文恢复%al的值是1,就return 0,进来执行操作访问临界资源

                

当线程释放锁时,需要执行以下步骤:

  1. 将内存中的mutex置为1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
  2. 唤醒等待mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。

⑥补充

  • 能走到解锁的一定是有锁的,解锁的过程一定是原子的 
  • 交换有个特征,内存的值mutex"1"只有一个,交换完就没了。条件成立永远只能是拥有1的线程能够申请到锁,其他的完全不可能。且拥有1的过程是—条汇编完成的,所以最终表现出来是原子的。
  • 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的
  • 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
  • CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的.
  • 原子性体现在—条汇编完成加锁; 竞争性体现在线程A,B不可能同时进行交换,必须有顺序
     

                

                        

                

二.可重入VS线程安全

1.基本概念

  • 线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
  • 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
  • 注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。

Linux线程安全同步和互斥_第10张图片

                

2.常见的线程不安全的情况

  • 不保护共享变量的函数。
  • 函数状态随着被调用,状态发生变化的函数。
  • 返回指向静态变量指针的函数。
  • 调用线程不安全函数的函数。

                 

3.常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  • 类或者接口对于线程来说都是原子操作。
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性。

                 

4.常见的不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
  • 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
  • 可重入函数体内使用了静态的数据结构。

         

5.常见的可重入的情况

  • 不使用全局变量或静态变量。
  • 不使用malloc或者new开辟出的空间。
  • 不调用不可重入函数。
  • 不返回静态或全局数据,所有数据都由函数的调用者提供。
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。

6.可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的。
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

                 

7.可重入与线程安全区别

  • 可重入函数是线程安全函数的一种。
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。

                

        

        

                

三.常见锁概念

1.死锁

  • 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

单执行流可能产生死锁吗?

单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
                

(1)测试示例

①代码

#include 
#include 

pthread_mutex_t mutex;

void* Routine(void* arg)
{
	pthread_mutex_lock(&mutex);
	pthread_mutex_lock(&mutex);
	
	pthread_exit((void*)0);
}

int main()
{
	pthread_t tid;
	pthread_mutex_init(&mutex, NULL);
	pthread_create(&tid, NULL, Routine, NULL);
	
	pthread_join(tid, NULL);
	pthread_mutex_destroy(&mutex);
	return 0;
}

②结果 : 查看该进程时可以看到,该进程当前的状态是sl+,其中中 l 实际上就是lock的意思,表示该进程当前处于一种死锁的状态。

Linux线程安全同步和互斥_第11张图片

                                 

(2) 阻塞(挂起等待)的理解

①进程运行时是被CPU调度的,进程在调度时是需要用到CPU资源,每个CPU都有一个运行等待队列(runqueue),CPU在运行时就是从该队列中获取进程进行调度的。

Linux线程安全同步和互斥_第12张图片

                        

运行等待队列中的进程本质上就是在等待CPU资源,实际上不止是等待CPU资源如此,等待其他资源也是如此,比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列。

Linux线程安全同步和互斥_第13张图片

                 

③详细的过程

  1. CPU经过OS进行执行OS的代码去调度当中的进程,所以就一个个的拿过来进行执行
  2. 其中我们的多个进程/线程在等待CPU资源可以排队,锁资源等其他资源也可以排队
  3.  现在假设有4个线程,CPU进行调度的时候,现在1号线程任务是申请锁向打印机上打东西,但是这个设备正在被使用,很显然我们CPU要执行打印的代码时没办法打印,(当前这个线程它要访问这个资源没有这个资源我不往后走,没有这个资源我们的线程/进程就不执行,那么我们的CPU可能说行吧,我们—起等,等人家使用完了我在帮你申请锁)
  • 不可能,CPU不可能等你,所以此时你这个进程要完成某种任务的时候可能发现某种条件不能满足,怎么办?    把当前进程的状态由R设置成S,紧接着将当前进程把它的PCB节点放下来链接到资源等待队列 (硬件在OS里面也有一个资源等待队列) , 我就把当前进程链接到这个等待队列里,那么这个进程就不在运行队列里面了,直接把这个进程移出运行队列拿走;CPU继续调度下一个进程,这个进程就在这个等待这个资源了,同时其他的线程比如2 , 3 , 4都想申请这个打印机资源,但是正在被别的使用,所以直接把2,3,4线程全部链接到资源等待队列
  • task-struct是一个结构体变量,他就可以有节点,那么它就可以往其他的队列里面链入;CPU在执行任务的同时,其他几个线程在等待资源的就绪,后来资源就绪了,比如OS在进行调度成功之后时间片到了顺便检测一下硬件,发现这些资源以及就绪了: 所谓的把进程再唤醒就是:把排队的这个进程设置进运行队列里链接到运行队列当中,把它的状态由S改为R,那么这个线程就可以被调度了,但是不一定立马被调度。
  • 有人说这个资源被别的线程占了怎么办? 不可能,因为等待这个资源的其他进程还处于休眠状态,OS没有唤醒其他进程所以意味着我们的OS将来在合适的时候调度这个进程的时候这个资源一定是让你访问的
                  

④精简过程

  • 当某一个进程在被CPU调度时,该进程需要用到打印机的资源,但是此时打印机的资源正在被其他进程使使用
  • 那么此时该进程的状态就会由R状态变为某种阻塞状态,比如S状态。并且该进程会被移出运行等待队列,被链接到等待打印机资源的资源等待队列,而CPU则继续调度运行等待队列中的下一个进程。
  • 此后若还有进程需要用到这个打印机的资源,那么这些进程也都会被移出运行等待队列,依次链接到这个打印机的资源等待队列当中。
  • 直到使用打印机的进程已经使用完毕,也就是打印机的资源已经就绪,此时就会从打印机的资源等待队列中唤醒一个进程,将该进程的状态由S状态改为R状态,并将其重新链接到运行等待队列,等到CPU再次调度该进程时,该进程就可以使用到打印机的资源了。

                 

⑤小结

  • 站在操作系统的角度,进程等待某种资源,就是将当前进程的task_struct放入对应的等待队列,这种情况可以称之为当前进程被挂起等待了。
  • 站在用户角度,当进程等待某种资源时,用户看到的就是自己的进程卡住不动了,我们一般称之为应用阻塞了。
  • 这里所说的资源可以是硬件资源也可以是软件资源,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中。

                

2.死锁的四个必要条件 

  • 互斥条件: 一个资源每次只能被一个执行流使用。
  • 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
  • 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。

注意: 只有同时满足了这四个条件才可能产生死锁。

                

3.避免死锁

  • 破坏死锁的四个必要条件。
  • 加锁顺序一致。
  • 避免锁未释放的场景。
  • 资源一次性分配。
  • 使用一些避免死锁的算法

                

                

                

                

四.Linux线程同步 

1.同步概念与竞态条件

  • 同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
  • 竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。

                 

单独使用互斥容易导致饥饿问题,为了解决引入了同步:

  • 首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
  • 单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。
  • 现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
  • 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。
  • 例子:拿苹果
    放苹果的过程,放还是没放是不确定的,不确定的放一定是不确定的拿。每个人拿/放苹果都要申请锁,申请成功再放,放完再释放锁。一旦临界区有锁的保护,拿苹果不会出错。先申请锁,判断有没有苹果,有拿出来,没有退出来,释放锁。有一种情况,一个线程申请锁的能力和竞争力就是比其他线程强,总是放苹果,但是其他线程拿不到锁,没办法拿苹果,一方不断地申请锁,释放锁,没有做实际有意义的事情,把大量的时间都放在申请,释放锁上面。不高效,不合理。

                         

2.条件变量

条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。

条件变量主要包括两个动作:

  • 一个线程等待条件变量的条件成立而被挂起。
  • 另一个线程使条件成立后唤醒等待的线程。

条件变量通常需要配合互斥锁一起使用。

                

3.条件变量函数

(1)初始化

函数: 

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);


参数:

  • cond:需要初始化的条件变量。
  • attr:初始化条件变量的属性,一般设置为NULL即可。

返回值:条件变量初始化成功返回0,失败返回错误码。


调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

                         

(2)销毁

函数:   int pthread_cond_destroy(pthread_cond_t *cond);

参数:   cond:需要销毁的条件变量。


返回值: 条件变量销毁成功返回0,失败返回错误码。


销毁条件变量需要注意:

使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。

                

(3)等待条件变量        

函数:

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);


参数:

  • cond:需要等待的条件变量。
  • mutex:当前线程所处临界区对应的互斥锁。

返回值: 函数调用成功返回0,失败返回错误码。

                         

(4)唤醒

函数: 

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);


区别:

  • pthread_cond_signal函数用于唤醒等待队列中首个线程。
  • pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。

参数: cond:唤醒在cond条件变量下等待的线程。


返回值: 函数调用成功返回0,失败返回错误码。

                         

(5)使用示例

①代码:  主线程控制新线程活动。新线程创建后都在条件变量下进行等待,直到主线程检测到键盘有输入时才唤醒一个等待线程

#include
#include
#include

pthread_mutex_t lock;
pthread_cond_t cond;


void* Run(void* arg)
{
  pthread_detach(pthread_self());
  std::cout<< (char*)arg << "run ..." << std::endl;
  while(true){
    pthread_cond_wait(&cond,&lock);//阻塞在这里
    std::cout << (char*)arg << " :  " << pthread_self() << " active ..." << std::endl;
  }
}


int main()
{
  pthread_mutex_init(&lock,nullptr);
  pthread_cond_init(&cond ,nullptr);

  pthread_t t1,t2,t3;
  pthread_create(&t1 ,nullptr , Run , (void*)"thread 1");
  pthread_create(&t2 ,nullptr , Run , (void*)"thread 2");
  pthread_create(&t3 ,nullptr , Run , (void*)"thread 3");

  //主线程控制新线程的任务ctrl
  while(true){
    getchar();
    pthread_cond_signal(&cond); //唤醒在该条件变量下等的一个线程
   // pthread_cond_broadcast(&cond);//唤醒所有线程,
  }

  pthread_mutex_destroy(&lock);
  pthread_cond_destroy(&cond);
  return 0;
}

②结果:  有序唤醒等待的线程,某个线程唤醒后执行完相关操作在该环境变量的等待队列的队尾继续等待 .

Linux线程安全同步和互斥_第14张图片

                        

4.为什么pthread_cond_wait需要互斥量

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
  • 当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题
  • 所以在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
  • 当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。

小结:

  • 等待条件满足的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。
  • 条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
  • pthread_cond_wait函数有两个功能,一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。

(1)错误的程序设计

当我们进入临界区上锁后,如果发现条件不满足,那我们先解锁,然后在该条件变量下进行等待也可以吧: 

pthread_mutex_lock(&mutex);
while (condition_is_false){
	pthread_mutex_unlock(&mutex);
	//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
	pthread_cond_wait(&cond);
	pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
  • 这种方案是不可行的,因为解锁和等待不是原子操作,调用解锁之后,在调用pthread_cond_wait函数之前,如果已经有其他线程获取到互斥量,发现此时条件满足,于是发送了信号,那么此时pthread_cond_wait函数将错过这个信号,最终可能会导致线程永远不会被唤醒,因此解锁和等待必须是一个原子操作。
  • 而实际进入pthread_cond_wait函数后,会先判断条件变量是否等于0,若等于0则说明不满足,此时会先将对应的互斥锁解锁,直到pthread_cond_wait函数返回时再将条件变量改为1,并将对应的互斥锁加锁。

                 

(2)条件变量使用规范

①条件变量等待

pthread_mutex_lock(&mutex);
while (条件为假)
	pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);

②唤醒等待

pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

你可能感兴趣的:(Linux系统,服务器,linux,多线程)