无锁读取 bool 属性 时的线程安全

Thread-safety while reading bool property without locks

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

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,但这可能吗?如果是,那就可以解释我的问题了。

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

谢谢!

看来问题是在您的 TestersOperationLoop 中您 LeaseTester 启用另一个线程的 ClearTestersLoop 以立即 Clear 新租用的测试。由于此时您尚未调用 SetStatusToTrue,因此 Clear 方法中的检查将失败并引发异常。

  var tester = testClassManager.LeaseTester();

  // Imagine that testClassManager.Clear(); gets called from the second thread at this point.
  //
  // Since _testerCollectionLock from testClassManager.LeaseTester() is already released, the Clear method from other thread can run as it can acquire the lock now.
  // This thread keeps running, and races to start executing the next line which would set the status to true and prevent the other thread from throwing exception.
  // But it is a nature of race conditions that there is a single winner... and in this case it is the other thread; checking for status which is not yet changed to true and thus throwing the exception :-(

  tester.SetStatusToTrue();
  testClassManager.ReLeaseTester(tester.Id);

在这种情况下,最简单的解决方法是在锁块中的 LeaseTester 方法中设置 TestClas 的状态。

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

    tester.SetStatusToTrue();

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

    return tester;
  }
}

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

有两个相关的概念在多线程编程中经常被混淆:mutual exclusion and memory consistency. Mutual exclusion involves the delineation of critical sections of your code, such that only one thread may execute them at any time. This is what lock主要是为了实现。

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

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

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

// 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 之前执行已发布。

每次添加或删除时,您都在锁定 TestClass 对象的集合,但对 TestClass 状态本身没有类似的锁定。因此,您正在锁外的不同线程上读取和修改相同的值。

如果不了解实际使用情况,很难推荐修复程序。尝试查看您正在修改和读取的值,并确保它们仅在同一个锁内完成。一种选择是使 TestManager 的锁对象可公开访问,或向所有 TestClass 实例添加共享锁对象。