Modern C++——无所有权指针的安全性保障

2024-08-31 05:28

本文主要是介绍Modern C++——无所有权指针的安全性保障,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

大纲

  • C++98中的实现
  • Modern C++的实现
  • 总结

C++98中的实现

在C++98标准中,当涉及到多线程环境时,对共享资源的访问需要特别小心以避免数据竞争(data race)问题。数据竞争是指两个或多个线程同时访问同一内存位置,且至少有一个线程正在修改该内存位置,同时又没有适当的同步机制(如互斥锁、信号量等)来协调这些访问的情况。

在C++98中,标准库本身并不直接支持多线程编程(尽管许多编译器和平台提供了扩展支持),但是理解和遵循多线程编程的最佳实践仍然是非常重要的。对于指针来说,当我们在多线程环境中释放了一个指针所指向的内存后,如果之后不将该指针设置为NULL(或现代C++中的nullptr,但C++98标准中尚未引入nullptr),并且没有适当的同步机制来保护这个指针的访问,就可能发生数据竞争。

这里是一个简化的例子来说明这个问题:

假设有两个线程,线程A和线程B,它们共享一个指针ptr

// 假设这是全局或共享的指针
int* ptr = new int(10);
// 线程A的函数
void threadAFunc() {// 假设这里线程A执行了一些操作,然后释放了ptr指向的内存delete ptr;ptr = NULL; 
}
// 线程B的函数
void threadBFunc() {// 线程B尝试访问ptr指向的内存if (ptr != NULL) { // 这里的检查在ptr已经被释放且未设为NULL时是无用的// 假设此时ptr被设置为NULL了,后续使用就会出错int* temp = ptr;int value = *temp; // 尝试解引用悬空指针,导致未定义行为}
}

因为是多线程执行,又没有使用同步机制,导致各个线程的执行流程不能保证。

当发生如下执行顺序时,上述代码就会发生Data Race问题:

  1. threadAFunc的delete ptr
  2. threadBFunc的 if (ptr != NULL)。测试该指针所指空间已经为threadAFunc释放,但是ptr = NULL还没来的及执行。
  3. threadAFunc的ptr = NULL。(这一步可能有可无,因为指针已经释放,后续threadBFunc执行一定会出错)
  4. threadBFunc的int* temp = ptr;int value = *temp;

所以上述代码的问题是delete ptr;ptr = NULL;这两步如何“原子化”。我们可以通过锁的方式来实现

// 假设这是全局或共享的指针
int* ptr = new int(10);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;// 线程A的函数
void* threadAFunc(void* arg) {pthread_mutex_lock(&mutex);// 假设这里线程A执行了一些操作,然后释放了ptr指向的内存delete ptr;ptr = NULL;pthread_mutex_unlock(&mutex);return NULL;
}// 线程B的函数
void* threadBFunc(void* arg) {pthread_mutex_lock(&mutex);// 线程B尝试访问ptr指向的内存if (ptr != NULL) {int* temp = ptr;int value = *temp; // 尝试解引用悬空指针,导致未定义行为std::cout << "Value: " << value << std::endl;}pthread_mutex_unlock(&mutex);return NULL;
}

Modern C++的实现

上述代码看起来很有C的风格。而且从形式上,ptr 和mutex 是强绑定的。这种设计非常容易因为使用不当导致问题出现(比如某次使用ptr忘记使用mutex来保护)。

Modern C++则引入了shared_ptr和weak_ptr来解决这个问题。

我们在《Modern C++——共享所有权指针保证内部对象析构安全的原因分析》中分析了在多线程下,shared_ptr释放内部对象需要依赖编译器的构造和析构排布来保证安全。但是有些场景下,我们需要让对象提前失效(如上述代码),而不是依赖对象的析构。这个时候weak_ptr就派上用场了。

void threadAFunc(std::shared_ptr<Data>& data) {std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟一些延迟data.reset(); // 销毁数据(实际不一定在此销毁)std::cout << "Data has been reset\n";
}void threadBFunc(std::weak_ptr<Data> weakData) {std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟一些延迟if (auto data = weakData.lock()) {data->process();} else {std::cout << "Data no longer exists\n";}
}int main() {std::shared_ptr<Data> data = std::make_shared<Data>();std::weak_ptr<Data> weakData = data;std::thread threadA(threadAFunc, std::ref(data));std::thread threadB(threadBFunc, weakData);threadA.join();threadB.join();return 0;
}

threadAFunc使用shared_ptr的引用,所以并不会增加它的引用计数。这样在threadAFunc中,reset操作会释放其内部管理的对象。(实际并不一定是在threadAFunc中释放的。后续会对此进行分析。)

threadBFunc中,如果weak_ptr管理的对象指针没有被释放,则lock操作会获得一个shared_ptr;否则将返回一个空指针。

现在的问题是if (auto data = weakData.lock())data->process();之间,threadAFunc会不会进行释放操作。

  1. threadBFunc的if (auto data = weakData.lock())
  2. threadAFunc的data.reset()
  3. threadBFunc的data->process()

这个问题的答案是:上述流程完全正常,且不会出现多线程安全问题。因为threadAFunc虽然执行了reset,但是并没有释放管理的对象。

这是因为weak_ptr和shared_ptr在底层共享了一个计数器指针。而weak_ptr的lock操作实际没有做任何锁的操作,而是返回了一个shared_ptr的副本。

      shared_ptr<_Tp>lock() const noexcept{ return shared_ptr<_Tp>(*this, std::nothrow); }};

这个副本的生成会导致weak_ptr和shared_ptr在底层共享的计数器原子递增。这样即使后续shared_ptr::reset被调用,实际它只是将计数器减少到1,而没有减少到0,于是reset操作并不会真正释放对象。

	  if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1) // 前值不是1,所以不会进入[[__unlikely__]]{_M_release_last_use_cold();return;}

总结

  • weak_ptr::lock没有任何原子操作,只是会生成一个shared_ptr副本。
  • 因为shared_ptr副本的生成,导致其引用计数原子递增,从而保证在使用该副本期间,它所管理的对象不会被释放。

这篇关于Modern C++——无所有权指针的安全性保障的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1123015

相关文章

C#如何调用C++库

《C#如何调用C++库》:本文主要介绍C#如何调用C++库方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录方法一:使用P/Invoke1. 导出C++函数2. 定义P/Invoke签名3. 调用C++函数方法二:使用C++/CLI作为桥接1. 创建C++/CL

C++如何通过Qt反射机制实现数据类序列化

《C++如何通过Qt反射机制实现数据类序列化》在C++工程中经常需要使用数据类,并对数据类进行存储、打印、调试等操作,所以本文就来聊聊C++如何通过Qt反射机制实现数据类序列化吧... 目录设计预期设计思路代码实现使用方法在 C++ 工程中经常需要使用数据类,并对数据类进行存储、打印、调试等操作。由于数据类

Linux下如何使用C++获取硬件信息

《Linux下如何使用C++获取硬件信息》这篇文章主要为大家详细介绍了如何使用C++实现获取CPU,主板,磁盘,BIOS信息等硬件信息,文中的示例代码讲解详细,感兴趣的小伙伴可以了解下... 目录方法获取CPU信息:读取"/proc/cpuinfo"文件获取磁盘信息:读取"/proc/diskstats"文

C++使用printf语句实现进制转换的示例代码

《C++使用printf语句实现进制转换的示例代码》在C语言中,printf函数可以直接实现部分进制转换功能,通过格式说明符(formatspecifier)快速输出不同进制的数值,下面给大家分享C+... 目录一、printf 原生支持的进制转换1. 十进制、八进制、十六进制转换2. 显示进制前缀3. 指

go 指针接收者和值接收者的区别小结

《go指针接收者和值接收者的区别小结》在Go语言中,值接收者和指针接收者是方法定义中的两种接收者类型,本文主要介绍了go指针接收者和值接收者的区别小结,文中通过示例代码介绍的非常详细,需要的朋友们下... 目录go 指针接收者和值接收者的区别易错点辨析go 指针接收者和值接收者的区别指针接收者和值接收者的

C++中初始化二维数组的几种常见方法

《C++中初始化二维数组的几种常见方法》本文详细介绍了在C++中初始化二维数组的不同方式,包括静态初始化、循环、全部为零、部分初始化、std::array和std::vector,以及std::vec... 目录1. 静态初始化2. 使用循环初始化3. 全部初始化为零4. 部分初始化5. 使用 std::a

C++ vector的常见用法超详细讲解

《C++vector的常见用法超详细讲解》:本文主要介绍C++vector的常见用法,包括C++中vector容器的定义、初始化方法、访问元素、常用函数及其时间复杂度,通过代码介绍的非常详细,... 目录1、vector的定义2、vector常用初始化方法1、使编程用花括号直接赋值2、使用圆括号赋值3、ve

如何高效移除C++关联容器中的元素

《如何高效移除C++关联容器中的元素》关联容器和顺序容器有着很大不同,关联容器中的元素是按照关键字来保存和访问的,而顺序容器中的元素是按它们在容器中的位置来顺序保存和访问的,本文介绍了如何高效移除C+... 目录一、简介二、移除给定位置的元素三、移除与特定键值等价的元素四、移除满足特android定条件的元

Python获取C++中返回的char*字段的两种思路

《Python获取C++中返回的char*字段的两种思路》有时候需要获取C++函数中返回来的不定长的char*字符串,本文小编为大家找到了两种解决问题的思路,感兴趣的小伙伴可以跟随小编一起学习一下... 有时候需要获取C++函数中返回来的不定长的char*字符串,目前我找到两种解决问题的思路,具体实现如下:

Java Optional避免空指针异常的实现

《JavaOptional避免空指针异常的实现》空指针异常一直是困扰开发者的常见问题之一,本文主要介绍了JavaOptional避免空指针异常的实现,帮助开发者编写更健壮、可读性更高的代码,减少因... 目录一、Optional 概述二、Optional 的创建三、Optional 的常用方法四、Optio