- 我有一个列表,其中一个线程只是执行 push_back,而其他线程偶尔会遍历列表并打印所有元素。在这种情况下我需要锁吗?
我有指向其他对象中元素的指针。安全吗?我知道向量会在需要更多空间时移动所有对象,因此指针将失效。
mylist.push_back(MyObj(1));
if(someCond)
{
_myLastObj = &mylist.back();
}
_myLastObj
是类型MyObj*
如果我使用了向量,则对象将被移动到不同的位置,并且指针将指向垃圾。清单安全吗?
我有指向其他对象中元素的指针。安全吗?我知道向量会在需要更多空间时移动所有对象,因此指针将失效。
mylist.push_back(MyObj(1));
if(someCond)
{
_myLastObj = &mylist.back();
}
_myLastObj
是类型MyObj*
如果我使用了向量,则对象将被移动到不同的位置,并且指针将指向垃圾。清单安全吗?
std::list
仅当从列表中删除同一元素时,指向 a 元素的指针才会失效。由于您的代码从不从列表中删除任何内容,因此您的指针是安全的。对于需要锁的具体原因,例如考虑list::push_back
允许按以下顺序执行以下操作:
如果您的阅读器线程位于 2 和 3 之间,那么它将从前一个尾部转到新节点,然后尝试跟随未初始化的指针。繁荣。
不过,一般来说,您需要同步,因为这是保证编写器线程中所做的更改以任何合理的顺序(或根本没有)发布到读取器线程的唯一方法。
如果您想象不同的线程在不同的星球上运行,每个线程都有自己的程序内存副本,并且当您使用同步对象(或 C++11 中的原子变量)时,您的代码应该是正确的(a) ),加上 (b) 当您不使用同步对象但传输某些特定的部分更改时会破坏您的代码(例如两个字对象的一半或在这种情况下您需要发生的两个指针写入之一按特定顺序)。有时这种模型比严格必要的模型更保守,并导致代码变慢。但是一个不太保守的模型依赖于线程系统和内存模型的特定实现细节。
我想知道列表是否通常是线程安全的,所以我询问并找到了这个线程。我得出的结论是,在 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
列表的,并从allocator_type
反弹到_List_node<_Tp>
. 这些节点的目的是获取和释放列表的存储空间,并使用它们的基础来维护数据的结构。(我推荐这篇论文来解释类型是如何从迭代器中分解出来的,它可能会阐明为什么某些事情是以http://www.stroustrup.com/SCARY.pdf的方式实现的。对于受虐狂,观看此向导解释 c++ 分配器的美丽噩梦https://www.youtube.com/watch?v=YkiYOP3d64E)。然后该列表处理构建和销毁,并为图书馆用户提供界面,等等。
为节点使用通用(类型无关)基类的一个主要优点是您可以将任意节点链接在一起。如果不计后果地放弃,这不是很有帮助,但他们以可控的方式使用它。“尾节点”不是 type value_type
,而是 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()
检索指向该数字的指针,然后+=
是正确的数量。
那么,发生了什么?线程正在和函数中进入数据竞争(https://en.cppreference.com/w/cpp/language/memory_model) 。我在网上找不到漂亮的图片,所以我们说那是尾巴,是“背部”,我们想推到后面。也就是说,我们有列表(片段),我们想要。最终,调用,然后发生以下情况:_M_hook()
_M_inc_size()
T
B
1
B <-> T
B <-> 1 <-> T
1
_M_hook
T
1
指向T
1
向后指向B
B
指向1
T
向后指向1
这样,就不会“忘记”任何位置。现在这么说,1
并2
被推回同一列表上的不同线程中。完全合理的是,步骤 (1) 和 (2) 完成1
,然后2
被完全推回,然后 (1) 必须完成。在这种情况下会发生什么?我们有 list B <-> 2 <-> T
,但是1
指向B
and T
,所以当他们的指针被调整时, list 看起来像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>
,但这该死的东西已经太长了。