使链表线程安全
本文关键字:安全 线程 链表 | 更新日期: 2023-09-27 18:33:59
我知道之前有人问过这个问题(我会继续研究(,但我需要知道如何以线程安全的方式制作特定的链表函数。我目前的问题是我有一个线程循环遍历链表中的所有元素,另一个线程可能会在此列表的末尾添加更多元素。有时,一个线程尝试将另一个元素添加到列表中,而第一个线程正忙于遍历它(这会导致异常(。
我正在考虑添加一个变量(布尔标志(来表示该列表当前正忙于迭代,但是我如何检查它并等待第二个线程(如果等待也可以,因为第一个线程运行得很快(。我能想到的唯一方法是通过使用 while 循环不断检查这个繁忙的标志。我意识到这是一个非常愚蠢的想法,因为它会导致CPU在做任何有用的事情时努力工作。现在我在这里要求更好的见解。我读过锁等,但这似乎与我的情况无关,但也许我错了?
与此同时,我会继续搜索互联网,如果我找到解决方案,我会回发。
编辑:让我知道我是否应该发布一些代码来解决问题,但我会尝试更清楚地解释它。
所以我有一个类,里面有一个链表,其中包含需要处理的元素。我有一个线程通过函数调用遍历此列表(我们称之为"processElements"(。我有第二个线程,它以非确定性的方式将元素添加到处理中。但是,有时它尝试在进程元素运行时调用此 addElement 函数。这意味着元素在第一个线程迭代时被添加到链表中。这是不可能的,并会导致异常。希望这能解决它。
我需要添加新元素以产生元素的线程,直到 processElements 方法完成执行。
- 对于任何偶然发现这个问题的人。接受的答案将为您提供快速,简单的解决方案,但是请查看下面的Brian Gideon的答案以获得更全面的答案,这肯定会给您更多的见解!
异常可能是在迭代过程中通过 IEnumerator
更改集合的结果。 可以使用很少的技术来维护线程安全。我将按难度顺序介绍它们。
锁定所有内容
这是迄今为止访问线程安全数据结构的最简单、最微不足道的方法。当读取和写入操作的数量相等匹配时,此模式非常有效。
LinkedList<object> collection = new LinkedList<object>();
void Write()
{
lock (collection)
{
collection.AddLast(GetSomeObject());
}
}
void Read()
{
lock (collection)
{
foreach (object item in collection)
{
DoSomething(item);
}
}
}
复制-读取模式
这是一个稍微复杂的模式。您会注意到,在读取数据结构之前,会制作数据结构的副本。当读取操作数与写入次数相比较少且副本的损失相对较小时,此模式非常有效。
LinkedList<object> collection = new LinkedList<object>();
void Write()
{
lock (collection)
{
collection.AddLast(GetSomeObject());
}
}
void Read()
{
LinkedList<object> copy;
lock (collection)
{
copy = new LinkedList<object>(collection);
}
foreach (object item in copy)
{
DoSomething(item);
}
}
复制-修改-交换模式
最后,我们有了最复杂和最容易出错的模式。我实际上不建议使用此模式,除非您真的知道自己在做什么。任何偏离我下面的内容都可能导致问题。很容易搞砸这个。事实上,我过去也无意中搞砸了这个。您会注意到,在所有修改之前都会制作数据结构的副本。然后修改副本,最后将原始引用与新实例交换。基本上,我们总是把collection
当作不可变的。当写入操作数与读取次数相比较少且副本的损失相对较小时,此模式非常有效。
object lockobj = new object();
volatile LinkedList<object> collection = new LinkedList<object>();
void Write()
{
lock (lockobj)
{
var copy = new LinkedList<object>(collection);
copy.AddLast(GetSomeObject());
collection = copy;
}
}
void Read()
{
LinkedList<object> local = collection;
foreach (object item in local)
{
DoSomething(item);
}
}
更新:
所以我在评论区提出了两个问题:
- 为什么
lock(lockobj)
而不是写入端lock(collection)
? - 为什么
local = collection
在阅读端?
关于第一个问题,请考虑 C# 编译器将如何扩展lock
。
void Write()
{
bool acquired = false;
object temp = lockobj;
try
{
Monitor.Enter(temp, ref acquired);
var copy = new LinkedList<object>(collection);
copy.AddLast(GetSomeObject());
collection = copy;
}
finally
{
if (acquired) Monitor.Exit(temp);
}
}
现在希望如果我们使用 collection
作为锁定表达式,可以更容易地看到会出错的地方。
- 线程 A 执行
object temp = collection
。 - 线程 B 执行
collection = copy
。 - 线程 C 执行
object temp = collection
。 - 线程 A 获取具有原始引用的锁。
- 线程 C 使用新引用获取锁。
显然,这将是灾难性的!写入会丢失,因为关键部分被多次输入。
现在第二个问题有点棘手。您不必使用我上面发布的代码来执行此操作。但是,那是因为我只使用过一次collection
。现在考虑以下代码。
void Read()
{
object x = collection.Last;
// The collection may get swapped out right here.
object y = collection.Last;
if (x != y)
{
Console.WriteLine("It could happen!");
}
}
这里的问题是collection
随时可能被换掉。这将是一个非常难以找到的错误。这就是为什么在执行此模式时,我总是在读取端提取本地引用的原因。这可确保我们在每个读取操作上使用相同的集合。
同样,由于此类问题非常微妙,除非您确实需要,否则我不建议使用此模式。
下面是如何使用锁同步对列表的访问权限的快速示例:
private readonly IList<string> elements = new List<string>();
public void ProcessElements()
{
lock (this.elements)
{
foreach (string element in this.elements)
ProcessElement(element);
}
}
public void AddElement(string newElement)
{
lock (this.elements)
{
this.elements.Add(element);
}
}
lock(o)
语句意味着执行线程应该在对象o
上获取互斥锁,执行语句块,最后释放o
上的锁。如果另一个线程尝试同时获取o
上的锁(针对同一代码块或任何其他代码块(,则它将阻塞(等待(直到释放锁。
因此,关键的一点是,您对要同步的所有lock
语句使用相同的对象。您使用的实际对象可能是任意的,只要它是一致的。在上面的示例中,我们声明了集合为只读,因此我们可以安全地将其用作锁。但是,如果不是这种情况,则应锁定另一个对象:
private IList<string> elements = new List<string>();
private readonly object syncLock = new object();
public void ProcessElements()
{
lock (this.syncLock)
{
foreach (string element in this.elements)
ProcessElement(element);
}
}
public void AddElement(string newElement)
{
lock (this.syncLock)
{
this.elements.Add(element);
}
}