std::shared_ptr线程安全

std::shared_ptr thread safety

本文关键字:线程 安全 ptr shared std      更新时间:2023-10-16

我读过

"多个线程可以同时读取和写入不同的 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 的有效shared_ptr?

==

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

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

你正在阅读的内容并不意味着你认为它的意思。 首先,尝试 msdn 页面shared_ptr本身。

向下滚动到"备注"部分,您将了解问题的实质。 基本上,shared_ptr<>指向一个"控制块",这是它跟踪有多少shared_ptr<>对象实际指向"真实"对象的方式。 因此,当您执行此操作时:

shared_ptr<int> ptr1 = make_shared<int>();

虽然这里只有 1 个调用通过 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)。

考虑它 IMO 的最佳方式是,shared_ptr<>确保指向同一内存的多个副本本身没有同步问题,但不会对指向的对象执行任何操作。 这样对待它。

为了补充 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 及更高版本中是合法的。

这意味着您将拥有有效的shared_ptr和有效的引用计数。

您正在描述尝试读取/分配给同一变量的 2 个线程之间的争用条件。

因为这通常是未定义的行为(它仅在单个程序的上下文和时间中有意义),shared_ptr不会处理它。

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

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

MSDN 文本中的关键字确实shared_ptr对象不同,如前面的答案中所述。

总结

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

  • 同一个std::shared_ptr实例可以由多个线程同时读取。

  • 同一std::shared_ptr实例不能由多个线程直接修改,而无需额外的同步。但可以通过互斥体和原子学来完成。

<小时 />

基本线程安全

该标准没有说明智能指针的线程安全性,特别是std::shared_ptr,或者它们如何帮助确保它。正如@Kevin Anderson上面指出的那样,std::shared_ptr实际上提供了一种工具来共享对象的所有权并确保它被正确销毁,而不是提供正确的并发访问。事实上, std::shared_ptr ,像任何其他内置类型一样,受制于所谓的 basic thread-safety guarantee .本文将其定义为:

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

至于标准,有以下措辞:

[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对象本身)上的竞争条件,并且程序具有未定义的行为。基本的线程安全保证在这里不起作用,我们必须使用强线程安全保证取这个定义:

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

基本上,对于非常量操作,我们必须同步对同一std::shared_ptr实例的访问。我们可以通过以下方式做到这一点:

  • 同步原语(如std::mutex
  • 原子函数(自 C++20 起已弃用)
  • std::shared_ptr的原子专业化(自 C++20 年起)。(我不确定今天是否有编译器支持这一点)。

一些例子:

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(); 
}

我认为到目前为止对这个问题的回答在所描述的场景方面具有误导性。我在问题中描述了一个非常相似的场景。所有其他线程(需要)仅对当前配置进行只读访问,这是通过以下方式实现的:

// 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 的 ref 计数设置回 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
这是我

对shared_ptr线程安全性的理解。IMO,在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