【问题标题】:std::shared_ptr thread safetystd::shared_ptr 线程安全
【发布时间】:2013-01-07 02:47:21
【问题描述】:

我读过

"多个线程可以同时读写不同的 shared_ptr 对象,即使对象是共享的副本 所有权。” (MSDN: Thread Safety in the Standard C++ Library)

这是否意味着更改 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 分配的新值,但无论哪种方式,它都会有一个有效的 shared_ptr 到 myClass?

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

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

【问题讨论】:

  • 多个线程可以同时读写不同的shared_ptr对象,关键词是不同
  • @AlokSave 这是我最初的想法,但后来这似乎是多余的事情。当然你可以读写不同的对象...
  • 这不是“偏门”。智能指针指向实现引用计数的通用帮助对象。使用简单的实现,辅助对象上的引用计数可能会出错。不过,我认为您的问题是一个很好的问题,非常欢迎您给出明确的答案。
  • 我已经扩展了我公司的 shared_ptr 实现以支持weak_ptr。我可以告诉你,除了在操作引用计数的方法中使用完整的临界区之外,没有其他方法可以使它成为线程安全的。 (这里的关键部分是复数)标准 shared_ptr 在 release() 方法中使用 atomic inc/dec 和 cmp/xch 在删除之前检查是否为 0。由于第二个引用计数(弱引用计数),这不是线程安全的。测试通过后,弱 ref 可能会被共享,并且您有一个悬空。繁荣。
  • private是关键字,不能用作变量名

标签: c++ std shared-ptr


【解决方案1】:

总结

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

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

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


基本线程安全

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

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

至于标准,有如下写法:

[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 的引用计数块是标准库的实现细节。尽管上面的操作是不断的(read from),但实现需要修改计数器。这种情况描述如下:

[16.4.6.10/7]

如果对象对用户不可见并且受到保护以防止数据竞争,则实现可以在线程之间共享它们自己的内部对象。

这就是 Herb Sutter calls 内部同步

那么内部同步的目的是什么?只是对内部知道共享和内部拥有的部分进行必要的同步,但调用者无法同步,因为他不知道共享,也不应该因为调用者不知道而需要同步拥有它们,内部拥有。所以在类型的内部实现中,我们做了足够的内部同步来回到调用者可以承担他通常的注意义务的水平,并以通常的方式正确同步任何可能实际上可能的对象共享。

因此,基本线程安全确保了@的不同实例上的所有操作(包括复制构造函数复制赋值)的线程安全987654340@ 无需额外同步,即使这些实例是副本并共享同一对象的所有权。

强大的线程安全性

但考虑以下情况:

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

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

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

【讨论】:

  • 您提到第二个代码 sn-p 的“分配是资源上的竞争条件并且程序具有未定义的行为”,我错过了那部分......竞争条件是什么?您是指ptr 有来自多个线程的多个分配的可能性吗?
  • 是的,我的意思是从不同的线程并发写访问同一对象ptr
【解决方案2】:

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

【讨论】:

    【解决方案3】:

    您所阅读的内容并不代表您认为的意思。首先,尝试 shared_ptr 本身的 msdn 页面。

    向下滚动到“备注”部分,您将了解问题的实质。基本上,shared_ptr&lt;&gt; 指向一个“控制块”,这就是它如何跟踪有多少shared_ptr&lt;&gt; 对象实际上指向“真实”对象。所以当你这样做时:

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

    虽然这里只有 1 次调用通过 make_shared 分配内存,但有两个“逻辑”块您不应该同样对待。一个是int,它存储实际值,另一个是控制块,它存储所有使其工作的shared_ptr&lt;&gt;“魔法”。

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

    为了强调,我把它放在了自己的位置上。 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 中提取的 sn-p 基本上意味着“对控制块的访问是线程安全的”,因此可以根据需要在不同的线程上创建和销毁其他 shared_ptr&lt;&gt; 实例。

    //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&lt;&gt; 对象来读取 global_instance 对象,但您不能对其进行写入。

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

    a 的值未定义。它可能是 7,也可能是 3,或者也可能是其他任何东西。 shared_ptr&lt;&gt; 实例的线程安全仅适用于管理相互初始化的 shared_ptr&lt;&gt; 实例,而不是它们指向的对象。

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

    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&lt;&gt; 是一种确保多个对象所有者 确保一个对象被破坏的机制,而不是一种确保多个线程 可以正确访问一个对象的机制。你仍然需要一个单独的同步机制来在多个线程中安全地使用它(比如std::mutex)。

    考虑它的最佳方式 IMO 是 shared_ptr&lt;&gt; 确保指向同一内存的多个副本对于自身没有同步问题,但不会为指向的对象。就这样对待吧。

    【讨论】:

    • 您在第一行中所说的是错误的。 make_shared 的存在是为了能够将 int 和 ref 计数器类嵌套到同一个内存块中的非常好的副作用。 (避免碎片和限制缓存未命中,也避免调用 2 new 的缓慢)。只有一个malloc 和2 个使用make_shared 的位置新闻。
    • 您需要进一步阅读:然后真正发生的是分配了两个不同的内存部分。它是一次完成的,但它是两个“逻辑”块。将其视为两个逻辑块对于理解什么是线程安全的和不是线程安全的很重要。
    • v.oddou 是对的,我认为只有一个内存分配。再说一次,零(和凯文)是对的,从逻辑上讲,有两个内存区域,其中只有一个是线程安全的。不过,我认为重要的是要指出 make_shared 只执行一次内存分配。
    • 我已经说过了,虽然可能不是您想要的方式:“它是一次完成的,但它是两个“逻辑”块。”
    • @KevinAnderson 我可能读错了,但在你的第一个'这很好'sn-ps 与in main, in thread1 是不是不安全从全局读取而其他线程正在写入它,它可以在那里被撕裂写/读吗?不确定构造是原子的
    【解决方案4】:

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

    // 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
    

    【讨论】:

      【解决方案5】:

      这意味着您将拥有一个有效的shared_ptr,以及一个有效的引用计数。

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

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

      【讨论】:

        【解决方案6】:

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

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

        MSDN 文本中的关键词确实是不同的 shared_ptr 对象,正如之前的答案中已经说明的那样。

        【讨论】:

          【解决方案7】:

          除了 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++14 (N4140) 匹配。我猜你的参考是从 C++14 后的草案中得到的,因为编号增加了,但文本是一样的
          • 您大概可以使用_explicit 表单进行优化,对吧? ... = atomic_load_explicit(&amp;global, std::memory_order_acquire);atomic_store_explicit(&amp;global, make_shared&lt;myClass&gt;(), std::memory_order_release);。在像 x86 这样的强排序系统上,these explicit forms don't even require memory barriers, where the default usage (with seq_cst) would.
          • 它是 C++ 11 标准第 20.7.2.5 节的一部分。
          猜你喜欢
          • 2019-06-07
          • 1970-01-01
          • 1970-01-01
          • 2013-05-05
          • 1970-01-01
          • 1970-01-01
          • 2021-12-19
          相关资源
          最近更新 更多