【问题标题】:What's the danger of adding to a list in one thread and removing from another without locking it?在一个线程中添加到列表并在不锁定的情况下从另一个线程中删除列表有什么危险?
【发布时间】:2023-02-19 02:05:17
【问题描述】:

前言

我知道 List 不是线程安全的,并且知道并发集合的存在,例如 ConcurrentBagConcurrentQueue 等,并且在某种程度上知道如何使用锁。我只想知道如果我这样做会有什么危险。

问题

比如说,如果我同时运行 2 个线程,一个向 List 添加值,另一个从同一个 List 中删除元素,会有什么危险?

我知道,如果我在两个线程中添加元素,危险可能很严重,因为 List 在内部调整集合的大小和重新索引,并且“种族”将破坏它,但 AddRemove 本质上是相反的操作,我没有看到其中有任何“冲突”。

我也知道 Remove 也在内部调整集合的大小和重新索引,但老实说我不知道​​它们内部是如何工作的,我真的不知道“2 个相反的操作是否仍然会破坏数据”。如果是,以什么方式? (除了“可能没有什么要删除的明显危险,因为元素还没有被添加”)。

【问题讨论】:

  • 它们在内部如何工作无关紧要。事实上,实现可以根据平台、环境甚至 .Net 版本而改变。关键是它不是线程安全的。这对你来说应该足够了。至于实现细节:在列表上添加和删除通常涉及内部指针递增和递减。这些操作通常不是原子的。此外,如果不是易变的(这是常见情况),则在线程外不可见。这可能会导致奇怪的问题,比如一个线程认为它需要调整大小,因为他从未看到另一个线程删除任何东西。
  • 并且无论什么特殊用例现在可能是线程安全的,都不能保证将来是。
  • 您可以同时从两个线程对一个对象执行的唯一操作是原子操作(如设置或读取整数)。当您向集合中添加内容或从中删除内容时,您正在访问集合的内部状态(例如,将对象添加到内部数组或将其插入链表,并更新计数)。即使是简单的“读-修改-写”操作(比如读取一个整数并递增它)也不能在没有同步或互锁的情况下以线程安全的方式完成。

标签: c# multithreading thread-safety


【解决方案1】:

很容易证明 List<T> 的这种使用会导致问题。

考虑以下程序 (also on DotNetFiddle):

public class Program
{
    public static void Main()
    {
        List<int> list = new();
        Task.Run(() => addItems(list));
        Task.Run(() => removeItems(list));

        Console.WriteLine("Started. Press <return> to exit");
        Console.ReadLine();
    }

    static void addItems(List<int> list)
    {
        for (int i = 0; i < 100_000; ++i)
        {
            list.Add(i);
        }

        Volatile.Write(ref finished, true);

        Console.WriteLine("Finished writing items");
    }

    static void removeItems(List<int> list)
    {
        int n = 0;

        while (true)
        {
            if (list.Count > 0)
            {
                ++n;
                list.RemoveAt(0);
            }
            else
            {
                bool done = Volatile.Read(ref finished);

                if (done)
                    break;
            }
        }

        Console.WriteLine($"Finished reading {n} items");
    }

    static bool finished;
}

这将启动两个线程:一个将 100K 项写入列表,另一个从该列表中读取项目。

finished 字段就在那里表示 addItems() 任务已完成添加项目。

readItems() 任务计算它从列表中读取的项目数。显然,结果数应该等于添加到列表中的项目数。

然而,你会发现它有时读取的项目比写入的多,因为对列表的多线程访问导致了错误。 (您可能需要运行几次才能看到这个问题。)

这个故事的寓意是不要那样做!


作为旁注,如果您在列表访问周围添加一个锁,它将正常工作并打印正确数量的项目:

public class Program
{
    public static void Main()
    {
        List<int> list = new();
        Task.Run(() => addItems(list));
        Task.Run(() => removeItems(list));

        Console.WriteLine("Started. Press <return> to exit");
        Console.ReadLine();
    }

    static void addItems(List<int> list)
    {
        for (int i = 0; i < 100_000; ++i)
        {
            lock (locker)
            {
                list.Add(i);
            }
        }

        Volatile.Write(ref finished, true);

        Console.WriteLine("Finished writing items");
    }

    static void removeItems(List<int> list)
    {
        int n = 0;

        while (true)
        {
            lock (locker)
            {
                if (list.Count > 0)
                {
                    ++n;
                    list.RemoveAt(0);
                }
                else
                {
                    bool done = Volatile.Read(ref finished);

                    if (done)
                        break;
                }
            }
        }

        Console.WriteLine($"Finished reading {n} items");
    }

    static object locker = new();

    static bool finished;
}

【讨论】:

    【解决方案2】:

    如果我同时运行 2 个线程,一个向列表添加值,另一个从同一个列表中删除元素,会有什么危险?

    最可能的错误是增加的值丢失,或者删除的值实际上没有被删除。也可能有例外,因为列表可能会进入无效状态。

    虽然添加和删除可以被认为是相反的操作,但它们不是原子,这意味着它们会做很多事情,如果在操作过程中发生某些变化,它们将无法正常工作。与 Interlocked.IncrementInterlocked.Decrement 对比原子的,因此可以安全地从多个线程使用。

    最后,您唯一应该关心的是它是否被记录为线程安全的。如果它是线程安全的,你就可以使用它。如果不是,则只要从多个线程使用它,就需要使用锁。程序的正确行为比诸如锁的性能影响之类的小事情重要得多。如果不需要正确的行为,您只需将整个程序替换为 return 0; 即可获得近乎无限的加速

    有一个可能的例外,并发读取内存是安全的,因此如果您确定该方法不会以任何方式改变对象,则可以在没有任何锁定的情况下并发使用它。 List&lt;T&gt; explicitly documents that 并发读取是安全的。

    【讨论】:

    • 它也有可能在移除点之后打乱元素;复制一些;删除一些;或者直接崩溃
    • 为什么我从来没有想过用“return 0;”替换我的整个程序?
    猜你喜欢
    • 2014-12-15
    • 1970-01-01
    • 2010-10-06
    • 2023-03-24
    • 2020-08-31
    • 2015-04-24
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多