【Linux】线程同步

目录

个人主页:矢望

个人专栏:C++LinuxC语言数据结构Coze-AIMySQL

一、线程同步

1.1 引子

如上图,你们学校有个很豪华的自习室,门上有一把钥匙,每次只有一个人可以进去学习。

今天,小明同学起的很早,四点就起床去了,他拿到钥匙就进去,然后把门反锁了。后来陆陆续续,三五成群,两两结对的来了很多人,他们在外面等待这个人出来,他们才好进去。小明学习了五个小时,肚子很饿,想去吃饭,刚出门将钥匙挂在门上,就想起来如果他走了,不知道什么时候能进去呢?由于他离钥匙最近,别人抢不过他,他又把钥匙摘下来,进去了。后来小明受到学习和吃饭的双重诱惑,来来回回干了好几次这样的事,每次都是因为他里钥匙最近,导致别人总是差一点抢到。

下图是我们上期博客抢票的代码的运行:

如上图,总是在很长一段时间由一个线程在执行临界区的代码

这就是故事,在这个故事中,在这个故事中,自习室是临界资源,人是线程,人的自习过程是临界区。小明没有错,他遵守了互斥的规则,每次只有一个线程访问临界区资源。但是不合理,因为竞争不合理,导致效率太低了,总是很长一段时间是一个人在访问临界区资源,而其它线程长期在阻塞等待

后来学校的管理员立下了规矩:归还钥匙的人不能和等待的人抢钥匙。这种在临界资源安全的前提下,让访问临界资源具有一定的顺序性就是线程同步!

1.2 条件变量

条件变量:让线程在某个条件不满足时主动阻塞(放弃CPU),等条件满足时再被唤醒的同步机制

条件变量相关函数

函数 作用 关键说明
pthread_cond_init 初始化条件变量 动态初始化,静态可用 PTHREAD_COND_INITIALIZER
pthread_cond_destroy 销毁条件变量 释放资源,要求无线程在等待
pthread_cond_wait 等待条件成立 原子操作:释放锁 + 阻塞 。被唤醒后重新加锁
pthread_cond_signal 唤醒至少1个等待线程 无广播效应,可能唤醒优先级高的线程
pthread_cond_broadcast 唤醒所有等待线程 用于条件状态变化涉及多个线程的场景

1.3 生产者消费者模型

在超市这个场所中,有很多个生产者可以给超市供货,也有很多的消费者可以从超市买东西。上一句话出现的角色就是生产者线程,消费者线程。超市是一个交易场所,也是共享资源、临界资源

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进⾏通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列⾥取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的

所以生产者消费者模型是多线程协同的一种模式,它能提高协作效率,本质是一种通信工作。生产者消费者模型的产生就是为了给生产者和消费者解耦的

生产者和生产者之间是互斥关系。消费者和消费者之间是互斥关系。生产者和消费者之间是互斥关系和同步关系

关系 性质 原因
生产者 ↔ 生产者 互斥 同时写缓冲区会冲突
消费者 ↔ 消费者 互斥 同时取缓冲区会冲突
生产者 ↔ 消费者 互斥 + 同步 互斥 :不能同时读写同一位置 同步:队列空时消费者等生产者;队列满时生产者等消费者

生产者和消费者的321原则:

3种关系 :生产者和生产者、消费者和消费者、生产者和消费者。

2种角色 :生产者线程和消费者线程。

1个交易场所:"超市",内存空间,通常由特定的数据结构承担。

生产者消费者模型的优点:解耦、支持并发、支持忙闲不均

  • 解耦:生产者和消费者不直接依赖,一方代码改动不影响另一方。如果直接调用,消费者挂掉可能影响生产者。
  • 支持并发 :生产者和消费者可以同时工作(各自操作缓冲区的不同位置,比如一个在队尾放,一个在队头取),不像管道那样必须串行。
  • 支持忙闲不均:缓冲区能"削峰填谷"。即使消费者处理慢,生产者也能先把数据放进队列继续生产,不会被阻塞太久;反之消费者也能从积压的队列中继续取数据,不用干等。

1.4 条件变量接口测试

将来我们的线程执行完临界区代码后,它就需要在条件变量下进行等待,直到它被唤醒。

测试代码:

cpp 复制代码
pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER; // 全局锁
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER; // 全局条件变量

void *Print(void *args)
{
    std::string name = static_cast<const char*>(args);

    while(true)
    {
        pthread_mutex_lock(&gmutex); // 加锁
        std::cout << name << " 线程正在运行..." << std::endl;
        pthread_cond_wait(&gcond, &gmutex); // 去条件变量下等待
        pthread_mutex_unlock(&gmutex); // 解锁

        sleep(1);
    }
}

int main()
{
    pthread_t tids[5];
    // 创建线程
    for(int i = 0; i < 4; i++)
    {
        char *name = new char[64];
        snprintf(name, 64, "New-Thread-%d", i + 1);

        pthread_create(tids + i, nullptr, Print, name);
    }

    // 等待线程
    for(int i = 0; i < 4; i++) pthread_join(tids[i], nullptr);

    return 0;
}

上面代码,创建出了几个线程,然后每个线程都会去执行Print代码,在这个函数的临界区,当线程执行完打印函数之后,就去条件变量下等待了,由于整个代码没有唤醒线程的逻辑,所以我们应该看到,每个线程打印一句话就停下来的情况。

编译运行:

如上图,线程在条件变量下等待,没有被唤醒。

测试唤醒接口:

cpp 复制代码
while(true)
{
    pthread_cond_signal(&gcond); // 唤醒指定条件变量下等待的线程
    sleep(1);
}

上面这个唤醒代码在主线程创建和等待线程中间。

编译运行:

如上,你会发现线程被唤醒了,并且唤醒的顺序是有条理的。

也可以使用广播唤醒所有等待的线程:

cpp 复制代码
while(true)
{
    pthread_cond_broadcast(&gcond);
    // pthread_cond_signal(&gcond); // 唤醒指定条件变量下等待的线程
    sleep(1);
}

编译运行:

如上,线程也被唤醒了,并且线程是同时唤醒的,他们会争夺同一个锁,所以线程打印的顺序也就没有条理了。

二、基于阻塞队列实现生产者消费者模型

2.1 BlockingQueue

在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列⾥存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。

在生产者消费者模型中,阻塞队列可能有多个生产者线程和消费者线程同时去访问,所以阻塞队列就是临界资源,所以对阻塞队列的操作就需要互斥保护起来。

阻塞队列的底层将使用queueBlockQueue.hpp:

cpp 复制代码
template<typename T>
class BlockQueue
{
public:
    BlockQueue(int cap = defaultcap)
        :_capacity(cap)
    {
        // 初始化 锁 和 条件变量
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_consumer_cond, nullptr);
        pthread_cond_init(&_producer_cond, nullptr);
    }

    void Push(T &in) // 生产者线程
    {
        pthread_mutex_lock(&_mutex); // 加锁
        
        // 增强代码的健壮性,鲁棒性,防御性编程
        while(_bq.size() == _capacity) // 阻塞队列满了
        {
            pthread_cond_wait(&_producer_cond, &_mutex);
        }

        // 现在阻塞队列绝对有空位置
        _bq.push(in);
        pthread_cond_signal(&_consumer_cond); // 生产了数据,唤醒消费者
        
        pthread_mutex_unlock(&_mutex); // 解锁
    }

    void Pop(T *out) // 消费者线程
    {
        pthread_mutex_lock(&_mutex); // 加锁
		
		// 增强代码的健壮性,鲁棒性,防御性编程
        while(_bq.empty()) // 阻塞队列为空
        {
            pthread_cond_wait(&_consumer_cond, &_mutex);
        }

        // 现在阻塞队列中绝对有数据
        *out = _bq.front();
        _bq.pop();
        pthread_cond_signal(&_producer_cond); // 消费了数据,唤醒生产者

        pthread_mutex_unlock(&_mutex); // 解锁
    }

    ~BlockQueue()
    {
        // 销毁 锁 和 条件变量
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_consumer_cond);
        pthread_cond_destroy(&_producer_cond);
    }
private:
    std::queue<T> _bq;
    int _capacity; // 阻塞队列的容量

    pthread_mutex_t _mutex; // 锁
    pthread_cond_t _consumer_cond; // 消费者条件变量
    pthread_cond_t _producer_cond; // 生产者条件变量
};

如上代码就是阻塞队列实现的核心逻辑。

其中:

1、由于不知道将来要存放的是什么类型的数据,所以采用模板类的方式实现。

2、阻塞队列只允许一个线程进入临界区,所以PushPop的加锁逻辑都是争夺同一把锁,因此成员变量中只有一把锁。但是生产者和消费者将来需要在不同的条件变量下等待,所以有两个条件变量。

3、在PushPop操作中,都需要进行加锁,因为这是临界区。为什么线程在条件变量下等待的时候需要把锁传递进去?因为等待的时候是在临界区内部等待的,需要把锁传递进去让pthread_cond_wait自动释放_mutex锁。如果不传递锁直接等待的话,线程是加着锁等待的,但是只能有一个线程在临界区,所以生产者不能生产数据,消费者不能消费数据,此时这个线程需要的条件,如有数据或者有数据空位,永远不可能被满足,这个线程也就永远不能醒来了,所以才需要传递锁。另外当线程再次醒来的时候,是在临界区内部醒来的,所以把锁传递进去后,等到线程苏醒时,pthread_cond_wait会自动竞争并获取_mutex锁。

4、线程为什么在临界区进行条件变量等待?因为访问临界资源,必然在临界区内部访问,判断资源是否就绪也是在访问临界资源。

5、为什么PushPop里面的等待是在while下,在if下不行吗?pthread_cond_wait函数是可能会调用失败的,如果它调用失败,在if条件下就会没有阻挡的执行生产/消耗资源的代码,这样是非法的。此外可能会有过量的唤醒信息,可能这个线程醒来的时候,资源还是不就绪的,此时也是非法的。还可能有伪唤醒的情况,如果醒来后,条件还是不就绪的,就需要在进行一次判断逻辑,所以while循环是可以满足的。所以基于过量的唤醒信息、函数调用失败、伪唤醒等原因,就需要在while循环下等待。

Main.cc:

cpp 复制代码
void *ConsumerRountine(void *args) // 消费者
{
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);

    while(true)
    {
        int x;
        bq->Pop(&x);

        std::cout << "消费数据:" << x << std::endl;
    }
}

void *ProducerRountine(void *args) // 生产者
{
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);

    int data = 1;
    while(true)
    {
        bq->Push(data);

        std::cout << "生产数据:" << data++ << std::endl;
    }
}

int main()
{
    BlockQueue<int> *bq = new BlockQueue<int>();

    pthread_t c, p;

    // 创建线程
    pthread_create(&c, nullptr, ConsumerRountine, bq); // 消费者线程
    pthread_create(&p, nullptr, ProducerRountine, bq); // 生产者线程

    // 等待线程
    pthread_join(c, nullptr);
    pthread_join(p, nullptr);

    return 0;
}

如上代码,当阻塞队列中没有数据时,等到生产者生产数据之后,就会唤醒消费者消费,等消费者消费之后又会唤醒生产者生产。

测试1,先在消费者的循环中加上sleep,我们应该看到生产者生产很快,但消费者消费慢,很快阻塞队列就满了,生产者不能再生产了,于是消费者消费一个,生产者就生产一个。

cpp 复制代码
void *ConsumerRountine(void *args) // 消费者
{
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);

    while(true)
    {
        sleep(3);
        int x;
        bq->Pop(&x);

        std::cout << "消费数据:" << x << std::endl;
    }
}

编译运行:

如上图,情况符合预期,并且消费者从队列的头部拿值,生产者在尾部生产。

测试2:先在生产者的循环中加上sleep,我们应该看到生产者生产很慢,但消费者消费快,阻塞队列总是空的,消费者不能再消费了,于是生产者生产一个,消费者就消费一个。

cpp 复制代码
void *ProducerRountine(void *args) // 生产者
{
    BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);

    int data = 1;
    while(true)
    {
        sleep(3);
        bq->Push(data);

        std::cout << "生产数据:" << data++ << std::endl;
    }
}

编译运行:

如上图,符合我们的预期。另外前面打印乱了,是因为打印是没有受到互斥保护的,很正常。

我们的BlockQueue.hpp中的唤醒策略写的很直接,这个唤醒策略是可以修改的。比如你可以设置一个水位线,一个是阻塞队列的高水位线,一个是阻塞队列的低水位线。当队列中的数据个数低于低水位线时才唤醒生产者快点生产,当队列中的数据高于高水位线时才唤醒消费者快点消费。或者可以有两个成员变量分别统计生产者和消费者在等待中的个数,只要个数不为0就唤醒。

以统计的策略为例:

cpp 复制代码
void Push(T &in) // 生产者线程
{
    pthread_mutex_lock(&_mutex); // 加锁
    
    // 增强代码的健壮性,鲁棒性,防御性编程
    while(_bq.size() == _capacity) // 阻塞队列满了
    {
        _sleep_producer_num++; // 等待中的生产者计数
        pthread_cond_wait(&_producer_cond, &_mutex);
        _sleep_producer_num++; // 等待中的生产者计数
    }

    // 现在阻塞队列绝对有空位置
    _bq.push(in);
    // pthread_cond_signal(&_consumer_cond); // 生产了数据,唤醒消费者

    if(_sleep_consumer_num > 0)
        pthread_cond_signal(&_consumer_cond);
    
    pthread_mutex_unlock(&_mutex); // 解锁
}

void Pop(T *out) // 消费者线程
{
    pthread_mutex_lock(&_mutex); // 加锁

    // 增强代码的健壮性,鲁棒性,防御性编程
    while(_bq.empty()) // 阻塞队列为空
    {
        _sleep_consumer_num++; // 等待中的消费者计数
        pthread_cond_wait(&_consumer_cond, &_mutex);
        _sleep_consumer_num--; // 等待中的消费者计数
    }

    // 现在阻塞队列中绝对有数据
    *out = _bq.front();
    _bq.pop();
    // pthread_cond_signal(&_producer_cond); // 消费了数据,唤醒生产者

    if(_sleep_producer_num > 0)
        pthread_cond_signal(&_producer_cond);

    pthread_mutex_unlock(&_mutex); // 解锁
}
  • 多生产多消费

上面我们展示的都是单生产单消费,但我们的代码现在是支持多生产,多消费的。

cpp 复制代码
int main()
{
    BlockQueue<int> *bq = new BlockQueue<int>();

    pthread_t c[3], p[3];

    // 创建线程
    pthread_create(c, nullptr, ConsumerRountine, bq); // 消费者线程
    pthread_create(c + 1, nullptr, ConsumerRountine, bq); // 消费者线程
    pthread_create(c + 2, nullptr, ConsumerRountine, bq); // 消费者线程
    pthread_create(p, nullptr, ProducerRountine, bq); // 生产者线程
    pthread_create(p + 1, nullptr, ProducerRountine, bq); // 生产者线程
    pthread_create(p + 2, nullptr, ProducerRountine, bq); // 生产者线程

    // 等待线程
    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(c[2], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);

    return 0;
}

编译运行:

三、修改完善代码

3.1 C++ 封装条件变量

由于条件变量的等待函数接口的,所以Cond.hpp在封装的时候,这个类需要我们之前封装的Mutex类,这里就直接拿过来了。

Mutex类增加返回锁的接口函数:

cpp 复制代码
class Mutex
{
public:
    // ...

    pthread_mutex_t *Ptr()
    {
        return &_lock;
    }

    // ...
private:
    pthread_mutex_t _lock;
};

Cond.hpp:

cpp 复制代码
#ifndef __COND__HPP
#define __COND__HPP

#include "Mutex.hpp"

class Cond
{
public:
    Cond()
    {
        pthread_cond_init(&_cond, nullptr);
    }

    void Wait(Mutex &mutex)
    {
        int n = pthread_cond_wait(&_cond, mutex.Ptr()); // 传递锁
        (void)n;
    }

    void Signal()
    {
        int n = pthread_cond_signal(&_cond);
        (void)n;
    }

    void BroadCast()
    {
        int n = pthread_cond_broadcast(&_cond);
        (void)n;
    }

    ~Cond()
    {
        pthread_cond_destroy(&_cond);
    }
private:
    pthread_cond_t _cond;
};

#endif

修改BlockQueue代码中使用锁和条件变量的部分:

cpp 复制代码
const int defaultcap = 5;

template <typename T>
class BlockQueue
{
public:
    BlockQueue(int cap = defaultcap)
        : _capacity(cap)
    {
        _sleep_consumer_num = 0;
        _sleep_producer_num = 0;
    }

    void Push(T &in) // 生产者线程
    {
        // 临界区
        {
            LockGuard lockguard(_mutex);

            // 增强代码的健壮性,鲁棒性,防御性编程
            while (_bq.size() == _capacity) // 阻塞队列满了
            {
                _sleep_producer_num++; // 等待中的生产者计数
                _producer_cond.Wait(_mutex);
                _sleep_producer_num--; // 等待中的生产者计数
            }

            // 现在阻塞队列绝对有空位置
            _bq.push(in);

            if (_sleep_consumer_num > 0)
                _consumer_cond.Signal();
        }
    }

    void Pop(T *out) // 消费者线程
    {
        {
            LockGuard lockguard(_mutex);

            // 增强代码的健壮性,鲁棒性,防御性编程
            while (_bq.empty()) // 阻塞队列为空
            {
                _sleep_consumer_num++; // 等待中的消费者计数
                _consumer_cond.Wait(_mutex);
                _sleep_consumer_num--; // 等待中的消费者计数
            }

            // 现在阻塞队列中绝对有数据
            *out = _bq.front();
            _bq.pop();

            if (_sleep_producer_num > 0)
                _producer_cond.Signal();
        }
    }

    ~BlockQueue()
    {
    }

private:
    std::queue<T> _bq;
    int _capacity; // 阻塞队列的容量

    Mutex _mutex;        // 锁
    Cond _consumer_cond; // 消费者条件变量
    Cond _producer_cond; // 生产者条件变量

    int _sleep_consumer_num; // 等待中的消费者数量
    int _sleep_producer_num; // 等待中的生产者数量
};

换成了自己的C++封装的接口。

使用C++封装的线程类修改Main.cc

cpp 复制代码
#include <unistd.h>
#include <memory>
#include "BlockQueue.hpp"
#include "Thread.hpp"

using namespace ThreadModule;

int main()
{
    std::unique_ptr<BlockQueue<int>> bq = std::make_unique<BlockQueue<int>>();

    // 生产者线程
    Thread p([&bq](){
        int data = 1;
        while(true)
        {
            sleep(1);
            bq->Push(data);

            std::cout << "生产数据:" << data++ << std::endl;
        }
    }); 

    // 消费者线程
    Thread c([&bq](){
        while(true)
        {
            sleep(1);
            int x;
            bq->Pop(&x);

            std::cout << "消费数据:" << x << std::endl;
        }
    });

    // 线程启动
    p.Start();
    c.Start();

    // 线程等待
    p.Join();
    c.Join();
    
    return 0;
}

编译运行:

任务模块

在传递数据的时候,整型、浮点型这种属于数据,类和对象当然也属于数据,所以生产者可以生产任务

任务类:加法任务。

cpp 复制代码
class Task
{
public:
    Task(){}
    Task(int x, int y)
        :_x(x)
        ,_y(y)
    {}

    void Excute()
    {
        _result = _x + _y;
    }

    std::string Result()
    {
        return std::to_string(_x) + " + " + std::to_string(_y) + " = " + std::to_string(_result);
    }

    std::string Question()
    {
        return std::to_string(_x) + " + " + std::to_string(_y) + " = ?";
    }

    ~Task(){}
private:
    int _x;
    int _y;
    int _result;
};

Main.cc:

cpp 复制代码
int main()
{
    srand(time(nullptr) ^ getpid());
    
    std::unique_ptr<BlockQueue<Task>> bq = std::make_unique<BlockQueue<Task>>();

    // 生产者线程
    Thread p([&bq](){
        int data = 1;
        while(true)
        {
            sleep(1);

            // 1. 获取数据
            int x = rand() % 100 + 1;
            usleep(157); // 让两个数更加随机
            int y = rand() % 100 + 1;
            Task t(x, y);
            // 2. 生产
            bq->Push(t);

            std::cout << "生产数据:" << t.Question() << std::endl;
        }
    }); 

    // 消费者线程
    Thread c([&bq](){
        while(true)
        {
            sleep(2);
            
            // 1. 取数据
            Task t;
            bq->Pop(&t);
            // 2. 处理数据
            t.Excute();

            std::cout << "消费数据:" << t.Result() << std::endl;
        }
    });

    // 线程启动
    p.Start();
    c.Start();

    // 线程等待
    p.Join();
    c.Join();
    

    return 0;
}

编译运行:

如上,这样就可以传递自己想要的任务了。也可以是回调函数等类型。

细节1:重谈生产者消费者模型。在任何时刻,我们可以看到只有一个线程在访问阻塞队列,那么它的高效从何谈起呢?在我们的示例代码中,我们知道数据是我们自己给的,但实际应用中,数据从哪里获取和在哪里处理都是不确定的,也许是从网络中获取数据,再做相应的处理数据的动作。所以获取任务数据和处理任务数据是可以并发的,所以它的高效体现在这里。

细节2:为什么要在临界区内部做判断? 因为我们将阻塞队列当成了一个整体去使用,既临界资源被我们当作一个完整的资源,要么用,要么不用,必须全部拥有。

总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~

相关推荐
糖果店的幽灵2 小时前
软件测试接口测试从入门到精通:curl命令行工具
linux·软件测试·接口测试·命令行·curl
毒爪的小新10 小时前
Linux 环境极速部署 vLLM:从零搭建生产级大模型推理服务
linux·人工智能·ai·语言模型·vllm
鹤落晴春10 小时前
RH124问答3:从命令行管理文件
linux·运维·服务器
凡人叶枫10 小时前
Effective C++ 条款30:透彻了解 inlining 的里里外外
linux·开发语言·c++·嵌入式开发·effective c++
Net_Walke11 小时前
【Linux系统】静态链接库与动态链接库
linux·嵌入式硬件
syc789012311 小时前
中文语境下AI编码工具实战对比:从迭代体验看日常开发选择
linux·人工智能·ubuntu
凡人叶枫12 小时前
Effective C++ 条款22:将成员变量声明为 private
linux·开发语言·c++
vsropy14 小时前
Ubuntu网络图标消失问题/有网络问号
linux·运维·ubuntu
coderwu14 小时前
Ubuntu 24.04 终端输入 openclaw config 提示未找到命令解决办法
linux·运维·ubuntu