C++中的智能指针

C++中的智能指针

简介

对裸指针的封装, 初衷是让程序员无需手动释放内存,来避免内存泄露。需要包含头文件memory

常见的智能指针

auto_ptr: 在C++11中就已经标为废弃了,取而代之的是unique_ptr。

unique_ptr: 作用域指针,低开销,不能被复制

1
2
3
4
5
6
7
8
9
10
11
12
13
#include<memory>

class Entity(){}

int main() {
{
std::unique_ptr<Entity>entity(new Entity());
// 这里不能这样std::unique_ptr<Entity>entity = new Entity(),因为unique_ptr定义中明确了显式构造

// 我们通常使用下面的语法定义unique_ptr,主要原因是出于异常安全,当构造函数抛出异常时,它会稍微安全一些
std::unique_ptr<Entity>entity = std::make_unique<Entity>();
}
}

shared_ptr: 有一点开销,能够被复制,因为它使用了引用计数,可以跟踪你的指针有多少个引用,一旦shared_ptr的引用计数为0,那它就会被删除

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <memory>

class Entity() {}

int main() {
std::shared_ptr<Entity>e0;
{
// 对于shared_ptr来说,不能用new来构造,因为shared_ptr需要分配另一块内存,叫作控制块,用来存储引用计数。
std::shared_ptr<Entity>shared_entity = std::make_shared<Entity<();

e0 = shared_enetity;
}
}

shared_ptr的内部维护了一个计数器,来跟踪有多少个shared_ptr对象指向了一个资源。当计数器减少到0时,shared_ptr会调用delete来释放资源。

何时增加:

  • 新建一个shared_ptr并指向了一个资源时
  • 复制构造函数创建一个新的shared_ptr时
  • 用复制运算符将一个shared_ptr给另一个shared_ptr对象赋值时

何时减少:

  • 当一个shared_ptr对象被销毁时,比如局部变量离开作用域,或者类成员变量析构时
  • 当一个shared_ptr对象不再指向一个资源时,例如通过reset方法或者赋值运算符指向另一个资源时

weak_ptr: 可以与shared_ptr配合使用,它同样能复制指针,但是它不会增加引用计数,是用来防止shared_ptr可能导致的循环引用

1
2
3
4
5
6
7
8
9
10
11
12
#include <memory>

class Entity() {}

int main() {
std::weak_ptr<Entity>e0;
{
std::shared_ptr<Entity>shared_entity = std::make_shared<Entity>();

e0 = shared_entity;
}
}

循环引用是如何发生的

两个或多个对象相互引用,可能会存在循环引用的问题,导致资源无法被释放掉。这时候需要使用weak_ptr来打破循环引用,比如说A强引用了B,B引用了A,这时候A和B的引用计数都为1,但是你要释放A就先必须释放B,因为B持有A的引用,要释放B就先必须释放A,造成循环引用。通过weak_ptr,A持有B的弱引用,B持有A的弱引用,这时候随便释放A和B,因为他们引用计数都是0

shared_ptr是线程安全的吗?

多线程代码操作同一个share_ptr对象是线程不安全的

比如下面的代码:

1
2
3
4
5
6
7
8
void fn(shared_ptr<A>& sp){ 
sp = global_sp;
}
int main(){
std::shared_ptr<A> sp1 = std::make_shared<A>();
std::thread t1(fn,sp1);
std::thread t2(fn,sp1);
}

fn函数传入的参数是引用,所以t1和t2使用的是同一个shared_ptr对象,shared_ptr在进行计数的增加和减少是原子操作,但是这里使用的同一个对象,原子操作已经没有意义了。

所以t1和t2线程的执行顺序有可能是交叉进行的。也就是线程1找到sp1指向的内存A后,内存A的引用计数从1减少到0,然后此时应该将sp1指向内存B的,但是线程2先执行了,所以sp1指向的内存A引用计数就从0减到-1了,产生了线程安全的问题。

多线程代码操作不同的shared_ptr对象是线程安全的

比如下面的代码:

1
2
3
4
5
6
7
8
void fn(shared_ptr<A> sp) {
sp = global_sp;
}
int main() {
std::shared_ptr<A> sp1 =std::make_shread<A>();
std::thread t1(fn, sp1);
std::thread t2(fn, sp1);
}

因为shared_ptr引用计数的减少和增加是原子操作,所以一个shared_ptr对象在进行引用计数的增加或减少时,另一个shared_ptr对象是无法进行这个操作的,保证了线程的安全。

(虽然shared_ptr对象是两个,但是它们指向的内存是同一份)

何时用shared_ptr/weak_ptr

何时用shared_ptr:

当引用的资源需要在多线程之间共享的时候,通常会使用shared_ptr。

何时用weak_ptr:

  • 在使用shared_ptr时,需要解决循环引用的问题,需要用weak_ptr
  • 当需要使用一个共享内存,但是从业务逻辑上讲,这个持有不应该对资源的生命周期有影响,这时我们应该使用weak_ptr
    • 缓存实现,假设有个缓存系统,某些对象是昂贵的资源,并且希望在缓存中缓存它们,但又不希望缓存的对象阻止它们的销毁,可以使用weak_ptr
    • 避免不必要的内存占用,某些情况,你可能只需要观察一个对象的状态而不希望它的存在延长对象的生命周期,weak_ptr允许你持有一个对对象的观察性引用。

针对上面第二点进行详细解释:

  • 从性能上看,weak_ptr的性能开销通常比share_ptr低,因为它不会增加引用计数,只是提供了被管理对象的弱引用
  • 从逻辑上看,如果我们业务逻辑本身就是对这个资源是一个观察者的角度来看的,那么我们确实也应该使用weak_ptr保证不影响资源的引用计数。

其他

引用计数的线程安全性是怎么实现的?

对于引用计数,通常使用std::atomic<int>std::atomic<std::size_t>来存储计数器,常见的操作包括原子增加(fetch_add)和原子减少(fetch_sub),这些操作是原子的,可以保证线程安全。

有⼀个场景需要用到shared_ptr,⼀般会怎么做?

使用std::shared_ptr本身:

如果有多个线程共享同一个资源,可以直接使用shared_ptr确保引用计数的线程安全性。std::shared_ptr可以自动管理对象的生命周期,保证当所有线程不再使用对象时自动释放

使用std::mutex包裹shared_ptr:

如果你的场景不只是涉及引用计数的线程安全,还需要对shared_ptr的修改进行复杂操作(如重置、交换等),可以使用互斥锁来保护对shared_ptr的访问。这种方法会使访问资源的操作变得串行化,但是有时是必要的,尤其是当需要执行复杂的更新操作时。

使用std::atomic<std::shared_ptr<T>>:

C++17引入了std::atomic<std::shared_ptr<T>>,使得在多线程环境中对shared_ptr对象的交换、加载和存储等操作可以是原子性的。比如修改shared_ptr本身。

使用std::weak_ptr和std::shared_ptr的配合:

多个线程需要共享一个资源,但只有一个线程负责引用计数,其他线程只需要读取或监控该对象。

语言层面,保证线程安全性都有哪些手段?

原子操作:

大多数语言都提供了对原子操作的支持,确保共享数据的修改是原子的。

  • C++:使用std::atomic提供原子操作,支持原子增加、原子减少、原子交换等操作
  • Rust:使用std::sync::atomic模块,提供原子操作如AtomicBool,AtomicIsize,AtomicUsize等

优点是执行简单,开销小,但只适用于较简单的操作

锁:

是一种常见的同步机制,确保同一时刻只有一个线程可以访问某个共享资源。常见的锁有互斥锁、读写锁(适合读多于写的操作)、自旋锁等

  • C++:使用std::mutex和和std::lock_guard(RAII风格)进行互斥操作
  • Rust:使用std::sync::Mutex,std::sync::RwLock提供互斥锁和读写锁的操作

优点是可以保护复杂的数据结构,确保一致性;缺点是容易引发死锁和性能瓶颈,尤其是在高并发环境下,锁的争用可能会影响程序的性能

条件变量:

允许线程在某个条件成立时通知另一个线程进行操作,通常和锁结合使用。条件变量可以实现更细粒度的线程同步,常用于生产者-消费者问题等场景

  • C++:std::condition_variable可以和std::mutex配合使用
  • Rust:std::sync::Condvar可以和std::sync::Mutex使用

优点是可以灵活地处理线程间的通信和同步,缺点是使用不当时容易导致死锁或不必要的线程唤醒

线程局部存储:

允许每个线程拥有自己的数据副本,而不是共享同一资源,这对于避免线程间的冲突特别有用,尤其是当线程使用独立的状态时

  • C++:使用thread_local关键字声明线程局部存储变量
  • Rust:使用std::thread::LocalKey进行线程局部存储

优点是避免了线程间的同步需求,性能开销非常小。缺点是它仅适用于每个线程独立的数据,不能用于需要线程间共享的资源。

无锁编程:

通过算法和数据结构的涉及,避免了使用锁的情况,从而减少锁竞争和上下文切换的开销。通常依赖于原子操作,利用硬件原子指令来保证操作的原子性。

适用于性能要求极高的场景,但通常非常复杂,需要对原子操作和并发编程有深刻理解。

事务内存:

是一种通过将一组内存操作打包成一个原子事务来实现线程安全的技术。关键思想是如果多个线程同时对同一内存区域进行操作,系统可以回滚未完成的操作,类似于数据库事务的概念。

简化了并发编程的模型,缺点是它目前的支持较为有限,而且实际使用中可能会受到硬件和平台的限制。

语言层面,如何避免死锁

避免死锁的原则和策略

避免循环依赖:

  • 死锁通常发生在两个或多个线程相互持有对方需要的资源,形成一个循环等待。
  • 策略:确保线程在获取锁时按照一定的顺序获取资源,避免出现循环等待。

使用超时机制:

  • 如果线程请求锁时设置了超时,就可以避免线程在获取不到锁时进入死锁状态
  • 策略:设置锁的等待潮湿,并在超时后进行处理(重试或退出),以避免程序在死锁时一直处于阻塞状态

避免持有锁的时间过长:

  • 可以通过减小锁的持有时间,确保线程尽早释放锁,减少死锁的风险。
  • 策略:尽量将需要加锁的代码块缩小,只在必要的地方加锁,避免长时间持有锁

避免锁嵌套:

  • 避免线程在持有一个锁时再去请求其他锁,这样可以-能会导致死锁
  • 策略:在设计时尽量避免复杂的锁嵌套

使用相关API避免死锁

C++:

  • std::lock():它能够同时锁定多个互斥量,并且避免死锁。它会确保多个锁能够按顺序获取,不会发生死锁

    1
    2
    3
    4
    5
    6
    7
    std::mutex mtx1, mtx2;
    void threadFunction() {
    std::lock(mtx1, mtx2); // 保证两个互斥量以相同的顺序被锁定,避免死锁
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    // 执行任务
    }
  • std::try_lock():尝试非阻塞地获取锁,如果无法获取锁,会立刻返回,这样可以避免因等待锁导致死锁。

Rust:

  • std::sync::Mutex和std::sync::RwLock通过lock方法保证某个时间只有一个线程能够访问数据。Rust的编译器和内存模型通过严格的所有权和借用规则,在一定程度上减少了死锁的风险。
  • try_lock(),它会尝试获取锁,而不会阻塞。如果锁无法获取,返回None。

shared_mutex

概述: C++17后引入的,主要用于读写锁的场景,与传统的mutex不同,允许多个线程并发地读取共享资源,同时确保写操作时只有一个线程能够访问该资源。

主要操作:

  • lock():请求并获取独占写锁,阻塞其他线程的读锁和写锁
  • lock_shared():请求并获取共享读锁,多个线程可以同时获得共享读锁
  • unlock():释放当前的独占写锁
  • unlock_shared():释放当前的共享读锁

与mutex的不同:

  • mutex:在一个时刻只能有一个线程持有锁,在读多写少的场景效率低
  • shared_mutex:允许多个线程并发地读取共享资源,只有写操作会阻塞其他线程

注意事项:

  • 避免过度使用共享锁:虽然多个线程可以同时持有共享锁,但如果有太多线程竞争共享锁,可能会导致性能下降。如果读写平衡,还是使用mutex好
  • 避免死锁

unique_ptr详解

有哪两种类型:

  • 管理单个对象(std::unique_ptr<T>):*std::unique_ptr<T>会封装一个裸指针,并且在其析构时自动调用delete来释放该资源
  • 管理数组(std::unique_ptr<T[]>):跟std::unique_ptr<T>有点类似,但是它使用delete[]而不是delete,它不能被复制,只能移动,以保证数组的所有权只能被一个unique_ptr拥有

unique_ptr可以直接用于布尔语境,为什么?

std::unique_ptr内部会重载operator bool,让unique_ptr在布尔上下文中能够自动转换为true或false。如果unique_ptr指向一个有效的对象,则转换结果为true,如果是空指针,则转换结果为false

vector的元素可以是unique_ptr吗?

可以,它能够在vector的生命周期结束时正确地管理内存。

unique_ptr可以实现虚派发吗?

unique_ptr可以用来管理包含虚函数的对象,并且支持虚函数的派发,因为unique_ptr是一个模板类型,实际上可以管理任何类型的对象。

虚派发工作原理:虚函数的调度时通过对象的虚表实现的。只要对象是通过unique_ptr管理的,智能指针的析构和访问都能正确地进行虚函数调度

shared_ptr详解

内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
class shared_ptr {
private:
T* ptr; // 托管对象的指针
control_block* ctrl_block: // 控制块指针

public:
// 控制块示例
struct control_block {
std::atomic<int> strong_count;// 强引用计数
std::atomic<int> weak_count; // 弱引用计数
deleter_type deleter; // 自定义删除器
}
};

std::make_shared和std::shared_ptr构造函数的区别:

  • 基本区别:
    • make_shared是一种通过内存池分配器一次性分配内存来创建shared_ptr和对象的方式。它在一个单独的内存块中分配了T对象以及一个控制块
    • 构造函数通过先创建对象并返回裸指针,再将其传递给构造函数来创建,对象和控制块通常是分开分配的
  • 性能差异:
    • make_shared会在一次内存分配中同时分配对象和控制块
    • 构造函数需要先为对象分配内存,然后再为控制块分配内存
  • 内存布局:
    • make_shared会将对象和控制块分配同一块内存中,避免内存碎片,提高缓存一致性
    • 构造函数将对象和控制块分配在两块不同的内存区域,可能会导致更多的内存碎片
  • 异常安全:
    • make_shared提供更好的异常按去啊逆行,因为内存分配只发生一次,如果抛出异常,内存泄露的风险更小。如果在创建过程中发生异常,内存可以在退出作用域时被统一释放
    • 构造函数则存在一定的内存泄露风险,如果创建shared_ptr对象时抛出异常,可能会导致控制块被泄露

如果构造函数传入同一个裸指针构造两个shared_ptr对象,会发生什么现象?

会发生双重删除问题,shared_ptr会自动管理它所拥有对象的生命周期。当两个shared_ptr对象被创建并指向同一个裸指针时,它们会各自持有对同一个对象的所有权,且会尝试在它们被销毁时释放该对象。

如果直接使用裸指针来初始化多个shared_ptr对象,它们会认为自己是该对象的所有者,当两个对象被销毁时,会对同一块内存释放两次,导致程序崩溃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <memory>

class MyClass {
public:
MyClass() { std::cout << "MyClass constructor\n"; }
~MyClass() { std::cout << "MyClass destructor\n"; }
};

int main() {
MyClass* ptr = new MyClass(); // 创建一个裸指针

// 创建两个 shared_ptr,指向同一个裸指针
std::shared_ptr<MyClass> sp1(ptr);
std::shared_ptr<MyClass> sp2(ptr);

return 0;
}

为什么shared_ptr都叫智能指针了,还是有上面的问题?

  • 智能指针设计的假设:shared_ptr的设计假设了每个对象只有一个清晰的所有者,shared_ptr并不能知道这个裸指针是否被其他shared_ptr管理了。
  • 裸指针不具备引用计数:裸指针只是内存地址,不会记录引用计数,shared_ptr无法知道裸指针是如何创建的

std::enable_shared_from_this

概述: 主要目的是让一个对象能够在其成员函数内部安全地生成一个指向自身的shared_ptr,即在对象的内部拥有对其自身的智能指针

如何使用:

  1. 继承std::enable_shread_from_this:要让类继承这个东西
  2. 调用shared_from_this:通过调用shared_from_this,可以获得一个指向当前对象的shared_ptr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <memory>

class MyClass : public std::enable_shared_from_this<MyClass> {
public:
MyClass() {
std::cout << "MyClass constructor\n";
}

~MyClass() {
std::cout << "MyClass destructor\n";
}

void print() {
std::cout << "MyClass print() called\n";
}

void doSomething() {
// 使用 shared_from_this 获取指向当前对象的 shared_ptr
std::shared_ptr<MyClass> self = shared_from_this();
std::cout << "Inside doSomething, shared_ptr count: " << self.use_count() << "\n";
// do something with self
}
};

int main() {
// 创建一个 MyClass 对象并用 shared_ptr 管理
std::shared_ptr<MyClass> ptr = std::make_shared<MyClass>();

// 在成员函数中使用 shared_from_this 来获取 shared_ptr
ptr->doSomething();

return 0;
}

工作原理:

  • 当一个对象被shared_ptr管理时,该对象的内存区域不仅包含对象的数据,还包括一个控制块
  • std::enable_shared_from_this通过继承和类型的绑定确保对象的控制块可访问
  • shared_from_this()通过调用shared_ptr的shared_ptr<T>::shared_from_this()来获取当前对象的控制块,并使用该控制块构造一个新的shared_ptr

限制:

不能在构造函数和析构函数中调用shared_from_this(),因为在构造函数中,shared_ptr尚未完全建立,因此对象的控制块尚未分配。而在析构函数中,对象已经销毁


C++中的智能指针
http://example.com/2024/12/09/C-中的智能指针/
作者
凌云行者
发布于
2024年12月9日
许可协议