【Linux系统】线程:线程同步




在这里插入图片描述



1. 线程同步


1.1 线程同步引入


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


为了更好地理解线程同步的概念,我们可以使用一个日常生活中常见的场景作为比喻:银行柜台服务

想象一下你走进了一家繁忙的银行,这里有几个柜台可以办理业务。每个柜台可以看作是一个资源(例如共享数据或关键代码段),而每个客户则代表一个线程。在这个银行里,一次只能有一位客户在某个特定的柜台前接受服务。这就像是多线程环境中对共享资源的访问控制——在同一时刻,只有一个线程能够操作某个特定的共享资源。银行柜台服务的规则如下:

  1. 排队等候:当一位客户到达银行时,如果所有柜台都被占用,那么这位客户必须加入等待队列,并按顺序等待直到轮到自己。
  2. 获取服务(加锁):一旦某个柜台空闲下来,第一个等待的客户就可以走到这个柜台前开始办理业务。这相当于线程成功申请到了互斥锁(mutex lock),从而获得了对该共享资源的独占访问权。
  3. 处理事务(临界区):在柜台前,客户可以与柜员交流并完成他们的交易。这段时间内,任何其他客户都不能打断正在进行的服务。同样地,在程序中,持有锁的线程可以在所谓的“临界区”内安全地执行其任务,而不必担心其他线程会干扰它。
  4. 结束服务(解锁):当客户的业务完成后,他们离开柜台,使得下一个等待的客户有机会上前接受服务。在线程世界里,这意味着当前线程释放了互斥锁,允许另一个线程进入临界区。
  5. 防止重复申请:值得注意的是,即使同一个客户再次来到银行,他也需要重新排队,而不是直接跳过队伍回到刚刚使用的那个柜台。同样的道理适用于线程:即便之前已经拥有过锁,每次都需要重新申请才能获得锁,以避免死锁的情况发生。

通过上述银行柜台服务的例子,我们可以清楚地看到线程同步是如何工作的。即使同一个客户再次来到银行,他也需要重新排队,而不是直接跳过队伍回到刚刚使用的那个柜台。这种需要在队列中等待申请互斥锁并且短时间内不允许多次重复申请的机制,就是一种线程同步机制!

由此也可以看来

  • 互斥保证安全性,安全不一定合理或高效!!
  • 同步主要是在保证安全的前提下,让系统变得更加合理和高效!


线程同步可以使用条件变量来实现:


1.2 条件变量


条件变量的作用

简单来说,一个线程负责向临界区写入数据,而另一个线程则负责从临界区读取这些数据。为了保证数据的一致性和完整性,这两个线程需要互斥地访问临界区以执行各自的读写操作。具体而言,只有当临界区为空时,写线程才会被允许写入数据;而只有当临界区包含有效数据时,读线程才会尝试读取数据。

然而,在这种场景下会出现一个问题:写线程如何判断当前临界区是否为空?同样地,读线程又如何确定临界区是否有待处理的数据呢?由于缺乏有效的通信机制,两个线程可能不得不采用轮询的方式持续尝试获取互斥锁并检查临界区的状态。这种忙等待(busy-waiting)策略虽然能够最终达到目的,但却带来了显著的性能开销,因为它会导致CPU资源的浪费,尤其是在临界区长时间不可用的情况下。

为了解决这一问题,条件变量提供了一种更为高效的解决方案。通过引入条件变量,我们可以实现一种基于事件的通知机制,从而避免不必要的轮询。具体来说,当临界区没有数据可供读取时,条件变量会让试图读取数据的线程进入阻塞状态,直到满足特定条件——即临界区中有新数据可用为止。相反地,如果临界区已经被填满,则条件变量同样会使试图写入数据的线程暂停执行,直到先前的数据被成功读取并释放空间。一旦某个条件发生变化,例如写线程完成了数据写入或读线程完成了数据读取,相应的条件变量就会唤醒处于等待状态的相关线程,通知它们继续执行下一步操作。

借助条件变量,线程不再需要频繁地尝试获取互斥锁并检查临界区的状态,而是可以高效地等待直到真正需要它们的时候才被激活。这种方式不仅极大地提高了系统的响应速度和吞吐量,还有效减少了CPU资源的无谓消耗,对于构建高性能、低延迟的应用程序尤为重要.

综上所述,条件变量作为一种重要的同步工具,在多线程编程中扮演着不可或缺的角色,它帮助开发者设计出更加优雅且高效的并发算法,同时确保了程序运行的安全性与稳定性.



条件变量的操作函数


局部的条件变量:需要调用初始化和销毁函数

  • 初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t*restrict attr);

参数:
cond:要初始化的条件变量
attr:NULL
  • 销毁
int pthread_cond_destroy(pthread_cond_t *cond)


全局的条件变量:只需一句定义

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;


  • 等待条件满足

    让目标线程在这个条件变量”维护“的等待队列中等待

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


参数:
cond:要在这个条件变量上等待
mutex:互斥量,后⾯详细解释
  • 唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);


条件变量的使用

  • 定义一个条件变量:这是一个全局的条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
  • 如果没票就在这个条件变量处阻塞着
else if (ticket <= 0)
{
    // 没票就阻塞等着
    std::cout << name << " : no ticket, wait ticket!" << std::endl;
    pthread_cond_wait(&cond, &mtx);
}
  • 为了简易,方便演示,这里直接用主线程获取全局变量 ticket 进行发票
// 主线程发票 
int num = 100;
while(true)
{
    sleep(3);
    ticket+=num;
    std::cout << "main thread add ticket: " << num << ", left ticket: " << ticket << std::endl;
    // 通知线程
    pthread_cond_broadcast(&cond);
}


#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <vector>
#include "Thread.hpp"
#include "Mutex.hpp"

using namespace THREAD;
using namespace MutexModule;

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int ticket = 0;

// 线程函数
void *Runtine(void *args)
{
    char *name = static_cast<char *>(args);
    // 抢票
    while (true)
    {
        pthread_mutex_lock(&mtx);
        if (ticket > 0)
        {
            // 抢票
            ticket--;
            std::cout << name << " get a ticket, left ticket: " << ticket << std::endl;
            pthread_mutex_unlock(&mtx);

            // 票入库
            usleep(100);
        }
        else if (ticket <= 0)
        {
            // 没票就阻塞等着
            std::cout << name << " : no ticket, wait ticket!" << std::endl;
            pthread_cond_wait(&cond, &mtx);
        }
    }
    return nullptr;
}

int main()
{
    // 创建几个线程抢票
    std::vector<Thread> v;
    for (int i = 0; i < 5; ++i)
    {
        Thread t(Runtine);
        v.push_back(t);
    }
    for (auto &t : v)
    {
        t.Start();
    }

    // 主线程发票 
    int num = 100;
    while(true)
    {
        sleep(3);
        ticket+=num;
        std::cout << "main thread add ticket: " << num << ", left ticket: " << ticket << std::endl;
        // 通知线程
        pthread_cond_broadcast(&cond);
    }

    for (auto &t : v)
    {
        t.Join();
    }

    return 0;
}



运行结果如下:

你会发现,主线程在发票后唤醒了子线程,但子线程似乎没有参与抢票???反而是主线程一直在发票,其他线程没有“反应”??

在这里插入图片描述


这实际上是产生了一个严重的问题!!!死锁!!

这个条件变量休眠函数 pthread_cond_wait(&cond, &mtx) 会将本线程阻塞等待该条件变量被主动唤醒,同时 该休眠线程持有的互斥锁也会被释放

当阻塞线程被唤醒时,这个条件变量休眠函数 pthread_cond_wait(&cond, &mtx) 在被唤醒时自动重新获取互斥锁!!!!


关键就在这:被唤醒时会获取互斥锁!

while (true)
{
 pthread_mutex_lock(&mtx);
 if (ticket > 0)
 {
     //....
 }
 else if (ticket <= 0)
 {
     pthread_cond_wait(&cond, &mtx);
 }
}

被唤醒后,就继续循环,执行到 pthread_mutex_lock(&mtx) ,再次获取互斥锁,但此时你自己本身因为 pthread_cond_wait(&cond, &mtx) ,已经持有互斥锁了!说明该锁是空的,你还要继续获取,就会进入阻塞,因为他没有多余的给你,只能等那个持有互斥锁的线程释放才能给你,但是那个持有互斥锁的线程是你自己呀


你自己拿着锁还去获取锁,没有锁你就阻塞,等待锁被释放,但你自己不可能在此时释放自己的锁,这就是形成了一种死锁!!!


导致整个程序阻塞不前!



因此我们必须调整代码的逻辑顺序,以确保在线程被唤醒后不会不必要地尝试重新获取已经持有的互斥锁。

void *Runtine(void *args)
{
 char *name = static_cast<char *>(args);
 while (true)
 {
     pthread_mutex_lock(&mtx);
     while (ticket <= 0) // 使用while循环来处理伪唤醒
     {
         std::cout << name << " : no ticket, wait ticket!" << std::endl;
         pthread_cond_wait(&cond, &mtx); // 注意:这里会在返回时自动重新获取锁
     }
     // 抢票
     ticket--;
     std::cout << name << " get a ticket, left ticket: " << ticket << std::endl;
     pthread_mutex_unlock(&mtx);

     // 票入库
     usleep(100);
 }
 return nullptr;
}

在这个修正版本中,我们利用了一个`while`循环来代替原来的`if-else`结构,这样不仅可以处理伪唤醒的情况,而且避免了重复锁定互斥锁的问题。通过这种方式,当一个线程被条件变量唤醒并发现票数仍不足时,它将继续等待而不会试图去获取已持有的互斥锁,从而避免了死锁的发生。



条件变量的封装

#pragma once

#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"

namespace CondModule
{
    using namespace MutexModule;
    class Cond
    {
    public:
        Cond()
        {
            pthread_cond_init(&_cond, nullptr);
        }
        ~Cond()
        {
            pthread_cond_destroy(&_cond);
        }

        // 阻塞等待
        void Wait(Mutex& mtx)
        {
            pthread_cond_wait(&_cond, mtx.getLockPtr());
        }

        // 随机唤醒一个
        void NotifyOne()
        {
            pthread_cond_signal(&_cond);
        }

        // 广播唤醒所有
        void NotifyAll()
        {
            pthread_cond_broadcast(&_cond);
        }

    private:
        pthread_cond_t _cond;
    };
}



2. 生产者消费者模型


## **生产者消费者模型的解耦**

生产者和消费者之间有个超市,生产者只需将自己的商品放到超市中卖,消费者只需要到超市中买货,生产者无需关心超市将货物卖给谁,消费者也无需关心超市背后的生产者是谁,这不就是生产者和消费者的解耦合吗!

生产者和消费者相互没有关系,不用相互关心对方,一个要做的就是输送货物,一个要做的就是购买所需货物,两者互不干扰



生产者消费者模型的优点

1、效率

2、生产消费解耦

3、支持忙闲不均


在这里插入图片描述


在这里插入图片描述




生产者消费者模型的三种关系

  • 生产者和生产者:互斥

    生产者负责生成同一类型的物品往超市供应,每个生产者之间互斥的使用超市

  • 消费者和消费者:互斥

    消费者会到超市中购买某类型的物品,每次只能有一个消费者到超市中消费,每个消费者之间互斥的使用超市

  • 生产者和消费者:互斥&&同步关系

    互斥:生产者向超市供货,消费者从超市买货,两者必须互斥使用超市,不能数据传输到一半 就被 其他线程把数据读取走

    同步:生产者负责生产商品,消费者负责消费物品,消费者如何知道超市中有货,消费者线程岂不是要轮询式访问超市,为了减少这种行为,生产者可以维护一个等待队列给消费者,消费者们都到队列中等待,当生产者生产好商品并放到超市中就会通知队列中的消费者,这样消费者就不用轮询访问,这就是生产者和消费者之间的同步关系



生产者消费者模型之 321 原则

  • 3种关系
  • 2种角色 – 生产者和消费者
  • 1个交易场所

面试时,面试官叫你谈一下生产者消费者模型,你可以在脑海中用 321原则 构建讲解逻辑:

生产消费者模型是一种多线程并发执行的模型。它有三种关系,分别是:生产者和生产者、消费者和消费者、生产者和消费者,它们之间的关系分别是互斥、互斥、同步与互斥。

这个模型一共有两种角色,通常由线程来承担生产者,由线程来承担消费者,而且这两种线程可以各有多个。通常情况下,会有一个特定数据结构提供的缓冲区,这个缓冲区一般作为我们的交易场所来使用。

所以321原则是我们后面给别人介绍,生产者消费者模型时进行条理化表达的思路。



3. 基于BlockingQueue的生产者消费者模型

【Linux系统】生产者消费者模型:阻塞队列 BlockingQueue-优快云博客



4. POSIX信号量

4.1 POSIX信号量的概念

POSIX信号量和SystemV信号量作⽤相同,都是⽤于同步操作,达到⽆冲突的访问共享资源⽬的。但POSIX可以⽤于线程间同步。

互斥锁实际上是一种二元信号量,申请互斥锁和释放互斥锁就是对互斥锁的PV操作!

PV操作是原子的,用于保护信号量自身的安全

如果根据前面锁的知识设计一款信号量,其实可以这样设计:

对计数器的PV操作 直接用加锁和解锁保护起来,使得PV操作具有原子性!

class sem
{
    int count;
    Mutex _mutex;

    // P操作
    void P(){
        // 加锁
        if(count > 0) count--;
        // 解锁
    }
    // V操作
    void V(){
        // 加锁
        count++;
        // 解锁
    }
};



4.2 POSIX信号量的接口设计

  • 初始化信号量

    #include <semaphore.h>
    int sem_init(sem_t *sem, int pshared, unsigned int value);
    
    
    参数:
    pshared:0表⽰线程间共享,⾮零表⽰进程间共享
    value:信号量初始值
    
  • 销毁信号量

    int sem_destroy(sem_t *sem);
    
  • 等待信号量

    功能:等待信号量,会将信号量的值减1
    int sem_wait(sem_t *sem); //也就是信号量的 P 操作
    
  • 发布信号量

    功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
    int sem_post(sem_t *sem); //也就是信号量的 V 操作
    

上⼀节生产者-消费者的例子是基于 queue 的,其空间可以动态分配,现在基于固定⼤⼩的环形队列重写这个程序(POSIX信号量):



5. 基于环形队列的生产消费模型

【Linux系统】生产者消费者模型:基于环形队列(信号量机制)-优快云博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值