我想了解列表是否在一般情况下是线程安全的,因此我进行了询问并找到了这个线程。 我得出的结论是,在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
。最终,
1
在
T
上调用
_M_hook
,然后发生以下事件:
1
向前指向T
1
向后指向B
B
向前指向1
T
向后指向1
这样,没有位置被“遗忘”。现在假设
1
和
2
在同一个列表的不同线程上被推回。完全有可能对于
1
,步骤(1)和(2)完成,然后
2
被完全推回,接着(1)必须完成。这种情况下会发生什么?我们有列表
B <-> 2 <-> T
,但是
1
指向
B
和
T
,因此当它们的指针被调整时,列表看起来像
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>
,但这个该死的东西已经太长了。