编辑。来自维基百科的完整引用是:
C++11 引入了对多线程的正式支持,并将数据竞争严格定义为非原子变量之间的竞争条件。虽然竞争条件通常会继续存在,但程序员必须避免“数据竞争”,如果访问是为了写入,程序员必须确保一次只有一个线程可以访问任何变量。
现在,假设这是正确的(它是维基百科,它在编程方面往往相当不错,但确实经常是非常错误的),它在这种情况下将“数据竞争”纯粹定义为明显不好的情况之一;那些可能导致价值剪切的东西。显然必须避免这种情况,因此必须避免数据竞争(此处定义的)。
根据这个定义,您问题中的两个程序都没有数据竞争。
我一般会在比赛条件下留下我原来的答案:
第二个例子也有数据竞争。事实上,它与第一个具有完全相同的数据竞争。
这很糟糕吗?那要看。 在其他任何内容之前请注意。正如我将在下面详细描述的那样,不仅许多案例很糟糕,而且那些糟糕的案例往往特别难以找到和修复,这本身就应该倾向于假设更糟糕的情况。
数据竞争不好的一个明显情况是它破坏了数据。假设我们更改了您的示例,使x 和y 大于体系结构的字长,我们正在设置x = -1。我们还将假设二进制补码。现在y 的可能值不仅是-1 和0,还有-4294967296 和4294967295。
在这种情况下,您建议的锁定不会完全删除数据竞争,但会删除可能导致剪切的部分:y 的唯一可能值将再次是 -1 和 @987654331 @。
另一个问题是序列化。通常需要能够将一系列并发事件视为一组有限的顺序事件中的一个。
例如,假设我们以X = 0 开头,然后有:
Thread 0 Thread 1
++x x = -50
现在,这里仍然存在可能导致虚假价值的风险。
假设x 是字大小或更小,我们仍然可能遇到问题。如果操作不是并发的,则有两个可能的值。 x 可以等于-50(递增,然后分配-50)或x 可以等于-49(分配-50 然后递增)。但是,同时我们可能会以 x 的值结束 1,因为线程 0 读取 0,线程 1 分配 -50,然后线程 0 递增并分配 1。
现在,这很可能完全没问题。很有可能不是。
作为程序员,我们有四种可能性:
- 识别数据争用。确定它是无害的(或相对无害的*),然后让它成为现实。
- 识别数据争用。确定它可能会导致问题并加以解决。
- 识别数据争用。只需修复它,因为这样我们就不会错误地确定它是无害的,而实际上它并非如此。
- 识别数据争用。确定它会导致问题。更改代码,以免比赛引起问题。
案例 2 的重要性显而易见 - 我们将有错误的代码转换为没有错误的代码。
案例 3 的重要性归结为时间和可证明性。我们很可能会降低代码效率(许多停止数据竞争的方法至少有一些开销),但通常需要更少的开发人员时间来消除竞争而不是证明它是无害的,并且错误示例的成本是稍微慢一点的代码而在另一个方向出错的代价是难以修复的错误。
数字 1 的重要性更复杂,在一些非常低级的并发代码中避免锁定可能很重要,因此在某些情况下我们希望容忍竞争。数字 4 是一种将事物从数字 2 转变为数字 1 的方法,并且当数据竞争是问题所固有的(我们无法删除它)或者我们正在执行的那种低级并发时出现第 1 项涉及。
这是一个有趣的 C# 示例:
public static SomeResource GetTheResource()
{
get
{
if(_theResource == null)
_theResource = CreateTheResource();
return _theResource
}
}
数据竞争应该是显而易见的;在设置theResource 并且所有 CPU 的缓存都看到更新之前,我们可能会从不同的线程多次分配给它。这是一个错误吗?很多人会说是,但实际上这取决于。有可能在短时间内使用不同版本的theResource 是安全的,而我们真正失去的只是从多次调用CreateTheResource() 开始时的一些效率。在对性能有很高要求的代码中,我们可能会决定容忍这种最初的较低效率,以实现无锁定的长期效率增益。或者我们锁定可能是至关重要的。或者我们可能只是锁定,因为我们没有避免它的迫切需要,而且假设 可能 是一个问题更简单。
要点 1:如果您确实决定容忍这样的比赛,您应该添加评论,说明原因。否则,每次有人遇到此代码时,他们都必须再次检查它是否安全,而不是最多检查您陈述的推理。
要点 2:虽然这里的原则与语言无关,但每种情况下的细节通常不是。在这种情况下,容忍竞争不仅取决于临时的多个副本是否安全,还取决于垃圾收集清理那些多余的副本。如果我们改为在 C++ 中分配一个指向堆的指针,那么即使在其他方面是安全的,上述内容也最好是泄漏的。
一个更复杂的情况是这样的(同样是一个 C# 示例,但适用于其他语言):
internal sealed class LockFreeQueue<T>
{
private sealed class Node
{
public readonly T Item;
public Node Next;
public Node(T item)
{
Item = item;
}
}
private volatile Node _head;
private volatile Node _tail;
public LockFreeQueue()
{
_head = _tail = new Node(default(T));
}
#pragma warning disable 420 // volatile semantics not lost as only by-ref calls are interlocked
public void Enqueue(T item)
{
Node newNode = new Node(item);
for(;;)
{
Node curTail = _tail;
if (Interlocked.CompareExchange(ref curTail.Next, newNode, null) == null) //append to the tail if it is indeed the tail.
{
Interlocked.CompareExchange(ref _tail, newNode, curTail); //CAS in case we were assisted by an obstructed thread.
return;
}
else
{
Interlocked.CompareExchange(ref _tail, curTail.Next, curTail); //assist obstructing thread.
}
}
}
public bool TryDequeue(out T item)
{
for(;;)
{
Node curHead = _head;
Node curTail = _tail;
Node curHeadNext = curHead.Next;
if (curHead == curTail)
{
if (curHeadNext == null)
{
item = default(T);
return false;
}
else
Interlocked.CompareExchange(ref _tail, curHeadNext, curTail); // assist obstructing thread
}
else
{
item = curHeadNext.Item;
if (Interlocked.CompareExchange(ref _head, curHeadNext, curHead) == curHead)
{
return true;
}
}
}
}
#pragma warning restore 420
}
此代码不会阻止数据争用,而是会对它们做出反应。如果一个操作受到另一个线程的影响,那么线程不会出错或返回不正确的结果,而是处理竞争并返回其他内容(在某些情况下甚至会帮助其他线程)。
因此,总而言之,数据竞争本身并不是坏事。它们虽然使事情复杂化,但这些复杂性可能会导致问题。当您遇到数据竞争时,您可以在证明它不是问题、更改代码以容忍竞争以使其不再是问题或更改代码以消除竞争之间做出选择。其中,仅移除种族通常是最简单的选择。
*我在这里的意思不是含糊不清的“相对无害”,而是相对于替代方案。例如。如果我们决定在给出的 C# 示例中退出竞争,那是因为我们已经确定创建冗余对象的成本比防止它的相对成本有害。