Linux线程安全,互斥量和条件变量

发布于:2024-05-06 ⋅ 阅读:(28) ⋅ 点赞:(0)


一、 Linux线程互斥

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

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

(1) 临界资源和临界区

临界资源和临界区是并发编程和操作系统中重要的概念,它们都与资源的共享和访问控制有关。

  1. 临界资源(Critical Resources)

    • 临界资源是一次仅允许一个进程使用的共享资源。这些资源可能是物理设备(如打印机、磁带机等),也可能是软件资源(如变量、数组、缓冲区、文件、数据库等)。
    • 为了确保数据的一致性和完整性,多个进程在访问这些资源时必须进行互斥操作,即同一时间只有一个进程可以访问临界资源。
  2. 临界区(Critical Sections)

    • 临界区是进程中访问临界资源的一段代码。这段代码的执行必须在一个进程内独占式地进行,即当一个进程进入临界区后,其他试图访问临界资源的进程必须等待,直到该进程离开临界区。
    • 临界区是并发控制中用于实现进程同步和互斥的一种手段。它确保了在同一时间只有一个进程能够访问某个特定的临界资源。
    • 进程在进入临界区之前,通常会执行一些检查(如查看资源是否正在被其他进程使用),并在离开临界区时执行一些恢复操作(如标记资源为未使用状态)。

区别与联系

  • 临界资源是多个进程共享的物理或软件资源,而临界区是访问这些资源的代码段。
  • 临界区的存在是为了确保临界资源的互斥访问,防止多个进程同时访问临界资源导致的数据不一致或冲突。
  • 临界区和临界资源都是并发控制中的重要概念,它们共同确保了在并发环境中资源访问的正确性和安全性。

在操作系统和并发编程中,有多种机制可以实现临界区的访问控制,如信号量(semaphores)、互斥锁(mutexes)、自旋锁(spinlocks)等。这些机制都旨在确保同一时间只有一个进程能够进入临界区,从而保护临界资源不被多个进程同时访问。

比如下面这段程序中,我们定义了一个变量count,让新线程每一秒将count+1,然后主线程每隔一秒打印依次count的值。在这里由于count被多个线程所共享(在这里是被创建的新线程和主线程共享),所以count变量就是一个临界资源。 在新线程中的count++,和主线程中的printf语句都对临界资源count进行了访问。所以count++ 和printf都叫做临界区。

#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;

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;
}

在这里插入图片描述

(2) 互斥和原子性

互斥(Mutual Exclusion)和原子性(Atomicity)是并发编程和操作系统中两个重要的概念,它们用于确保多线程或多进程访问共享资源时的正确性和安全性。

互斥(Mutual Exclusion)

互斥是指同一时间只允许一个线程或进程访问某个共享资源,以防止多个线程或进程同时访问该资源时产生的冲突或数据不一致。当一个线程或进程正在访问一个共享资源时,其他尝试访问该资源的线程或进程将被阻塞,直到当前线程或进程释放该资源。

互斥通常通过锁(Lock)机制来实现,例如互斥锁(Mutex)或读写锁(Read-Write Lock)。这些锁提供了对共享资源的保护,确保同一时间只有一个线程或进程能够访问它。

原子性(Atomicity)

原子性是指一个操作或多个操作要么全部执行,要么全部不执行,中途不会被其他操作打断。在并发编程中,原子性用于确保一系列操作作为一个整体来执行,以避免在并发环境下出现竞态条件(Race Condition)和数据不一致的问题。

原子性通常通过事务(Transaction)或原子操作(Atomic Operation)来实现。事务是一系列操作的集合,这些操作要么全部成功提交,要么全部失败回滚。原子操作则是一个不可分割的操作,它在执行过程中不会被其他操作打断。

区别与联系

  • 区别:互斥关注的是资源的访问控制,即同一时间只允许一个线程或进程访问共享资源;而原子性关注的是操作的执行过程,即一系列操作作为一个整体来执行,中途不会被打断。
  • 联系:在并发编程中,互斥和原子性经常一起使用来确保数据的一致性和正确性。例如,在数据库事务中,为了保证数据的完整性,通常会使用锁来实现互斥访问,并使用事务来确保一系列操作的原子性。同样地,在并发控制中,也经常会使用原子操作来避免竞态条件和数据不一致的问题。

下面我们简单模拟一个抢票系统,用来说明互斥的重要性,和原子性的原理。
如下,我们定义了一个全局变脸tickets用来充当票数,然后在主线程中创建5个线程,这五个线程都去执行Routine函数,该函数的功能就是判断票数是否大于0,如果大于,那就减少一张票,一直重复上述过程,来模拟抢票的过程。

#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;

int tickets=100;
void* Routine(void* arg)
{
  char* name=(char*)arg;
  while(1)
  {
    if(tickets>0)
    {
      usleep(10000);
      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 tid[5];
  pthread_create(&tid[0],nullptr,Routine,(void*)"thread 1");
  pthread_create(&tid[1],nullptr,Routine,(void*)"thread 2");
  pthread_create(&tid[2],nullptr,Routine,(void*)"thread 3");
  pthread_create(&tid[3],nullptr,Routine,(void*)"thread 4");
  pthread_create(&tid[4],nullptr,Routine,(void*)"thread 5");
   
  for(int i=0;i<5;i++)
    pthread_join(tid[i],nullptr);
  return 0;
}

在这里插入图片描述
但是最后我们却发现一个问题,票数都是负数了,怎么还能抢票呢?这显然和现实生活中不一样啊。那为什么会出现剩余票数为负数的情况呢?
原因主要有三条:

  1. if 语句判断条件为真以后,代码可以并发的切换到其他线程
  2. usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  3. –ticket 操作本身就不是一个原子操作

– 操作并不是原子操作,而是对应三条汇编指令:

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

图示:
在这里插入图片描述

–对应的汇编操作如下:
在这里插入图片描述
由于–需要三条汇编指令才能完成,所以有可能线程1即thread1在执行完load 指令将共享变量ticket(1000)从内存加载到寄存器中之后就被切换了,此时thread1读到的数据是1000,当thread1被切换之后,寄存器中的数据会被当成thread1的上下文数据被保存起来,等到thread1被切回来的时候,再次恢复上下文。
在这里插入图片描述
在这个时候,加入thread2被调度了,由于thread只执行了–三条汇编的第一步,所以内存中的ticket还是1000。thread2执行了完整的100次–操作后被切换走,内存中的ticket就变成了900.
在这里插入图片描述
此时操作系统再把thread1恢复上来的时候,就会继续执行被切换前thread1的操作,并且把上下文数据恢复,也就是说此时寄存器当中的值是恢复出来的1000,然后thread1继续执行–操作的第二步和第三步,最终将999写回内存。
在这里插入图片描述
在上述过程中thread1抢了1张票,thread2抢了100张票,但最终ticket中存的确实999,这不就是平白无故的多出来100张票吗?

因此–操作不是原子的,虽然–ticket只是一条指令,但对应的汇编指令确是三条,因此–不是原子的,相应的++操作也不是原子的。

2. 互斥量mutex

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

要解决以上问题,需要做到三点:

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

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
在这里插入图片描述

3. 互斥量的接口

(1)初始化互斥量
初始化互斥量有两种方法:

  1. 方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 
  1. 方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 

参数:

  • mutex:要初始化的互斥量
  • attr:初始化互斥量的属性,一般设置成NULL

返回值:

  • 初始化成功返回0,不成功返回错误码

(2)销毁互斥量
销毁互斥量需要注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex)

参数:

  • mutex:要销毁的互斥量

返回值说明:

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

销毁互斥量需要注意:

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

(3)互斥量加锁和解锁

int pthread_mutex_lock(pthread_mutex_t *mutex); 
int pthread_mutex_unlock(pthread_mutex_t *mutex); 

参数:

  • mutex:要加锁或解锁的互斥量

返回值:

  • 返回值:成功返回0,失败返回错误号

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

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

使用示例:
流程:

  1. 定义一把锁
  2. 初始化锁
  3. 加锁
  4. 解锁
  5. 销毁锁
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;

pthread_mutex_t mutex;//定义一把锁
int tickets=100;
void* Routine(void* arg)
{
  char* name=(char*)arg;
  while(1)
  {
    pthread_mutex_lock(&mutex);//加锁
    if(tickets>0)
    {
      usleep(10000);
      printf("[%s] get a ticket, left: %d\n", name, --tickets);
      pthread_mutex_unlock(&mutex);//解锁
    }
    else 
    {
      pthread_mutex_unlock(&mutex);//解锁
      break;
    }
  }
  printf("%s,quit\n",name);
  pthread_exit((void*)0);
}
int main()
{
  pthread_t tid[5];
  pthread_mutex_init(&mutex,nullptr);//初始化锁
  pthread_create(&tid[0],nullptr,Routine,(void*)"thread 1");
  pthread_create(&tid[1],nullptr,Routine,(void*)"thread 2");
  pthread_create(&tid[2],nullptr,Routine,(void*)"thread 3");
  pthread_create(&tid[3],nullptr,Routine,(void*)"thread 4");
  pthread_create(&tid[4],nullptr,Routine,(void*)"thread 5");
   

  for(int i=0;i<5;i++)
    pthread_join(tid[i],nullptr);
    pthread_mutex_destroy(&mutex);//销毁锁
  return 0;
}

在这里插入图片描述

4. 互斥量实现原理探究

加锁之后的原子性体现在哪里?

加锁后的原子性主要体现在对共享资源的访问控制上。当一个线程或进程获得了一个锁(如互斥锁、自旋锁等)并进入了临界区时,它就可以独占式地访问临界资源,即那个被保护的共享资源。在这个线程或进程持有锁并访问临界资源的过程中,其他试图访问该资源的线程或进程将被阻塞,直到锁被释放。

这种加锁机制确保了同一时间只有一个线程或进程能够访问临界资源,从而实现了对临界资源的互斥访问。这种互斥访问是原子性的一个重要体现,因为它保证了一系列操作(即访问临界资源的操作)作为一个整体来执行,中途不会被其他线程或进程打断。

因此,加锁后的原子性主要体现在以下几个方面:

  1. 同一时间只有一个线程或进程能够访问临界资源,确保了操作的互斥性。
  2. 访问临界资源的操作作为一个整体来执行,不会被其他线程或进程打断,确保了操作的原子性。
  3. 避免了竞态条件和数据不一致的问题,提高了并发程序的正确性和安全性。

需要注意的是,虽然加锁机制可以实现原子性,但过度使用锁也可能会导致性能下降和死锁等问题。因此,在并发编程中需要谨慎使用锁,并根据实际情况选择合适的同步机制来确保程序的正确性和性能。

在临界区内的线程可能被切换吗?

临界区内的线程是可以进行线程切换的

临界区是一段独占对某些共享资源访问的代码,在任意时刻只允许一个线程对共享资源进行访问。然而,线程切换是CPU根据调度策略进行的,可能会在线程执行过程中因为各种原因(如时间片用完、等待某个条件等)而暂停当前线程的执行,切换到另一个线程执行。

虽然临界区内的线程可能会被切换出去,但此时由于临界区内的线程已经获得了对共享资源的访问权,因此其他线程无法进入临界区访问该共享资源,直到临界区内的线程完成操作并释放锁。这种机制确保了共享资源在任意时刻只被一个线程访问,从而避免了数据的不一致性和冲突。

锁本身需不需要被保护呢?

锁是所有线程共享的,也就是说锁本身就是一个临界资源,因此锁肯定是需要被保护的,那锁用什么来保护呢?
其实锁是自己保护自己的,因为申请锁是原子的。申请锁只有两种状态,要么成功要么不成功,只要成功了,其他线程就无法访问临界区的临界资源了。

锁如何保证原子性

  • 经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题

  • 在并发编程中,特别是在多处理器或多线程环境中,确保对共享资源的访问是原子性的至关重要。原子性意味着一个操作要么完全执行,要么完全不执行,中间状态不会被其他线程或处理器看到。

  • 为了实现这种原子性,许多现代处理器体系结构都提供了swap或exchange指令(有时也被称为xchg指令)。这个指令的作用是将内存单元的值与寄存器的值进行交换,并且这个交换操作是原子的,即它在执行过程中不会被中断。

  • 在多处理器环境中,由于多个处理器可能同时尝试访问同一个内存地址,因此需要确保每个处理器的访问都是有序的,并且不会相互干扰。总线周期(bus cycle)是处理器与内存之间通信的一个关键概念。当一个处理器正在访问内存时,它会锁定总线,确保在其完成访问之前其他处理器不能访问该内存地址。

  • 因此,当一个处理器执行swap或exchange指令时,它会锁定总线,完成寄存器和内存单元之间的数据交换,然后释放总线。在这个过程中,其他试图执行swap或exchange指令的处理器将被阻塞,直到第一个处理器完成其操作并释放总线。这种机制确保了每次只有一个处理器能够执行swap或exchange指令,从而实现了原子性。

基于这种原子性操作,我们可以实现各种同步机制,如互斥锁、信号量等,以确保并发程序中的正确数据访问和同步。

  • 现在我们把lock和unlock的伪代码改一下
    在这里插入图片描述

我们可以认为mutex的初始值是1,a1是一个寄存器,当一个线程要申请锁的时候就要执行以下步骤:

  1. 先把a1寄存器清零,每个线程都需要这样做,因为每个线程都有自己的上下文信息,每个线程要把自己的a1寄存器清零。
  2. 执行xchgb指令,将内存中的mutex的值和a1寄存器中的值进行互换,由于xchgb是一条汇编,所以它是原子的,保证了只有两种状态,要么交换了,要么没换。
  3. 判单a1寄存器中的值是否大于0,如果大于0,则说明竞争到了锁,就可以进入临界区访问临界资源,反之则没有竞争到锁,需要被挂起等待,直到锁被释放之后继续竞争锁。

例如下图:此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换。
交换之后判断a1是否为1,如果等于1,则竞争到了锁,可以进入临界区访问临界资源。
在这里插入图片描述
而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
在这里插入图片描述
当释放锁的时候需要执行以下步骤:

  1. 将内存中的mutex置1,表示解锁了,其他线程可以去竞争锁了。
  2. 唤醒被挂起的线程,让它们去竞争锁。

注意:

  1. 竞争锁的本质就是谁先交换,把1换到自己的上下文数据中去
  2. 在归还锁的时候没有把自己寄存器置为0,不会有影响,因为在下一次竞争的时候第一步事情就是把自己的寄存器清零。

二、 可重入VS线程安全

1. 概念

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

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

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

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

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

4. 常见不可重入的情况

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

5. 常见可重入的情况

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

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

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

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

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

三、 常见锁概念

1. 死锁

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

产生死锁的原因主要有以下几点:

  1. 系统资源不足:如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低。然而,当系统资源有限时,进程可能会因争夺有限的资源而陷入死锁。
  2. 进程运行推进的顺序不合适:进程运行推进的顺序与速度不同,也可能导致死锁。例如,进程A锁住了资源1并等待资源2,而进程B锁住了资源2并等待资源1,这样就形成了死锁。
  3. 资源分配不当:如果系统的资源分配策略不当,或者程序员写的程序有错误,也可能导致进程因竞争资源不当而产生死锁的现象。

比如在一个线程中连续两次申请同一把锁就会导致死锁。因为在第一次申请锁的时候已经申请成功了,在第二次申请锁的时候就会申请失败,我们知道如果一个线程申请锁失败后就会加入等待队列中被挂起。因此第二次申请锁失败之后这个线程就会被挂起,但是它是拿着锁被挂起的,因为被挂起了,所以就没办法释放锁,也就不会被唤醒,因此该执行流就处于一种死锁的状态了。

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

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;
}

此时处于被挂机的状态
在这里插入图片描述
在stat一列中显示的是SL其中的l就表示lock的含义表示处于死锁状态
在这里插入图片描述

2. 死锁四个必要条件

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

3. 避免死锁

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

4. 避免死锁算法

  • 死锁检测算法(了解)
  • 银行家算法(了解)

四、 Linux线程同步

1. 条件变量

  • 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
  • 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

2. 同步概念与竞态条件

  1. 同步(Synchronization):

    • 同步是指两个或两个以上随时间变化的量在变化过程中保持一定的相对关系。在并发编程中,同步通常指的是对共享资源的访问进行协调,以确保在任意时刻只有一个线程或进程能够访问该资源,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,
    • 同步的目的是为了保证数据的完整性和一致性,防止多个线程或进程同时修改同一数据而导致的冲突或错误。
    • 同步的实现方式有多种,包括互斥锁(Mutex)、信号量(Semaphore)、事件(Event)等。这些机制可以控制对共享资源的访问,确保同一时间只有一个线程或进程能够访问该资源。
    • 同步也可以理解为一种协作关系,即多个线程或进程在访问共享资源时需要进行协调和配合,以确保程序的正确执行。
  2. 竞态条件(Race Condition):

    • 竞态条件是指在并发程序中,由于多个线程或进程对共享资源的访问顺序不确定而导致的程序执行结果不确定的情况。
    • 当多个线程或进程同时访问共享资源时,如果它们之间的执行顺序和时序关系没有得到适当的控制,就可能导致竞态条件的发生。
    • 竞态条件可能会导致程序出现错误或不可预测的结果,例如数据不一致、死锁等问题。
    • 为了避免竞态条件的发生,需要采取适当的同步措施来控制对共享资源的访问。例如,可以使用互斥锁来确保同一时间只有一个线程或进程能够访问共享资源,或者使用信号量来限制对共享资源的并发访问数量。

以下是对上述内容重新分点说明的表述:

单纯加锁的问题

  1. 竞争力不均衡:在并发环境中,如果某个线程特别频繁地获取到锁,而其他线程则很少有机会,这会导致其他线程长时间处于等待状态,即所谓的“饥饿”现象。

  2. 效率低下:尽管加锁可以确保同一时间只有一个线程访问临界区,但如果某些线程在持有锁时并不进行实际的工作,而是在不断地申请和释放锁,这会造成资源的浪费和整体效率的降低。

引入新规则:等待队列机制

  1. 规则描述:当一个线程释放锁后,它不能立即重新申请该锁,而是必须排到等待该锁的资源队列的末尾。

  2. 保证公平性:这个规则确保了每个等待的线程都有机会按照它们在队列中的顺序获取锁,从而避免了某些线程因竞争力过强而长时间占用资源的情况。

  3. 有序访问:如果有多个线程在等待访问临界区,新的规则将确保它们按照一定的次序(通常是先进先出,FIFO)进行访问,从而提高了系统的整体公平性和效率。

应用场景示例

  1. 读写冲突:假设有两个线程,一个负责向临界区写入数据,另一个负责从临界区读取数据。如果写入线程竞争力过强,它将一直持有锁并写入数据,导致读取线程长时间无法获取锁进行读取。

  2. 引入同步后的改善:通过应用上述的等待队列机制,即使写入线程在一段时间内频繁地获取和释放锁,它也不会立即重新获取锁,而是必须等待其他线程(包括读取线程)按照顺序访问临界区。这样,读取线程就有机会在写入线程之后获取锁并进行读取操作,从而避免了长时间的等待和饥饿现象。

3. 条件变量函数

初始化函数原型:

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

参数:

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

返回值:

  • 初始化成功返回0,否则返回错误码

销毁函数原型:

int pthread_cond_destroy(pthread_cond_t *cond) 

参数:

  • cond:要销毁的条件变量

返回值:

  • 销毁成功返回0,失败返回错误码

等待条件满足

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

参数:

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

返回值说明:

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

唤醒等待

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,失败返回错误码。

使用示例:
我们这里在主线程中创建三个新线程,然后让着三个线程直接去条件变量中去等待,然后每当键盘输入一个字符的时候,主线程就会唤醒一个线程,如此往复下去。

#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;

pthread_mutex_t mutex;
pthread_cond_t cond;
void* Routine(void* arg)
{
  pthread_detach(pthread_self());
  cout << (char *)arg << " run..." << endl;
  while(1)
  {
    pthread_cond_wait(&cond,&mutex);
    std::cout << (char*)arg << "活动..." << std::endl;
  }
}
int main()
{
  pthread_t tid[3];
  pthread_mutex_init(&mutex,nullptr);
  pthread_cond_init(&cond,nullptr);
  pthread_create(&tid[0],nullptr,Routine,(void*)"thread 1"); 
  pthread_create(&tid[1],nullptr,Routine,(void*)"thread 2"); 
  pthread_create(&tid[2],nullptr,Routine,(void*)"thread 3"); 
  while(1)
  {
    getchar();
    pthread_cond_signal(&cond);
  }

  pthread_mutex_destroy(&mutex);
  pthread_cond_destroy(&cond);

  return 0;
}

但我们运行程序之后,连续按下回车键(其它键也可以,只不过回车键效果比较好罢了),就会发现三个线程按照一定的顺序依次运行。原因就是这三个线程启动时直接会去条件变量下等待,每次输入一个字符的时候就会唤醒当前条件变量下等待的头部线程,该线程执行完打印操作之后就会继续排到等待队列的尾部进行wait,因此我们可以看到上面的现象。
在这里插入图片描述
当然我们可以把pthread_cond_signal(&cond);换成pthread_cond_broadcast(&cond);这样每次输入一个字符就会唤醒该条件变量下等待的所有线程。

在这里插入图片描述

4. 为什么 pthread_cond_wait 需要互斥量?

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据
    在这里插入图片描述
  • 按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:
// 错误的设计 
 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 。所以解锁和等待必须是一个原子操作。
  • int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。

5. 条件变量使用规范

等待条件代码

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);