std::shared_ptr 线程安全性

64

我读到过:

"即使被复制的 shared_ptr 对象共享所有权,多个线程也可以同时读写不同的 shared_ptr 对象。" (MSDN:标准 C++ 库中的线程安全)

那是否意味着更改 shared_ptr 对象是安全的?
例如,下面的代码是否被认为是安全的:

shared_ptr<myClass> global = make_shared<myClass>();
...

//In thread 1
shared_ptr<myClass> private = global;
...

//In thread 2
global = make_shared<myClass>();
...

在这种情况下,我能确定线程1中的private变量将具有global的原始值还是线程2分配的新值,但无论哪种情况它都将具有有效的共享指针指向myClass吗?

==编辑==
只是为了解释我的动机。我想要一个共享指针来保存我的配置,并且我有一个线程池来处理请求。
所以global是全局配置。
线程1在开始处理请求时采取当前配置。
线程2正在更新配置。(仅适用于未来的请求)

如果它起作用,我可以通过这种方式更新配置,而不会在处理请求的过程中破坏它。


11
多个线程可以同时读取和写入不同的shared_ptr对象,关键词是“不同”。 - Alok Save
4
那是我的最初想法,但后来似乎是一件多余的事情。当然,你可以读取和写入不同的对象... - Roee Gavirel
6
这不是“离题”。智能指针指向实现引用计数的常见辅助对象。使用天真的实现方式,辅助对象上的引用计数可能会出错。尽管如此,我认为你的问题很好,非常欢迎一份明确的答案。 - Suma
我已经扩展了公司的shared_ptr实现以支持weak_ptr。我可以告诉你,除了在操作引用计数的方法中使用完整的临界区之外,没有其他使其线程安全的方法。(这里的关键部分是复数)标准shared_ptr在release()方法中使用原子inc/dec和cmp/xch来检查是否为0,然后再进行删除。由于第二个引用计数(弱引用计数),这不是线程安全的。在测试通过后,弱引用可能会变成共享引用,从而导致悬空。砰。 - v.oddou
2
private 是一个关键字,不能用作变量名。 - M.M
显示剩余2条评论
7个回答

115
你所读的内容并不意味着你所想的。首先,请参阅shared_ptr页面的“Remarks”部分。
向下滚动,你会找到问题的核心。基本上,shared_ptr<>指向一个“控制块”,这是它跟踪多少shared_ptr<>对象实际上指向“真实”对象的方式。所以当你这样做时:
shared_ptr<int> ptr1 = make_shared<int>();

尽管只有一个调用make_shared来分配内存,但有两个“逻辑”块,您不应该将它们视为相同。其中一个是存储实际值的int,另一个是控制块,它存储使其工作的所有shared_ptr<>“魔法”。

仅控制块本身是线程安全的。

我将其放在了单独的一行以突出显示。shared_ptr内容和写入同一个shared_ptr实例都不是线程安全的。以下是演示我的意思的内容:

// In main()
shared_ptr<myClass> global_instance = make_shared<myClass>();
// (launch all other threads AFTER global_instance is fully constructed)

//In thread 1
shared_ptr<myClass> local_instance = global_instance;

事实上,您可以在所有线程中尽情这样做。 当local_instance被销毁(超出范围)时,它也是线程安全的。 可以访问global_instance,不会有影响。 你从msdn中提取的代码段基本上意味着“对控制块的访问是线程安全的”,因此可以在不同的线程上创建和销毁其他shared_ptr<>实例,只要需要。

//In thread 1
local_instance = make_shared<myClass>();

这很好。它会影响global_instance对象,但只是间接地。它指向的控制块将以线程安全的方式递减。local_instance将不再指向与global_instance相同的对象(或控制块)。

//In thread 2
global_instance = make_shared<myClass>();

如果您从其他线程访问 global_instance(正如您所说的那样),那么几乎可以肯定这是不好的。如果您这样做,它需要一个锁,因为您正在写入到 global_instance 所在的位置,而不仅仅是从中读取。因此,除非通过锁定对其进行了保护,否则从多个线程写入对象是不好的。因此,您可以通过将新的shared_ptr<>对象分配给它来从 global_instance 中读取对象,但无法对其进行写入。

// In thread 3
*global_instance = 3;
int a = *global_instance;

// In thread 4
*global_instance = 7;

a 的值是未定义的。它可能是 7,也可能是 3,或者可能是其他任何值。 shared_ptr<> 实例的线程安全仅适用于管理从彼此初始化的 shared_ptr<> 实例,而不是它们所指向的内容。

为了强调我的意思,请看这个例子:

shared_ptr<int> global_instance = make_shared<int>(0);

void thread_fcn();

int main(int argc, char** argv)
{
    thread thread1(thread_fcn);
    thread thread2(thread_fcn);
    ...
    thread thread10(thread_fcn);

    chrono::milliseconds duration(10000);
    this_thread::sleep_for(duration);

    return;
}

void thread_fcn()
{
    // This is thread-safe and will work fine, though it's useless.  Many
    // short-lived pointers will be created and destroyed.
    for(int i = 0; i < 10000; i++)
    {
        shared_ptr<int> temp = global_instance;
    }

    // This is not thread-safe.  While all the threads are the same, the
    // "final" value of this is almost certainly NOT going to be
    // number_of_threads*10000 = 100,000.  It'll be something else.
    for(int i = 0; i < 10000; i++)
    {
        *global_instance = *global_instance + 1;
    }
}

shared_ptr<>是一种机制,用于确保多个对象所有者确保对象被销毁,而不是用于确保多个线程可以正确访问一个对象。您仍需要单独的同步机制才能在多个线程中安全使用它(例如std::mutex)。

我认为最好的理解方式是,shared_ptr<>确保指向同一内存的多个副本没有自身的同步问题,但对所指向的对象没有任何作用。 把它当作这样处理。


6
你开头所说的话是错误的。make_shared 的存在非常好,因为它能够将 int 和 ref 计数器类嵌套到同一内存块中(以避免碎片化和减少缓存未命中,并避免调用两个 new 带来的慢速问题)。使用 make_shared 只需要进行一次 malloc 和两次 placement new。 - v.oddou
4
您需要再读一点:实际上正在分配两个不同的内存部分。虽然是同时进行的,但是它们是两个“逻辑”块。将其视为两个逻辑块非常重要,以了解什么是线程安全的,什么不是线程安全的。 - Zero
6
v.oddou是正确的,只有一个内存分配。然而,Zero(和Kevin)在逻辑上也是正确的,即存在两个内存区域,其中只有一个是线程安全的。不过,我认为重要的是指出make_shared仅执行一次内存分配。 - Shachar Shemesh
5
我之前说过了,虽然可能不是你想要的表达方式:“它一次完成,但是分为两个‘逻辑’块。” - Kevin Anderson
3
@KevinAnderson 我可能理解错误,但在你最早的“这没问题”的片段之一中,“在主函数中,在 thread1 中”读取全局变量时是否不安全,因为另一个线程正在写入它,那里是否有可能出现竞争写/读的情况?我不确定这个结构是原子性的。 - Oleg Bogdanov
显示剩余5条评论

34

除了Kevin所写的内容外,C++14规范还提供了对shared_ptr对象本身进行原子访问的额外支持:

20.8.2.6 shared_ptr 原子访问 [util.smartptr.shared.atomic]

如果通过本节中的函数独占性地访问共享的shared_ptr对象,并将该实例作为它们的第一个参数传递,那么从多个线程对该对象的并发访问不会引入数据竞争。

因此,如果你这样做:

//In thread 1
shared_ptr<myClass> private = atomic_load(&global);
...

//In thread 2
atomic_store(&global, make_shared<myClass>());
...

它将是线程安全的。


在 C++17 及更高版本中,您应该使用

atomic<shared_ptr<myClass>> global;

并且所有对 global 的访问将是线程安全的。然而,这只在 C++17 及更高版本中合法。


已经进行了编辑,使参考编号与 C++14(N4140)相匹配。我猜你的参考资料来自C++14草案之后的版本,因为编号已经增加,但文本是相同的。 - M.M
你可以通过使用 _explicit 形式进行优化,对吧?例如:... = atomic_load_explicit(&global, std::memory_order_acquire);atomic_store_explicit(&global, make_shared<myClass>(), std::memory_order_release);。在像 x86 这样的强序系统上,这些显式形式甚至不需要内存屏障,而默认用法(使用 seq_cst)则需要。 - ShadowRanger
6
这是C++11标准的一部分,位于第20.7.2.5节。 - bop
1
请注意,在C++17和C++20中,这已成为一个废弃的特性,是[depr.util.smartptr.shared.atomic]的一部分。Annex D是规范性文件,因此您仍然可以依赖它,但它可能会在未来的C++标准修订版中被删除。 - Adam Rosenfield
另外,由于新版本的std::atomic<std::shared_ptr>模板,std::atomic_*已被弃用。然而截至2023年1月,它尚未得到clang的支持(https://en.cppreference.com/w/cpp/compiler_support#C.2B.2B20_library_features)。 - Louis Go

5
这意味着您将拥有一个有效的共享指针(shared_ptr),且有效地进行引用计数。
您描述了两个线程之间正在尝试读取/赋值相同变量的竞争条件。
由于这通常是未定义行为(只在程序的上下文和时序中才有意义),shared_ptr无法处理该情况。

4

摘要

  • 不同的 std::shared_ptr 实例可以同时被多个线程读取和修改,即使这些实例是副本并共享相同对象的所有权。

  • 相同的 std::shared_ptr 实例可以被多个线程同时读取。

  • 相同的 std::shared_ptr 实例不能直接由多个线程修改而无需额外的同步。但可以通过互斥锁和原子操作来实现。


基本线程安全性

标准并未说明智能指针的线程安全性,特别是 std::shared_ptr 的线程安全性或其如何确保线程安全。正如 @Kevin Anderson 上面所指出的,std::shared_ptr 实际上提供了一个共享对象所有权并确保正确销毁它的功能,而不是提供正确的并发访问。实际上,像任何其他内置类型一样,std::shared_ptr 受到所谓的“基本线程安全保证”的影响。该保证在 this 论文中定义为:

基本线程安全保证是指标准库函数需要可重入,并且对于标准库类型的对象的非变异使用不会引入数据竞争。这对性能几乎没有影响。它确实提供了承诺的安全性。因此,实现必须满足这种基本线程安全保证。

至于标准,有以下措辞:

[16.4.6.10/3]

一个 C++ 标准库函数不得直接或间接地修改除当前线程之外的其他线程可访问的对象,除非这些对象是通过函数的非 const 参数(包括 this)直接或间接访问的。
因此,以下代码必须被视为线程安全:
std::shared_ptr<int> ptr = std::make_shared<int>(100);

for (auto i= 0; i<10; i++){
    std::thread([ptr]{                        
    auto local_p = ptr;  # read from ptr
    //...
    }).detach(); 
}

但是我们知道,std::shared_ptr 是一个引用计数指针,当使用计数降至零时,所指向的对象将被删除。 std::shared_ptr 的引用计数块是标准库的实现细节。尽管上面的操作(读取)是常量级别的,但实现需要修改计数器。这种情况描述如下:

[16.4.6.10/7]

如果对象对用户不可见且受到数据竞争的保护,实现可以在线程之间共享自己的内部对象。这就是Herb Sutter所称的“内部同步”。因此,“基本线程安全”确保了对不同std::shared_ptr实例上的所有操作(包括复制构造函数和复制赋值)的线程安全,即使这些实例是副本并共享对同一对象的所有权,也无需额外的同步。

强线程安全性

但请考虑以下情况:

std::shared_ptr<int> ptr = std::make_shared<int>(100);

for (auto i= 0; i<10; i++){
    std::thread([&ptr]{                        
    ptr = std::make_shared<int>(200);
    //...           
    }).detach(); 
}

该 lambda 函数通过引用绑定了 `std::shared_ptr` 的 `ptr`。因此,赋值操作是对资源(即 `ptr` 对象本身)的竞争条件,并且程序具有未定义行为。这里基本线程安全保证不起作用,我们必须使用强线程安全保证。参考这个 definition

强线程安全保证是指对标准库类型对象的突变使用要求不引入数据竞争。这将严重影响性能。此外,实际上的安全通常需要锁定多个成员函数调用,因此提供每个函数调用锁定会产生一种不存在的安全错觉。出于这些原因,没有为突变共享对象提供一个全面的强线程安全保证,并相应地对程序施加约束。

基本上,我们必须同步访问同一个非 const 操作的 `std::shared_ptr` 实例。我们可以通过以下方式来实现:

一些例子:

std::mutex

std::shared_ptr<int> ptr = std::make_shared<int>(100);
std::mutex mt;

for (auto i= 0; i<10; i++){
    std::thread([&ptr, &mt]{  
      std::scoped_lock lock(mt);                      
      ptr = std::make_shared<int>(200);
      //...                   
      }).detach(); 
}

原子函数:
std::shared_ptr<int> ptr = std::make_shared<int>(100);

for (auto i= 0; i<10; i++){
  std::thread([&ptr]{      
    std::atomic_store(&ptr, std::make_shared<int>(200));                   
  }).detach(); 
}

你提到了第二段代码存在“赛跑条件”,程序的行为是未定义的,我错过了那部分...什么是赛跑条件?你是指ptr可能会被多个线程多次赋值的情况吗? - HCSF
1
是的,我的意思是不同线程对同一对象ptr进行并发写访问。 - alex_noname

2

读取操作在它们之间不会发生数据竞争,因此只要所有线程仅使用const方法(包括创建副本)即可安全地共享相同的shared_ptr实例。一旦一个线程使用非const方法(如“将其指向另一个对象”),这种用法就不再是线程安全的。

OP示例不是线程安全的,并且需要在线程1中使用原子加载和在线程2中使用原子存储(C++11中的第2.7.2.5节)才能使其线程安全。

MSDN文本中的关键词确实是不同的shared_ptr对象,正如先前的答案所述。


1
我认为目前对于所描述的情况,此问题的答案都是误导性的。我在问题中描述了一个非常相似的情况。所有其他线程只需要对当前配置进行只读访问,这可以通过以下方式实现:
// In thread n
shared_ptr<MyConfig> sp_local = sp_global;

这些线程都不会修改MyConfig对象的内容。每执行一次上述行,sp_global的引用计数就会增加。

线程1会定期将sp_global重置为另一个配置实例:

// In thread 1
shared_ptr<MyConfig> sp_global = make_shared<MyConfig>(new MyConfig);

这也应该是安全的。它将sp_global的引用计数设置回1,并且sp_global现在指向最新的配置,与所有新的本地副本一样。因此,如果我没有漏掉任何东西,这应该完全是线程安全的。
#include <iostream>
#include <memory>

using namespace std;

shared_ptr<int> sp1(new int(10));

int main()
{
    cout<<"Hello World! \n";

    cout << "sp1 use count: " << sp1.use_count() << ", sp1: " << *sp1 << "\n";
    cout << "---------\n";

    shared_ptr<int> sp2 = sp1;
    shared_ptr<int>* psp3 = new shared_ptr<int>;
    *psp3 = sp1;
    cout << "sp1 use count: " << sp1.use_count() << ", sp1: " << *sp1 << "\n";
    cout << "sp2 use count: " << sp2.use_count() << ", sp2: " << *sp2 << "\n";
    cout << "sp3 use count: " << psp3->use_count() << ", sp3: " << *(*psp3) << "\n";
    cout << "---------\n";

    sp1.reset(new int(20));

    cout << "sp1 use count: " << sp1.use_count() << ", sp1: " << *sp1 << "\n";
    cout << "sp2 use count: " << sp2.use_count() << ", sp2: " << *sp2 << "\n";
    cout << "sp3 use count: " << psp3->use_count() << ", sp3: " << *(*psp3) << "\n";
    cout << "---------\n";

    delete psp3;
    cout << "sp1 use count: " << sp1.use_count() << ", sp1: " << *sp1 << "\n";
    cout << "sp2 use count: " << sp2.use_count() << ", sp2: " << *sp2 << "\n";
    cout << "---------\n";

    sp1 = nullptr;

    cout << "sp1 use count: " << sp1.use_count() << "\n";
    cout << "sp2 use count: " << sp2.use_count() << ", sp2: " << *sp2 << "\n";

    return 0;
}

和输出

Hello World!
sp1 use count: 1, sp1: 10
---------
sp1 use count: 3, sp1: 10
sp2 use count: 3, sp2: 10
sp3 use count: 3, sp3: 10
---------
sp1 use count: 1, sp1: 20
sp2 use count: 2, sp2: 10
sp3 use count: 2, sp3: 10
---------
sp1 use count: 1, sp1: 20
sp2 use count: 1, sp2: 10
---------
sp1 use count: 0
sp2 use count: 1, sp2: 10

0

这是我对shared_ptr线程安全性的理解。在我看来,当涉及到shared_ptr的线程安全性时,有三个方面需要考虑。

第一个方面是shared_ptr本身。我认为shared_ptr本身并不是线程安全的,这意味着当我们尝试在多个线程中访问一个shared_ptr对象,并且其中一个访问是写入操作时,就会出现数据竞争。例如,在以下情况下,我们会遇到数据竞争:

# Main Thread
shared_ptr<string> global_ptr = make_shared<string>();
string str = *global_ptr;

# Thread 1
global_ptr.reset();

第二个方面是shared_ptr的内部结构。我认为它是线程安全的。结果是,在访问多个shared_ptr对象并且这些对象指向同一个托管对象时,不存在数据竞争。例如,在以下情况下,我们没有数据竞争:
# Main Thread
shared_ptr<string> global_ptr = make_shared<string>();
string str = *global_ptr;

# Thread 1
shared_ptr<string> local_ptr = global_ptr;
local_ptr.reset();

第三个方面是shared_ptr中的托管对象可能是线程安全的,也可能不是。例如,在以下情况下,我会说存在数据竞争:
# Main Thread
shared_ptr<string> global_ptr = make_shared<string>();
string str = *global_ptr;

# Thread 1
shared_ptr<string> local_ptr = global_ptr;
(*local_ptr).clear();

参考资料

https://gcc.gnu.org/onlinedocs/libstdc++/manual/memory.html#shared_ptr.thread

https://en.cppreference.com/w/cpp/memory/shared_ptr/atomic


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接