【问题标题】:Make a linked list thread safe使链表线程安全
【发布时间】:2012-05-11 18:36:21
【问题描述】:

我知道以前有人问过这个问题(我会继续研究),但我需要知道如何以线程安全的方式制作特定的链表函数。我当前的问题是我有一个线程循环遍历链表中的所有元素,而另一个线程可能会在此列表的末尾添加更多元素。有时会发生一个线程尝试将另一个元素添加到列表中,而第一个线程正忙于迭代它(这会导致异常)。

我只是想添加一个变量(布尔标志)来表示列表当前正忙于迭代,但是我如何检查它并等待第二个线程(如果它等待也没关系,因为第一个线程运行得很快)。我能想到的唯一方法是使用 while 循环不断检查这个忙标志。我意识到这是一个非常愚蠢的想法,因为它会导致 CPU 努力工作而没有做任何有用的事情。现在我在这里寻求更好的见解。我读过关于锁等的内容,但似乎与我的情况无关,但也许我错了?

与此同时,我会继续搜索互联网,如果找到解决方案,我会回复。

编辑: 让我知道我是否应该发布一些代码来解决问题,但我会尝试更清楚地解释它。

所以我有一个类,其中包含需要处理的元素的链表。我有一个线程通过函数调用(我们称之为“processElements”)遍历这个列表。我有第二个线程,它以不确定的方式添加要处理的元素。但是,有时它会在 processElements 运行时尝试调用此 addElement 函数。这意味着当第一个线程迭代它时,一个元素被添加到链表中。这是不可能的并且会导致异常。希望这能解决问题。

在 processElements 方法执行完成之前,我需要添加新元素的线程。

  • 致任何遇到此问题的人。接受的答案将为您提供快速、简单的解决方案,但请查看下面 Brian Gideon 的答案以获得更全面的答案,这肯定会给您带来更多见解!

【问题讨论】:

  • 你错了。锁正是您所需要的。
  • 没关系(因此“也许我错了”)。不过谢谢,我会坚持下去,直到我弄清楚如何应用它。 +1 为您提供解决方案
  • 您需要使 List 线程安全,而不是 'a' 方法。这已经被问过很多次了。
  • 你不能只是make a method threadsafe。线程安全在不同的上下文中可能意味着不同的事情。准确告诉我们您要完成的工作。
  • 好的,我会提供更多细节!

标签: c# multithreading


【解决方案1】:

异常可能是由于在迭代过程中通过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 使用 new 引用获取锁。

显然这将是灾难性的!由于多次进入临界区,写入会丢失。

现在第二个问题有点棘手。您不一定必须使用我在上面发布的代码来执行此操作。但是,那是因为我只使用了一次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 随时可能被换掉。这将是一个难以置信难以找到的错误。这就是为什么我在执行此模式时总是在读取端提取本地引用。这确保我们在每个读取操作上都使用 same 集合。

再说一次,因为此类问题非常微妙,我不建议您使用这种模式,除非您真的需要。

【讨论】:

  • 测验...谁能猜出原因 1) 我在写入端使用lock(lockobj) 而不是lock(collection) 2) 在“读取端”中通过local = collection 使用本地引用复制、修改、交换”模式?
  • 我会详细研究这个,希望能回答你的问题:P
  • 我将放弃回答 OP 测验的乐趣 :-) 快速技术问题:volatile 真的有必要吗? lock 语句不能保证完整的栅栏吗?
  • @Douglas:在写入方面......是的!在阅读方面......不!不过,您可以省略 volatile 并改用 Thread.VolatileRead
  • 我经常回顾这个答案!太有用了:)
【解决方案2】:

下面是一个快速示例,说明如何使用锁来同步您对列表的访问:

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);
    }
}

【讨论】:

  • 哇,看起来真的很简单。我查看的所有示例都有 Object o = new Object(),然后他们会锁定它,这让我感到困惑。但我想他们是在举一个普遍的例子。太多了,伙计!
  • @Denzil 您需要一些其他代码库无法访问的私有(私有很重要)字段来锁定。 (如果它可以在其他地方访问,而您没有为此做好准备,则更有可能陷入死锁。)通常没有适合该目的的对象,因此人们创建了一个新对象(它没有字段,因此占用尽可能小的内存占用)仅用于锁定目的。大多数时候这是最好的;锁定其他任何东西通常是一个等待发生的错误。
  • @Denzil:根据经验,您可以使用任何声明为私有只读的字段。如果您已经碰巧使用了一个(例如第一个示例中的 elements 集合),那么您就可以开始了。如果没有,请专门为此目的创建一个新对象。
  • 哦,我明白了,太棒了。你值得再上一个。但是如果我可能会问,如果解释不是太长,为什么对象必须是只读的?是不是在某些进程锁定它时我们不会在外部更改它?...哦,我看到 Servy 已经回答了这个问题
  • 这是预防措施,所以我们不会在内部进行更改。 (无论如何,private 修饰符都会阻止外部访问。)如果允许更改该字段的值,那么您将面临两个竞争线程可能会在 distinct 实例上获得锁并被允许同时进行的风险(访问不同的集合)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-01-13
  • 1970-01-01
  • 1970-01-01
  • 2021-07-12
相关资源
最近更新 更多