我想知道列表是否是线程安全的,所以我询问并找到了这个线程。我得出的结论是,在 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<_Tp> _M_storage 的结构。这些节点知道列表的value_type,并从allocator_type 反弹到_List_node<_Tp> 派生。这些节点的目的是获取和释放列表的存储空间,并使用它们的基础来维护数据的结构。 (我推荐这篇论文来解释类型是如何从迭代器中分解出来的,它可能会阐明为什么某些事情是以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>,但这该死的东西已经太长了。