多线程——互斥和同步

多线程—互斥和同步

位图 (8)

多线程互斥

在这里要引入几个概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源

样例中的ticket是主线程的全局数据,在这里属于临界资源

  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区

ticket是临界资源,那么后续的判断ticket>0 和ticket–等操作就是访问临界资源的代码,属于临界区

  • 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

for循环内的代码,即临界区其中执行到一半会被进行线程切换,那么该临界区并不具有原子性,也正是这个原因导致的bug

  • 并行:是指两个或者多个事件在同一时刻发生,是在不同实体上的多个事件。一般适用于多CPU的情况

多核 CPU 真正实现了“同时执行多个任务”,多核 CPU 的每个核心都可以独立地执行一个任务,而且多个核心之间不会相互干扰。在不同核心上执行的多个任务,是真正地同时运行,这种状态就叫做并行。

下图展示了两个任务并行执行的过程:

image-20230714164204460

  • 而并发是指两个或多个事件在同一时间间隔发生,是在同一实体上的多个事件。

就是通过一种算法将 CPU 资源合理地分配给多个任务,当一个任务执行 I/O 操作时,CPU 可以转而执行其它的任务,等到 I/O 操作完成以后,或者新的任务遇到 I/O 操作时,CPU 再回到原来的任务继续执行。

下图展示了两个任务并发执行的过程:

image-20230714164516485

  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用

互斥能使多线程串行访问临界资源,对临界资源进行保护,有效避免出现多线程并发出现错误情况

互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。

  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。

  • 多个线程并发的操作共享变量,会带来一些问题。

在这里只针对单CPU进行分析

先模拟一个多线程抢取火车票的程序。每个线程进到循环先判断ticket是否大于0,大于就进入循环,然后休眠,之后再模拟一个火车票数量减少。否则退出循环。

thread.cc

#include<iostream>
#include<string.h>
#include<pthread.h>
#include<stdio.h>
#include<vector>
#include<assert.h>
#include<unistd.h>
#include<memory>
#include"mythread.hpp"
using namespace std;

int ticket=1000;
void* getticket(void*args)
{
    
    
    string username=static_cast<const char*> (args);
   while(true)
    {
    
    
        if(ticket>0)
        {
    
    
             usleep(1234);
        cout<<"User name:"<<username<<"get tickets ing..."<<"ticket num: "<<ticket<<endl;
                   ticket--;
        }else
        {
    
    
            break;//没票了退出循环
        }
        
    }
    return nullptr;
}

int main()
{
    
    
unique_ptr<thread> thread1(new thread(getticket,(void*)"user1",1));
unique_ptr<thread> thread2(new thread(getticket,(void*)"user2",2));
unique_ptr<thread> thread3(new thread(getticket,(void*)"user3",3));
thread1->join();
thread2->join();
thread3->join();
    return 0;
}

mythread.hpp

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


class thread;//声明
class Context
{
    
    
 public:
 thread* _this;//this指针
 void* _args;//函数参数
 public:
 Context()
 :_this(nullptr)
 ,_args(nullptr)
 {
    
    }
 ~Context()
 {
    
    }
};

class thread
{
    
    
public:
typedef  function<void* (void*)> func_t;//包装器构建返回值类型为void* 参数类型为void* 的函数类型
const int num=1024;
 thread(func_t func,void* args,int number=0)//构造函数
 : fun_(func)
 ,args_(args)
 {
    
    
char namebuffer[num];
snprintf(namebuffer,sizeof namebuffer,"threa--%d",number);//缓冲区内保存线程的名字即几号线程
Context* ctx=new Context();//
ctx->_this=this;
ctx->_args=args_;
int n=pthread_create(&pid_,nullptr,start_rontine,ctx);//因为调用函数start_rontine是类内函数,具有缺省参数this指针,在后续解包参数包会出问题,所以需要一个类来直接获取函数参数
assert(n==0);
(void)n;
 }

static void* start_rontine(void* args)
{
    
    
Context* ctx=static_cast<Context*>(args);
void *ret= ctx->_this->run(ctx->_args);//调用外部函数
delete ctx;
return ret;
}
void* run(void* args)
{
    
    
    return fun_(args);//调用外部函数
}
void join()
{
    
    
  int n=  pthread_join(pid_,nullptr);
  assert(n==0);
  (void)n;
}
~thread()
{
    
    
    //
}

    private:
    string name_;//线程的名字
    pthread_t pid_;//线程id
  func_t fun_;//线程调用的函数对象
  void* args_;//线程调用的函数的参数
};

image-20230714154928176

  • 可以看到票数会出现负数的情况

原因如下:

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • 进程一进来先判断ticket是否大于0,先将内存上的ticket数据加载到CPU的寄存器上,然后进行判断,此时ticket大于0,线程一进入循环,然后usleep休眠,此时线程一要被OS切换,CPU上的上下文数据也随之被切换走。线程二和线程三也如此进行,也都进入了循环中。

image-20230714160946123

  • ticket–操作本身就不是一个原子操作,而是对应三条汇编指令:
  • load :将共享变量ticket从内存加载到寄存器中

  • update : 更新寄存器里面的值,执行-1操作

  • store :将新值,从寄存器写回共享变量ticket的内存地址

  • 线程一再次被切换到,先把上下文加载回CPU中,然后做ticket–。再次把内存中的ticket数据加载到CPU中,然后做–,再把ticket数据放回到内存中,此时ticket=0。到线程二,也做着与线程一同样的工作,ticket–后将数据放回到内存中,此时ticket=-1,同样的退出线程三操作后ticket=-2。这样就ticket为负数的情况。

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

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

这三点本质上就是互斥量的概念,Linux上提供这的这把锁叫互斥量

image-20230714183431846

互斥量的接口

初始化互斥量

静态分配
 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//静态分配,一般用于在全局定义
动态分配:pthread_mutex_init初始化互斥量

函数原型

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
  • mutex是需要初始化的互斥量,需要传互斥量的地址
  • attr是锁的属性,通常设为nullptr
  • 申请成功返回0,申请失败返回错误码

销毁互斥量

int pthread_mutex_destroy销毁互斥量

函数原型

int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • mutex是需要销毁的互斥量,需要传互斥量的地址
  • 申请成功返回0,申请失败返回错误码

注意一下:

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

互斥量加锁和解锁

pthread_mutex_lock加锁

函数原型

 int pthread_mutex_lock(pthread_mutex_t *mutex);
  • mutex是需要加锁的互斥量,需要传互斥量的地址
  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 加锁成功返回0,失败将阻塞等指定互斥量解锁,返回错误码
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。即该函数加锁是阻塞式申请
pthread_mutex_trylock非阻塞式申请加锁

函数原型

int pthread_mutex_trylock(pthread_mutex_t *mutex);
  • mutex是需要加锁的互斥量,需要传互斥量的地址

  • 申请成功返回0,申请失败立刻返回错误码

pthread_mutex_unlock解锁

函数原型

int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • mutex是需要加解锁的互斥量,需要传互斥量的地址

  • 申请成功返回0,申请失败返回错误码

对上面出错的代码进行修改

int ticket=1000;
pthread_mutex_t mut;//定义全局的互斥量
void* getticket(void*args)
{
    
    

    string username=static_cast<const char*> (args);
   while(true)
    {
    
    
        {
    
    
        pthread_mutex_lock(&mut);//阻塞式加锁
        if(ticket>0)
        {
    
    
             usleep(1234);
        cout<<"User name:"<<username<<"get tickets ing..."<<"ticket num: "<<ticket<<endl;
                   ticket--;
                   pthread_mutex_unlock(&mut);//解锁
        }else
        {
    
    
             pthread_mutex_unlock(&mut);//解锁
            break;//没票了退出循环
        }
        }//将临界区放进代码块内
        usleep(1000);//模拟产生订单
        
    }
    return nullptr;
}

int main()
{
    
    

pthread_mutex_init(&mut,nullptr);//初始化互斥量
unique_ptr<thread> thread1(new thread(getticket,(void*)"user1",1));
unique_ptr<thread> thread2(new thread(getticket,(void*)"user2",2));
unique_ptr<thread> thread3(new thread(getticket,(void*)"user3",3));
thread1->join();
thread2->join();
thread3->join();
pthread_mutex_destroy(&mut);//互斥量的销毁
    return 0;
}

有关锁的概念:

  • 给临界资源加锁后,多个执行流是串行访问,那么程序的执行速度相比于并发执行的速度是要慢的
  • 加锁只规定线程串行执行临界区,而线程执行优先级由竞争结果决定
  • 加锁的过程是安全的,即加锁的过程具有原子性
  • 当先申请到锁的线程被切换时,锁也随之切换,其余线程也无法申请成功,共享区也就无法继续执行下去,直到该线程释放锁
  • 使用锁的时候,保持共享区的粒度尽量小
  • 对于访问临界资源的线程,尽量做到加锁一致性,要么给全部线程加锁,要么都不加

需要注意的是:

  • 经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单 元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一 个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下
lock:
     mob $0,%al  //第一条指令
     xchgb,%al,mutex  //第二条指令
     if(al寄存器的内容>0)
     {
      return 0;
     }else
          挂起等待;
    goto lock;
 
unlock:
    movb $1,mutex   
    唤醒等待Mutex的线程;
    return 0;
  • 当线程一申请加锁时:第一条指令,将0放进CPU中的al寄存器中

image-20230714211146861

  • 第二条指令:将内存中的mutex的值(这里设置为1)与al寄存器中的值交换

image-20230714211225113

  • 然后判断al寄存器中的值是否大于0,若大于0则返回0,即加锁成功,此时线程一就已经对指定临界资源加锁成功;若为其他结果,则线程挂起等待。等待占用锁的线程释放锁

  • 若此时线程一完成了一、二条指令即al寄存器中的值是1,mutex含的值是0。OS将线程一切换为线程二,线程一被切换的同时,在CPU的上下文也随之被切换。线程二被切换进来后,一样的执行第一、二条指令,先将0设置进al寄存器,然后将al寄存器的值与mutex含的值进行交换,此时mutex的值为1,那么进而判断mutex的值不大于0,判断为假,进而线程二需要挂起等待。

image-20230714214138895

C++二次封装互斥量

mutex.hpp

#include<iostream>
using namespace std;

class Mutex{
    
    
public:
Mutex(pthread_mutex_t * mutex=nullptr):mutex_(mutex){
    
    }
void Lock()
{
    
    
    if(mutex_)
    {
    
    
        pthread_mutex_lock(mutex_);//加锁
    }
    
}

void UnLock()
{
    
    
    if(mutex_)
    {
    
    
 pthread_mutex_unlock(mutex_);//解锁
    }
   
}
private:
pthread_mutex_t *mutex_;

};

class LockReady
{
    
    
    public:
    LockReady(pthread_mutex_t* mutex)
    :mutex_(mutex)
    {
    
    
mutex_.Lock();//加锁
    }
    ~LockReady()
    {
    
    
        mutex_.UnLock();//解锁
    }
    public:
  Mutex mutex_;
};

线程安全

概念

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

常见的线程不安全的情况

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

而常见的线程安全的情况

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

常见不可重入的情况

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

常见可重入的情况

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

那么可重入与线程安全之间有什么关系呢?

  1. 若函数是可重入的,那么调用该函数的线程是安全的
  2. 若函数是不可重入的,那么该函数不能被多个线程使用,否则可能会引发线程安全问题
  3. 若一个函数中有一个全局变量,那么该函数即是不可重入函数也可能会引发线程安全问题

而可重入函数和线程安全有什么关系呢?

  1. 可重入函数属于线程安全函数的一种
  2. 线程安全那么调用的函数不一定是可重入函数,而调用可重入函数的线程一定是安全的
  3. 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会引发死锁,因此是不可重入的。

死锁

死锁概念

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。换言之,一个线程持有资源不释放,向申请不到的资源进行申请,而申请失败处于阻塞等待状态,那么持有的资源将无法释放或者让别的线程使用,该线程就处于死锁状态。

这个概念多数应用于多线程场景,单执行流线程会造成死锁吗?答案是会的。一个线程连续对同一份资源申请加锁两次,那么该线程会被挂起。第一次申请成功,对资源成功加锁,第二次申请失败被挂起直到该锁被释放。然而此时该资源已经被线程自己加锁了,没有释放,那么线程就处于永久等待状态。

下面是一个单执行流引发死锁的案例

#include<iostream>
#include<pthread.h>

using namespace std;

void* start_routine(void* args)
{
    
    
    long long * num=static_cast<long long*>(args);
    pthread_mutex_t mut;
    pthread_mutex_init(&mut,nullptr);//初始化锁
    pthread_mutex_lock(&mut);//加锁
    pthread_mutex_lock(&mut);//二次加锁
    cout<<"加锁的数据是num: "<<*num<<endl;
    pthread_mutex_unlock(&mut);//解锁
    return nullptr;
}
int main()
{
    
    
    pthread_t t1;
    long long num=199;
    pthread_create(&t1,nullptr,start_routine,(void*)num);
    pthread_join(t1,nullptr);
    return 0;
}

image-20230718170044135

  • 其造成死锁的线程处在的进程处于永久等待状态,即进程阻塞在等待队列中。我们知道进程是要被CPU调度的,换言之进程要占用CPU资源的。然而一个CPU在同一个时刻只允许被一个进程占用,所以要把这些调度CPU的进程用队列管理起来,加上CPU是运行速度是最快的,所以这个队列叫做运行等待队列,处于运行等待队列的进程属于R状态。
  • 相同的,进程访问其他资源也是要被放进队列中,然而访问硬件的速度较慢,这些进程要被放进资源等待队列中等待访问资源,那么这些进程属于S状态即阻塞状态,然而OS有可能会将处于阻塞状态的进程相关的代码和数据放回到磁盘上,只保留task_struct在队列中,那么这些进程是被OS挂起的。

image-20230718172242033

综上:

  • 站在操作系统角度,进程阻塞式的、将task_struct放置在资源等待队列,这里可以说进程在挂起等待
  • 站在用户角度,进程在等待占用资源,程序卡住了,这里可以说是进程被挂起了
  • 实际上锁也可以看作是一种软件资源,进程在申请锁时阻塞等待,可以看作是进程进入锁的等待资源队列

造成死锁的四个条件

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

如何避免死锁

  1. 造成死锁的四个条件至少不具备一个

  2. 加锁顺序应当一致。即多个线程同时竞争同个资源,那么在一个资源竞争到还未释放时,其他线程应当阻塞等待,这样该线程申请别的资源时就能释放该资源,对其他资源加锁

  3. 避免锁未释放

  4. 资源一次性分配。避免一个线程多次对不同资源进行加锁解锁

  5. 可以运用避免死锁的算法,如死锁检测法,银行家算法等

多线程同步

同步概念

线程同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。

需要明确的是:

  • 锁作为一种资源是要被线程去竞争式占用的,若存在某些竞争力特别强的线程,就会一直占用锁。该线程先是申请加锁然后解锁,再次申请加锁然后解锁。这样会导致其他线程无法占用到锁造成饥饿问题。
  • 加锁本身是没有问题的,加锁后只会有一个线程进入临界区,保护了临界资源,但并不会让临界资源高效的被利用起来。
  • 但在线程同步的概念下,先占用锁的线程解锁后,自动去到锁的资源等待队列尾部进行阻塞式等待,让每个线程按照特定的顺序依次访问到锁,即依次能够进入临界区访问临界资源,避免了饥饿问题。

条件变量

条件变量概念

  • 条件变量是用来等待线程的,通常和互斥锁一起使用。
  • 互斥锁有个特点是它只有锁定和非锁定,而条件变量允许线程阻塞和等待另一个线程发送信号来弥补互斥量的不足

条件变量函数

  • 条件变量函数的返回值都是:调用成功返回0,调用失败返回错误码
  • 条件变量是pthread_cond_t 类型,其本质是一个结构体为简化理解,应用时可忽略其实现细节,简单当成整数看待

pthread_cond_init动态初始化条件变量

函数原型

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
  • 参数cond是条件变量,这里需要传参条件变量的地址
  • attr是条件变量的属性,通常设置为空
  • 动态初始化通常用在局部作用域

静态初始化条件变量

pthread_cond_t cond=PTHREAD_COND_INITIALIZER;//静态初始化条件变量—通常用在全局作用域
  • 注意:静态初始化的条件变量不需要销毁

pthread_cond_wait阻塞等待条件变量

函数原型

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
  • 参数cond是条件变量,这里需要传参条件变量的地址
  • 参数mutex是调用条件变量的线程申请到的互斥锁,这里需要传参互斥锁的地址
  • 函数的作用
  • 先对调用函数的线程进行解锁(mutex),然后自动在条件变量cond队列尾部挂起等待
  • 当接收到其他线程发来的信号时,唤醒该线程,并在函数返回时解除阻塞并重新申请获取互斥锁即加锁(mutex)

唤醒等待

唤醒等待有两个函数

pthread_cond_signal唤醒等待队列中首个线程
int pthread_cond_signal(pthread_cond_t *cond);
  • 参数cond是条件变量,这里需要传参条件变量的地址
pthread_cond_broadcast唤醒等待队列中的全部线程
int pthread_cond_broadcast(pthread_cond_t *cond);
  • 参数cond是条件变量,这里需要传参条件变量的地址

pthread_cond_destroy销毁条件变量

函数原型

int pthread_cond_destroy(pthread_cond_t *cond);
  • 参数cond是条件变量,这里需要传参条件变量的地址

这里我用抢票系统这段来作演示,在没有条件变量时

#include<iostream>
#include<pthread.h>
#include<vector>
#include<assert.h>
#include<string.h>
#include<unistd.h>

using namespace std;
#define NUM 5
pthread_mutex_t mut=PTHREAD_MUTEX_INITIALIZER;//全局初始化互斥量
int ticket=1000;
void* start_routine(void* args)
{
    
    
    string str=static_cast<const char*>(args);

    while(true)
    {
    
    
         pthread_mutex_lock(&mut);//加锁
         if(ticket>0)
    {
    
    
        cout<<str<<"-> ticket: "<<ticket<<endl;
        ticket--;
    }else
    {
    
    
     pthread_mutex_unlock(&mut);//解锁
     break;
    }       
      pthread_mutex_unlock(&mut);//解锁
    }

}
int main()
{
    
    
    pthread_t dt[NUM];//数组存储多个线程id
    for(int i=0;i<NUM;i++)
    {
    
    
        char * name=new char[64];
        snprintf(name,64,"thread:%d",i);//设置线程名字
       int n= pthread_create(&dt[i],nullptr,start_routine,(void*)name);
    assert(n==0);
    }
   
   while(true)
   {
    
    
    usleep(500000);//1-1000-1000000
    cout<<"main thread wake up one thread"<<endl;
   }

   for(int i=0;i<NUM;i++)
   {
    
    
    pthread_join(dt[i],nullptr);//回收线程
   }
    return 0;
}
  • 主线程创建5个新线程,并让新线程去完成抢票的工作。在抢票前先加锁,因此每次只有一个线程进入临界区,ticket–后解锁然后又一次循环,让全部线程去竞争同一个互斥锁。会出现某个竞争力较强的线程一直做着抢票的工作,让其他线程存在饥饿问题。

image-20230719111239072

加上条件变量

#include<iostream>
#include<pthread.h>
#include<vector>
#include<assert.h>
#include<string.h>
#include<unistd.h>

using namespace std;
#define NUM 5
pthread_mutex_t mut=PTHREAD_MUTEX_INITIALIZER;//全局初始化互斥量
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;//全局初始化条件变量
int ticket=1000;
void* start_routine(void* args)
{
    
    
    string str=static_cast<const char*>(args);

    while(true)
    {
    
    
         pthread_mutex_lock(&mut);//加锁
         if(ticket>0)
    {
    
    
 pthread_cond_wait(&cond,&mut);//让调用函数线程在条件变量出阻塞等待
        //到这里说明在条件变量处阻塞等待的线程已经被唤醒
        cout<<str<<"-> ticket: "<<ticket<<endl;
        ticket--;
    }else
    {
    
    
     pthread_mutex_unlock(&mut);//解锁
     break;
    }       
      pthread_mutex_unlock(&mut);//解锁
    }

}


int main()
{
    
    
    pthread_t dt[NUM];//数组存储多个线程id
    for(int i=0;i<NUM;i++)
    {
    
    
        char * name=new char[64];
        snprintf(name,64,"thread:%d",i);//设置线程名字
       int n= pthread_create(&dt[i],nullptr,start_routine,(void*)name);
    assert(n==0);
    }
   
   while(true)
   {
    
    
    usleep(500000);//1-1000-1000000
    pthread_cond_signal(&cond);//给条件变量发信号,唤醒处在条件变量处的阻塞的一个线程
    pthread_cond_broadcast(&cond);给条件变量发信号,唤醒处在条件变量处的阻塞的全部线程
    cout<<"main thread wake up one thread"<<endl;
   }

   for(int i=0;i<NUM;i++)
   {
    
    
    pthread_join(dt[i],nullptr);//回收线程
   }
    return 0;
}
  • 加上条件变量后,线程申请加锁成功进入临界区后,由于pthread_cond_wait函数,新线程先是解锁然后在条件变量等待主线程发送信号,主线程发送信号给新线程,新线程接收到信号后,先加锁,然后执行临界区的代码。之后解锁。再次竞争式加锁,进入临界区后,由于pthread_cond_wait函数,新线程先是解锁然后自动在条件变量队列尾部阻塞等待。
  • 由于上一个进入临界区的线程会自动在条件变量队列尾部阻塞等待,这样会看到抢票的线程呈现一定的顺序性。不会出现一个竞争力较强的线程一直争夺互斥锁进入临界区导致其他线程无法访问临界资源的饥饿问题。

image-20230719122706978

为什么pthread_cond_wait需要互斥量

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须 要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件 变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有 互斥锁就无法安全的获取和修改共享数据。
  • 进入临界区必然先加锁,由于不满足条件所以线程需要挂起,而线程挂起是要携带着锁一起挂起的,但条件不满足会一直导致锁无法释放而造成死锁问题
  • 所以pthread_cond_wait函数需要传入互斥量,将线程挂起时自动把锁释放掉,避免了死锁问题,而满足条件后唤醒线程,能够往后执行临界区的代码,这时候就需要给线程重新加锁

错误的执行逻辑

前面提到pthread_cond_wait函数一是会将线程挂起并把锁释放,二是线程被唤醒后自动给线程加锁,那么我们也可以让线程进入临界区后先将锁释放,然后再让线程通过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);

  • 实际上在解锁之后,等待之前,有可能其他线程拿到了互斥锁并且也满足了条件,phread_cond_signal函数给该线程发送了信号,而此时挂起的线程将错过这个信号,最终可能会导致线程永远不会被唤醒,因此解锁和等待必须是一个原子操作。
  • 而实际进入pthread_cond_wait函数后,会先判断条件变量是否等于0,若等于0则说明不满足,此时会先将对应的互斥锁解锁,直到pthread_cond_wait函数返回时再将条件变量改为1,并将对应的互斥锁加锁。

条件变量使用规范

  • 等待条件代码
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);

猜你喜欢

转载自blog.csdn.net/m0_71841506/article/details/131806708