最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

线程安全实战解读:从 What、Why、How到Do

网站源码admin1浏览0评论

线程安全实战解读:从 What、Why、How到Do

您好,我是昊天,国内某头部音频公司的C++主程,多年的音视频开发经验,熟悉Qt、FFmpeg、OpenGL。如果你对这些方面感兴趣,欢迎关注我的公众号,一起学习,一起进步。

各位读者朋友,我的上一篇文章,在发文64小时内,获得8000多阅读、近600转发,推荐大家看看:回调函数实战解读:从 C/C++ 到现代 C++ 实现方案

在上一篇文章的评论区中有位读者朋友希望多出点线程安全的模块

所以今天这篇文章从What、Why、How三个方面阐述线程安全,并提出了一些实战指南(Do)。

1. 什么是线程安全(What)

如下为一个简化的可能会触发线程不安全的例子

代码语言:javascript代码运行次数:0运行复制
#include<iostream>
#include<thread>

int main(){
    int a=0;
    std::thread t1([&a](){
        for(int i =0;i<10000;i++){
            a++;
        }
    });

    std::thread t2([&a](){
        for(int i =0;i<10000;i++){
            a++;
        }
    });

    if(t1.joinable()){
        t1.join();
    }

    if(t2.joinable()){
        t2.join();
    }

    std::cout<<"after  thread process  a= "<<a<<" \n";
    return0;
}
//输出
//after  thread process  a= 16642

从上面的代码中可以看到,a的期望结果为20000,但是实际输出的结果却是16642,这就是线程不安全的表现。

2. 为什么会有线程安全问题(Why)

还是如上的例子,只是将两个线程访问的变量分别设置为ab,即t1访问at2访问b

代码语言:javascript代码运行次数:0运行复制
#include<iostream>
#include<thread>

int main()
{
    int a = 0, b = 0;
    std::thread t1([&a]() {
        for (int i = 0; i < 1000; i++ ) {
            a++;
        }
        });

    std::thread t2([&b]() {
        for (int i = 0; i < 1000; i++ ) {
            b++;
        }
        });

    //与上文相同,省略了join操作

    std::cout << "after t1 thread a= " << a << " \n";
    std::cout << "after t2 thread b= " << b << "\n";

    return0;
}
//输出
//after t1 thread a= 1000 
//after t2 thread b= 1000

通过输出可以看到,a和b的值都为1000,即此时并不存在线程安全问题。对比如上两个代码,可以发现,当两个线程访问同一个变量时,才有可能出现线程安全问题。 同理,如果我们将a、b变量更改为std::vector<int> vec(2,0)t1访问vec[0]t2访问vec[1],此时也不会出现线程安全问题。【代码示例略】

综合本节和上节中的3个例子,可以得出结论:

  • 多线程并不必然导致线程安全问题:本节的两个例子中,多线程并没有导致线程安全问题,但是上节中的例子却出现了线程安全问题。
  • 多线程访问数据结构并不必然导致线程安全问题:初学者可能会认为,当两个线程访问同一个数据结构时,就一定会出现线程安全问题,但并不是。如本节中使用的std::vector<int>并不存在线程安全问题,std::mapstd::setstd::queue同理。而上节例子中的int变量却存在线程安全问题。
  • 多线程访问同一个变量/地址才有可能导致线程安全问题:本节和上节中的例子都表明,多个线程访问同一变量/地址导致了线程安全。原因在于:多个线程访问同一变量时,某个线程对变量的修改,不能被其他线程立即看到,导致操作时出现错乱。

3. 如何避免线程安全问题(How)

前文分析了产生线程安全问题的根本原因为:多个线程同时访问同一变量时,某个线程对于变量的修改不能被其他线程立即看到。因此,要避免线程安全问题,可以从两个方面入手:

  • 避免在多线程环境中访问同一变量
  • 保证多线程对同一变量的修改立即被其他线程看到

具体方法可以有:

3.1 原子变量

原子变量是C++11中引入的用于解决数据线程安全问题的工具,其借助硬件层的支持,实现了三个“绝活”:

  • 原子性操作:对于原子变量的读写操作是原子性,即不可分割的,不会被其他线程打断。
  • 内存可见性:对于原子变量的修改,其他线程可以立即看到。
  • 内存序控制:对于原子变量的修改,可以控制其内存序,从而避免指令重排带来的问题。

原子变量的使用又是非常简单的,如第一节中的int a修改为std::atomic<int> a即可避免数据安全问题。

关于原子变量,写过很多文章,可通过如下链接查看:

原子变量一

原子变量——原子操作

原子变量——内存模型

内存模型的内存序选择技巧

3.2 避免同时访问同一变量

从导致线程安全的原因入手,避免在多线程环境下访问同一变量是可以避免线程安全问题的。具体的方法可以有:

  • 环形缓冲区:环形缓冲区是一种数据结构,其可以保证多个线程不会同时操作缓冲区中的一个区块。双缓冲作为环形缓冲区中只存在2个区块的特例,也常被应用在多线程环境避免线程安全问题。在之前的文章中,详细介绍过双缓冲,只是当时提供的双缓冲仅适用于单生产者单消费者且生产者消费者同频、生产者丢失数据影响不大的场景。原文链接如下:抛弃锁,拥抱双缓冲吧
  • :锁是一种用于控制多个线程访问同一变量的工具,其可以保证同一时刻只有一个线程访问同一变量。C++11以来,提供了多种类型的锁以及RAII操作。
    • std::mutex:互斥锁,最简单的锁,同一时刻只有一个线程可以访问被保护的变量。
    • std::recursive_mutex:递归互斥锁,允许同一个线程多次加锁,但必须加锁多少次,就要解锁多少次。
    • std::timed_mutex:定时互斥锁,允许在一段时间内加锁,如果超过时间则加锁失败。
    • std::recursive_timed_mutex:递归定时互斥锁,允许在一段时间内加锁,如果超过时间则加锁失败,允许同一个线程多次加锁,但必须加锁多少次,就要解锁多少次。
    • std::shared_mutex:共享互斥锁,允许多个线程同时读,但同一时刻只有一个线程可以写。
    • std::lock_guard:RAII操作,在构造时加锁,在析构时解锁。
    • std::unique_lock:RAII操作,在构造时加锁,在析构时解锁;但允许在析构前解锁,允许解锁后加锁,允许加锁后再解锁。
    • std::shared_lock:RAII操作,在构造时加锁,在析构时解锁;但允许在析构前解锁,允许解锁后加锁,允许加锁后再解锁。

此时我想提一句:条件变量并不能避免数据安全问题,条件变量只是用于线程间的同步,避免线程间的竞争。

3.3 无锁编程

无锁编程基于原子变量实现,在之前的章节中做过详细的介绍,这里不再赘述。见如下链接:无锁数据结构

4. 实战指南(Do)

  • 关于原子变量:原子变量相较于普通变量而言是以更大性能开销、更多内存占用为代价来保证线程安全的,(当然原子变量相较于锁的开销还是要低的)。所以需要做好权衡,不要滥用原子变量。
  • 关于锁:锁可以避免线程安全问题,但是使用锁时需要注意:
    • RAII:推荐使用RAII操作来管理锁,这样可以避免忘记解锁导致死锁的问题。
    • 粒度要小:锁的粒度越小越好,锁的粒度越小,锁的竞争就越小,性能开销就越小。
    • 避免死锁:在多线程环境下,如果多个线程需要访问多个变量,那么应该按照相同的顺序加锁,这样可以避免死锁。std::lock可以一次性加锁多个锁,避免了死锁。
    • 调用建议:函数加锁区域内调用的函数要打起12分警惕,确认被调用的函数内部是否有加锁操作,这往往是产生问题的重灾区。

5. 总结

随着多核时代的到来,多线程编程已经成为了程序员必备的技能,进而的,线程安全也成为了程序员必须掌握的知识。本文从是什么、为什么、怎么做三个方面介绍了线程安全,也提出了一些实操建议,希望对大家有所帮助。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。原始发表:2025-03-20,如有侵权请联系 cloudcommunity@tencent 删除线程线程安全变量多线程内存
发布评论

评论列表(0)

  1. 暂无评论