在没有锁的情况下读取 bool 属性时的线程安全

iam*_*man 3 c# multithreading volatile thread-safety .net-core

我一直在试图找出一个非常奇怪的问题,该问题很少发生并且需要很长时间才能显现出来。这个代码模式似乎很突出,我想确保它是线程安全的。这里的模式的简化形式显示了一个TestClassManager管理TestClass对象租赁的类。对象TestClass将被租用、使用和释放。一旦 aTestClass被释放,它就不会任何其他线程进一步修改/使用。

class Program
{
    public static void Main(string[] args)
    {
        var tasks = new List<Task>();
        var testClassManager = new TestClassManager();

        tasks.Add(Task.Factory.StartNew(() => TestersOperationLoop(testClassManager), TaskCreationOptions.LongRunning));
        tasks.Add(Task.Factory.StartNew(() => ClearTestersLoop(testClassManager), TaskCreationOptions.LongRunning));

        Task.WaitAll(tasks.ToArray());
    }

    public class TestClassManager
    {
        private readonly object _testerCollectionLock = new object();

        private readonly Dictionary<long, TestClass> _leasedTesters = new Dictionary<long, TestClass>();
        private readonly Dictionary<long, TestClass> _releasedTesters = new Dictionary<long, TestClass>();

        public TestClass LeaseTester()
        {
            lock (_testerCollectionLock)
            {
                var tester = new TestClass();

                _leasedTesters.Add(tester.Id, tester);
                _releasedTesters.Remove(tester.Id);

                return tester;
            }
        }

        public void ReleaseTester(long id)
        {
            lock (_testerCollectionLock)
            {
                var tester = _leasedTesters[id];

                _leasedTesters.Remove(tester.Id);
                _releasedTesters.Add(tester.Id, tester);
            }
        }

        public void Clear()
        {
            lock (_testerCollectionLock)
            {
                foreach (var tester in _releasedTesters)
                {
                    if (!tester.Value.IsChanged)
                    {
                        // I have not seen this exception throw ever, but can this happen?
                        throw new InvalidOperationException("Is this even possible!?!");
                    }
                }

                var clearCount = _releasedTesters.Count;

                _releasedTesters.Clear();
            }
        }
    }

    public class TestClass
    {
        private static long _count;

        private long _id;
        private bool _status;

        private readonly object _lockObject = new object();

        public TestClass()
        {
            Id = Interlocked.Increment(ref _count);
        }

        // reading status without the lock
        public bool IsChanged
        {
            get
            {
                return _status;
            }
        }

        public long Id { get => _id; set => _id = value; }

        public void SetStatusToTrue()
        {
            lock (_lockObject)
            {
                _status = true;
            }
        }
    }

    public static void TestersOperationLoop(TestClassManager testClassManager)
    {
        while (true)
        {
            var tester = testClassManager.LeaseTester();

            tester.SetStatusToTrue();
            testClassManager.ReleaseTester(tester.Id);
        }
    }

    public static void ClearTestersLoop(TestClassManager testClassManager)
    {
        while (true)
        {
            testClassManager.Clear();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

TestClass.IsChanged方法内的属性检查是否是TestClassManager.Clear线程安全的?我从来没有看到过InvalidOperationException,但这可能吗?如果是的话,那就可以解释我的问题了。

不管怎样,无论如何,我都会锁定读取,以遵循通常建议的锁定读取模式(如果锁定写入)。但我想了解一下这是否真的会导致问题,因为这可以解释那个极其奇怪的罕见错误!这让我彻夜难眠。

谢谢!

Dou*_*las 5

TLDR:您的代码是线程安全的。您担心_status通过IsChanged属性读取字段可能会导致值过时,这是正确的;但是,这不会在您的Clear方法中发生,因为其他现有lock语句已经防止了这种情况。

\n\n

在多线程编程中有两个经常被混淆的相关概念:互斥内存一致性。互斥涉及对代码关键部分的划分,以便任何时候只有一个线程可以执行它们。这就是lock主要要实现的目标。

\n\n

内存一致性是一个更深奥的主题,它涉及一个线程对内存位置的写入与其他线程对相同内存位置的读取的顺序。出于性能原因(例如更好地利用本地缓存),内存操作可能会跨线程重新排序。如果没有同步,线程可能会读取已由其他线程更新的内存位置的过时值 \xe2\x80\x93从读取器线程的角度来看,写入似乎是在读取之后执行的。为了避免这种重新排序,程序员可能会在代码中引入内存屏障,这将阻止内存操作被移动。实际上,此类内存屏障通常仅作为其他线程操作的结果隐式生成。例如,该lock语句在进入和退出时都会生成内存屏障。

\n\n

然而,该lock语句生成的内存屏障只是一个副作用,因为该语句的主要目的lock是强制互斥。当程序员遇到其他也实现了互斥但不会隐式生成内存屏障的情况时,这可能会引起混乱。其中一种情况是宽度高达 32 或 64 位的字段的读写,在该宽度的体系结构上保证是原子的。读取或写入布尔值本质上是线程安全的 \xe2\x80\x93 您lock在执行此操作时永远不需要强制互斥,因为没有其他并发线程可能“损坏”该值。但是,在没有lock内存屏障的情况下读取或写入布尔值意味着不会生成内存障碍,因此可能会导致过时的值。

\n\n

让我们将此讨论应用于代码的精简版本:

\n\n
// Thread A:\n_testerStatus = true;                  // tester.SetStatusToTrue();\n_testerReleased = true;                // testClassManager.ReleaseTester(tester.Id);\n\n// Thread B:\nif (_testerReleased)                               // foreach (var tester in _releasedTesters)\n    Console.WriteLine($"Status: {_testerStatus}"); //     if (!tester.Value.IsChanged)\n
Run Code Online (Sandbox Code Playgroud)\n\n

上面的程序有可能输出吗false?在这种情况下,是的。这是非阻塞同步(推荐阅读)下讨论的经典示例。该错误可以通过添加内存屏障来修复(显式地,如下所示,或隐式地,如下文进一步讨论):

\n\n
// Thread A:\n_testerStatus = true;                  // tester.SetStatusToTrue();\nThread.MemoryBarrier();                // Barrier 1\n_testerReleased = true;                // testClassManager.ReleaseTester(tester.Id);\nThread.MemoryBarrier();                // Barrier 2\n\n// Thread B:\nThread.MemoryBarrier();                            // Barrier 3\nif (_testerReleased)                               // foreach (var tester in _releasedTesters)\n{\n    Thread.MemoryBarrier();                        //     Barrier 4\n    Console.WriteLine($"Status: {_testerStatus}"); //     if (!tester.Value.IsChanged)\n}\n
Run Code Online (Sandbox Code Playgroud)\n\n

在您的代码中,您的ReleaseTester方法有一个 external lock,它隐式生成与上面的障碍 1 和 2 等效的内容。同样,您的Clear方法也有一个 external lock,因此它生成与 Barrier 3 等效的内容。您的代码不会生成与 Barrier 4 等效的内容;但是,我认为这个屏障在您的情况下是不必要的,因为如果测试器被释放,则由 执行的互斥意味着lock屏障 2必须在屏障 3 之前执行。

\n