【问题标题】:std::list thread_safetystd::list 线程安全
【发布时间】:2012-07-05 09:50:14
【问题描述】:
  1. 我有一个列表,其中一个线程只是执行 push_back,而其他线程偶尔会循环遍历列表并打印所有元素。在这种情况下我需要锁吗?
  2. 我有指向其他对象中元素的指针。安全吗?我知道向量在需要更多空间时会移动所有对象,因此指针将失效。

    mylist.push_back(MyObj(1));
    如果(某些条件)
    {
    _myLastObj = &mylist.back();
    }

_myLastObj 的类型为 MyObj*

如果我使用了向量,对象将被移动到不同的位置,并且指针将指向垃圾。列表安全吗?

【问题讨论】:

标签: c++ stl thread-safety stdlist


【解决方案1】:
  1. 是的,您需要一个锁(某种形式的同步)。
  2. 指向std::list 元素的指针只有在从列表中删除相同元素时才会失效。由于您的代码从不从列表中删除任何内容,因此您的指针是安全的。

对于需要锁的具体原因,例如,考虑list::push_back 被允许按以下顺序执行以下操作:

  1. 分配一个新节点,
  2. 将链表尾部的“next”指针设置为新节点,
  3. 将新节点的“next”指针设置为 null(或其他一些列表结束标记),
  4. 将新节点的“previous”指针设置为前一个尾部,
  5. 将列表自身的指向尾指针设置为新节点。

如果您的阅读器线程位于 2 和 3 之间,那么它将从前一个尾部转到新节点,然后尝试跟随未初始化的指针。轰隆隆。

不过,一般来说,您需要同步,因为这是保证编写器线程中所做的更改以任何合理的顺序(或完全)发布到读取器线程的唯一方法。

如果您想象不同的线程在不同的星球上运行,每个线程都有自己的程序内存副本,并且当您使用同步对象(或 C 中的原子变量)时,您的代码应该是正确的(a) ++11),加上 (b) 当您不使用同步对象但传输某些特定的部分更改时会破坏您的代码(例如两个字对象的一半,或者在这种情况下,两个指针之一写入您需要以特定顺序发生)。有时这种模型比严格必要的模型更保守,并导致代码变慢。但不太保守的模型依赖于线程系统和内存模型的特定实现细节。

【讨论】:

  • 感谢您的解释。如果我使用 boost::slist 并执行 push_front?在这种情况下是否安全,因为我不会修改现有成员,因为它只会添加一个新节点并将下一个节点指向列表的开头?
  • @balki:仍然不能保证安全。假设实现首先修改链表的指针指向新节点,然后修改新节点的下一个指针指向前一个头。如果您想通过 Boost 源并将其与您的特定 C++ 实现的内存模型进行比较(例如,缓存是否一致),并让自己满意 boost::s_list 目前没有做任何此类事情,那么这就是您的决定。一般的答案是它不安全。如果您想要一个无锁队列,请搜索一个,但 lists_list 不是。
  • 即使在 std::list 的情况下,如果序列是 1,3,4,2,5 会怎样。然后它要么看到新节点,要么看不到。可以有中间状态吗?我同意如果多个线程尝试 push_back,这是一个问题,但就我而言,我确信只有一个线程会插入。
  • @balki:标准允许编写list 的实现以使您的代码有效,这对您没有用。它不能保证它,所以你的代码是不安全的。
【解决方案2】:

我想知道列表是否是线程安全的,所以我询问并找到了这个线程。我得出的结论是,在 gcc libstdc++ 中 std::list 的当前实现中,您可以安全地在一个线程中修改列表,并任意同时遍历列表,但不敢有两个线程修改同一个列表(没有同步)。此外,这种行为不应依赖。我已经撕掉了库代码以更详细地指出问题。我希望它会有所帮助。

首先让我们从列表线程安全的一般问题开始。我认为通过示例“证明”列表是不安全的会很好,所以我将以下代码放在一起。

#include <iostream>
#include <list>
#include <mutex>
#include <thread>

using namespace std;

list<int> unsafe_list;

class : public list<int> {
  mutex m;
  public:
  void push_back(int i) {
    lock_guard<mutex> lock{m};
    list<int>::push_back(i);
  }
} safe_list;

template <typename List> void push(List &l) {
  for (auto i = 0; i < 10000; ++i)
    l.push_back(0);
}

void report_size(const list<int> &li, char const *name) {
  size_t count{};
  for (auto && i : li) ++count;
  cout << name << endl;
  cout << "size() == " << li.size() << endl;
  cout << "count  == " << count << endl;
}

int main() {
  auto unsafe = []() { push(unsafe_list); };
  auto safe = []() { push(safe_list); };
  thread pool[]{
      thread{unsafe}, thread{unsafe}, thread{unsafe},
      thread{safe},   thread{safe},   thread{safe},
  };
  for (auto &&t : pool)
    t.join();
  report_size(unsafe_list, "unsafe_list");
  report_size(safe_list, "safe_list");
}

我得到的输出是:

unsafe_list
size() == 19559
count  == 390
safe_list
size() == 30000
count  == 30000

哎呀。这意味着我推送的几乎没有任何元素最终出现在列表中。但比这更糟糕!它不仅没有正确数量的元素,它认为它的数量与实际数量不同,而且这个数字也不是我想要的!虽然这意味着几乎可以肯定存在内存泄漏,但当我使用 valgrind 运行它时,所有操作都成功完成。我听说 valgrind 和其他工具在尝试处理并发时可能没有多大帮助,我想这就是证据。

首先我尝试一次推送 10 个元素左右,但没有发生任何可疑的事情。我认为它正在设法在其时间片内推送所有内容,因此我将其提高到 10000 并得到了我想要的结果。仅供尝试复制实验的任何人注意,它可能是否有效取决于系统配置和调度算法等。

鉴于链表的性质,我预计这样的实验会导致 seg-fault 或其他损坏的链表。如果这是您正在寻找的某些错误的原因,那么段错误将是一种仁慈。

这是怎么回事

在这里,我将准确地解释发生了什么以及为什么(或者至少给出一个非常合理的解释)。如果您未初始化并发问题,请将此视为介绍。如果您是专家,请告诉我我哪里错了或不完整。

我很好奇,所以我只是看了一下 gcc libstdc++ 的实现。为了解释观察到的行为,按顺序快速解释列表的工作原理。

实现细节

底层结构或算法一点也不有趣或奇怪,但有各种 C++ 实现细节需要提及。首先,列表的节点都派生自一个只存储两个指针的公共基类。这样,列表的所有行为都被封装了。实际的节点,除了从基派生之外,是具有非静态数据成员__gnu_cxx::__aligned_membuf&lt;_Tp&gt; _M_storage 的结构。这些节点知道列表的value_type,并从allocator_type 反弹到_List_node&lt;_Tp&gt; 派生。这些节点的目的是获取和释放列表的存储空间,并使用它们的基础来维护数据的结构。 (我推荐这篇论文来解释类型是如何从迭代器中分解出来的,它可能会阐明为什么某些事情是以http://www.stroustrup.com/SCARY.pdf 的方式实现的。对于受虐狂,看这个向导解释c++的美丽噩梦分配器https://www.youtube.com/watch?v=YkiYOP3d64E)。然后列表处理构造和销毁,并为库用户提供接口,等等等等。

为节点使用通用(类型无关)基类的一个主要优点是您可以将任意节点链接在一起。如果不计后果地放弃,这不是很有帮助,但他们以可控的方式使用它。 “尾节点”不是value_type 类型,而是size_t 类型。尾节点保存列表的大小! (我花了几分钟才弄清楚发生了什么,但这很有趣,所以没什么大不了的。这样做的主要优点是存在的每个列表都可以具有相同类型的尾节点,因此代码重复更少用于处理尾节点,列表只需要一个非静态数据成员来完成它需要做的事情)。

所以,当我将一个节点推到列表的后面时,end() 迭代器将传递给以下函数:

 template<typename... _Args>
   void
   _M_insert(iterator __position, _Args&&... __args)
   {
 _Node* __tmp = _M_create_node(std::forward<_Args>(__args)...);
 __tmp->_M_hook(__position._M_node);
 this->_M_inc_size(1);
   }

_M_create_node() 最终使用正确的分配器为节点获取存储空间,然后尝试使用提供的参数在那里构造一个元素。 _M_hook() 函数的“点”是将指针指向它们应该指向的指针,并在此处列出:

void
_List_node_base::
_M_hook(_List_node_base* const __position) _GLIBCXX_USE_NOEXCEPT
{
  this->_M_next = __position;
  this->_M_prev = __position->_M_prev;
  __position->_M_prev->_M_next = this;
  __position->_M_prev = this;
}

指针的操作顺序很重要。这就是我声称您可以在同时操作列表的同时进行迭代的原因。稍后再谈。然后,调整大小:

void _M_inc_size(size_t __n) { *_M_impl._M_node._M_valptr() += __n; }

正如我之前所说,列表有一个 size_t 类型的尾节点,因此,正如您所猜想的那样,_M_impl._M_node._M_valptr() 检索到该数字的指针,然后 += 是正确的数量。

观察到的行为

那么,发生了什么?线程正在_M_hook()_M_inc_size() 函数中进入数据竞争 (https://en.cppreference.com/w/cpp/language/memory_model)。我在网上找不到好看的图片,所以假设T 是尾部,B 是“后部”,我们想将1 推到后部。也就是说,我们有列表(片段)B &lt;-&gt; T,我们想要B &lt;-&gt; 1 &lt;-&gt; T。最终,1T 上调用_M_hook,然后出现以下情况:

  1. 1 指向T
  2. 1 向后指向B
  3. B 指向1
  4. T 向后指向1

这样,就不会“忘记”任何位置。现在说12 在同一个列表的不同线程中被推回。对于1,步骤(1)和(2)完成,然后2 被完全推回,然后(1)必须完成,这是完全合理的。在这种情况下会发生什么?我们有列表B &lt;-&gt; 2 &lt;-&gt; T,但1 指向BT,所以当调整它们的指针时,列表看起来像B &lt;-&gt; 1 &lt;-&gt; T,这是一个内存泄漏儿子。

就迭代而言,无论您是向后还是向前,都将始终正确地在列表中递增。但是,这种行为似乎是由标准保证的,因此如果代码依赖于这种行为,那么它是脆弱的。

大小呢???

好吧,这就像并发101,一个老故事可能被更好地讲述了很多次,我希望它至少值得看看库代码。我认为尺寸问题更有趣一些,我当然从中学到了一些东西。

基本上,因为被递增的值不是“局部”变量,所以必须将其值读入寄存器,将一个值加到该值上,然后将该值存储回变量中。先看一些拼装(我的拼装游戏比较弱,有指正的请不要客气)。考虑以下程序:

int i{};
int main() {
  ++i;
}

当我在对象上运行 objdump -D 时,我得到:

Disassembly of section .text:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   8b 05 00 00 00 00       mov    0x0(%rip),%eax        # a <main+0xa>
   a:   83 c0 01                add    $0x1,%eax
   d:   89 05 00 00 00 00       mov    %eax,0x0(%rip)        # 13 <main+0x13>
  13:   b8 00 00 00 00          mov    $0x0,%eax
  18:   5d                      pop    %rbp
  19:   c3                      retq   

4:i 的值移动到寄存器eax0x1 被添加到eax,然后eax 被移回i。那么,这与数据竞赛有什么关系呢?再看一下更新列表大小的函数:

void _M_inc_size(size_t __n) { *_M_impl._M_node._M_valptr() += __n; }

将列表的当前大小加载到寄存器中是完全合理的,然后在此列表上运行的另一个线程会执行我们的操作。因此,我们将列表的旧值存储在寄存器中,但我们必须保存该状态并将控制权转移给其他人。也许他们成功地将一个项目添加到列表中并更新了大小,然后将控制权交还给我们。我们恢复我们的状态,但我们的状态不再有效!我们有列表的旧大小,然后我们将其递增,并将其值存储回内存中,忘记其他线程执行的操作。

最后一件事

正如我之前提到的,i 的“局部性”在上述程序中发挥了作用。这一点的重要性体现在以下几点:

int main() {
  int i{};
  ++i;
}

Disassembly of section .text:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)
   b:   83 45 fc 01             addl   $0x1,-0x4(%rbp)
   f:   b8 00 00 00 00          mov    $0x0,%eax
  14:   5d                      pop    %rbp
  15:   c3                      retq   

这里可以看出没有值被存储到寄存器,也没有寄存器被压入某个变量。不幸的是,据我所知,这并不是避免并发问题的好技巧,因为对同一个变量进行操作的多个线程必然必须通过内存访问对其进行操作,而不是只能通过寄存器。我很快就离开了我的联盟,但我很确定情况就是这样。下一个最好的方法是使用atomic&lt;int&gt;,但这该死的东西已经太长了。

【讨论】:

  • 标准容器不是线程安全的。时期。故事结束。
  • 我支持我的分析,但我在一开始就修改了我的重点。
猜你喜欢
  • 1970-01-01
  • 2013-02-10
  • 2013-01-07
  • 2012-03-07
  • 1970-01-01
  • 2019-06-07
  • 2017-07-07
相关资源
最近更新 更多