std::list 线程安全

5
  1. 在我的列表中,一个线程只是执行push_back操作,而另一个线程偶尔会遍历整个列表并打印所有元素。在这种情况下,我需要锁定吗?
  2. 我有指向其他对象中元素的指针。这样做安全吗?我知道当vector需要更多空间时,它会移动所有对象,因此指针将无效。

    mylist.push_back(MyObj(1));
    if(someCond)
    {
    _myLastObj = &mylist.back();
    }

_myLastObj 的类型为 MyObj*

如果我使用vector,对象将被移动到不同的位置,指针将指向垃圾。使用列表是否安全?


它不是线程安全的。1)是的,你应该!2)我不明白。能再解释一下吗? - nullpotent
1
他有时也会对其进行变异。无重复。 - Puppy
2个回答

12
  1. 是的,你需要一个锁(某种形式的同步)。
  2. std::list 元素的指针仅在从列表中移除相应元素时才会失效。由于您的代码从未从列表中删除任何内容,因此您的指针是安全的。

关于为什么需要锁的具体原因,请考虑例如list::push_back可能按以下顺序执行:

  1. 分配新节点,
  2. 将列表尾部的 "next" 指针设置为新节点,
  3. 将新节点的 "next" 指针设置为 null (或其他结束列表标记),
  4. 将新节点的 "previous" 指针设置为先前的尾部,
  5. 将列表自己的指向尾部的指针设置为新节点。

如果您的读取线程在步骤 2 和 3 之间进入,则它将从先前的尾部到达新节点,然后尝试跟随未初始化的指针。炸掉了。

总的来说,需要同步是因为这是唯一保证写线程中所做更改以任何合理顺序(或根本)发布到读取线程的方法。

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


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

9
我想了解列表是否在一般情况下是线程安全的,因此我进行了询问并找到了这个线程。 我得出的结论是,在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并得到了我想要的结果。对于任何试图复制实验的人,只是一个注释,它可能会根据系统配置和调度算法等因素而起作用或不起作用。
考虑到链表的性质,我预计这样的实验会导致段错误或其他损坏的列表。如果这是您正在寻找的某些错误的原因,段错误将是一种仁慈。
这到底是怎么回事
在这里,我将解释发生了什么以及为什么(或者至少给出一个极其合理的解释)。如果您对并发问题不熟悉,请将其视为介绍。如果您是专家,请告诉我哪里不对或不完整。
我很好奇,所以我只看了gcc libstdc++实现。为了解释观察到的行为,需要简要解释列表的工作原理。
实施细节

这个链表的底层结构和算法没有什么有趣或奇怪的地方,但是有一些需要提到的C++实现细节。首先,列表的所有节点都派生自一个仅存储两个指针的共同基类。通过这种方式,列表的所有行为都被封装起来了。实际的节点除了从基类派生之外,还是带有非静态数据成员__gnu_cxx::__aligned_membuf<_Tp> _M_storage的结构体。这些节点知道列表的value_type,并从重新绑定到_List_node<_Tp>allocator_type中派生出来。这些节点的目的是获取和释放列表的存储空间,并使用它们的基类来维护数据的结构。(我推荐这篇论文来解释迭代器中类型如何被分离,它可能会阐明为什么某些事情被实现的方式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 <-> T,我们想要B <-> 1 <-> T。最终,1T上调用_M_hook,然后发生以下事件:
  1. 1向前指向T
  2. 1向后指向B
  3. B向前指向1
  4. T向后指向1
这样,没有位置被“遗忘”。现在假设12在同一个列表的不同线程上被推回。完全有可能对于1,步骤(1)和(2)完成,然后2被完全推回,接着(1)必须完成。这种情况下会发生什么?我们有列表B <-> 2 <-> T,但是1指向BT,因此当它们的指针被调整时,列表看起来像B <-> 1 <-> 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的值移动到寄存器eax中。将0x1添加到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<int>,但这个该死的东西已经太长了。


2
标准容器不是线程安全的。就这样。故事结束。 - Lightness Races in Orbit
2
我坚持我的分析,但我已经编辑了开头的重点。 - Nathan Chappell

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