【Linux系统】线程的同步与互斥:核心原理、锁机制与实战代码

文章目录

1. 前置知识

临界资源: 在并发环境下,多线程共享的资源,当多线程同时访问该资源时,可能造成资源的不一致。

临界区: 访问临界资源的代码段。

互斥: 在并发环境中,保证同一时刻只有一个线程访问临界资源,常见的互斥方式:

  • 互斥锁(Mutex)
  • 信号量(Semaphore)
  • 读写锁

原子性: 操作不可分割,要么完全执行成功,要么完全不执行。在并发环境中,原子性是确保数据一致性的关键。

  • 对一个整数进行自增操作(x++),通常不是原子的,可以拆解为多个步骤(载入、增加、写回)。

2. 线程的互斥

并发抢票

我们来实现下多线程抢票的demo

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>

int ticket = 1000;

void* thread_func(void* args)
{
    std::string s = static_cast<const char*>(args);
    while (1)
    {
        if(ticket > 0)
        {
            usleep(1000);
            ticket--;
            std::cout << s << " get ticket, ticket left: " << ticket << std::endl;
        }
        else{
            break;
        }
    }
    return nullptr;
    
}

int main()
{
    pthread_t tids[4];
    for(int i = 0; i < 4; i++)
    {
        std::string s = "thread-" + std::to_string(i);
        pthread_create(&tids[i], nullptr, thread_func, (void *)s.c_str());
    }

    for(int i = 0; i < 4; i++)
    {
        pthread_join(tids[i], nullptr);
    }
    return 0;
}

![[Pasted image 20250423221924.png]]

由上图可以看到票数被抢到了负数,明显这是不行,这属于在并发环境下,不加锁访问临界资源造成的数据不一致问题。

造成ticket为负数的原因:

  1. ticket--操作不是原子性,该操作可以拆解为将ticket载入到CPU的寄存器、CPU的加法器其进--、将结果写回内存,多线程同时进行ticket--会造成数据不一致的问题,举个例子:

    • 假如ticket100,线程A进行进行ticket--,前两个步骤完成但是第三个步骤(写回内存)被打断,CPU执行其他线程。
    • 线程B、C、D...成功执行了ticket--ticket变为77
    • 此时CPU切换上下文,重新执行线程A,将结果(99)写回内存。
    • 此时出现数据不一致问题,内存的ticket77,但是线程A写回的结果覆盖(99)。
  2. ticket--操作不是原子性还不足以使得ticket减到负数,真正的原因在于ticket > 0这个判断条件不是原子性,该操作同样可以拆为载入CPUCPU运算、返回结果,该操作被打断是造成ticket减到负数的原因,举例说说明:

    • 假设ticket1,线程A执行ticket > 0,将ticket载入CPUCPU运算得到布尔值结果(true),此时被打断,CPU切换线程执行。
    • 其他线程可能执行ticket--ticket已经被减为0甚至负数。
    • CPU切换上下文,重新执行线程A,将ticket > 0的结果(true)写回内存,但是ticket已经为0甚至负数,线程A再去执行ticket--ticket就会被减到负数。

互斥锁(mutex)

为了抢票抢到负数的问题,需要引入互斥锁(mutex),保证访问临界资源ticket时的操作是原子的。

mutex(互斥锁)是一个用于确保多个线程在访问共享资源时不会发生数据竞争,保证访问资源的互斥性。

  1. mutex只允许一个线程在某一时刻访问共享资源。
  2. 多个线程会同时竞争mutex,成功获得mutex则可以访问临界资源,竞争失败的线程需要挂起等待,知道mutex被释放。

mutex有两种初始化方式:

cpp 复制代码
//静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

//------------------------------------
//动态初始化
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr);
  • PTHREAD_MUTEX_INITIALIZER:一个宏,用于初始化一个互斥锁。

pthread_mutex_init是用于初始化互斥锁的函数。

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
  • pthread_mutex_t *mutex:指向互斥锁的指针。
  • const pthread_mutexattr_t *:指向互斥锁属性对象的指针,一般设为nullptr,表示使用默认属性。
  • 成功返回0,失败返回错误码。
    • EINVAL:传入的属性对象无效。
    • ENOMEM:系统资源不足,无法创建互斥锁。

静态初始化的mutex使用完后可以自动销毁,但是动态初始化的mutex需要调用pthread_mutex_destroy手动销毁。

pthread_mutex_lockpthread_mutex_unlock两个函数用于对临界资源进行加锁与解锁。

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

![[Pasted image 20250424151824.png]]

对刚才的并发抢票进行加锁,就能解决抢票抢到负数的问题。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>

int ticket = 1000;
//静态初始化一把锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* thread_func(void* args)
{
    std::string s = static_cast<const char*>(args);
    while (1)
    {
        //加锁
        pthread_mutex_lock(&mutex);
        if(ticket > 0)
        {
            usleep(1000);
            ticket--;
            std::cout << s << " get ticket, ticket left: " << ticket << std::endl;
            //解锁
            pthread_mutex_unlock(&mutex);
        }
        else{
            //解锁
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
    
}

互斥锁的原理

以下为加锁lock和解锁unlock的分解多个指令,对于这些指令(movbxchgb等),可以认为是原子的:

  1. CPU内有众多的寄存器,保存的是各个进程或者线程的上下文,寄存器只有一套,但是寄存器存放的数据有很多,因为CPU无时无刻都在运行着很多进程。
  2. 互斥锁mutex可以看作内存的一个变量,一般存储大于0的值。
  3. 对于lock可以拆解成多个步骤,movb指令将al寄存器的值值为0xchgb指令将al寄存器与mutex进行交换,对al寄存器的值进行判断,大于0则返回0,说明当前线程加锁成功,其他情况竞争锁失败,线程被挂起等待。
  4. 由于每个线程的上下文都是独一无二的,切换线程会带走属于当前线程的上下文,在多线程竞争锁的情况下,即使lock的过程即便被打断,只要一个线程交换了al寄存器和mutex,那么该线程就可以获得锁,其他线程不可能再获得锁。

简单来说,可以认为加锁是原子的,要么不加,要么加,多线程去竞争锁的使用权,被加锁的线程拥有访问临界资源的权利,其他线程在这之间必须挂起等待,直到锁被释放才能去重新竞争锁的使用权。

互斥锁的简单封装

Mutex.hpp

cpp 复制代码
#pragma once

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

namespace MutexModule
{
    class Mutex
    {

    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

        void Lock()
        {
            pthread_mutex_lock(&_mutex);
        }

        void Unlock()
        {
            pthread_mutex_unlock(&_mutex);
        }

    private:
        pthread_mutex_t _mutex;
    };


    class LockGuard
    {
    public:
        LockGuard(Mutex& mutex) :_mutex(mutex)
        {
            _mutex.Lock();
        }

        ~LockGuard()
        {
            _mutex.Unlock();
        }
    private:
        Mutex& _mutex;
    };
}

testMutex.cc

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include "Mutex.hpp"

using namespace MutexModule;
int ticket = 1000;


class ThreadData
{
public:
    ThreadData(const std::string& s, Mutex& lock)
        :lockp(&lock)
        ,name(s)
    {}

    ~ThreadData()
    {}

    Mutex* lockp;
    std::string name;
};

void* thread_func(void* args)
{
    ThreadData* ptd = static_cast<ThreadData*>(args);
    while (1)
    {
        LockGuard guard(*ptd->lockp);
        if(ticket > 0)
        {
            usleep(1000);
            ticket--;
            std::cout << ptd->name << " get ticket, ticket left: " << ticket << std::endl;
            //ptd->lockp->Unlock();
        }
        else{
            //ptd->lockp->Unlock();
            break;
        }
    }
    return nullptr; 
}

int main()
{
    pthread_t tids[4];
    Mutex lock;
    for(int i = 0; i < 4; i++)
    {
        std::string s = "thread-" + std::to_string(i);
        ThreadData* td = new ThreadData(s, lock);
        pthread_create(&tids[i], nullptr, thread_func, (void *)td);
    }

    for(int i = 0; i < 4; i++)
    {
        pthread_join(tids[i], nullptr);
    }
    return 0;
}

3. 线程的同步

互斥解决了多线程并发环境下可能的数据不一致问题,但同时也带来了线程同步的相关问题。

举个例子,多个线程往文件写数据,多个线程从文件读取数据,文件是临界资源,访问临界资源需要加锁,也就是说多个线程需要先去竞争锁的使用权,不对线程进行同步管理可能会出现极端情况,文件数据已经满了但是写线程一直竞争获取到锁,读线程就无法读取数据,或者文件为空但是读线程一直竞争获取到锁,写线程无法写入数据。这样肯定是不合理的,理想状况应该是写一部分读一部分,所以需要对这些线程进行同步。

上面举的例子其实是饥饿问题:某些线程长时间无法获得资源,导致其无法继续执行。

对于以上例子,既然文件的数据已满,我们就不让读线程再去竞争,或者者竞争锁发现数据已满,立刻释放锁并不再参与下次锁的竞争,所以我们引入 条件变量(Condition Variable) 解决饥饿问题。

条件变量

条件变量(Condition Variable)用于线程间的通信,允许一个线程等待某个条件成立,而另一个线程可以通知它该条件已满足并唤醒它继续执行。条件变量通常与互斥锁(mutex)一起使用,以确保线程在等待和通知时的同步。

  1. 线程等待:某个线程在某个条件满足之前会等待,直到其他线程通知它。
  2. 线程通知:某个线程在满足特定条件时通知一个或多个等待线程,唤醒它们继续执行。

条件变量的初始化方式也有两种:

cpp 复制代码
//静态初始化,进程结束自动销毁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER

//动态初始化,使用init与destroy管理条件变量的生命周期
pthread_cond_t cond;
pthread_cond_init(&cond, nullptr);
phtrad_cond_destroy(&cond);

pthread_cond_init 用于初始化一个条件变量;pthread_cond_destroy 用于销毁一个条件变量,释放它占用的资源。

cpp 复制代码
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cond);
  • cond:指向 pthread_cond_t 类型的条件变量对象的指针。
  • attr:指向 pthread_condattr_t 类型的属性对象的指针,通常为nullptr,表示使用默认属性。
  • 成功返回0,失败返回错误码。

wait和signal的使用规范

pthread_cond_wait 让当前线程等待一个条件变量的通知,调用这个函数会首先释放与条件变量关联的互斥锁,然后进入阻塞状态,等待其他线程通过 pthread_cond_signalpthread_cond_broadcast 唤醒它。当线程被唤醒后,pthread_cond_wait会重新申请互斥锁,只有申请成功,线程才能继续往下执行,失败则会等待并继续申请。

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
  • cond:条件变量。
  • mutex:互斥锁,线程在等待之前必须持有该锁,且该锁会在进入等待时被释放。
  • 成功返回0,失败返回错误码。
cpp 复制代码
pthread_mutex_lock(&mutex);

pthread_cond_wait(&cond, &mutex);

pthread_mutex_unlock(&mutex);

pthread_cond_wait必须在pthread_mutex_lockpthread_mutex_unlock之间 ,因为根据临界资源的状态来判断当前线程是否等待,本质上也是是访问临界资源(类比抢票的ticket>0判断条件),所以必须要加锁。

pthread_cond_signal 用于唤醒一个正在等待条件变量的线程。如果有多个线程再等待,则会按照先进先出的顺序唤醒其中一个锁。

cpp 复制代码
int pthread_cond_signal(pthread_cond_t *cond);

pthread_cond_broadcast 用于唤醒所有正在等待条件变量的线程。

cpp 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);

由于pthread_cond_signal在多核处理器可能同时唤醒多个线程,因此对于pthread_cond_wait的使用也有要求,如下:

cpp 复制代码
pthread_mutex_lock(&mutex);

//为什么这里需要用while?
while(condition) pthread_cond_wait(&cond, &mutx);

pthread_mutex_unlock(&mutex);
  1. pthread_cond_signal可能同时唤醒多个线程,但是只有一个线程能够持有锁并向下执行,其他线程需要继续等待。严格来说,这里的等待是申请锁失败的等待,因为多个线程被唤醒首先需要竞争锁,竞争失败的线程阻塞在申请锁的队列中。
  2. 在多线程并发环境下,pthread_cond_wait唤醒的线程需要跟其他线程竞争锁,其他线程可能获得锁抢先修改临界资源的状态,这可能导致临界资源的状态不满足唤醒条件,而线程就会被 伪唤醒 ,所以使用while保证临界资源的状态是符合线程唤醒的条件,不满足则继续wait

pthread_cond_wait必须在加锁区内,但pthread_cond_signalpthread_cond_broadcast并没有这样的要求。

cpp 复制代码
//锁内唤醒
pthread_mutex_lock(&mutex);

pthread_cond_signal()

pthread_mutex_unlock(&mutex);

这种唤醒方式会有一定的效率损耗(非Linux环境),当前的线程还没有解锁,被唤醒的线程申请锁失败再次挂起等待,直到pthread_mutex_unlock,唤醒的线程才能成功向下执行。这其中涉及到从内核空间返回到用户空间,会有效率损耗。

Linux下,有cond_waitmutex_lock队列(申请锁),signal只是让线程从cond_wait队列转移移到mutex_lock队列,不会返回用户空间,没有性能损耗。

cpp 复制代码
//锁外唤醒
pthread_mutex_lock(&mutex);

pthread_mutex_unlock(&mutex);

pthread_cond_signal()

锁外唤醒的线程也会跟其他线程竞争锁,成功获得锁就可以继续向下执行。

使用案例:

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include <pthread.h>

const int NUM = 5;
int cnt = 0;

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void *thread_func(void *args)
{
    std::string s = static_cast<const char *>(args);
    while (true)
    {
        pthread_mutex_lock(&lock);
        // 注意,这里的等待是有条件的等,而测试代码没有条件限制
        pthread_cond_wait(&cond, &lock);
        std::cout << s << ", cnt: " << cnt << std::endl;
        cnt++;
        pthread_mutex_unlock(&lock);
    }
    return nullptr;
}

int main()
{
    std::vector<pthread_t> tids;
    for (int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        char *name = new char[64];
        snprintf(name, 64, "thread-%d", i);
        pthread_create(&tid, nullptr, thread_func, name);
        tids.push_back(tid);
    }
    sleep(2);

    while (1)
    {
        // std::cout << "wake up all thread" << std::endl;
        // pthread_cond_broadcast(&cond);
        std::cout << "wake up a thread" << std::endl;
        pthread_cond_signal(&cond);
        sleep(1);
    }

    for (auto tid : tids)
        pthread_join(tid, nullptr);

    return 0;
}

![[Pasted image 20250424195035.png]]

条件变量的封装

复用刚才封装的互斥锁对条件变量进行封装。

Cond.hpp

cpp 复制代码
#pragma once

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

using namespace MutexModule;

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

        void Wait(Mutex& mutex)
        {
            pthread_cond_wait(&_cond, mutex.Get());
        }
        
        void Signal()
        {
            pthread_cond_signal(&_cond);
        }

        void Broadcast()
        {
            pthread_cond_broadcast(&_cond);
        }
        
        ~Cond()
        {
            pthread_cond_destroy(&_cond);
        }

    private:
        pthread_cond_t _cond;
    };
};

4.生产者消费者模型

生产者-消费者模型(Producer-Consumer Problem)是经典的多线程同步问题之一,描述的是两个线程或者多个线程之间如何通过共享缓冲区进行数据交换,面临的主要问题是如何在多个生产者和消费者线程之间安全的共享数据,并且不发生数据竞争和同步问题。

![[Pasted image 20250424224202.png]]

3种关系:生产者与生产者的关系:互斥;消费者与消费者的关系:互斥;生产者与消费者的关系:互斥和同步。

2种角色:生产者与消费者。

1个交易场所:共享的缓冲区。

  • 生产者: 生产数据,并将数据放入缓冲区中。
  • 消费者: 从缓冲区中取出数据并进行处理。
  • 缓冲区: 共享资源,用于存放生产者生产的数据。

协调问题:

  • 生产者: 当缓冲区满时,生产者必须等待消费者消费数据,腾出空间。
  • 消费者:当缓冲区空时,消费者必须等待生产者生产数据,填充缓冲区。

为了解决上面的问题,就需要使用互斥锁和条件变量,具体操作为:

  • 生产者:当缓冲区数据满时,生产者线程挂起等待,直到消费者线程消费数据。当缓冲区有空时,生产者往缓冲区添加数据,然后通知消费者消费数据。
  • 消费者:当缓冲区数据为空时,消费者线程挂起等待,直到生产者线程生产数据。当缓冲区有数据时,消费者从缓冲区取数据,然后通知生产者生产数据。

基于阻塞队列的生产者和消费者模型

阻塞队列(Blocking Queue) 能够在多线程环境下有效地协调生产者和消费者的工作。

  • 当队列为空时,消费者线程会被阻塞,直到队列中有数据;当队列满时,生产者线程会被阻塞,直到队列中有空间。

基本操作:

  • 入队:将数据添加到队列的尾部。如果队列已满,操作会阻塞直到有空位。
  • 出队:从队列的头部取出数据。如果队列为空,操作会阻塞直到队列中有数据。

BlockQueue.hpp

cpp 复制代码
#pragma once

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

const int defaultcap = 5;  // 队列的默认容量

// 阻塞队列模板类
template<class T>
class BlockQueue
{
private:
    // 判断队列是否满
    bool isFull() { return _q.size() >= _cap; }
    
    // 判断队列是否空
    bool isEmpty() { return _q.empty(); }

public:
    // 构造函数,初始化队列容量、条件变量和互斥锁
    BlockQueue(int cap = defaultcap)
        :_cap(cap)  // 队列的最大容量,默认为5
        ,_csleep_num(0)  // 消费者等待的数量
        ,_psleep_num(0)  // 生产者等待的数量
    {
        // 初始化互斥锁和条件变量
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_full_cond, nullptr);
        pthread_cond_init(&_empty_cond, nullptr);
    }

    // 向队列中添加元素(生产者操作)
    void Equeue(const T& data)
    {
        pthread_mutex_lock(&_mutex);  // 加锁,确保线程安全
        
        // 如果队列满了,生产者需要挂起并等待
        while(isFull())
        {
            _psleep_num++;  // 记录当前生产者等待的数量
            std::cout << "producer hang up and wait" << std::endl;
            pthread_cond_wait(&_full_cond, &_mutex);  // 阻塞生产者线程,直到队列不满
            _psleep_num--;  // 生产者等待完毕,减少等待计数
        }

        // 将数据放入队列
        _q.push(data);

        // 如果有消费者在等待,就唤醒一个消费者
        if(_csleep_num > 0)
        {
            std::cout << "wake up consumer" << std::endl;
            pthread_cond_signal(&_empty_cond);  // 唤醒一个消费者线程
        }

        pthread_mutex_unlock(&_mutex);  // 解锁
    }

    // 从队列中取出元素(消费者操作)
    T Pop()
    {
        pthread_mutex_lock(&_mutex);  // 加锁,确保线程安全

        // 如果队列为空,消费者需要挂起并等待
        while (isEmpty())
        {
            _csleep_num++;  // 记录当前消费者等待的数量
            std::cout << "consumer hang up and wait" << std::endl;
            pthread_cond_wait(&_empty_cond, &_mutex);  // 阻塞消费者线程,直到队列不为空
            _csleep_num--;  // 消费者等待完毕,减少等待计数
        }

        // 从队列中取出数据
        T data = _q.front();
        _q.pop();

        // 如果有生产者在等待,就唤醒一个生产者
        if(_psleep_num > 0)
        {
            std::cout << "wake up producer" << std::endl;
            pthread_cond_signal(&_full_cond);  // 唤醒一个生产者线程
        }

        pthread_mutex_unlock(&_mutex);  // 解锁
        return data;  // 返回队列中的数据
    }

    // 析构函数,销毁互斥锁和条件变量
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_full_cond);
        pthread_cond_destroy(&_empty_cond);
    }

private:
    std::queue<T> _q;  // 队列,存放数据
    int _cap;  // 队列的最大容量

    pthread_mutex_t _mutex;  // 互斥锁,用来保证线程安全
    pthread_cond_t _full_cond;  // 条件变量,用于队列满时的阻塞
    pthread_cond_t _empty_cond;  // 条件变量,用于队列空时的阻塞

    int _csleep_num;  // 当前等待的消费者线程数量
    int _psleep_num;  // 当前等待的生产者线程数量
};

Main.cc

cpp 复制代码
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <unistd.h>
#include <vector>

const int CON_NUM = 1, PRO_NUM = 1;

void* con_func(void* args)
{
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*> (args);
    //BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);
    while(1)
    {
        int data = bq->Pop();
        std::cout << "consum data: " << data << std::endl;
        sleep(1);

        // task_t t = bq->Pop();
        // t();
        // sleep(1);
    }
}

void* pro_func(void* args)
{
    BlockQueue<int>* bq = static_cast<BlockQueue<int>*> (args);
    //BlockQueue<task_t>* bq = static_cast<BlockQueue<task_t>*> (args);
    
    while(1)
    {
        sleep(1);
        int data = rand() % 1000;
        std::cout << "produce a data: " << data << std::endl;
        bq->Equeue(data);
        // std::cout << "生产了一个任务: " << std::endl;
        // bq->Equeue(Download);
        // sleep(1);
    }
}

int main()
{
    srand((unsigned int)time(0));

    std::vector<pthread_t> con, pro;
    BlockQueue<int>* bq = new BlockQueue<int>();
    //BlockQueue<task_t> *bq = new BlockQueue<task_t>();

    for(int i = 0; i < CON_NUM; i++)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, con_func, bq);
        con.push_back(tid);
    }

    
    for(int i = 0; i < PRO_NUM; i++)
    {
        pthread_t tid;
        pthread_create(&tid, nullptr, pro_func, bq);
        pro.push_back(tid);
    }


    for(auto& tid : con) pthread_join(tid, nullptr);
    for(auto& tid : pro) pthread_join(tid, nullptr);

    return 0;
}

![[Pasted image 20250424224557.png]]

简单来说,生产者消费者模型支持生产者并发生产任务,消费者并发处理任务,这也是它们效率高的原因,而且将生产者和消费者解耦,增强代码的健壮性和可维护性。

5. POSIX信号量

POSIX 信号量(POSIX Semaphores)是一种同步机制,用于多线程或多进程环境中管理对共享资源的访问。

信号量本质上是一个计数器,用于统计资源的数量,而之前提到的互斥锁(mutex)本质上是二元信号量。在多线程并发环境下,互斥锁 用于临界资源整体使用的场景,而 信号量 用于临界资源精细化使用的场景,也就是临界资源划分成多份,多个线程并发访问。

信号量的类型:

  • 匿名信号量:不需要显式的名字,通常在线程或进程内使用。
  • 命名信号量:有一个文件系统路径名,允许跨进程使用。可以在多个进程间共享。

相关函数

sem_init 用于初始化信号量。

cpp 复制代码
int sem_init(sem_t *sem, int pshared, unsigned int value);
  • sem:指向信号量对象的指针。信号量通常是一个类型为 sem_t 的变量。
  • pshared:标志信号量是否在进程间共享:
    • 0:用于线程间同步(进程内)
    • 0:用于进程间同步。
    • value:信号量的初始值,表示资源的数量。

sem_wait 用于执行信号量的 等待操作 ,即 P 操作

作用:如果信号量为0,阻塞当前线程或进程;如果信号量大于0,信号量的值会减 1,表示某个资源已被占用。

cpp 复制代码
int sem_wait(sem_t *sem);

sem_post用于执行信号量的 发布操作 ,即 V 操作

作用:增加信号量的值,表示一个资源已被释放,并唤醒等待该信号量的其中一个线程或者进程。

cpp 复制代码
int sem_post(sem_t *sem);

sem_destroy用于销毁一个信号量,要被销毁的信号量必须不被任何线程使用。

cpp 复制代码
int sem_destroy(sem_t *sem);

sem_getvalue用于获取当前信号量的值。

cpp 复制代码
int sem_getvalue(sem_t *sem, int *sval);
  • sval 是一个指向整数的指针,用于存储信号量的当前值。

sem_open用于打开一个已存在的命名信号量或创建一个新的命名信号量。

cpp 复制代码
sem_t* sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
  • name :信号量的名称(必须以 / 开头,通常是一个路径名)。
  • oflag :信号量的打开标志,通常是 O_CREATO_EXCLO_RDWR 等。
  • mode:权限模式,类似文件的权限设置。
  • value:信号量的初始值。

sem_unlink用于删除命名信号量。

cpp 复制代码
int sem_unlink(const char *name);

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

生产者和消费者通过共享的环形队列进行交互,生产者负责将数据放入队列中,而消费者则从队列中取出数据进行处理。为了避免资源竞争,生产者和消费者之间需要进行同步,通常使用信号量或互斥锁来控制并发访问。

  • 生产者:生产数据并将其放入队列。
  • 消费者:从队列中消费数据。

当环形队列不为空或不为满时,生产者和消费访问环形队列的不同位置;环形队列为空或者为满时,生产者和消费者会访问同一位置的资源。

我们需要保证:当队列为满时,生产者需要等待消费者消费数据,直到有空位可以插入数据;当队列为空时,消费者需要等待生产者生产数据,直到有新的数据可供消费。

通常我们需要用两个信号量来完成同步:

  • empty :表示队列中的空位置数,生产者每插入一个元素,空位置信号量减 1;初始化为环形队列的长度N
  • full :表示队列中的数据数,消费者每取出一个元素,数据信号量减 1;初始化为0

对于生产者:

cpp 复制代码
int pi = 0;

empty.P()  //申请空位信号量

//向队列插入数据
q[pi++] = data;
pi %= N  //维护生产者索引和环形特性

full.V()   //更新数据信号量

对于消费者:

cpp 复制代码
int ci = 0;

full.P()  //申请数据信号量

//从队列取数据
data = q[ci++]
ci %= N  //维护消费者索引和环形特性

empty.V()   //更新数据信号量

通过两个信号量就能保证队列为满,生产者等待,消费者执行;队列为空,消费者等待,生产者执行,同时还维护了队列的环形特性。

Sem.hpp

cpp 复制代码
#pragma once

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

namespace SemModule
{
    const int default_val = 1;
    class Sem
    {
    public:
        Sem(unsigned int sem_val = default_val)
        {
            sem_init(&_sem, 0, sem_val);
        }

        void P()
        {
            sem_wait(&_sem);
        }
    
        void V()
        {
            sem_post(&_sem);
        }

        ~Sem()
        {
            sem_destroy(&_sem);
        }

    private:
        sem_t _sem;
    };
}

Mutex.hpp

cpp 复制代码
#pragma once

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

namespace MutexModule
{
    class Mutex
    {

    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

        void Lock()
        {
            pthread_mutex_lock(&_mutex);
        }

        void Unlock()
        {
            pthread_mutex_unlock(&_mutex);
        }

        pthread_mutex_t* Get()
        {
            return &_mutex;
        }

    private:
        pthread_mutex_t _mutex;
    };


    class LockGuard
    {
    public:
        LockGuard(Mutex& mutex) :_mutex(mutex)
        {
            _mutex.Lock();
        }

        ~LockGuard()
        {
            _mutex.Unlock();
        }
    private:
        Mutex& _mutex;
    };
}

RIngQueue.hpp

cpp 复制代码
#pragma once

#include "Sem.hpp"
#include "Mutex.hpp"
#include <vector>

static const int g_cap = 5;

using namespace SemModule;
using namespace MutexModule;

template <class T>
class RingQueue
{
public:
    RingQueue(int cap = g_cap)
        :_cap(cap)
        ,_rq(cap)
        ,_blank_sem(cap)
        ,_p_step(0)
        ,_data_sem(0)
        ,_c_step(0)
    {}

    ~RingQueue()
    {}

    void Equeue(const T& in)
    {
        //生产者
        //申请空位置的信号量,信号量值减1,代表空位数减少一个
        //先申请信号量,再去申请锁,类比于,先买票,再去去电影院排队访问
        _blank_sem.P();
        {
            // 使用生产者锁,保证对队列的访问是线程安全的
            LockGuard gurad(_pro_lock);
            //入队列
            _rq[_p_step] = in;
            //维护下标
            ++_p_step;
            _p_step %= _cap; // 保证索引循环
        }
        // 生产者生产数据后,唤醒消费者,数据信号量值加1
        _data_sem.V();
    }

    void Pop(T* out)
    {
        //消费者
        //申请数据的信号量,信号量值减1,代表数据数减少一个
        _data_sem.P();
        {
            LockGuard gurad(_con_lock);
            //从队列中取出数据
            *out = _rq[_c_step];
            //维护下标
            ++_c_step;
            _c_step %= _cap; // 保证索引循环
        }
        // 消费者消费一个数据后,唤醒生产者,空位信号量值加1
        _blank_sem.V();
    }


private:
std::vector<T> _rq;  // 存储数据的环形队列
int _cap;            // 队列的最大容量

// 信号量:管理空位置和数据的同步
Sem _blank_sem; // 空位置的信号量,表示可以插入数据的位置
int _p_step;    // 生产者索引,指向下一个插入的位置

Sem _data_sem;  // 数据信号量,表示可以被消费的数据
int _c_step;    // 消费者索引,指向下一个待消费的位置

// 互斥锁:保护生产者和消费者的操作
Mutex _con_lock;  // 消费者锁,保证消费者线程安全
Mutex _pro_lock;  // 生产者锁,保证生产者线程安全
};

Main.cc

cpp 复制代码
#include "RingQueue.hpp"  // 引入环形队列头文件
#include <string>
#include <unistd.h>  // 引入 POSIX 的 sleep 函数

// 常量定义,设置生产者和消费者线程的数量
const int CON_NUM = 3, PRO_NUM = 3;
Mutex print_lock;  // 用于保护打印操作的互斥锁

// 定义一个结构体,用于存储线程的相关数据
struct thread_data
{
    thread_data(RingQueue<int>* rq = nullptr, std::string name = "", int i = 0)
        :_rq(rq)
        ,_name(name)
    {
        _name += " thread-" + std::to_string(i);  // 线程名称,带上索引以区分不同的线程
    }
    
    RingQueue<int>* _rq;  // 环形队列的指针
    std::string _name;    // 线程的名称
};

// 消费者线程的函数
void* con_func(void* args)
{
    thread_data* td = static_cast<thread_data*> (args);  // 将传入的参数转换为 thread_data 指针
    while(1)  // 无限循环,消费者持续消费
    {
        int data = 0;
        td->_rq->Pop(&data);  // 从环形队列中取出数据

        // 锁住打印操作,保证输出不会被多个线程干扰
        {
            LockGuard guard(print_lock);
            std::cout << td->_name << " consume data: " << data << std::endl;  // 打印消费的数据
        }
        sleep(1);  // 模拟消费延时
    }
}

// 生产者线程的函数
void* pro_func(void* args)
{
    thread_data* td = static_cast<thread_data*> (args);  // 将传入的参数转换为 thread_data 指针
    while(1)  // 无限循环,生产者持续生产
    {
        int data = rand() % 1000;  // 生成一个随机数据,模拟生产的任务数据
        td->_rq->Equeue(data);  // 向环形队列中插入数据

        // 锁住打印操作,保证输出不会被多个线程干扰
        {
            LockGuard guard(print_lock);
            std::cout << td->_name << " produce a data: " << data << std::endl;  // 打印生产的数据
        }
        // sleep(1);  // 如果需要模拟生产者操作的延时,可以解除注释
    }
}

int main()
{
    std::vector<pthread_t> con, pro;  // 存储消费者和生产者线程的 ID
    RingQueue<int>* rq = new RingQueue<int>();  // 创建一个环形队列对象,用于生产者和消费者之间的数据交换

    // 创建消费者线程
    for(int i = 0; i < CON_NUM; i++)
    {
        thread_data* td = new thread_data(rq, "consumer", i);  // 创建线程数据对象
        pthread_t tid;
        pthread_create(&tid, nullptr, con_func, td);  // 创建消费者线程
        con.push_back(tid);  // 将线程 ID 存入列表
    }

    // 创建生产者线程
    for(int i = 0; i < PRO_NUM; i++)
    {
        thread_data* td = new thread_data(rq, "producer", i);  // 创建线程数据对象
        pthread_t tid;
        pthread_create(&tid, nullptr, pro_func, td);  // 创建生产者线程
        pro.push_back(tid);  // 将线程 ID 存入列表
    }

    // 等待所有消费者线程结束
    for(auto& tid : con) pthread_join(tid, nullptr);

    // 等待所有生产者线程结束
    for(auto& tid : pro) pthread_join(tid, nullptr);

    return 0;  // 程序结束
}
相关推荐
玖剹1 小时前
递归练习题(四)
c语言·数据结构·c++·算法·leetcode·深度优先·深度优先遍历
向阳逐梦1 小时前
DC-DC Buck 电路(降压转换器)全面解析
人工智能·算法
Mz12211 小时前
day04 小美的区间删除
数据结构·算法
weixin_660096781 小时前
zsh中使用自动补全zsh-autosuggestions
linux·ubuntu·zsh·zshrc
Ghost Face...2 小时前
Linux音频控制神器:amixer完全指南
linux·chrome·音视频
_OP_CHEN2 小时前
算法基础篇:(十九)吃透 BFS!从原理到实战,解锁宽度优先搜索的核心玩法
算法·蓝桥杯·bfs·宽度优先·算法竞赛·acm/icpc
大柏怎么被偷了2 小时前
【Linux】进程替换
linux·运维·服务器
小猪咪piggy2 小时前
【算法】day 20 leetcode 贪心
算法·leetcode·职场和发展
Xの哲學2 小时前
Linux 指针工作原理深入解析
linux·服务器·网络·架构·边缘计算