【线程同步与互斥】:互斥量(锁)、条件变量(唤醒等待线程)、生产者消费者模型

1. 线程互斥

1.1 进程线程间的互斥

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

1.2 互斥量 mutex

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

int ticket = 1000; // 票数

void *route(void *args)
{
    const char *name = static_cast<const char *>(args);
    while (1)
    {
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s: sells ticket: %d\n", name, ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3, t4; // 4个线程

    // id,属性,调用函数,参数
    pthread_create(&t1, NULL, route, (void *)"thread-1");
    pthread_create(&t2, NULL, route, (void *)"thread-2");
    pthread_create(&t3, NULL, route, (void *)"thread-3");
    pthread_create(&t4, NULL, route, (void *)"thread-4");

    pthread_join(t1,NULL);
    pthread_join(t2,NULL);
    pthread_join(t3,NULL);
    pthread_join(t4,NULL);

    return 0;
}
bash 复制代码
thread-3: sells ticket: 2
thread-4: sells ticket: 0
thread-1: sells ticket: -1
thread-2: sells ticket: -2

上述模拟售票的过程,多次运行后票数可能会减到负数,为什么可能无法获得正确结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
  • --ticket 操作本身就不是一个原子操作。
  1. if (ticket > 0) 的判断和 ticket-- 的操作不是绑定的:当线程A判断完票数大于0,进入了 if 内部后,它还没有来得及执行 ticket--。
  2. 线程切换的发生:此时,如果发生了线程切换(可能是因为时间片到了,也可能是因为 usleep(1000) 或 printf 这样的系统调用导致线程主动让出CPU),线程A就会被暂停并挂起。
  3. 其他线程的介入:在线程A暂停期间,线程B、C、D等同样会去判断 if (ticket > 0)。因为线程A还没减票,内存里的票数没变,所以它们也能顺利进入 if 内部。
  4. 造成负数:最后,这些线程都会依次执行 ticket--。明明只有1张票,却有好几个线程都减了一次,票数自然就变成负数了。

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

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

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

1.3 互斥量的接口

1.3.1 初始化互斥量

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

  • 方法1,静态分配:
cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  • 方法2,动态分配:
cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const 
pthread_mutexattr_t *restrict attr);

参数:

mutex:要初始化的互斥量。

attr:锁的属性,通常设为NULL,表示使用默认属性。

1.3.2 销毁互斥量

需要注意:

  1. 使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁。
  2. 不要销毁一个已经加锁的互斥量。(指的就是一个正在被某个线程占用、还没有被释放的锁)
  3. 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

1.3.3 互斥量加锁和解锁

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

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

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

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

改进上面的售票系统:

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

// 加锁
int ticket = 1000;     // 票数
pthread_mutex_t mutex; // 互斥量

void *route(void *args)
{
    const char *name = static_cast<const char *>(args);
    while (1)
    {
        pthread_mutex_lock(&mutex); // 加锁
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s: sells ticket: %d\n", name, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex); // 解锁
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3, t4;         // 4个线程
    pthread_mutex_init(&mutex, NULL); // 初始化互斥量

    // id,属性,调用函数,参数
    pthread_create(&t1, NULL, route, (void *)"thread-1");
    pthread_create(&t2, NULL, route, (void *)"thread-2");
    pthread_create(&t3, NULL, route, (void *)"thread-3");
    pthread_create(&t4, NULL, route, (void *)"thread-4");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    pthread_mutex_destroy(&mutex);

    return 0;
}

1.4 互斥量实现原理探究

  • 经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题。
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把lock和unlock的伪代码改一下。

加锁流程:

  1. 将 AL 寄存器清零
  2. 执行原子交换操作,将互斥量 mutex 的值与 AL 寄存器内容交换
  3. 检查 AL 寄存器值:
    • 若大于0,表示成功获取锁
    • 若等于0,表示锁已被占用,当前线程进入等待状态

解锁流程:

  1. 将 mutex 值恢复为1
  2. 唤醒正在等待该互斥量的线程

1.5 互斥量的封装

mutex.hpp:

cpp 复制代码
#pragma once

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

namespace LockModule
{
    class Mutex
    {
    public:
        // 禁用不要的拷贝和赋值
        Mutex(const Mutex &) = delete;                  // 右值拷贝
        const Mutex &operator=(const Mutex &) = delete; // 右值赋值
        Mutex()
        {
            int n = pthread_mutex_init(&mutex, NULL);
            (void)n;
        }
        void Lock()
        {
            pthread_mutex_lock(&mutex);
        }
        void UnLock()
        {
            pthread_mutex_unlock(&mutex);
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&mutex);
        }

    private:
        pthread_mutex_t mutex;
    };

    class lockguard
    {
    public:
        lockguard(Mutex& mutex)
        :_mutex(mutex)
        {
            _mutex.Lock();
        }
        ~lockguard()
        {
            _mutex.UnLock();
        }
    private:
        // 必须在构造函数的初始化列表中绑定一个外部对象,不能在函数体内赋值
        // 一旦绑定,不可改变指向
        Mutex &_mutex;// 引用成员变量,表示不是一个独立的对象,而是外部某个已存在Mutex对象的别名
    };
}

Main.cc:

cpp 复制代码
#include"mutex.hpp"

using namespace LockModule;

int ticket = 1000;     // 票数
Mutex mutex;

void *route(void *args)
{
    const char *name = static_cast<const char *>(args);
    while (1)
    {
        lockguard lockguard(mutex);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s: sells ticket: %d\n", name, ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3, t4;         // 4个线程

    // id,属性,调用函数,参数
    pthread_create(&t1, NULL, route, (void *)"thread-1");
    pthread_create(&t2, NULL, route, (void *)"thread-2");
    pthread_create(&t3, NULL, route, (void *)"thread-3");
    pthread_create(&t4, NULL, route, (void *)"thread-4");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);


    return 0;
}
  • 线程进入 while 循环内后创建 lockguard 对象,传入 Mutex 对象。
  • Mutex 对象调用初始化函数,即 pthread_mutex_init 初始化互斥量,然后 lockguard 对象调用构造函数,即 pthread_mutex_lock,对互斥量 mutex 加锁。
  • while 循环结束后,lockguard 对象调用析构函数,即 pthread_mutex_unlock,对互斥量解锁,然后 Mutex 对象调用析构函数,即 pthread_mutex_destroy 销毁互斥量。
  • 于是就完成了互斥量的初始化、加锁、解锁、删除等操作。

2. 线程同步

2.1 条件变量

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

简单来说,条件变量就是多线程协作中的"排队 + 叫号"系统。

  • 等待队列(排队):当线程发现条件不满足(比如没资源了),就乖乖去队列里睡觉,把 CPU 让出来,避免死等浪费资源。
  • 通知信号(叫号):当另一个线程改变了现状(比如补充了资源),就发个信号,把队列里睡觉的线程叫醒起来干活。

补充一条铁律:它必须和互斥锁绑在一起用。互斥锁负责保护资源不被乱动,条件变量负责指挥线程什么时候该休息、什么时候该干活。

2.2 同步概念和竞争条件

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。

2.3 条件变量函数

2.3.1 初始化条件变量

静态分配:

cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

动态分配:

cpp 复制代码
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t 
*restrict attr);

cond:要初始化的条件变量。

attr:条件变量的属性,一般设为NULL,表示使用默认属性。

2.3.2 销毁条件变量

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond)

2.3.3 等待条件满足

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict 
mutex);

cond:在这个条件变量上等待。

mutex:互斥量。

2.3.4 唤醒等待

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

pthread_cond_signal(精准叫醒):唤醒 1 个正在等待的线程。

pthread_cond_broadcast(全体叫醒):唤醒 所有正在等待的线程。

2.3.5 示例:

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

// 静态分配,不用初始化和销毁互斥量和条件变量了
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void* active(void* args)
{
    const char* name=static_cast<const char*>(args);
    while(1)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond,&mutex);
        std::cout<<name<<",active..."<<std::endl;
        pthread_mutex_unlock(&mutex);
        sleep(1);
    }
}

int main()
{
    pthread_t t1,t2;
    pthread_create(&t1,NULL,active,(void*)"thread-1");
    pthread_create(&t2,NULL,active,(void*)"thread-2");

    sleep(3);
    while(1)
    {
        // 3秒后唤醒等待
        pthread_cond_broadcast(&cond);// 唤醒全部线程
        //pthread_cond_signal(&cond);// 唤醒一个线程
    }

    pthread_join(t1,NULL);
    pthread_join(t2,NULL);
    return 0;
}

3秒后,线程被唤醒。

2.4 生产者消费者模型

生产者消费者模型讲的是一个多线程通信的故事。

生产者消费者模型:

3种要素:生产者,消费者,一个交易场所。

  • 生产者:线程。
  • 消费者:线程。
  • 中间的交易场所:就是一块 "内存" 空间,也是一种临界资源。

生产者消费者模型遵循 "321" 原则:

3种关系:

  • 生产者之间:竞争关系,互斥关系。
  • 消费者之间:互斥关系。
  • 生产者和消费者之间:互斥和同步。

2种角色:生产者角色和消费者角色(线程承担)。

1个交易场所:以特性结构构成的一种 "内存" 空间。

2.5 为什么要使用生产者消费者模型

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

2.6 生产者消费者模型的优点

  • 生产过程和消费过程解耦。
  • 支持忙闲不均。
  • 提高效率,不是体现在出入交易场所上,而是在于未来获取任务和处理具体任务,是并发的!

2.7 基于BlockingQueue的生产者消费者模型

2.7.1 BlockingQueue

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

2.7.2 C++ queue模拟阻塞队列的生产消费模型

BlockingQueue.hpp:

  • 采用模板,可以插入任意类型的数据。
  • 构造函数负责互斥量和两个条件变量的初始化,两个条件变量用于生产者等待队列和消费者等待队列。创建对象时,传入 cap,规定阻塞队列的存储上限。
  • 析构函数扶着销毁互斥量和两个条件变量。
  • Enqueue 是生产者的接口,先获取锁,然后判断阻塞队列是否满,如果满了,数据放不下了,释放锁,让消费者线程获取锁,等待条件变量,等待成功后向阻塞队列中插入数据,此时队列中一定是有数据的,此时再去看看消费者的等待队列中是否有线程在等待,如果有,使用 pthread_cond_signal 唤醒一个消费者线程,最后解锁。
  • Pop 是消费者的接口,先获取锁,然后判断阻塞队列是否为空,如果为空,没数据可取,释放锁,让生产者线程获取锁,等待条件变量,等待成功后获取阻塞队列队头元素返回,此时阻塞队列数据被消耗,如果生产者等待队列有线程在等待,使用 pthread_cond_signal 唤醒一个生产者线程,最后解锁。
cpp 复制代码
#ifndef _BLOCKING_QUEUE_ // 如果未定义,进入内部
#define _BLOCKING_QUEUE_ // 定义,下次再被包含不会进入内部

#include <iostream>
#include <queue>
#include <unistd.h>
#include <functional>
#include <pthread.h>

// 模板类,可以向阻塞队列中插入不同的元素
template <typename T>
class BlockingQueue
{
private:
    bool IsFull()
    {
        return _block_queue.size() == _cap;
    }
    bool IsEmpty()
    {
        return _block_queue.size() == 0;
    }

public:
    BlockingQueue(int cap)
        : _cap(cap), _producer_wait_num(0), _consumer_wait_num(0)
    {
        // 初始化互斥量和条件变量
        pthread_mutex_init(&mutex, NULL);
        pthread_cond_init(&_empty_cond, NULL);
        pthread_cond_init(&_full_cond, NULL);
    }

    // 生产者接口
    void Enqueue(const T &data)
    {
        // 获取锁
        pthread_mutex_lock(&mutex);
        while (IsFull())
        {
            // 阻塞队列满了,等待
            _producer_wait_num++;
            pthread_cond_wait(&_full_cond, &mutex);
            _producer_wait_num--;
        }

        // 没满,插入数据,如果有消费者等待,唤醒消费者
        _block_queue.push(data);
        if (_consumer_wait_num > 0)
            pthread_cond_signal(&_empty_cond);
        pthread_mutex_unlock(&mutex);
    }

    // 消费者接口
    // 不能使用T&返回,data是一个临时变量,T&是返回一个引用(即某个已存在对象的别名)
    T Pop()
    {
        pthread_mutex_lock(&mutex);
        while (IsEmpty())
        {
            // 阻塞队列空了,等待
            _consumer_wait_num++;
            pthread_cond_wait(&_empty_cond, &mutex);
            _consumer_wait_num--;
        }

        // 没满,取出数据,如果有生产者等待,唤醒生产者
        T data = _block_queue.front();
        _block_queue.pop();

        if (_producer_wait_num > 0)
            pthread_cond_signal(&_full_cond);
        pthread_mutex_unlock(&mutex);

        return data;
    }
    ~BlockingQueue()
    {
        // 销毁互斥量和条件变量
        pthread_mutex_destroy(&mutex);
        pthread_cond_destroy(&_empty_cond);
        pthread_cond_destroy(&_full_cond);
    }

private:
    std::queue<T> _block_queue;
    int _cap;                   // 阻塞队列存储上限
    pthread_mutex_t mutex;      // 保护阻塞队列的互斥量
    pthread_cond_t _empty_cond; // 消费者的条件变量
    pthread_cond_t _full_cond;  // 生产者的条件变量

    int _producer_wait_num; // 生产者等待个数
    int _consumer_wait_num; // 消费者等待个数
};

#endif
  • Main.cc
  • 上述模拟实现的生产消费模型,在面对 1 对 1 和 多对多的情况下,都能正确运行。
  • 因为临界资源被锁保护了。
cpp 复制代码
#include "BlockingQueue.hpp"
#include "Task.hpp"

// 包装器
using task_t=std::function<void()>;

void DownLoad()
{
    std::cout<<"我是一个下载任务..."<<std::endl;
}

// 生产者线程
void *Producer(void *args)
{
    BlockingQueue<task_t> *bq = static_cast<BlockingQueue<task_t> *>(args);
    int cnt = 1;
    while (1)
    {
        // 获取任务,生产任务
        bq->Enqueue(DownLoad);
        std::cout << cnt++ << ": 生产者:" << ", 生产了一个任务..." << std::endl;
    }
}

// 消费者线程
void *Consumer(void *args)
{
    BlockingQueue<task_t> *bq = static_cast<BlockingQueue<task_t> *>(args);
    int cnt = 1;
    while (1)
    {
        sleep(3); // 3秒消耗一个任务
        // 获取任务,消费任务
        task_t t = bq->Pop();
        std::cout << cnt++ << ": 消费者:" << "消费了一个任务..." << std::endl;
        t();
    }
}

int main()
{
    // 多对多
    pthread_t c[2], p[3];
    BlockingQueue<task_t> *bq = new BlockingQueue<task_t>(5);
    pthread_create(p, NULL, Producer, bq);
    pthread_create(p+1, NULL, Producer, bq);
    pthread_create(p+2, NULL, Producer, bq);
    pthread_create(c, NULL, Consumer, bq);
    pthread_create(c+1, NULL, Consumer, bq);

    pthread_join(c[0], NULL);
    pthread_join(c[1], NULL);
    pthread_join(p[0], NULL);
    pthread_join(p[1], NULL);
    pthread_join(p[2], NULL);

    // 生产者:消费者->1:1
    // pthread_t t1, t2;
    // BlockingQueue<task_t> *bq = new BlockingQueue<task_t>(5);
    // pthread_create(&t1, NULL, Producer, bq);
    // pthread_create(&t1, NULL, Consumer, bq);

    // pthread_join(t1, NULL);
    // pthread_join(t2, NULL);
}

2.8 为什么 pthread_cond_wait 需要传入互斥量?

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict 
mutex);
  • 条件等待是线程间同步的一种手段。如果只有一个线程,条件不满足,就会一直等待下去且条件永远不会满足。
  • 所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好地通知等待在条件变量上的线程。
  • 条件不会无缘无故地突然变得满足,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护。没有互斥锁,就无法安全地获取和修改共享数据。
  • 由于解锁和等待不是原子操作。调用解锁之后,pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,并且条件满足、发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 上。所以解锁和等待必须是一个原子操作。
  • int pthread_cond_wait (pthread_cond_t *cond, pthread_mutex_t *mutex); 进入该函数后,会去看条件量是否等于 0。如果等于,就把互斥量释放,直到 cond_wait 返回,把条件量改成 1,把互斥量恢复成原样。

条件变量等待函数,传入锁的目的是为了释放锁,让其他线程拿到锁,然后唤醒自己。

总结一下,pthread_cond_wait 传入锁的一整套"原子化服务"流程是这样的:

  • 原子地释放锁,并让当前线程进入等待队列(彻底堵死信号丢失的缝隙)。
  • 线程进入阻塞睡眠状态,等待被唤醒。
  • 被其他线程唤醒后,自动重新尝试获取锁(抢不到就继续等,抢到了才会让函数返回)。
相关推荐
来杯@Java16 分钟前
图书管理系统(基于springboot+vue前后端分离的项目)计算机毕业设计java
java·spring boot·spring·vue·毕业设计·mybatis·课程设计
卷毛的技术笔记1 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
编程大师哥1 小时前
匿名函数 lambda + 高阶函数
java·python·算法
isyangli_blog1 小时前
OpenDayLight (Carbon 版本) 启动与组件安装
开发语言·php
vb2008111 小时前
FastAPI APIRouter
开发语言·python
Benszen1 小时前
KVM虚拟化解决方案
开发语言·perl
会编程的土豆1 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
東雪木1 小时前
多线程与并发编程 专属复习笔记
java·开发语言·笔记·java面试
adrninistrat0r1 小时前
Java调用链MCP分析工具
java·python·ai编程
杨充2 小时前
1.3 浮点型数据设计灵魂
开发语言·python·算法