0

0

C++如何在多线程中安全访问自定义对象

P粉602998670

P粉602998670

发布时间:2025-09-06 10:26:03

|

1042人浏览过

|

来源于php中文网

原创

答案:C++多线程中安全访问自定义对象需通过同步机制保护共享状态,常用方法包括互斥锁(std::mutex)保护临界区、std::atomic用于简单原子操作、std::shared_mutex优化读多写少场景,并结合RAII(如std::lock_guard)确保异常安全;设计线程安全数据结构时应封装共享资源、最小化临界区、避免死锁、使用条件变量协调线程,或在高性能需求下考虑无锁编程,核心原则是根据访问模式选择合适工具以平衡安全与性能。

c++如何在多线程中安全访问自定义对象

在C++多线程环境中安全访问自定义对象,核心在于管理好共享状态。简单来说,就是确保在任何时刻,只有一个线程能够修改对象的数据,或者在多个线程同时读取时,数据保持一致性。这通常通过同步原语来实现,比如互斥锁(

std::mutex
)、读写锁(
std::shared_mutex
)或原子操作(
std::atomic
),关键在于识别并保护所有可能被多个线程同时访问和修改的共享资源。

解决方案

谈到多线程中自定义对象的安全访问,我的经验是,没有银弹,只有最适合你场景的工具组合。最常见、也最基础的,无疑是互斥锁(

std::mutex

当你有一个自定义对象,比如一个复杂的结构体或类,里面包含了一些成员变量,这些变量可能被多个线程同时读写时,

std::mutex
就是你的第一道防线。它的工作原理很简单:在访问共享资源之前,线程需要“锁住”互斥量;访问完成后,再“解锁”。这样就保证了在锁定的时间内,只有一个线程能够执行临界区代码。

我个人非常推荐使用

std::lock_guard
std::unique_lock
来管理互斥锁。它们是RAII(Resource Acquisition Is Initialization)的典范,能够自动在作用域结束时解锁,极大地减少了忘记解锁或异常发生时死锁的风险。

立即学习C++免费学习笔记(深入)”;

#include 
#include 
#include 
#include 
#include 

class MyCustomObject {
public:
    void addValue(int val) {
        std::lock_guard lock(mtx_); // 自动锁定和解锁
        data_.push_back(val);
        std::cout << std::this_thread::get_id() << ": Added " << val << ". Current size: " << data_.size() << std::endl;
    }

    std::vector getCopyOfData() {
        std::lock_guard lock(mtx_);
        return data_; // 返回一份拷贝,避免外部直接操作共享数据
    }

private:
    std::vector data_;
    std::mutex mtx_;
};

// 示例用法(在实际项目中,线程函数通常会更复杂)
void worker_function(MyCustomObject& obj, int start, int end) {
    for (int i = start; i < end; ++i) {
        obj.addValue(i);
        // 模拟一些其他工作
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

// int main() {
//     MyCustomObject obj;
//     std::vector threads;
//     int num_threads = 4;
//     int values_per_thread = 25;

//     for (int i = 0; i < num_threads; ++i) {
//         threads.emplace_back(worker_function, std::ref(obj), i * values_per_thread, (i + 1) * values_per_thread);
//     }

//     for (auto& t : threads) {
//         t.join();
//     }

//     std::cout << "Final data size: " << obj.getCopyOfData().size() << std::endl;
//     return 0;
// }

除了

std::mutex
,如果你的自定义对象是简单类型(如
int
,
bool
, 指针等),并且你只需要保证单个读写操作的原子性,那么
std::atomic
系列模板会是更轻量、性能更好的选择。它能保证变量的加载、存储、修改等操作是不可中断的,从而避免数据撕裂。但请注意,
std::atomic
不适用于保护复杂的数据结构,例如
std::vector
push_back
操作,因为它本身涉及多个步骤(内存分配、元素拷贝、更新大小),这些步骤不是原子性的。

对于读多写少的场景,

std::shared_mutex
(C++17引入,之前是
std::shared_timed_mutex
)是一个非常棒的性能优化工具。它允许多个线程同时获取共享(读)锁,而排他(写)锁则只能被一个线程获取,且在持有写锁时,所有读锁和写锁都会被阻塞。这能显著提高并发读取的效率。

#include  // for std::shared_mutex and std::shared_lock

class MySharedObject {
public:
    std::string getValue() const {
        std::shared_lock lock(mtx_); // 读锁,允许多个读者
        return value_;
    }

    void setValue(const std::string& new_val) {
        std::unique_lock lock(mtx_); // 写锁,排他性
        value_ = new_val;
    }

private:
    mutable std::string value_ = "initial"; // mutable for const getValue
    mutable std::shared_mutex mtx_; // 互斥量本身不修改,所以可以声明为mutable
};

最后,有时我们也可以通过避免共享来解决问题。例如,使用线程局部存储(Thread-Local Storage, TLS),每个线程都有自己独立的变量副本,自然就不存在竞争条件了。或者,设计你的对象使其成为不可变对象(Immutable Object)。一旦创建,其状态就不能再改变。如果需要修改,就创建一个新的对象。这种方式在函数式编程中很常见,能从根本上消除数据竞争。

C++多线程中如何有效避免数据竞争?

数据竞争(Data Race)是多线程编程中最常见的错误之一,它发生在至少两个线程同时访问同一个内存位置,并且其中至少一个访问是写入操作,而这些访问又没有被恰当的同步机制所保护时。其后果是未定义行为(Undefined Behavior),这意味着你的程序可能崩溃、产生错误结果,或者在不同运行环境下表现出完全不同的行为,这让调试变得异常困难。

避免数据竞争,首先要建立一个清晰的“共享数据”意识。当你定义一个类,或者使用一个全局变量时,问问自己:这个数据会不会被多个线程同时访问?如果会,那么它就是共享数据,必须被保护。

具体的策略有:

企奶奶
企奶奶

一款专注于企业信息查询的智能大模型,企奶奶查企业,像聊天一样简单。

下载
  1. 识别共享可变状态: 这是第一步,也是最重要的一步。任何可能被多个线程同时修改的变量或对象成员都属于此列。如果数据是只读的,通常不需要额外的同步(除非它在某个时刻会被修改,然后又变成只读)。
  2. 细粒度锁定与粗粒度锁定:
    • 粗粒度锁定:用一个互斥锁保护一大块数据或整个对象。优点是实现简单,不容易出错。缺点是可能限制并发性,因为即使是访问不同部分的数据,也需要等待同一个锁。
    • 细粒度锁定:使用多个互斥锁,每个锁保护数据中更小的、独立的部分。优点是提高了并发性,因为不同的线程可以同时访问不同的受保护区域。缺点是实现更复杂,更容易引入死锁(Deadlock)等问题。选择哪种粒度,需要根据实际场景和性能需求来权衡。我通常倾向于从粗粒度开始,如果性能瓶颈出现在锁竞争上,再考虑细粒度优化。
  3. 使用原子操作: 对于简单的计数器、标志位等,
    std::atomic
    是比互斥锁更高效的选择。它提供了硬件级别的原子性保证,避免了互斥锁的开销。但是,记住它只适用于单个操作的原子性,不能保护复合操作。
  4. 不可变性: 这是我个人非常推崇的一种范式。如果一个对象一旦创建就不能被修改,那么它就是线程安全的,因为它没有可变状态可以被竞争。如果需要“修改”,实际上是创建了一个新的对象,旧对象保持不变。这在很多现代语言和库中都有体现,比如Java的
    String
    类。
  5. 消息传递: 避免直接共享数据,而是让线程之间通过消息队列进行通信。一个线程将数据打包成消息发送给另一个线程,接收线程处理完后再发送回去。这种模式天然地解耦了线程,降低了数据竞争的风险。像Actor模型就是这种思想的体现。
  6. 避免全局变量: 全局变量是隐式的共享状态,很容易被多个线程同时访问而忘记保护。尽量将数据封装在对象内部,并通过参数传递。

互斥锁与读写锁:在哪些场景下选择它们?

互斥锁(

std::mutex
)和读写锁(
std::shared_mutex
)都是为了保护共享资源而生,但它们在设计哲学和适用场景上有着明显的区别。理解这些区别,能帮助我们做出更明智的选择,以平衡程序的安全性和性能。

互斥锁(

std::mutex

  • 特点: 独占性。在任何时刻,只有一个线程能够持有互斥锁。无论是读取还是写入操作,只要涉及到被互斥锁保护的资源,其他线程都必须等待。
  • 适用场景:
    • 写操作频繁且读写比例接近的场景: 如果你的共享资源会被频繁修改,并且读取操作的频率与写入操作相近,那么
      std::mutex
      通常是简单且有效的选择。因为即使引入读写锁,频繁的写操作也会导致读锁等待,性能优势不明显。
    • 复杂或多步操作: 当你需要对共享数据执行一系列相互依赖的操作,并且这些操作必须作为一个整体被原子地执行时,
      std::mutex
      能提供最简单的保护。例如,从队列中取出元素并处理,这通常涉及检查队列是否为空、取出元素、更新队列状态等多个步骤。
    • 对性能要求不是极端苛刻的场景:
      std::mutex
      的开销相对较低,对于大多数应用而言,它的性能已经足够。过度优化往往会引入不必要的复杂性。
    • 实现简单: 它的API直观,不容易出错,特别是在使用
      std::lock_guard
      std::unique_lock
      时。

读写锁(

std::shared_mutex

  • 特点: 允许多个线程同时持有共享(读)锁,但排他(写)锁只能被一个线程持有。当一个线程持有写锁时,所有读锁和写锁请求都会被阻塞。
  • 适用场景:
    • 读操作远多于写操作的场景(读多写少): 这是
      std::shared_mutex
      最能发挥优势的地方。例如,一个配置缓存,它在启动时加载一次,之后大部分时间都是被多个线程并发读取,偶尔才会被更新。在这种情况下,读写锁能显著提高并发读取的性能,因为多个读者可以同时访问数据而无需等待。
    • 性能瓶颈出现在读操作的锁竞争时: 如果你通过性能分析工具发现,在读多写少的场景下,
      std::mutex
      成为了性能瓶颈,那么
      std::shared_mutex
      就是值得考虑的优化方案。
    • 数据结构相对稳定,修改不频繁: 适用于那些一旦初始化后,其内部结构和大部分数据项不经常变动,但会被频繁查询的数据结构。

我的个人观点和建议:

在实际开发中,我通常会遵循一个原则:先用

std::mutex
,如果遇到性能瓶颈再考虑
std::shared_mutex

为什么这样?因为

std::mutex
更简单,出错的概率更低。
std::shared_mutex
虽然能提供更好的并发性,但它的逻辑稍微复杂一些,比如要区分读锁和写锁,这在某些复杂场景下可能会引入新的问题,例如写饥饿(Writer Starvation),即如果读者持续不断地请求读锁,写者可能永远无法获得写锁。现代的
std::shared_mutex
实现通常会考虑公平性,但这仍然是需要注意的潜在问题。

所以,在选择时,我会先评估共享数据的访问模式:是读写均衡,还是明显偏向读取?如果后者,并且性能至关重要,那么

std::shared_mutex
无疑是更好的选择。否则,
std::mutex
的简洁和可靠性往往是更重要的考量。

如何设计线程安全的自定义数据结构?

设计线程安全的自定义数据结构,不仅仅是简单地在每个方法前加个锁那么简单,它需要更深入的思考和设计。这更像是一种思维模式的转变,从单线程的“操作数据”到多线程的“协调访问数据”。

  1. 明确不变量(Invariants): 每个数据结构都有一些核心的不变量,例如一个链表的

    head
    tail
    指针不能同时为空除非列表为空,或者一个平衡二叉树必须保持平衡。在多线程环境中,你需要确保在任何时候,即使在并发操作中,这些不变量也始终被维护。锁的作用就是保护这些不变量在操作过程中不被其他线程破坏。

  2. 封装与隔离: 将所有需要同步的成员变量封装在一个类中,并只通过公共方法提供访问接口。在这些公共方法内部,应用同步机制。避免将原始的、未受保护的数据成员暴露给外部,否则外部代码可能会绕过你的同步机制。这意味着你的类应该成为一个“同步单元”。

  3. 最小化临界区: 锁的粒度很重要。临界区(Critical Section)是指被锁保护的代码段。临界区越小,线程持有锁的时间就越短,其他线程等待的时间也就越少,从而提高并发性。例如,如果你需要从一个队列中取出数据,处理数据,然后记录日志。通常,只需要在取出数据这一步加锁,数据处理和日志记录可以放在锁之外,因为它们不再直接操作共享队列。

  4. 避免死锁: 死锁是多线程编程的噩梦。它通常发生在两个或更多线程互相等待对方释放资源时。避免死锁的关键原则是:

    • 固定加锁顺序: 如果一个线程需要获取多个锁,始终以相同的顺序获取它们。
    • 避免循环等待: 确保没有线程形成一个循环,等待其他线程释放资源。
    • 使用
      std::lock
      C++11提供了
      std::lock
      函数,它可以同时锁定多个互斥量,并保证在所有锁都获取成功之前不会释放任何一个已获取的锁,从而有效避免死锁。配合
      std::unique_lock
      使用非常方便。
    • 超时加锁: 使用
      std::mutex::try_lock_for
      std::mutex::try_lock_until
      尝试在一定时间内获取锁,如果失败则放弃或重试。
  5. 考虑异常安全性: 当临界区内的代码抛出异常时,确保互斥锁能够被正确释放。这就是

    std::lock_guard
    std::unique_lock
    等RAII机制的价值所在。它们保证在对象离开作用域时(无论是正常退出还是异常退出),互斥锁都会被自动解锁。

  6. 善用条件变量(

    std::condition_variable
    ): 当线程需要等待某个条件满足后才能继续执行时,条件变量是必不可少的。它通常与互斥锁一起使用,用于实现生产者-消费者模型等线程间协作模式。例如,一个线程生产数据放入队列,另一个线程从队列取出数据。如果队列为空,消费者线程就会在条件变量上等待,直到生产者放入数据并通知它。

    #include 
    #include 
    
    template
    class ThreadSafeQueue {
    public:
        void push(T value) {
            std::lock_guard lock(mtx_);
            queue_.push(std::move(value));
            cv_.notify_one(); // 通知一个等待的消费者
        }
    
        T pop() {
            std::unique_lock lock(mtx_);
            cv_.wait(lock, [this]{ return !queue_.empty(); }); // 等待直到队列非空
            T value = std::move(queue_.front());
            queue_.pop();
            return value;
        }
    
        bool empty() const {
            std::lock_guard lock(mtx_);
            return queue_.empty();
        }
    
    private:
        std::queue queue_;
        mutable std::mutex mtx_;
        std::condition_variable cv_;
    };
  7. 无锁(Lock-Free)数据结构: 对于对性能有极致要求的场景,可以考虑无锁数据结构。它们不使用传统的互斥锁,而是依赖于原子操作和内存序(Memory Orderings)来保证线程安全。这通常非常复杂,需要深入理解CPU架构和内存模型,并且调试困难。除非你确实遇到了严重的锁竞争瓶颈,并且对多线程底层原理有深刻理解,否则不建议轻易尝试。

设计线程安全的数据结构,需要我们从并发的角度重新审视数据的访问模式和状态转换。它是一个迭代的过程,通常从简单的互斥锁开始,根据性能分析和需求逐步引入更复杂的同步机制。

热门AI工具

更多
DeepSeek
DeepSeek

幻方量化公司旗下的开源大模型平台

豆包大模型
豆包大模型

字节跳动自主研发的一系列大型语言模型

通义千问
通义千问

阿里巴巴推出的全能AI助手

腾讯元宝
腾讯元宝

腾讯混元平台推出的AI助手

文心一言
文心一言

文心一言是百度开发的AI聊天机器人,通过对话可以生成各种形式的内容。

讯飞写作
讯飞写作

基于讯飞星火大模型的AI写作工具,可以快速生成新闻稿件、品宣文案、工作总结、心得体会等各种文文稿

即梦AI
即梦AI

一站式AI创作平台,免费AI图片和视频生成。

ChatGPT
ChatGPT

最最强大的AI聊天机器人程序,ChatGPT不单是聊天机器人,还能进行撰写邮件、视频脚本、文案、翻译、代码等任务。

相关专题

更多
string转int
string转int

在编程中,我们经常会遇到需要将字符串(str)转换为整数(int)的情况。这可能是因为我们需要对字符串进行数值计算,或者需要将用户输入的字符串转换为整数进行处理。php中文网给大家带来了相关的教程以及文章,欢迎大家前来学习阅读。

443

2023.08.02

resource是什么文件
resource是什么文件

Resource文件是一种特殊类型的文件,它通常用于存储应用程序或操作系统中的各种资源信息。它们在应用程序开发中起着关键作用,并在跨平台开发和国际化方面提供支持。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

153

2023.12.20

全局变量怎么定义
全局变量怎么定义

本专题整合了全局变量相关内容,阅读专题下面的文章了解更多详细内容。

78

2025.09.18

python 全局变量
python 全局变量

本专题整合了python中全局变量定义相关教程,阅读专题下面的文章了解更多详细内容。

96

2025.09.18

golang结构体相关大全
golang结构体相关大全

本专题整合了golang结构体相关大全,想了解更多内容,请阅读专题下面的文章。

220

2025.06.09

golang结构体方法
golang结构体方法

本专题整合了golang结构体相关内容,请阅读专题下面的文章了解更多。

192

2025.07.04

golang结构体相关大全
golang结构体相关大全

本专题整合了golang结构体相关大全,想了解更多内容,请阅读专题下面的文章。

220

2025.06.09

golang结构体方法
golang结构体方法

本专题整合了golang结构体相关内容,请阅读专题下面的文章了解更多。

192

2025.07.04

Python 自然语言处理(NLP)基础与实战
Python 自然语言处理(NLP)基础与实战

本专题系统讲解 Python 在自然语言处理(NLP)领域的基础方法与实战应用,涵盖文本预处理(分词、去停用词)、词性标注、命名实体识别、关键词提取、情感分析,以及常用 NLP 库(NLTK、spaCy)的核心用法。通过真实文本案例,帮助学习者掌握 使用 Python 进行文本分析与语言数据处理的完整流程,适用于内容分析、舆情监测与智能文本应用场景。

10

2026.01.27

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
C# 教程
C# 教程

共94课时 | 7.7万人学习

C 教程
C 教程

共75课时 | 4.2万人学习

C++教程
C++教程

共115课时 | 14.2万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号