繁体   English   中英

std::shared_ptr 线程安全

[英]std::shared_ptr thread safety

我读过那个

“多个线程可以同时读取和写入不同的 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正在更新配置。 (仅适用于未来的请求)

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

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

向下滚动到“备注”部分,您将了解问题的实质。 基本上,一个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>());
...

它将是线程安全的。

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

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

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

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

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

MSDN 文本中的关键词确实是不同的 shared_ptr objects ,如之前的答案中所述。

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

// 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的引用计数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

概括

  • 不同的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++ 标准库函数不应直接或间接修改当前线程以外的线程可访问的对象,除非这些对象是通过函数的非常量参数直接或间接访问的,包括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 因此,分配是资源上的竞争条件,并且程序具有未定义的行为 基本的线程安全保证在这里不起作用,我们必须使用强线程安全保证 采取这个定义

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

基本上,对于非常量操作,我们必须同步对同一个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(); 
}

这是我对 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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM