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

标签 c# multithreading .net-core thread-safety volatile

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

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

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

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

谢谢!

最佳答案

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

有两个相关的概念在多线程编程中经常被混淆:mutual exclusionmemory consistency .互斥涉及代码关键部分的描述,这样在任何时候只有一个线程可以执行它们。这就是lock主要是为了实现。

内存一致性是一个更深奥的话题,它处理一个线程写入内存位置与其他线程读取相同内存位置的顺序。出于性能原因,例如更好地利用本地缓存,内存操作可能会跨线程重新排序。如果没有同步,线程可能会读取已被其他线程更新的内存位置的陈旧值 - 从读取线程的角度来看,写入似乎是在读取之后执行的。为了避免这种重新排序,程序员可以引入 memory barriers到他们的代码中,这将防止内存操作被移动。实际上,此类内存屏障通常仅作为其他线程操作的结果隐式生成。例如,lock 语句会在进入和退出时生成一个内存屏障。

但是,lock 语句产生的内存屏障只是一个副作用,因为 lock 的主要目的是强制互斥。当程序员遇到其他也实现互斥但未隐式生成内存屏障的情况时,这可能会引起混淆。一种这样的情况是读取和写入宽度最大为 32 或 64 位的字段,这些字段保证在该宽度的体系结构上是原子的。读取或写入 bool 值本质上是线程安全的——您永远不需要 lock 来强制互斥,因为没有其他并发线程可能“破坏”该值。但是,在没有 lock 的情况下读取或写入 bool 值意味着不会生成内存屏障,因此可能会产生陈旧的值。

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

// Thread A:
_testerStatus = true;                  // tester.SetStatusToTrue();
_testerReleased = true;                // testClassManager.ReleaseTester(tester.Id);

// Thread B:
if (_testerReleased)                               // foreach (var tester in _releasedTesters)
    Console.WriteLine($"Status: {_testerStatus}"); //     if (!tester.Value.IsChanged)

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

// Thread A:
_testerStatus = true;                  // tester.SetStatusToTrue();
Thread.MemoryBarrier();                // Barrier 1
_testerReleased = true;                // testClassManager.ReleaseTester(tester.Id);
Thread.MemoryBarrier();                // Barrier 2

// Thread B:
Thread.MemoryBarrier();                            // Barrier 3
if (_testerReleased)                               // foreach (var tester in _releasedTesters)
{
    Thread.MemoryBarrier();                        //     Barrier 4
    Console.WriteLine($"Status: {_testerStatus}"); //     if (!tester.Value.IsChanged)
}

在您的代码中,您的 ReleaseTester 方法有一个外部 lock,它隐式生成与上述障碍 1 和 2 等效的内容。类似地,您的 Clear 方法也有一个外部 lock,因此它生成了 Barrier 3 的等效项。您的代码不会生成 Barrier 4 的等效项;但是,我相信这个障碍在你的情况下是不必要的,因为由 lock 强制执行的互斥意味着如果测试人员是,障碍 2 必须 在障碍 3 之前执行发布。

关于c# - 在没有锁的情况下读取 bool 属性时的线程安全,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/60085017/

相关文章:

c# - 如何设置ServiceStack ResponseStatus StatusCode?

c# - 在 C# 中获取 Windows Server 关闭原因

java - 使用 Mockito 模拟的 Runnable 创建线程

c# - Microsoft Graph API 测试 - 代表用户获取访问权限

Java 从工作线程更新 UI

java - java程序中线程的字数统计

c# - Entity Framework Core - 不使用主键的两个表之间的关系

c# - Trim() 无法正常工作?

c# - 增强 XML 解析的复杂性 - c# XML Looping

c# - 如何判断线程是否成功退出?