Linux学习:线程的同步与互斥

前面我们已经学习了线程的概念与控制相关的内容。而涉及到多线程就必须要学习线程的同步与互斥,如果这个搞不清楚,那么多线程代码就是一团乱麻毫无规律,甚至不如直接改成单线程代码。

那么本期我们就来深入线程的同步与互斥的内容。

相关代码已经上传至作者的个人gitee:楼田莉子/Linux学习喜欢请点个赞谢谢

目录

线程互斥

相关概念

互斥量mutex

互斥量相关的接口

初始化

静态初始化:

动态初始化:

销毁

锁定

pthread_mutex_lock

pthread_mutex_trylock

pthread_mutex_timedlock

解锁

互斥量实现原理

互斥量的封装

线程同步

同步概念与竞争条件

条件变量

条件变量相关函数

初始化

销毁

等待

唤醒等待

pthread_cond_timedwait()

pthread_cond_signal()

pthread_cond_broadcast()

代码示例

条件变量使用规范

生产者消费者模型

概念

为什么需要用到生产者消费者模型?

典型应用场景

优缺点

基于Blockingqueue的生产者消费者模型

代码实现

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

代码实现

线程安全与可重入问题

线程安全

概念

常见线程安全情况

常见线程不安全情况

可重入问题

概念

常见可重入情况

常见不可重入情况

联系

侧重点分析

死锁

概念

死锁产生的四个必要条件

如何避免死锁?

STL、智能指针与线程安全

STL与线程安全

[1. 标准中的定义](#1. 标准中的定义)

[2. 安全与不安全的场景](#2. 安全与不安全的场景)

安全的场景:

不安全的场景:

[3. 注意事项与最佳实践](#3. 注意事项与最佳实践)

智能指针与线程安全

[1. std::shared_ptr 的线程安全性](#1. std::shared_ptr 的线程安全性)

引用计数是线程安全的

[对 shared_ptr 实例本身的读写不是原子的](#对 shared_ptr 实例本身的读写不是原子的)

对指向对象的访问需要额外同步

[特殊情况:const shared_ptr 对象](#特殊情况:const shared_ptr 对象)

[2. std::unique_ptr 的线程安全性](#2. std::unique_ptr 的线程安全性)

[3. std::weak_ptr 的线程安全性](#3. std::weak_ptr 的线程安全性)

[4. C++20 的 std::atomic](#4. C++20 的 std::atomic)

[5. 安全与不安全的场景总结](#5. 安全与不安全的场景总结)

[6. 注意事项与最佳实践](#6. 注意事项与最佳实践)

其他常见的锁介绍

按行为/语义分类

[1. 读写锁(Shared-Exclusive Lock / Readers-Writer Lock)](#1. 读写锁(Shared-Exclusive Lock / Readers-Writer Lock))

[2. 递归锁(Recursive Lock / Reentrant Mutex)](#2. 递归锁(Recursive Lock / Reentrant Mutex))

[3. 自旋锁(Spinlock)](#3. 自旋锁(Spinlock))

按并发控制策略分类

[1. 悲观锁(Pessimistic Lock)](#1. 悲观锁(Pessimistic Lock))

[2. 乐观锁(Optimistic Lock)](#2. 乐观锁(Optimistic Lock))

按调度策略分类

[1. 公平锁(Fair Lock)](#1. 公平锁(Fair Lock))

[2. 非公平锁](#2. 非公平锁)

按锁的粒度分类

CAS(Compare-And-Swap)

如何选择合适的锁?


线程互斥

相关概念

共享资源:在多线程或多进程环境中,可以被多个执行流 (线程或进程)共同访问的数据、设备、文件或其他系统资源

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

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

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

原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

互斥量mutex

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

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

多个线程并发的操作共享变量,会带来一些问题。我们以以下代码为示例:

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <cstdio>
#include <cstdlib>
#include <string>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;

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

int main(void)
{
    pthread_t t1, t2, t3, t4;
    
    pthread_create(&t1, nullptr, route, (void*)"thread 1");
    pthread_create(&t2, nullptr, route, (void*)"thread 2");
    pthread_create(&t3, nullptr, route, (void*)"thread 3");
    pthread_create(&t4, nullptr, route, (void*)"thread 4");
    
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);
    
    return 0;
}

结果为:(以上结果正常执行,这里只截取异常片段)

为什么可能无法获得争取结果?

if 语句判断条件为真以后,代码可以并发的切换到其他线程

usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段

--ticket 操作本身就不是一个原子操作

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

1、代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

2、如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。

3、如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

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

互斥相关的结构可以这么理解:

互斥量相关的接口

初始化

静态初始化:

**作用:**编译器初始化互斥量

表达式:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
动态初始化:

**作用:**动态初始化互斥量

表达式:

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *mutex, 
                        const pthread_mutexattr_t *attr);

参数:

  • mutex:指向要初始化的互斥量指针

  • attr:互斥量属性,NULL表示默认属性

**返回值:**如果成功了返回0 ,否则返回错误码

销毁

作用:销毁互斥量,释放资源

表达式:

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

参数mutex:指向要销毁的互斥量指针

返回值:如果成功了返回0 ,否则返回错误码

锁定

pthread_mutex_lock

**作用:**阻塞式加锁,如果互斥量已被锁定,线程将阻塞

表达式:

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

参数: mutex:指向要加锁的互斥量指针

**返回值:**如果成功了返回0 ,否则返回错误码

pthread_mutex_trylock

**作用:**非阻塞式尝试加锁,立即返回结果

表达式:

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

参数: mutex:指向要尝试加锁的互斥量指针

返回值:

  • 成功返回0

  • 如果互斥量已被锁定,返回EBUSY

  • 其他错误返回相应错误码

pthread_mutex_timedlock

**作用:**带超时的加锁,在指定时间内尝试加锁

表达式:

cpp 复制代码
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
                           const struct timespec *restrict abstime);

参数:

  • mutex:指向要加锁的互斥量指针

  • abstime:绝对超时时间(使用CLOCK_REALTIME时钟)

返回值:

  • 成功返回0

  • 超时返回ETIMEDOUT

  • 其他错误返回相应错误码

解锁

作用:解锁互斥量

表达式

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

参数mutex:指向要解锁的互斥量指针

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

接下来我们用以上的函数改进上面的代码

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
//加锁后
#include <cstdio>
#include <cstdlib>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>

int ticket = 100;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;

void *route(void *arg)
{
    char *id = (char*)arg;
    
    while (1)
    {
        pthread_mutex_lock(&mutex);
        
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    
    return nullptr;
}

int main(void)
{
    pthread_t t1, t2, t3, t4;
    
    // 初始化互斥锁
    pthread_mutex_init(&mutex, nullptr);
    
    // 创建线程
    pthread_create(&t1, nullptr, route, (void*)"thread 1");
    pthread_create(&t2, nullptr, route, (void*)"thread 2");
    pthread_create(&t3, nullptr, route, (void*)"thread 3");
    pthread_create(&t4, nullptr, route, (void*)"thread 4");
    
    // 等待线程结束
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);
    
    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);
    
    return 0;
}

结果为:(结果依照正常进行,这里只截取一部分)

接下来我们就有了几个重要的问题

1、加锁的原则问题?

我们通过加锁,让部分场景下并行变串行,但是这样会导致程序运行效率降低,因此我们必须严格规定加锁的范围必须为非临界区

2、mutex是共享资源,它保护别人谁来保护它呢?

lock和unlock被设计为原子操作,只有成功和失败,不会因为任何其他的原因被打断

3、为什么一些线程先加锁后解锁,另一些线程不需要?

访问临界资源所有线程必须加锁和不加锁,这是红线!加锁的本质就是对相关线程的互斥!

4、如果有一个线程申请锁失败,会干什么呢?

如果有一个线程申请锁失败就会阻塞等待。 而加锁就会保证原子性的!

互斥量实现原理

通过以上的代码,我们已经意识到单纯的 i++ 或者 ++i 都不是原⼦的,有可能会有数据⼀致性

问题。

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

关于以上的内容,我们需要先记住一下内容:

  1. CPU在调度线程,以线程为载体执行的加锁逻辑

  2. CPU内寄存器只有一套,但是寄存器内部的数据,可以有多份。就会将内存变量交换到CPU内部的寄存器中,本质上是将共享数据变为某个线程的私有数据
    寄存器!=寄存器的内容(硬件上下文)!

  3. 内存中的变量,被线程共享的,只要拿到地址!

    把内存变量 交换到CPU内部的寄存器中

互斥量的封装

lock.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#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, nullptr);
            (void)n;
        }
        
        // 加锁
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            (void)n;
        }
        
        // 解锁
        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            (void)n;
        }
        
        // 获取原始指针
        pthread_mutex_t *GetMutexOriginal()
        {
            return &_mutex;
        }
        
        // 析构函数
        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_mutex);
            (void)n;
        }
        
    private:
        pthread_mutex_t _mutex;
    };
    
    // 采用RAII风格,进行锁管理
    class LockGuard
    {
    public:
        // 构造函数,创建时自动加锁
        LockGuard(Mutex &mutex) : _mutex(mutex)
        {
            _mutex.Lock();
        }
        
        // 析构函数,自动解锁
        ~LockGuard()
        {
            _mutex.Unlock();
        }
        
    private:
        Mutex &_mutex;  // 引用被管理的互斥锁
    };
}

main.cpp

cpp 复制代码
// 抢票的代码更新版本,使用RAII风格的锁
#include <cstdio>
#include <cstdlib>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include "Lock.hpp"

using namespace LockModule;

int ticket = 1000;
Mutex mutex;

void *route(void *arg)
{
    char *id = (char *)arg;
    
    while (1)
    {
        LockGuard lockguard(mutex);  // 使用RAII风格的锁
        
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    
    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3, t4;
    
    pthread_create(&t1, nullptr, route, (void*)"thread 1");
    pthread_create(&t2, nullptr, route, (void*)"thread 2");
    pthread_create(&t3, nullptr, route, (void*)"thread 3");
    pthread_create(&t4, nullptr, route, (void*)"thread 4");
    
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);
    
    return 0;
}

线程同步

同步概念与竞争条件

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

竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

条件变量

当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。

例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

条件变量相关函数

初始化

作用:初始化条件变量,创建可用于线程同步的条件变量对象。条件变量必须在使用前初始化。

表达式:

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

参数:

参数 类型 说明
cond pthread_cond_t * 指向要初始化的条件变量的指针
attr pthread_condattr_t * 条件变量属性对象指针: • NULL:使用默认属性 • 指向属性对象:使用指定属性

属性选项(通过pthread_condattr_t设置):

  • PTHREAD_PROCESS_PRIVATE(默认):仅同一进程内线程可见

  • PTHREAD_PROCESS_SHARED:可被多个进程共享

  • 时钟选择(影响pthread_cond_timedwait)

  • 条件变量作用域

返回值:

成功:返回0,表示条件变量已成功初始化。

失败:返回非零错误码,表示初始化失败。常见的错误码包括:

  • EINVAL:参数无效,可能是attr指向的属性对象包含无效值

  • EBUSY:尝试重新初始化已初始化的条件变量

  • EFAULT:cond或attr指向的地址无效或不可访问

  • ENOMEM:内存不足,无法为条件变量分配必要的资源

销毁

作用:销毁条件变量,释放条件变量占用的资源。条件变量被销毁后不能再使用,除非重新初始化。

表达式:

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

参数:cond:指向要销毁的条件变量的指针。该条件变量必须已初始化且当前没有被任何线程等待。

返回值:

成功:返回0,表示条件变量已成功销毁,其占用的资源已释放。

失败:返回非零错误码,常见的错误码包括:

  • EBUSY:条件变量正在被使用,即有线程正在此条件变量上等待。必须先确保所有等待线程都已停止等待或已被唤醒

  • EINVAL:cond指定的条件变量无效,可能未初始化或已被销毁

  • EFAULT:cond指向无效的内存地址

重要注意事项

  1. 静态初始化的条件变量(使用PTHREAD_COND_INITIALIZER)不需要调用此函数

  2. 销毁后不应再使用该条件变量,除非重新调用pthread_cond_init进行初始化

  3. 在多线程程序中,必须确保没有线程在等待此条件变量时才能安全销毁

等待

作用:等待条件变量,使调用线程在条件变量上阻塞等待。函数会自动释放关联的互斥锁,使其他线程可以获取锁并修改条件

表达式:

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

参数:

cond:指向要等待的条件变量的指针。线程将在此条件变量上阻塞,直到被其他线程唤醒。

mutex:指向与条件变量关联的互斥锁的指针。调用此函数前,调用线程必须已经成功获取(锁定)此互斥锁。

返回值:

成功 :返回0,表示线程已被成功唤醒。这通常意味着其他线程调用了pthread_cond_signalpthread_cond_broadcast

失败:返回非零错误码,常见的错误码包括:

  • EINVAL:cond或mutex参数无效,可能指向未初始化的对象

  • EPERM:调用线程未持有指定的互斥锁。这是常见错误,必须在调用前锁定互斥锁

  • EFAULT:cond或mutex指向无效的内存地址

关键注意事项

  1. 必须用while循环而不是if语句检查条件,因为存在"虚假唤醒"的可能

  2. 在调用pthread_cond_wait之前,必须已经获得mutex锁

  3. 函数返回时,线程已经重新获得mutex锁

函数的内部操作流程是原子性的

  1. 将调用线程放入条件变量的等待队列

  2. 释放指定的互斥锁(允许其他线程获取锁并修改条件)

  3. 线程进入阻塞等待状态

  4. 当线程被唤醒时,重新获取互斥锁(可能阻塞直到获得锁)

  5. 函数返回,线程持有互斥锁继续执行

唤醒等待

pthread_cond_timedwait()

作用:限时等待条件变量,在指定的时间范围内等待条件变量。如果超时前条件满足则返回成功,否则返回超时错误。

表达式:

cpp 复制代码
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
                          pthread_mutex_t *restrict mutex,
                          const struct timespec *restrict abstime);

参数:

cond:指向要等待的条件变量的指针。

mutex:指向关联的互斥锁的指针,调用前必须已锁定。

abstime :指向struct timespec结构的指针,指定等待的绝对超时时间。这是绝对时间,不是相对时间间隔。

struct timespec结构定义:

cpp 复制代码
struct timespec {
    time_t tv_sec;   // 秒(自1970-01-01 00:00:00 UTC以来的秒数)
    long   tv_nsec;  // 纳秒,范围必须是0到999,999,999
};

返回值:

成功:返回0,表示在超时前条件满足,线程被唤醒。

失败:返回非零错误码,常见的错误码包括:

  • ETIMEDOUT:在指定的绝对时间abstime之前,条件未满足。这是预期的超时情况,不是错误

  • EINVAL:参数无效,可能是abstime.tv_nsec不在有效范围[0, 999999999]内,或cond/mutex无效

  • EPERM:调用线程未持有指定的互斥锁

  • EFAULT:参数指向无效的内存地址

使用提示

  1. 超时时间使用绝对时间而非相对时间,简化了多次调用的时间计算

  2. 即使超时返回,函数返回时线程仍持有互斥锁

  3. 超时后应重新检查条件,因为可能在超时时刻条件刚好满足

pthread_cond_signal()

作用:唤醒单个等待线程,向指定条件变量发送信号,唤醒至少一个正在该条件变量上等待的线程。

表达式:

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

参数:

参数 cond:指向要发送信号的条件变量的指针。函数会唤醒在此条件变量上等待的一个线程。

返回值:

成功:返回0,表示信号已成功发送。注意这并不保证有线程被唤醒(如果没有等待线程)。

失败:返回非零错误码,常见的错误码包括:

  • EINVAL:cond参数无效,指向未初始化或已销毁的条件变量

  • EFAULT:cond指向无效的内存地址

pthread_cond_broadcast()

作用:唤醒所有等待线程,向指定条件变量广播信号,唤醒所有正在该条件变量上等待的线程

表达式:

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

参数:

参数 cond:指向要广播信号的条件变量的指针。函数会唤醒所有在此条件变量上等待的线程。

返回值:

成功:返回0,表示广播信号已成功发送。

失败:返回非零错误码,常见的错误码包括:

  • EINVAL:cond参数无效

  • EFAULT:cond指向无效的内存地址

注意:即使没有线程在等待,调用此函数也是安全的,只是没有效果。

pthread_cond_signal的主要区别

  1. 唤醒数量:signal唤醒至少一个线程,broadcast唤醒所有等待线程

  2. 使用场景:signal适用于单消费者模型或任何等待线程都能处理事件的情况;broadcast适用于多消费者模型或所有等待线程都需要知道状态变化的情况

  3. 性能影响:broadcast可能引起"惊群效应",所有等待线程同时被唤醒并竞争锁,可能降低性能

典型应用场景

  1. 资源可用性通知(如多个线程等待同一个资源释放)

  2. 程序状态改变(如关闭信号,所有工作线程需要退出)

  3. 广播事件(如多个线程等待同一个事件发生)

代码示例

初版代码,如果不加锁的话可能导致错误的信息打印

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

pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
void* Print(void*arg)
{
    std::string name =static_cast<const char*>(arg);
    while(1)
    {
        std::cout<<"我是新线程"<<name<<std::endl;
        sleep(2);
    }
    return nullptr;
}
int main()
{
    pthread_t tids[4];
    for(int i=0;i<4;++i)
    {
        char* name =new char[64];
        snprintf(name,64,"thread-%d",i+1);
        pthread_create(tids+i,nullptr,Print,static_cast<void*>(name));
    }
    for(int i=0;i<4;++i)
    {
        pthread_join(tids[i],nullptr);
    }
    return 0;
}

因为没有加锁,数据执行会乱套

加了锁后,就好了很多。

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

pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
void* Print(void*arg)
{
    std::string name =static_cast<const char*>(arg);
    while(1)
    {
        pthread_mutex_lock(&mutex);
        std::cout<<"我是新线程"<<name<<std::endl;
        pthread_mutex_unlock(&mutex);
        sleep(2);
    }
    return nullptr;
}
int main()
{
    pthread_t tids[4];
    for(int i=0;i<4;++i)
    {
        char* name =new char[64];
        snprintf(name,64,"thread-%d",i+1);
        pthread_create(tids+i,nullptr,Print,static_cast<void*>(name));
    }
    for(int i=0;i<4;++i)
    {
        pthread_join(tids[i],nullptr);
    }
    return 0;

条件变量使用规范

  1. 必须与互斥量配合使用

    条件变量本身不持有状态,所有对共享数据的读写都必须受到互斥量保护,避免数据竞争。

  2. 等待前必须锁定互斥量,并使用 std::unique_lock
    std::condition_variable::wait 需要 std::unique_lock 而非 std::lock_guard,因为 wait 内部会暂时解锁互斥量,并在返回前重新锁定。

  3. 始终在循环中检查条件

    无论使用显式 while 循环还是 wait 的谓词版本,都必须重复检查条件,以应对虚假唤醒(spurious wakeup)和通知丢失后的状态变化。

  4. 修改条件后正确通知

    • 必须在持有锁的情况下修改共享数据(即条件相关状态)。

    • 通知(notify_one / notify_all)可以在锁内或锁外进行:
      锁内通知 :等待线程被唤醒后会立即尝试获取锁,但可能因通知线程尚未释放锁而阻塞,导致一定开销;
      锁外通知 :避免唤醒的线程立即阻塞,通常能减少锁竞争,是推荐做法。

      无论哪种方式,都需确保修改数据的操作已完成,且通知不会丢失。

  5. 避免丢失唤醒

    条件变量不记忆通知,因此若通知发生在等待之前,等待线程将永久阻塞。必须保证:

    • 条件状态的改变总是伴随一次通知。

    • 等待线程在检查条件前已锁定互斥量,防止条件改变与等待之间的竞态。

  6. 优先使用 wait 的谓词重载
    cv.wait(lock, predicate) 自动处理了循环检查和虚假唤醒,代码更简洁、健壮。

  7. 尽早释放互斥量

    在数据取出后、处理数据前及时解锁,可减少锁持有时间,提升并发性能。

  8. 考虑使用 notify_all 当多个线程需要同时响应

    若多个等待线程都依赖于同一条件变化,且均需被唤醒,应使用 notify_all。但需注意"惊群效应"可能带来的性能影响。

  9. 避免在持有锁时执行耗时操作

    等待线程在持有锁期间不应执行耗时的处理逻辑,防止阻塞其他线程对共享数据的访问。

  10. 理解条件变量的局限

    条件变量适用于"等待某个条件成立"的场景,若线程间需要更复杂的同步(如读写锁、信号量),应考虑更合适的同步原语。

示例代码如下:

cpp 复制代码
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>

std::queue<int> queue;          // 共享数据
std::mutex mtx;                  // 保护共享数据的互斥量
std::condition_variable cv;      // 条件变量

// 生产者线程
void producer() {
    int data = produce();        // 生产数据
    {
        std::lock_guard<std::mutex> lock(mtx); // 1. 必须加锁修改共享数据
        queue.push(data);                       // 2. 修改条件相关的状态
    } // 3. 锁在通知前释放(可选但推荐)
    cv.notify_one();              // 4. 通知等待线程
}

// 消费者线程
void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx); // 5. 必须使用unique_lock(wait需要)
        // 6. 必须用while循环检查条件,防止虚假唤醒
        while (queue.empty()) {
            cv.wait(lock);         // 7. wait原子性地解锁并等待,被唤醒后重新加锁
        }
        int data = queue.front();
        queue.pop();
        lock.unlock();             // 8. 尽早释放锁,减少持有时间
        process(data);             // 9. 处理数据(无需锁保护)
    }
}

// 更简洁的写法:使用wait的谓词重载
void consumer_modern() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, []{ return !queue.empty(); }); // 自动处理循环和条件检查
        int data = queue.front();
        queue.pop();
        lock.unlock();
        process(data);
    }
}

生产者消费者模型

概念

生产者消费者模型是一种在并发编程分布式系统 中广泛使用的架构模式协同模式 。其核心思想是将数据生产数据处理 两个职责分离,通过一个共享缓冲区进行异步通信,使生产者和消费者可以独立地以不同的速率运行,而无需直接依赖彼此。

为什么需要用到生产者消费者模型?

该模型主要为了解决多线程/分布式环境下生产与消费速度不匹配耦合过紧资源竞争等问题,其核心价值可归纳为以下四大特点:

  1. 解耦

    生产者和消费者不直接通信,仅依赖缓冲区接口。生产者的代码变更不会影响消费者,反之亦然。系统的模块化程度和可维护性显著提升。

  2. 异步与削峰

    当生产者瞬时产生大量数据时,缓冲区可暂存堆积的消息,防止消费者被突发流量压垮;消费者可根据自身能力逐步处理,实现"削峰填谷"。

  3. 并发能力提升

    允许多个生产者、多个消费者同时工作,充分利用多核CPU或分布式节点,大幅提高系统吞吐量。

  4. 流量控制与背压

    缓冲区容量限制可天然形成背压(Backpressure),当生产速度持续高于消费速度时,生产者被阻塞,避免系统资源耗尽。

典型应用场景

生产者消费者模型几乎贯穿整个软件工程领域,常见场景包括:

  • 消息队列与事件驱动架构:如Kafka、RabbitMQ,生产者推送消息到Topic,消费者订阅处理。

  • 线程池与任务调度:主线程生产任务放入队列,工作线程从队列拉取任务执行。

  • 日志处理系统:应用线程(生产者)将日志写入缓冲区,日志服务线程(消费者)批量落盘或发送。

  • I/O缓冲:如键盘输入、网络数据包接收,底层驱动与上层应用通过内核缓冲区解耦。

  • 流水线(Pipeline)处理:在图像处理、ETL等场景中,将复杂任务拆分为多个阶段,阶段间以队列衔接。

  • 分布式爬虫:URL管理器作为缓冲区,多个爬虫线程(生产者)将待爬URL加入队列,多个下载线程(消费者)取出URL抓取。

优缺点

优点 缺点
解耦生产与消费:降低模块间直接依赖,便于独立开发和维护 增加系统复杂度:需处理线程同步、并发竞争、死锁等问题
支持异步处理:生产者无需等待消费者完成,可立即返回 引入额外延迟:数据在缓冲区停留,不适合极低延迟场景
削峰填谷:缓冲区可吸收瞬时流量波动,增强系统稳定性 缓冲区成为瓶颈:单一缓冲区可能限制整体吞吐量
提升并发度:可灵活增加生产者或消费者实例,水平扩展 数据一致性风险:多消费者时需考虑重复消费或消息丢失
流量自调节:缓冲区满自动阻塞生产者,实现背压保护 资源占用:缓冲区本身占用内存/磁盘,需合理规划容量
容错性增强:消费者临时故障,数据可暂存缓冲区不丢失 调试困难:并发环境下的执行顺序难以追踪和复现

基于Blockingqueue的生产者消费者模型

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

代码实现

BlockQueue.hpp

cpp 复制代码
#pragma once 
#include<queue>
#include<unistd.h>
#include<pthread.h>
const size_t defaultcap=5;
template<class T>
class BlockQueue
{
    public:
        BlockQueue(size_t capacity=defaultcap)
            :_capacity(capacity)
        {
            pthread_mutex_init(&_mutex,nullptr);
            pthread_cond_init(&_cuscond,nullptr);
            pthread_cond_init(&_procond,nullptr);
            sleep_productor_num = 0;
            sleep_consumer_num = 0;
        }
        ~BlockQueue()
        {
            pthread_mutex_destroy(&_mutex);
            pthread_cond_destroy(&_cuscond);
            pthread_cond_destroy(&_procond);
        }
        void enqueue(T&indata)//生产者
        {
            pthread_mutex_lock(&_mutex);
            while(IsFull())
            {
                sleep_productor_num++;
                pthread_cond_wait(&_procond,&_mutex);
                sleep_productor_num--;
            }
            _bq.emplace(indata);
            if(sleep_consumer_num > 0)
                pthread_cond_signal(&_cuscond);
            pthread_mutex_unlock(&_mutex);
        }
        void pop(T* outdata)//消费者
        {
            pthread_mutex_lock(&_mutex);
            while(IsEmpty())
            {

                //为什么要在临界区等待??
                //访问临界资源必然在临界区访问,确定资源是否就绪也是其一。
                //为什么这里要传递锁?
                //因为等待的时候是在临界区等待的,需要将锁传递进去
                //让pthread_cond_wait自动解放锁
                //线程唤醒的时候是在临界区唤醒的,将锁传递进去
                //让pthread_cond_wait自动竞争释放锁
                sleep_consumer_num++;
                pthread_cond_wait(&_cuscond,&_mutex);
                sleep_consumer_num--;
            }
            *outdata=_bq.front();
            _bq.pop();
            if(sleep_productor_num > 0)
                pthread_cond_signal(&_procond);
            pthread_mutex_unlock(&_mutex);
        }
    private:
        std::queue<T> _bq;
        size_t _capacity;
        pthread_mutex_t _mutex;
        pthread_cond_t _cuscond;
        pthread_cond_t _procond;


        // size_t _BlockQueueLowerLine;//BlockQueue低水平线
        // size_t _BlockQueueHigherLine;//BlockQueue高水平线
        size_t sleep_productor_num;
        size_t sleep_consumer_num;
        bool IsFull()
        {
            return _block_queue.size() == _cap;
        } 
        bool IsEmpty()
        {
            return _block_queue.empty();
        }
};

main.cpp

cpp 复制代码
#include"BlockQueue.hpp"
#include<iostream>
#include<memory>
void*CusRun(void*arg)
{
    BlockQueue<int> *bq=static_cast<BlockQueue<int>*>(arg);
    while(1)
    {
        sleep(10);
        int data;
        bq->pop(&data);
        std::cout<<"消费:"<<std::endl;
    }
}
void*ProRun(void*arg)
{
    BlockQueue<int> *bq=static_cast<BlockQueue<int>*>(arg);
    while(1)
    {
        int data=5;
        bq->enqueue(data);
        std::cout<<"生产:"<<std::endl;
        sleep(1);
    }
}
int main()
{
    std::unique_ptr<BlockQueue<int>>bq=std::make_unique<BlockQueue<int>>();
    pthread_t c,p;
    pthread_create(&c,nullptr,CusRun,bq.get());
    pthread_create(&p,nullptr,ProRun,bq.get());

    pthread_join(c,nullptr);
    pthread_join(p,nullptr);
    return 0;
}

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

我们接下来尝试一个更高效的结构模式------环形队列。

如上图所示,当队列为空时或者队列满时,head和tail指向的是同一块内存

当我们填充数据的时候,tail会向后移动一格。

判断是否为空或者为慢,可以用计数器或者浪费一个位置来判断。

我们采用数组 来模拟环形队列,用模运算来模拟环状特性。

在多线程里,利用环形队列的时候,我们只需要处理两种情况------队列为空或者队列为满。因为只有这两种情况head指针和tail指针会指向同一块内存,也就会导致谁先访问(同步)和只让谁访问(互斥)的问题。

规则:

  1. 为空:tail(生产),head(消费),我们两个在同一个位置;tail(生产)(消费)

  2. 为满:我们两个在同一个位置;head(消费)(消费者)

  3. 生成者不能把消费者塞一个圈,之后在继续访问。不为空,不为满,生成和消费指向不同的位置,并发生生产和消费

  4. 消费者不能超过生产者

生产者角度:空袋子是资源

cpp 复制代码
sem_t blank_sem = N;
P(blank_sem); // 申请信号量

// 生产数据
ring[tail++]=data;
tail%=N;

V(data_sem); // 释放信号量data_sem

消费者角度:数据是资源

cpp 复制代码
sem_t data_sem = 0;
P(data_sem);

// 消费数据
out=ring[head++];
head%=N;

V(blank_sem);

代码实现

Sem.hpp

cpp 复制代码
#pragma once 
#include<iostream>
#include<semaphore.h>

class Sem
{
    public:
        Sem(int init_value)
        {
            if(init_value>=0)
            {
                sem_init(&_sem,0,init_value);
            }
            else
            {
                std::cerr<<"init_value should be greater than or equal to 0"<<std::endl;
                exit(-1);
            }
        }
        void P()
        {
            int n=sem_wait(&_sem);
            static_cast<void>(n);
        }
        void V()
        {
            int n=sem_post(&_sem);
            static_cast<void>(n);
        }
        ~Sem()
        {
            sem_destroy(&_sem);
        }
    private:
        sem_t _sem;
};

Mutex.hpp

cpp 复制代码
#pragma once

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

class Mutex
{
    public:
        Mutex()
        {
            pthread_mutex_init(&_lock, nullptr);
        }
        void Lock()
        {
            pthread_mutex_lock(&_lock);
        }
        pthread_mutex_t *Ptr()
        {
            return &_lock;
        }
        void Unlock()
        {
            pthread_mutex_unlock(&_lock);
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_lock);
        }
    private:
        pthread_mutex_t _lock;
};

class LockGuard // RAII风格代码
{
    public:
        LockGuard(Mutex &lock):_lockref(lock)
        {
            _lockref.Lock();
        }
        ~LockGuard()
        {
            _lockref.Unlock();
        }
    private:
        Mutex &_lockref;
};

CircularQueue.hpp

cpp 复制代码
#pragma once
#include<iostream>
#include<string>
#include<vector>
#include<pthread.h>
#include"Sem.hpp"
#include"Mutex.hpp"

const int default_capacity=5;
template<class T>
class CircularQueue
{
    private:
        // bool IsEmpty()
        // {
            
        // }   
        // bool IsFull()
        // {
            
        // }
    public:
        CircularQueue(size_t capacity=default_capacity)
            :_capacity(capacity)
             ,_cq(capacity)
             ,_consumer(0)
             ,_producer(0)
             ,_blankSem(capacity)
             ,_dataSem(0)
        {}
        ~CircularQueue()
        {}
        void Enqueue(T&indata)//生产者
        {
            
            //预定资源
            _blankSem.P();
            {
                LockGuard lock(_pmutex);
                //找位置
                _cq[_producer++]=indata;
                _producer%=_capacity;
                _pmutex.Unlock();
            }
            //释放资源
            _dataSem.V();
        }
        void Dequeue(T*outdata)//消费者
        {
            //预定资源
            _dataSem.P();
            {
                LockGuard lock(_cmutex);
                //找位置    
                *outdata=_cq[_consumer++];
                _consumer%=_capacity;
            }
            //释放资源
            _blankSem.V();
        }
        
    private:
        size_t _capacity;//容量
        std::vector<T> _cq;//环形队列
        int _consumer;//消费者索引
        int _producer;//生产者索引
        Sem _blankSem;//格子信号量计数器------>生产者
        Sem _dataSem;//数据信号量计数器------>消费者
        Mutex _cmutex;//消费者互斥锁
        Mutex _pmutex;//生产者互斥锁
};

Task.hpp

cpp 复制代码
#include <iostream>
#include <string>
#include <functional>

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

// void Print()
// {
//     std::cout << "我是一个任务...." << std::endl;
// }


class Task
{
    public:
        Task()
        {
        }
        Task(int x, int y)
            : _x(x), _y(y)
        {
        }
        void Execute()
        {
            _result = _x + _y;
        }
        void operator()()
        {
            Execute();
        }
        std::string getResult()
        {
            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.cpp

cpp 复制代码
#include"CircularQueue.hpp"
#include"Task.hpp"
#include<unistd.h>
#include<memory>
#include<ctime>
#include<cstdlib>
void* Producer(void* arg)
{
    auto td=std::unique_ptr<CircularQueue<Task>>(static_cast<CircularQueue<Task>*>(arg));
    while(true)
    {
        int x = rand() % 20 + 1;
        sleep(1);
        int y = rand() % 20 + 1;
        sleep(1);
        Task t(x, y);
        td->Enqueue(t);          //将任务放入队列
        std::cout << "生产任务:" << t.Question() << std::endl;
        sleep(2);
    }
}
void* Consumer(void* arg)
{
    auto td=std::unique_ptr<CircularQueue<Task>>(static_cast<CircularQueue<Task>*>(arg));
    // int data=0;
    while(true)
    {
        Task t;
        td->Dequeue(&t);
        t();
        std::cout<<"消费任务:"<<t.getResult()<<std::endl;
        sleep(2);
    }
}

int main()
{
    srand((unsigned int)time(nullptr)^(getpid()<<16)); // 使用当前时间和进程ID的组合来生成随机数种子
    auto cq = std::make_unique<CircularQueue<Task>>(20);
    pthread_t p,c;
    pthread_create(&p, nullptr, Producer, cq.get());
    pthread_create(&c, nullptr, Consumer, cq.get());
    pthread_join(p, nullptr);
    pthread_join(c, nullptr);
    return 0;
}

运行结果为:

线程安全与可重入问题

线程安全

概念

如果一个函数、对象或代码片段在多个线程同时执行 时,能够保证数据的正确性和一致性,并且不会出现竞态条件(race condition)、数据损坏等问题,那么它就是线程安全的。线程安全的代码通常通过同步机制(如互斥锁、原子操作、读写锁等)来保护共享数据的访问。

常见线程安全情况

  • 无共享数据:每个线程操作完全独立的变量(如函数内的局部变量),天生线程安全。

  • 只读共享数据 :多个线程同时读取同一个全局常量(例如 const 全局变量),不会产生问题。

  • 使用原子操作 :例如 std::atomic<int> 的读写,保证了操作的原子性。

  • 加锁保护 :通过互斥锁(std::mutex)将临界区保护起来,确保同一时间只有一个线程访问共享数据。

  • 线程局部存储 :使用 thread_local 变量,每个线程有自己的独立副本,如 errno(现代C/C++实现)。

  • 某些标准库组件std::shared_ptr 的引用计数操作是线程安全的(但指向的对象需要额外保护),std::call_once 用于线程安全的单次初始化。

常见线程不安全情况

  • 无保护地修改全局/静态变量 :例如两个线程同时执行 counter++,如果没有原子操作或锁,结果会不确定。

  • 返回内部静态数据的指针/引用:例如:

    cpp 复制代码
    const char* get_error_string(int err) {
        static char buffer[256];
        sprintf(buffer, "Error %d", err);
        return buffer;
    }

    多个线程同时调用会覆盖缓冲区。

  • 同时调用非线程安全的库函数 :例如 strtok()(内部使用静态指针)、rand()(内部有静态状态)。

  • 不加锁地修改容器 :例如多个线程同时向 std::vectorpush_back 而不加锁,会导致内存崩溃。

  • 双重检查锁(错误实现):在多线程环境下,不恰当的双重检查锁可能导致对象被多次构造。

可重入问题

概念

可重入 描述的是一个函数可以被多个执行流(如信号处理程序、中断服务例程、多线程)同时调用,并且每次调用都能产生正确的结果,而不依赖于任何外部状态或静态数据。可重入函数通常只使用局部变量,不访问全局/静态变量,也不调用任何不可重入的函数。

可重入的核心要求是:函数在执行过程中被中断,然后在中断处理程序中再次调用该函数,不会破坏之前未完成调用的状态

常见可重入情况

  • 只使用局部变量的函数

    cpp 复制代码
    int add(int a, int b) {
        return a + b;  // 只使用传入参数和局部栈变量
    }
  • 使用只读全局数据:如果全局数据是常量且从不修改,读取它是安全的(但仍需注意其他线程的写入干扰,但信号中断场景下只读通常没问题)。

  • 原子操作 :例如 std::atomic<int>load/store,但要注意信号处理中调用可能被中断,但原子操作本身通常设计为可重入(取决于平台)。

  • POSIX 异步信号安全函数 :如 memcpy()strcpy()write() 等(在信号处理函数中可以安全调用的函数列表)。

常见不可重入情况

  • 使用静态/全局缓冲区

    cpp 复制代码
    char* to_lower(const char* str) {
        static char buf[1024];
        strcpy(buf, str);
        // ... 转换 buf 中的字符
        return buf;
    }

    如果两次调用交错,缓冲区内容会被覆盖。

  • 调用不可重入函数 :例如在函数中调用 strtok()rand()malloc()/free()

  • 使用锁 :如果在函数中使用了互斥锁(如 std::mutex::lock()),在信号处理程序中调用该函数可能导致死锁(因为可能被中断的线程已经持有锁)。

  • 修改 errno :虽然现代C/C++中 errno 通常是线程局部变量,但在信号处理函数中修改它可能会干扰主程序,所以POSIX规定信号处理函数必须保存和恢复 errno,否则不可重入。

  • 返回指向局部静态对象的指针(同上)。

联系

  • 可重入函数一定是线程安全的

    因为可重入函数不依赖任何共享状态,多个线程同时调用它不会产生冲突。所以,可重入是线程安全的充分条件。

  • 两者都旨在保证并发环境下的正确性

    它们都试图解决"多个控制流同时执行同一段代码"的问题,只是考虑的并发场景不同。

侧重点分析

线程安全的侧重点:并发访问下的数据完整性

  • 它解决的是"多个线程同时读写同一份数据"的问题。

  • 关注的是如何让多个线程有序、正确地访问共享资源

  • 典型例子:用 std::mutex 保护计数器,确保 ++count 是原子的。

可重入的侧重点:异步中断下的执行独立性

  • 它解决的是"函数在执行过程中被中断,然后在中断处理程序中再次调用该函数"的问题。

  • 关注的是函数是否不依赖任何外部可变状态,从而保证两次调用互不干扰。

  • 典型例子:信号处理函数中只能调用异步信号安全的函数(如 write),不能调用 printf(因为它内部有全局缓冲区且可能使用锁)。

维度 线程安全 可重入
关注场景 多线程并发(多个执行流同时运行) 单个执行流被异步中断后再次进入(如信号处理),或者多个执行流交错但强调中断恢复
核心要求 保护共享数据的一致性 函数内部状态完全独立,不依赖外部可变数据
实现手段 使用互斥锁、读写锁、原子操作等同步机制 只使用局部变量,避免全局/静态变量,避免调用不可重入函数,避免使用锁
锁的使用 允许使用锁(但要注意死锁、性能) 不能使用锁(否则可能在信号处理中死锁,因为可能已经持有锁)
副作用 可能因锁竞争导致性能下降 通常无锁,性能高,但适用范围窄
包含关系 线程安全函数不一定可重入(例如加锁的函数在信号处理中调用会死锁) 可重入函数在多线程环境下通常是线程安全的(因为它不共享可变数据),但如果有只读全局变量且被其他线程修改,则可能不安全

死锁

概念

**死锁(Deadlock)**指的是在多线程或多进程环境中,两个或多个执行单元(线程/进程)彼此持有对方所需要的资源,同时又等待对方释放资源,导致所有执行单元都无法继续向前推进的状态。简单来说,就是"互相等待,谁也没法动"。

在C++多线程编程中,资源通常是互斥锁(mutex),但也可能是其他资源(如数据库连接、文件句柄等)。死锁最常见的场景是:线程A持有锁L1,等待锁L2;线程B持有锁L2,等待锁L1。结果两个线程都卡住了。

死锁产生的四个必要条件

根据操作系统理论,死锁发生必须同时满足以下四个条件缺一不可):

  1. 互斥条件:资源在同一时刻只能被一个线程使用(比如互斥锁)。

  2. 持有并等待:线程已经持有了至少一个资源,同时又等待获取其他线程持有的资源。

  3. 非剥夺条件:线程持有的资源不能被强制剥夺,只能由持有者主动释放。

  4. 循环等待:存在一组线程{T1, T2, ..., Tn},T1等待T2持有的资源,T2等待T3持有的资源,...,Tn等待T1持有的资源,形成一个循环。

示例代码

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex mtx1, mtx2;

void thread_func1() 
{
    std::cout << "Thread1 trying to lock mtx1..." << std::endl;
    std::lock_guard<std::mutex> lock1(mtx1);
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟操作,增加死锁概率
    std::cout << "Thread1 locked mtx1, trying to lock mtx2..." << std::endl;
    std::lock_guard<std::mutex> lock2(mtx2); // 此时如果Thread2已经锁了mtx2,就会死锁
    std::cout << "Thread1 locked both mutexes" << std::endl;
}

void thread_func2() 
{
    std::cout << "Thread2 trying to lock mtx2..." << std::endl;
    std::lock_guard<std::mutex> lock2(mtx2);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::cout << "Thread2 locked mtx2, trying to lock mtx1..." << std::endl;
    std::lock_guard<std::mutex> lock1(mtx1);
    std::cout << "Thread2 locked both mutexes" << std::endl;
}

int main() 
{
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);
    t1.join();
    t2.join();
    return 0;
}

如何避免死锁?

避免死锁的核心思路就是破坏上述四个必要条件中的任意一个。以下是几种常用的方法:

1. 破坏"持有并等待"条件

可以要求线程一次性获取所有需要的锁,如果无法全部获取,就释放已持有的锁,稍后重试。C++标准库提供了std::lock函数,它可以同时锁定多个互斥量,且避免死锁(内部采用某种避免死锁的算法,比如按固定顺序尝试)。改造上面的例子:

cpp 复制代码
void thread_func1() 
{
    std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock); // 先不锁
    std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
    std::lock(lock1, lock2); // 同时锁定两个,不会死锁
    // 使用资源...
}

void thread_func2() 
{
    std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
    std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
    std::lock(lock2, lock1); // 顺序不重要,std::lock会处理好
    // 使用资源...
}

2. 破坏"循环等待"条件

最常用的方法是对锁进行排序,规定所有线程必须按照相同的顺序获取锁。比如规定必须先锁mtx1,再锁mtx2。只要所有线程都遵守这个约定,就不会出现循环等待。

cpp 复制代码
void thread_func1() 
{
    std::lock_guard<std::mutex> lock1(mtx1);
    std::lock_guard<std::mutex> lock2(mtx2); // 顺序正确
}

void thread_func2() 
{
    std::lock_guard<std::mutex> lock1(mtx1); // 线程2也先锁mtx1
    std::lock_guard<std::mutex> lock2(mtx2);
}

如果线程2必须先锁mtx2,就需要重新设计,或者使用std::lock

3. 使用锁的层次/等级

给锁分配等级(例如整数),规定线程只能先获取等级低的锁,再获取等级高的锁。获取锁时检查等级,违反规则则抛出异常或断言。这能有效避免循环等待。

4. 使用"尝试-回退"策略

使用try_lock尝试获取锁,如果失败就释放已持有的锁,等待随机时间后重试。但这种方法可能导致活锁(livelock),而且效率较低,一般不建议作为主要方案。

5. 使用RAII封装,避免显式lock/unlock

C++的std::lock_guardstd::unique_lock配合std::lock能帮助避免因忘记解锁造成的死锁(但无法避免循环等待)。RAII确保异常安全。

6. 使用无锁编程

在某些场景下,可以使用原子操作、无锁数据结构(如std::atomic)来避免锁的使用,自然也就消除了死锁的可能性。但无锁编程难度较高,需谨慎。

注意:

  • 避免嵌套锁:尽量减少在一个锁的临界区内再去获取另一个锁。如果必须嵌套,确保全局加锁顺序一致。

  • 避免在持有锁时调用外部代码:因为你不知道外部代码会不会也去获取其他锁,这可能导致难以预料的死锁。

  • 使用死锁检测工具:如Thread Sanitizer、Valgrind的Helgrind等,可以在测试阶段检测潜在死锁。

  • 设计时考虑锁的粒度:锁的粒度越细,死锁风险越小(但并发度可能提高)。需要在性能和安全性之间权衡。

STL、智能指针与线程安全

STL与线程安全

1. 标准中的定义

C++标准库对容器的线程安全性有明确的规定(参考C++11及以后的标准):

  • 多个线程同时读取同一个容器是安全的

    即多个线程可以同时调用容器的const成员函数(如begin(), end(), size(), find()等)而不需要外部同步。

  • 如果有任何线程正在写入(修改)同一个容器,则必须确保其他线程没有同时对该容器进行任何读写操作(包括const操作)

    写入操作包括:插入、删除、修改元素值、clear(), reserve()等任何可能修改容器内部状态的操作。此时,如果其他线程同时访问(读或写),就会产生数据竞争,导致未定义行为。

  • 不同线程操作不同的容器实例总是安全的

    因为容器对象是独立的,互不影响。

简单来说:标准库容器不是线程安全的,需要用户通过外部同步(如互斥锁)来保证并发访问的正确性。

2. 安全与不安全的场景

安全的场景:
  • 多个线程只读取同一个容器的内容,没有写操作。

  • 多个线程各自操作完全独立的容器对象。

  • 一个线程写入容器,但其他线程没有访问该容器(例如通过锁保证互斥)。

不安全的场景:
  • 一个线程写入容器(例如push_back),另一个线程同时读取容器(例如遍历begin()end()),即使读取操作是const,也可能导致迭代器失效或读到不一致的状态。

  • 两个线程同时写入同一个容器(例如同时insert),会导致内部数据结构损坏。

  • 一个线程在修改容器时,另一个线程也在修改同一容器。

3. 注意事项与最佳实践

  • 使用互斥锁保护共享容器 :例如用std::mutex配合std::lock_guard将对容器的访问序列化。

  • 尽量减少锁的持有时间 :如果需要在锁内执行耗时操作(如遍历大型容器),考虑是否可以先拷贝出所需数据(但拷贝本身也需要锁保护),或者使用读写锁(std::shared_mutex)来允许多个读者同时访问,但写者独占。

  • 注意迭代器失效:即使加锁保护,也要注意容器修改可能导致已有迭代器失效,因此在持有锁的期间使用迭代器,并在锁外不再使用可能失效的迭代器。

  • 考虑使用并发容器:如果性能要求高,可以寻找第三方库(如Intel TBB、Boost.Lockfree)提供的线程安全容器,它们内部实现了无锁或细粒度锁,但使用起来也有各自注意事项。

智能指针与线程安全

智能指针(std::shared_ptr, std::unique_ptr, std::weak_ptr)的线程安全性比容器复杂,因为涉及引用计数的原子操作指向对象的访问两个层面。

1. std::shared_ptr 的线程安全性

引用计数是线程安全的
  • shared_ptr 的控制块(包含引用计数)使用原子操作,因此多个线程同时拷贝、赋值、销毁同一个 shared_ptr 对象是安全的

    例如:线程A拷贝sp,线程B也拷贝sp,引用计数会正确增减,不会出现数据竞争。

  • 这也意味着,当最后一个shared_ptr被销毁时,指向的对象会被正确删除一次。

对 shared_ptr 实例本身的读写不是原子的
  • 虽然引用计数安全,但shared_ptr对象本身(即存放指针和控制块的成员)并不是原子变量。

    因此,如果一个线程正在修改 shared_ptr 实例(例如调用 reset() 或赋值新值),另一个线程同时读取该实例(例如调用 get()、解引用),就会产生数据竞争,导致未定义行为(可能读到中间状态,如指针已变但控制块未更新)。

  • 例如:

    cpp 复制代码
    std::shared_ptr<int> sp = std::make_shared<int>(42);
    // 线程1:
    sp.reset(new int(100));
    // 线程2:
    if (sp) {   // 读取 sp 的指针
        *sp = 200;
    }

    这段代码存在数据竞争,因为线程1修改sp的同时线程2在读。

对指向对象的访问需要额外同步
  • shared_ptr 只保证引用计数的安全,不保证指向对象本身的线程安全

    如果多个线程通过shared_ptr访问同一个对象(例如读写对象的成员),并且至少有一个是写操作,则需要用户自行加锁或使用原子操作。

特殊情况:const shared_ptr 对象
  • 如果shared_ptr本身是const的,那么它的指针和控制块都不能被修改,所以多个线程同时读取它是安全的(引用计数不会变)。但指向对象仍然需要保护。

2. std::unique_ptr 的线程安全性

  • unique_ptr 不提供任何线程安全保证。它的语义是独占所有权,因此多个线程同时访问同一个 unique_ptr 对象(无论是读还是写)都会导致数据竞争

  • 通常,unique_ptr 应该只由一个线程拥有,或者在转移所有权时通过同步机制(如锁、原子操作)来保证安全。C++20 的 std::atomic<std::unique_ptr> 尚不完善,一般不推荐跨线程直接传递。

3. std::weak_ptr 的线程安全性

  • weak_ptr 也涉及控制块的引用计数(弱引用计数),所以多个线程同时拷贝、赋值、销毁同一个 weak_ptr 是安全的(引用计数原子操作)。

  • 调用 lock() 会返回一个 shared_ptr,这个过程是原子的(内部使用控制块的原子操作来尝试提升),所以是安全的。

  • 但是,weak_ptr 实例本身的修改(如 reset)与其他线程的读取(如 expired())同时发生,也会有数据竞争 ,与 shared_ptr 类似。

4. C++20 的 std::atomic<std::shared_ptr>

  • C++20 引入了对 shared_ptr 的原子操作特化,允许将 shared_ptr 作为原子类型使用。例如 std::atomic<std::shared_ptr<int>> asp;。这样可以通过 load()store()exchange() 等原子操作安全地在多个线程间读写同一个 shared_ptr 实例,无需外部锁。

  • 但这仍然不能保护指向对象的并发访问。

5. 安全与不安全的场景总结

智能指针操作 是否线程安全
多个线程拷贝、赋值、销毁同一个 shared_ptr ✅ 安全(引用计数原子操作)
一个线程修改 shared_ptr(reset/赋值),另一线程读 ❌ 不安全(对shared_ptr实例本身的数据竞争)
多个线程通过 shared_ptr 读同一个对象 ✅ 安全(如果只是读)
多个线程通过 shared_ptr 读写同一个对象 ❌ 不安全(对象本身需要同步)
多个线程操作不同的 shared_ptr(指向不同对象) ✅ 安全
多个线程操作同一个 unique_ptr ❌ 完全不安全
多个线程拷贝、赋值、销毁同一个 weak_ptr ✅ 安全(引用计数原子操作)
多个线程通过 weak_ptr::lock() 获取 shared_ptr ✅ 安全(内部原子操作)
使用 std::atomic<std::shared_ptr>> 操作同一实例 ✅ 安全(原子操作保证实例本身的一致性)

6. 注意事项与最佳实践

  • 区分"智能指针本身"和"所指向的对象":前者可能涉及数据竞争(实例修改),后者需要额外同步。

  • 避免对同一个 shared_ptr 实例进行并发修改 :如果多个线程需要更新同一个 shared_ptr(例如交换指向的对象),请使用互斥锁或 std::atomic<std::shared_ptr>>(C++20)。

  • 使用 std::atomic_loadstd::atomic_store(C++11 临时方案) :在C++20之前,标准库提供了非成员函数 std::atomic_load(&sp) 等来对 shared_ptr 进行原子操作,但需要确保 shared_ptrconst std::shared_ptr 类型(实际上是要求使用专门的自由函数)。这种方法较繁琐且容易出错,建议直接加锁或升级到C++20。

  • 保护共享对象的访问 :如果多个线程通过 shared_ptr 读写同一个对象,使用互斥锁或将对象设计为线程安全(例如内部包含锁)。

  • 对于只读共享对象,使用 std::shared_ptr<const T>:可以避免误修改,并且允许多线程同时读取(但对象本身仍需保证const操作是线程安全的,如不涉及可变成员)。

其他常见的锁介绍

按行为/语义分类

1. 读写锁(Shared-Exclusive Lock / Readers-Writer Lock)

  • 概念:读写锁区分"读"和"写"操作。它允许多个线程同时持有读锁(共享),但写锁是独占的,即同一时刻只能有一个线程持有写锁,且写锁与读锁互斥。

  • 特点

    • 适用于读多写少的场景,能显著提高并发读的吞吐量。

    • 写操作可能被源源不断的读操作阻塞,导致写线程饥饿。一些实现支持写优先或读优先策略。

    • 需要注意锁升级 (读锁尝试升级为写锁)和锁降级(写锁降级为读锁)可能引发的死锁问题。

  • C++实现 :C++17 提供了 std::shared_mutex,C++14 提供了 std::shared_timed_mutex(带超时)。读锁用 std::shared_lock 管理,写锁用 std::unique_lockstd::lock_guard 管理。

  • 适用场景:配置表、路由表、缓存、DNS解析结果等读操作远多于写操作的数据结构。

2. 递归锁(Recursive Lock / Reentrant Mutex)

  • 概念 :允许同一个线程 多次获取同一个锁,而不会造成死锁。内部会记录持有线程的ID和加锁次数,每次 lock 增加计数,unlock 减少计数,计数归零时才真正释放锁给其他线程。

  • 特点

    • 解决了在递归函数或一个函数调用另一个需要同一锁的函数时,因重复加锁而阻塞的问题。

    • 但递归锁通常意味着设计上有一定混乱(锁的职责不清晰),应优先考虑重构代码而非依赖递归锁。

    • 性能略低于普通互斥锁,因为需要维护计数和线程身份检查。

  • C++实现std::recursive_mutexstd::recursive_timed_mutex

  • 适用场景:不得已需要在一个线程内多次获取同一锁时使用,例如递归遍历树形结构且每个节点需要锁保护。

3. 自旋锁(Spinlock)

  • 概念 :当锁被占用时,线程不是进入休眠状态,而是忙等待(循环检查锁状态),直到锁可用。

  • 特点

    • 避免了线程上下文切换的开销,适合锁持有时间极短的场景(如内核中保护临界区仅数条指令)。

    • 如果锁持有时间较长,自旋会浪费大量CPU时间,甚至导致性能下降。

    • 通常与禁用抢占或中断结合使用(在内核态),用户态使用时需谨慎,避免高竞争下CPU空转。

  • C++实现 :C++标准库没有直接提供自旋锁,但可以利用 std::atomic_flag 轻松实现:

    cpp 复制代码
    class spinlock {
        std::atomic_flag flag = ATOMIC_FLAG_INIT;
    public:
        void lock() { while (flag.test_and_set(std::memory_order_acquire)); }
        void unlock() { flag.clear(std::memory_order_release); }
    };

    也可以用 std::atomic<bool> 配合 exchange 实现更灵活的自旋锁(如加入退避策略)。

  • 适用场景:实时系统、底层驱动、以及对延迟极其敏感且锁竞争概率低的场景。

按并发控制策略分类

1. 悲观锁(Pessimistic Lock)

  • 概念:假设并发冲突频繁,因此每次访问共享资源前都先加锁(阻塞其他线程),确保数据的完整性。

  • 特点

    • 实现简单,能有效避免数据竞争。

    • 但锁的开销大,并发度受限,容易成为性能瓶颈。

    • 典型的实现就是各种互斥锁、读写锁。

  • C++体现std::mutexstd::shared_mutex 等都属于悲观锁。

2. 乐观锁(Optimistic Lock)

  • 概念:假设并发冲突很少,操作数据时不加锁,而是在更新时检查数据是否被其他线程修改过(通常通过版本号或CAS操作)。如果检测到冲突,则放弃本次操作并重试。

  • 特点

    • 无锁开销,在读多写极少且冲突概率低的场景下性能极高。

    • 需要处理ABA问题(可通过带有标记的指针或双CAS解决)。

    • 重试机制可能在高冲突时导致活锁。

  • C++实现 :主要依赖 std::atomic 的CAS操作(compare_exchange_weak/strong)。无锁数据结构(如无锁栈、无锁队列)大多基于乐观锁思想实现。

按调度策略分类

1. 公平锁(Fair Lock)

  • 概念:线程获取锁的顺序与请求锁的顺序一致(FIFO),通过维护等待队列实现。

  • 特点

    • 避免线程饥饿,每个线程最终都能获得锁。

    • 通常需要额外的队列维护开销,性能略低于非公平锁。

  • C++实现 :标准库的 mutex 未规定公平性,不同平台实现不同。如果需要保证公平性,可以基于条件变量和队列自行构建:

    cpp 复制代码
    class fair_mutex {
        std::mutex mtx;
        std::condition_variable cv;
        size_t current_ticket = 0;
        size_t next_ticket = 0;
    public:
        void lock() {
            std::unique_lock<std::mutex> lock(mtx);
            size_t my_ticket = next_ticket++;
            while (my_ticket != current_ticket)
                cv.wait(lock);
        }
        void unlock() {
            std::lock_guard<std::mutex> lock(mtx);
            ++current_ticket;
            cv.notify_all();
        }
    };
  • 适用场景:必须防止线程饥饿的系统,如实时调度、资源分配严格公平的环境。

2. 非公平锁

  • 概念:线程获取锁时不保证顺序,可能允许新请求的线程"插队",直接尝试获取锁。

  • 特点

    • 性能通常更高,因为减少了唤醒等待线程的开销,且能利用时间片局部性。

    • 可能导致某些线程长时间得不到锁(饥饿),但概率较低。

  • C++实现 :多数平台默认的 mutex 是非公平的(如Linux的pthread_mutex默认采用非公平策略,基于futex的实现倾向于让正在运行的线程优先获得锁)。

按锁的粒度分类

  • 粗粒度锁:用一个锁保护整个共享数据结构(如整个容器)。实现简单,但并发度低。

  • 细粒度锁:将数据结构分成多个独立部分,每个部分用独立的锁保护(如哈希表的分段锁、链表每个节点一把锁)。并发度高,但容易引发死锁,且锁开销增加。

  • C++实践 :设计并发容器时需要在复杂度和并发度之间权衡。例如,JDK的ConcurrentHashMap采用分段锁,而C++无锁数据结构则常用原子操作实现细粒度并发,避免显式锁。

CAS(Compare-And-Swap)

  • 不是锁 ,而是硬件支持的原子指令,是实现无锁编程和乐观锁的基础。C++通过 std::atomic::compare_exchange_weak/strong 提供。

  • 典型应用:实现无锁栈、无锁队列、引用计数等。

如何选择合适的锁?

场景 推荐锁类型 理由
通用临界区,读写比例不明 互斥锁(std::mutex 简单可靠,性能尚可
读多写少,并发读是主要操作 读写锁(std::shared_mutex 允许多个读者并发,提高读吞吐量
同一个线程需要重复获取锁 递归锁(std::recursive_mutex 避免递归调用死锁
锁持有时间极短,且不允许线程切换 自旋锁(自定义) 避免上下文切换开销
冲突概率很低,追求极高并发 乐观锁(CAS)或无锁数据结构 无阻塞,性能优异
必须保证线程获取锁的顺序公平 公平锁(自定义) 防止饥饿
需要保护有限的资源个数(如连接池) 计数信号量(std::counting_semaphore 允许多个线程同时访问资源,但不超过上限

线程相关的知识到这里就告一段落了,后续我们还会进一步学习其他的多线程内容。喜欢的话请点个赞谢谢

封面图自取:

相关推荐
不吃橘子的橘猫1 小时前
《集成电路设计》复习资料4(Verilog HDL概述)
学习·算法·fpga开发·集成电路·仿真·半导体
宇木灵1 小时前
C语言基础-五、数组
c语言·开发语言·学习·算法
小草儿7992 小时前
PG18备份恢复
linux·运维·服务器
Mikowoo0072 小时前
KaLi系统基本使用
运维·服务器
liulun2 小时前
C++ WinRT中的异步
c++·windows
笑口常开xpr2 小时前
Linux 命 令 界 的 “王 炸 组 合”!Gitee 提 交 + 权 限 控 制 + 热 键 神 操,学 会 直 接 霸 屏 终 端!
linux·gitee·权限
王老师青少年编程3 小时前
2020年信奥赛C++提高组csp-s初赛真题及答案解析(阅读程序第1题)
c++·题解·真题·初赛·信奥赛·csp-s·提高组
Starry_hello world3 小时前
Linux http代码
linux·运维·http
会周易的程序员3 小时前
cNetgate插件架构设计详解 动态库 脚本二开lua, python, javascript
javascript·c++·python·物联网·lua·iot