【C++11多线程与并发编程】(2)使用<mutex>互斥量与<condition_variable>条件变量实现线程安全

C++11 多线程与并发编程(2)


C++11多线程与并发编程

线程安全

当多个线程共享数据时,需要注意线程安全问题(数据竞争,死锁等)。

如:假设多个线程同时访问一个全局变量,且其中至少一个线程对该变量进行了修改,就会出现数据竞争问题。数据竞争可能会导致程序崩溃、产生未定义的结果,或者得到错误的结果。

image-20240410202911418

一种数据竞争情况

为了保证线程安全,我们可以采用以下一些方式。

利用<mutex>库使用互斥量(C++11)

互斥量(mutex):是一种用于实现多线程同步的机制,用于确保多个线程之间对共享资源的访问互斥。提供两个基本操作:

  • lock() :线程调用lock时,若互斥量当前没有被其他线程占用,则可获得该互斥量的所有权;否则会阻塞该线程,直到其他线程释放此互斥量。
  • unlock():释放该互斥量

C++ 11提供<mutex>库,提供互斥量及其基本操作可以在多线程中保护共享数据。

mutex类

1、创建互斥量:std::mutex mtxName;

2、mutex互斥量的锁定与释放

成员函数名作用
lock()锁定mutex互斥量,若mutex互斥量被占用,则阻塞
unlock()解锁mutex互斥量
try_lock()尝试锁定互斥量。成功获得锁返回true,否则则返回false

代码示例:

#include<thread>
#include<mutex>
#include<iostream>
#include<chrono>

// 定义一个时间变量
std::chrono::milliseconds interval(100);

std::mutex mtx;
int job_shared = 0;     // 该变量将会被多个线程共享
int job_exclusive = 0;  // 只有一个线程会修改此变量

void job_1(){
    std::this_thread::sleep_for(interval);  //休眠,使job_2一开始持锁

    while(true){
        // 不断尝试锁定mtx,获取job_shared的当前值,成功则返回
        if(mtx.try_lock()){
            std::cout<< "share mission: " << job_shared <<std::endl;
            mtx.unlock();
            return; 
        }else{
            // 未获得锁时,线程还可以进行别的任务
            ++job_exclusive;
            std::cout<< "running other mission: "<< job_exclusive <<std::endl;
            std::this_thread::sleep_for(interval);  // 休眠
        }
    }
}

void job_2(){
    // 上锁
    mtx.lock();
    std::this_thread::sleep_for(5*interval);
    ++job_shared;
    mtx.unlock();   // 释放
}

int main(){
    std::thread thread_1(job_1);
    std::thread thread_2(job_2);

    thread_1.join();
    thread_2.join();
    
    return 0;
}

image-20240412150945279

运行结果
mutex的使用中避免死锁

假设有两个互斥量mtx1和mtx2,还有两个线程t1和t2,两个线程都需要获取mtx1和mtx2且上下锁的顺序刚好相反。

由于通常线程创建后就会开始执行,可能会出现一种意外,t1获取了其中一个互斥量,t2获取另一个互斥量,双方都需要再获得对方的互斥量才能运行下去,导致互相阻塞,产生死锁。

这种情况,我们需要让多个线程保持一致的取锁顺序,保证每次至少能有一个线程执行下去

image-20240412154900222

关于互斥量的分配,有一些很经典的题目,可以自行查阅:

  • 生产者—消费者问题
  • 读者—写手问题
  • 哲学家吃饭问题
  • 吸烟者问题

针对死锁可以学习以下内容:

  • 死锁避免——银行家算法
  • 死锁检测——资源分配图
  • 死锁接触:① 资源剥夺法 ②撤销进程法 ③ 进程回退法

由于笔者操作系统目前掌握不好,所以不在此班门弄斧,只能告诉大家可以去衍生学习这些内容

lock_guard类

std::lock_guard是C++标准库中的一种互斥量封装类,用于管理互斥量的锁。

template< class Mutex >
class lock_guard;		// 模板类,接受一个互斥量作为模板参数

// 初始化创建一个mutex的lock_guard对象
std::lock_guard<std::mutex> lg(mtx);	
// 只要是互斥量对象都可以,如下
std::lock_guard<std::timed_mutex> tlg(tmtx);
  • lock_guard类不可以复制(其拷贝构造已被删除)
  • 将一个互斥量对象传参给lock_guard后,lock_guard将会自动给其加锁
  • 然后在lock_guard所属代码块运行结束,自动释放锁(调用其析构函数)
  • i.e. 特性是自动加解锁、不可复制
#include<iostream>
#include<thread>
#include<mutex>

std::mutex mtx;  // 创建互斥量

void sharedFunction(int id) {
    // 创建一个 lock_guard 对象,对 mtx 进行加锁
    std::lock_guard<std::mutex> lg(mtx);
    // 此时mtx已经被锁定

    // 模拟访问共享资源
    std::cout<<"Thread "<<id<<" is accessing shared resource."<< std::endl;

    // 离开此作用域,lg会自动对mtx进行释放  ==>  mtx.unlock();
}

int main(){
    const int count = 5;

    // 创建一个大小为5的线程池
    std::thread threads[count];

    // 给线程池中的每个线程初始化,并启动
    for(int i = 0; i < count; i++){
        threads[i] = std::thread(sharedFunction, i);
    }

    // 等待所有线程执行完毕
    for(int i = 0; i < count; i++){
        threads[i].join();
    }

    return 0;
}

image-20240412175453371

有互斥量的存在,线程一定是按顺序执行完的
unique_lock类

std::unique_lock也是一种互斥量封装类, 它在lock_guard的基础上提供更多功能,可以更灵活的管理互斥量,如:延迟加锁、条件变量、超时等。

构造函数效果
unique_lock()默认构造函数,一个未关联互斥量的类对象
unique_lock(mtx)和lock_guard()效果相同,根据互斥量初始化并加锁,作用域结束自动解锁。注意:该函数为explicit(即不支持类型隐式自动转换)
unique_lock(mtx,可选参数t)① t为defer_lock: 利用mtx初始化,但不加锁
② t为try_to_lock: 初始化并尝试加锁,若加锁失败则不关联任何互斥量
③t为adopt_lock: 绑定一个mtx,并假定其已经被其他方式上锁(也就是可以接管已经被其他方式锁定的互斥量);对象被销毁时,也不会再次对互斥量进行解锁操作
unique_lock(mtx, 可选时间)①可选 若为chrono::duration:通过调用 m.try_lock_for(timeout_duration) 尝试锁定关联互斥体。阻塞到经过指定的 timeout_duration 或获得锁这两个事件的先达成者为止。
②可选 若为chrono::point: 通过调用 m.try_lock_until(timeout_time) 尝试锁定关联互斥体。阻塞到抵达指定的 timeout_time 或获得锁这两个事件的先达成者为止。
  • 根据需求选用合适的构造函数
常用成员函数用法
lock()锁定互斥量,若互斥量被占用,则阻塞,直到成功加锁
unlock()释放互斥量
try_lock()尝试加锁,成功则返回true,否则返回false
try_lock_for(rel_time)尝试加锁,若不成功,阻塞当前线程,直到成功加锁,或超过了rel_time参数(chrono::duration)指定的时间
try_lock_until(abs_time)尝试加锁,若不成功,阻塞当前线程,直到成功加锁,或超过了abs_time参数(chrono::point)指定的时间点

练习:实现一个线程安全的计数器类(Counter),其中包含两个方法:increment()decrement(),分别用于增加和减少计数器的值。要求在多线程环境下使用该类时,能够确保对计数器的操作是线程安全的。

#include <thread>
#include <mutex>
#include <iostream>
#include <chrono>

class Counter
{
public:
    Counter() : count(0) {}

    // 增加计数器的值
    void increment()
    {
        // 先暂缓加锁,使用时间互斥量,因为后面需要判断是否超时
        std::unique_lock<std::timed_mutex> ulg(tmtx, std::defer_lock);
        // 最多尝试五次
        int frequency = 5;
        while (frequency--)
        {
            if (ulg.try_lock())
            {
                // 增加次数1
                count += 1;
                return;
            }
            // 未获锁,休眠0.5秒尝试
            std::this_thread::sleep_for(std::chrono::milliseconds(500));
        }
        std::cout << "[Warning] Increment is failed" << std::endl;
    }

    // 减少计数器的值
    void decrement()
    {
        std::unique_lock<std::timed_mutex> ulg(tmtx, std::defer_lock);
        int frequency = 3;
        // 最多尝试三次
        while (frequency--)
        {
            // 未获得锁,阻塞0.5秒
            if (ulg.try_lock_for(std::chrono::milliseconds(500)))
            {
                count -= 1;
                return;
            }
        }
        std::cout << "[Warning] Decrement is failed" << std::endl;
    }

    int get_value()
    {
        return count;
    }

private:
    int count;
    std::timed_mutex tmtx; // 带有超时功能的互斥量
};

void increment_counter(Counter &counter, int n)
{
    for (int i = 0; i < n; i++)
    {
        counter.increment();
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟一些工作时间
    }
}

void decrement_counter(Counter &counter, int n)
{
    for (int i = 0; i < n; ++i)
    {
        counter.decrement();
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟一些工作时间
    }
}

int main()
{
    Counter counter;
    // 启动一个线程增加计数器五次
    std::thread t1(increment_counter, std::ref(counter), 5);
    // 启动一个线程减少计数器值3次
    std::thread t2(decrement_counter, std::ref(counter), 3);

    t1.join();
    t2.join();

    std::cout << "Final counter value: " << counter.get_value() << std::endl;

    return 0;
}
特殊互斥量类
  1. std::recursive_mutex:递归互斥量。允许同一个线程多次锁定同一个互斥量,而不会发生死锁。
  2. std::timed_mutex:带有超时功能的互斥量。带有超时功能的互斥量允许线程尝试在一定时间内获取互斥量的锁,如果在指定时间内无法获取到锁,则可以进行其他处理或者放弃获取锁。(具有try_lock_for()等成员函数)
  3. std::shared_mutex(头文件<shared_mutex>,【since C++17】):共享互斥量。共享互斥量允许多个线程同时获取读取权限,但在写入时需要互斥。(配合shared_lock()使用,则允许多个线程获取互斥量)
函数:call_once()

作用:确保函数或代码片段在多线程环境下,只需要执行一次。

语法:void call_once(flag, func, args);

  • flag是std::once_flag,使call_once中的func只调用一次。注意:如果在多线程中使用了call_once,想要只调用一次func,那么这多个线程的flag一定要是同一个flag。下面代码说明。
  • func是可调用对象,通常为函数
  • args是func中的参数,没有则不写

运用场景:call_once()多用于多线程需要只初始化一次资源的情景。比如在多线程操作时编写单例模式。以下是一个代码说明案例:

#include <iostream>
#include <thread>
#include <mutex>

// (1)使用call_once实现单例模式
class Singleton
{
private:
    Singleton() {} // 构造函数私有化,禁止外部实例化

    static Singleton *instance; // 单例实例指针
    static std::once_flag flag; // 用于标记初始化是否完成的标志
    static int instance_count;  // 记录实例数量
    
    // 通常根据需求应该是作为静态成员变量
    // 但此处,我们只会实例化一次
    // 为了更直观看到是否能成功加解锁,所以定义为普通成员变量
    std::mutex mtx;             

public:
    // 获取单例实例的静态成员函数
    static Singleton *getInstance()
    {
        // 创建一个instance实例
        std::call_once(flag, []()
                       { 
                instance = new Singleton(); 
                instance_count++; });
        return instance;
    }

    // 返回实例总数
    static int get_instance_count()
    {
        return instance_count;
    }

    // 示意性的单例方法
    void doSomething()
    {
        std::lock_guard<std::mutex> lock(mtx);
        std::cout << "Singleton doing something. Instance count: "<< instance_count << std::endl;
    }
};

// 静态成员初始化,静态成员被所有类对象(实例)共享
Singleton *Singleton::instance = nullptr;
std::once_flag Singleton::flag;
int Singleton::instance_count = 0;

// 测试函数,创建多个线程验证单例模式
void test_singleton()
{
    Singleton *s = Singleton::getInstance();
    s->doSomething();
}

// (2)call_once错误用法的测试函数
void init()
{
    // 若下面的init_flag不同,这行代码会被调用多次。
    std::cout << "data has inited!" << std::endl;
}
void failureCase()
{
    // 注意,我们给call_once 传参flag时,不可以是一个局部变量
    // 否则如果每次flag不同,call_once会根据不同flag调用函数
    // 下面是错误用法
    std::once_flag init_flag;
    std::call_once(init_flag, init);
}

int main()
{
    Singleton *s = Singleton::getInstance(); // 获取单例实例

    // 创建多个线程来测试单例模式
    std::thread t1(test_singleton);
    std::thread t2(test_singleton);
    std::thread t3(test_singleton);

    // 等待线程结束
    t1.join();
    t2.join();
    t3.join();

    // 錯誤用法示例
    std::cout << "[Warning] If two lines are printed below, it is wrong" << std::endl;
    std::thread ft1(failureCase);
    std::thread ft2(failureCase);

    ft1.join();
    ft2.join();

    return 0;

image-20240413215538325

不上锁的运行结果,输出是混乱的

image-20240413215031854

上锁的运行结果

<condition_variable>条件变量

头文件:<condition_variable>

std::condition_variable是一个同步原语,用于多线程间的通信和同步。与std::mutex一起使用,可以实现线程间的等待和通知机制。

使用的步骤如下:

  • 创建一个 std::condition_variable 对象。
  • 创建一个互斥量 std::mutex 对象,用于保护共享资源的访问。
  • 在需要等待条件变量的地方,使用 std::unique_lock 或其他互斥封装类锁定互斥量,然后调用 std::condition_variable 的等待原语,将线程阻塞,等待条件变量。
  • 当其他线程利用 std::condition_variable 进行通知时,被阻塞的线程会被唤醒,继续运行。

特性:

  • 不可复制(operator= (=delete))

【用于阻塞的成员函数】

1、void wait( std::unique_lock<std::mutex>& lock , Predicate pred)

  • 会使当前线程任务阻塞,并释放lock所代表的互斥量;直到收到通知,再次锁定互斥量,继续当前线程任务
  • pred(参数可选):是一个谓词,是一个可调用的对象,用于判断是否满足等待条件。如果pred返回false会阻塞当前线程,等该线程收到通知,被唤醒后,依然会调用pred检查,需要pred返回true,该线程才会继续执行。

2、void wait_for( std::unique_lock<std::mutex>& lock ,chrono::duration timeout_duration , Predicate pred)

  • wait()基础上新增超时时间,若超过指定的timeout_duration,该线程会被自动唤醒(但有pred时,仍然会检查是否为true)。

3、void wait_until( std::unique_lock<std::mutex>& lock ,chrono::point abs_time, Predicate pred)

  • wait()基础上新增超时时间,到达指定时间,该线程自动唤醒(但有pred时,仍然会检查是否为true)。

【用于通知的成员函数】

1、void notify_one()

  • 若有任何线程在 *this 上等待,则调用 notify_one 会除阻等待线程之一。

2、void notify_all()

  • 解除所有在*this上等待的线程

代码说明:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

using namespace std;

mutex mtx;
condition_variable cv;
bool ready = false;

void thread_function() {
    unique_lock<mutex> lock(mtx);
    while (!ready) {
        // 阻塞当前线程,并释放lock拥有的线程锁
        // 当运行这一行以后,这个线程就会停在在这里
        // 不会进行while(!ready)的循环判断
        // 直到下面cv.notify通知它,你可以继续运行
        // 这个循环判断才会继续判断。
        cv.wait(lock);			
    }
    cout << "Thread is ready!" << endl;
}

int main() {
    thread t(thread_function);
    
    // 模拟主线程的一些工作
    this_thread::sleep_for(chrono::seconds(2));
    
    // 准备好了,通知线程
    {
        lock_guard<mutex> lock(mtx);
        ready = true;
    }
    cv.notify_one();			// 唤醒使用cv阻塞的线程
    
    t.join();
    
    return 0;
}

参考:

[1] 面试必考的:并发和并行有什么区别?

[2] C++网络编程高并发 讲师:陈子青

[3] cppreference——thread

[4] cppreference——mutex

[5] 帝江VII ——c++11 call_once用法(多线程时仅初始化一次的完美解决方案)

  • 25
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值