是否有必要锁定一个*只从一个线程写入*而*只从*另一个读取?

时间:2014-07-10 17:11:24

标签: c++ c arrays multithreading

我有两个线程正在运行。他们共享一个阵列。其中一个线程向数组添加新元素(并删除它们),另一个使用此数组(仅限读取操作)。 在我添加/删除数组或从中读取数组之前,是否有必要锁定数组?

进一步详情:

  • 我需要继续在另一个线程中迭代整个数组。如前所述,那里没有写操作。 “只需扫描类似固定大小的循环缓冲区”
  • 在这种情况下,最简单的方法就是使用锁。然而,锁可能非常慢。如果可以避免使用锁,我不想使用锁。此外,从讨论中得出,可能没有必要(实际上不是)锁定阵列上的所有操作。只是锁定数组的迭代器管理(将由另一个线程使用的计数变量)就足够了

我不认为这个问题“过于宽泛”。如果仍然如此,请告诉我。我知道这个问题并不完美。为了能够解决问题,我必须至少结合3个答案 - 这表明大多数人无法完全了解所有问题,并被迫做一些猜测工作。但大多数都是通过我试图纳入问题的评论得出的。答案帮助我非常客观地解决了我的问题,我认为这里提供的答案对于从多线程开始的人来说是非常有用的资源。

10 个答案:

答案 0 :(得分:18)

如果两个线程在同一个内存位置上执行操作,并且至少有一个操作是写操作,那么您就有一个所谓的数据竞争。根据C11和C ++ 11,具有数据竞争的程序的行为是未定义的。

因此,您必须使用某种同步机制,例如:

答案 1 :(得分:8)

如果您是从多个线程编写和读取同一位置,则需要执行锁定或使用原子。我们可以通过查看C11草案标准( C ++ 11标准看起来几乎相同,等效部分为1.10 )来看到这一点。在5.1.2.4部分中说明了以下内容多线程执行和数据竞争

  

如果其中一个修改内存,则两个表达式评估会发生冲突   位置,另一个读取或修改相同的内存位置。

  

如果程序包含两个,则程序的执行包含数据竞争   不同线程中的冲突操作,其中至少有一个不是   原子,并没有发生在另一个之前。任何这样的数据竞争   导致未定义的行为。

和:

  

将潜在分配引入的编译器转换   共享内存位置,不会被摘要修改   这种标准通常排除了该标准   赋值可能会覆盖另一个线程中的另一个赋值   抽象机器执行不具备的情况   遇到了数据竞赛。这包括数据成员的实现   在单独的内存中覆盖相邻成员的赋值   位置。我们通常也会排除原子载荷的重新排序   所讨论的原子可能是别名的情况,因为这可能   违反了“可见序列”规则。

如果您只是将数据添加到数组中,那么在C ++世界中,std::atomic索引就足够了,因为您可以添加更多元素,然后以原子方式递增索引。但是既然你想要增长和缩小数组,那么你将需要使用互斥体,在C ++世界中std::lock_guard将是一个典型的选择。

答案 2 :(得分:4)

  

其中一个线程向数组添加新元素,另一个[读取]此数组

为了向数组添加元素或从数组中删除元素,您需要一个索引来指定存储有效数据的数组的最后位置。这样的索引是必要的,因为没有潜在的重新分配(完全不同的故事),数组不能调整大小。您可能还需要第二个索引来标记允许读取的初始位置。

如果您有一个或两个这样的索引,并且假设您从不重新分配数组,则只要您锁定有效索引的写入,就不必在写入数组本身时锁定。 / p>

int lastValid = 0;
int shared[MAX];
...
int count = toAddCount;
// Add the new data
for (int i = lastValid ; count != 0 ; count--, i++) {
    shared[i] = new_data(...);
}
// Lock a mutex before modifying lastValid
// You need to use the same mutex to protect the read of lastValid variable
lock_mutex(lastValid_mutex);
lastValid += toAddCount;
unlock_mutex(lastValid_mutex);

这样做的原因是当你在锁定区域外对shared[]执行写操作时,读者不会看到"超过lastValid索引。写入完成后,锁定互斥锁,这通常会导致刷新CPU缓存,因此在允许读者查看数据之前,对shared[]的写入将完成。

答案 3 :(得分:4)

回答你的问题:也许

简单地说,问题框架的方式并没有提供关于是否需要锁定的足够信息。

在大多数标准用例中,答案是肯定的。这里的大部分答案都很好地涵盖了这个案例。

我将覆盖另一个案例。

根据您提供的信息,您何时不需要锁?

这里还有一些其他问题可以帮助您更好地定义是否需要锁定,是否可以使用无锁同步方法,或者是否可以避免显式同步。

写数据是不是非原子的?意思是,写数据会导致数据被破坏"?如果您的数据在x86系统上是单个32位值,并且您的数据已对齐,那么您可能会遇到写入数据已经是原子的情况。可以安全地假设,如果你的数据大小超过指针的大小(x86上4个字节,x64上8个),那么没有锁定你的写入就不可能是原子的。

您的阵列大小是否会以需要重新分配的方式发生变化?如果您的读者正在浏览您的数据,那么数据是否会突然消失?#34; (记忆已被"删除" d)?除非您的读者考虑到这一点(不太可能),否则如果可以重新分配,您将需要锁定。

当您向阵列写入数据时,如果读者看到"是否可以。旧数据?

如果您的数据可以原子方式编写,那么您的阵列就不会突然出现在那里,读者可以查看旧数据...那么您就不会需要一把锁。即使满足这些条件,使用内置的原子函数进行读取和存储也是合适的。但是,这是一个你不需要锁定的情况:)

使用锁可能最安全,因为您不确定是否可以提出这个问题。但是,如果你想玩一个你不需要锁的地方的边缘情况......那么你去:)

答案 4 :(得分:3)

锁定?不。但你确实需要一些同步机制。

你所描述的内容听起来很糟糕,就像一个" SPSC" (Single Producer Single Consumer)队列,其中有大量无锁实现,包括Boost.Lockfree

中的一个

这些工作的一般方法是在封面下面有一个包含对象和索引的循环缓冲区。编写器知道它写入的最后一个索引,如果它需要写入新数据,它(1)写入下一个槽,(2)通过将索引设置为前一个槽+ 1来更新索引,然后(3)向读者发出信号。然后读者读取,直到它找到一个不包含它所期望的索引的索引并等待下一个信号。删除是隐式的,因为缓冲区中的新项目会覆盖以前的项目。

您需要一种原子方式更新索引的方法,该索引由atomic<>提供。并有直接的硬件支持。你需要一种方法让作家向读者发出信号。你也可能需要内存防护,具体取决于平台s.t. (1-3)按顺序发生。你不需要像锁一样沉重的东西。

答案 5 :(得分:2)

"古典"对于这种情况,POSIX确实需要锁定,但这是过度的。您只需确保读取和写入是原子的。自2011年版本的标准以来,C和C ++都使用该语言。编译器开始实现它,至少最新版本的ClangGCC拥有它。

答案 6 :(得分:1)

据我所知,这正是锁的用例。同时访问一个数组的两个线程必须确保一个线程已准备就绪。 如果线程A没有完成工作,线程B可能会读取未完成的数据。

答案 7 :(得分:1)

这取决于。可能不好的一种情况是,如果要删除一个线程中的项目,然后通过读取线程中的索引读取最后一个项目。该读线程会引发OOB错误。

答案 8 :(得分:0)

如果它是一个固定大小的数组,并且您不需要像编写/更新的索引那样进行任何额外的通信,那么您可以通过读者可能看到的警告来避免相互排斥:

  • 根本没有更新
    • 如果您的内存订购足够放松以至于发生这种情况,您需要在编写器中使用商店围栏并在消费者中使用加载围栏来修复它
  • 部分写入
    • 如果存储的类型在您的平台上不是原子的(通常应该是int)
    • 或您的值未对齐,特别是如果它们可能跨越缓存行

这完全取决于您的平台 - 硬件,操作系统和编译器都会影响它。你还没有告诉我们它们是什么。

便携式C ++ 11解决方案是使用atomic<int>的数组。您仍然需要确定所需的内存排序约束,以及这对您平台上的正确性和性能意味着什么。

答案 9 :(得分:0)

如果您使用例如您的数组vector(以便它可以动态增长),然后在写入期间可能会重新分配,您将失败。

如果您使用的数据条目大于始终写入并以原子方式读取(几乎任何复杂的数据类型),则会丢失。

如果编译器/优化器决定在某些操作期间将某些东西保存在寄存器中(例如计数器中包含数组中的有效条目数),则会丢失。

或者即使编译器/优化器决定切换数组元素分配和计数器增量/减量的执行顺序,也会丢失。

所以你认真地需要一些类的同步。这样做的最佳方法是什么(例如,仅锁定数组的某些部分可能值得),这取决于您的具体情况(线程访问数组的频率和模式)。