【问题标题】:This is Thread-Safe right?这是线程安全的吧?
【发布时间】:2013-11-04 18:25:09
【问题描述】:

只是检查..._count 正在被安全访问,对吧?

这两种方法都被多个线程访问。

private int _count;

public void CheckForWork() {
    if (_count >= MAXIMUM) return;
    Interlocked.Increment(ref _count);
    Task t = Task.Run(() => Work());
    t.ContinueWith(CompletedWorkHandler);
}

public void CompletedWorkHandler(Task completedTask) {
    Interlocked.Decrement(ref _count);
    // Handle errors, etc...
}

【问题讨论】:

  • 字面意思是“哇哈哈哈哈哈!”在这里的标题。
  • 我只想指出,这是实现具有最大并行度的任务工厂的一种非常糟糕的方式。在设计方面都有很多问题(我猜 CheckForWork() 正在计时器和“...”代码中被调用;这很糟糕。此外,默认情况下这是使用默认值线程池实现,它打败了很多你正在尝试做的事情)和实现(最明显的问题;如果 Work() 抛出异常或有人忘记调用 CompletedWorkHandler(),你将饿死你的工作队列) .

标签: c# multithreading thread-safety interlocked


【解决方案1】:

这是线程安全的,对吧?

假设 MAXIMUM 为 1,count 为 0,五个线程调用 CheckForWork。

所有五个线程都可以验证计数小于最大值。然后柜台将增加到五个,五个工作将开始。

这似乎与代码的意图相反。

此外:该字段不是易失性的。那么什么机制可以保证任何线程都会在无内存屏障路径上读取最新值呢?没有什么可以保证!如果条件为假,您只会设置内存屏障。

更笼统地说:你在这里制造了一种虚假的经济。通过使用低锁定解决方案,您可以节省非竞争锁定所需的十几纳秒。 只要拿锁。你可以承受额外的十几纳秒。

更笼统地说:除非您是处理器架构方面的专家并且知道 CPU 可以在低锁路径上执行的所有优化,否则不要编写低锁代码。你不是这样的专家。我也不是。这就是我不编写低锁定代码的原因。

【讨论】:

  • @Unlimited071:是什么让您认为读取值类型是安全的?值类型的读取甚至不是原子,除非它是(对齐的)整数或更小!原子性只是线程安全的一个非常小的方面。您正在执行原子读取和原子增量;因为这是两个的东西,放在一起的操作不是原子的,它当然不会在返回路径上造成障碍。
  • @Unlimited071 当您尝试同步访问而不使用语言的“锁定”功能并使用其他东西时使用术语低锁定代码,例如'Interlocked.*' 就像在您的代码中一样用于同步。 Eric 建议,除非您是使用低锁技术的专家,否则最好使用 'lock' 关键字来实现同步。使用“lock”关键字的代码更易于编写、维护和推理。
  • @BlueMonkMN:是的,这很重要。在 32 位框架中,long 的读写不能保证是原子的。因此,如果没有同步,一个线程可能正在读取,而另一个线程正在写入,最终得到前一个值的高 32 位和新值的低 32 位。我会让你决定这是否是一个潜在的问题。
  • @BlueMonkMN:首先:如果您只是阅读,那么您已经解决了线程安全问题。其次,好的,让我们假设读取是非原子的,而写入是原子的。现在我们从谎言中推理,但让我们继续吧。您以原子方式将 DEADBEEFBAADF00D 写入 long。您以非原子方式读取 DEADBEEF,然后以原子方式写入 0000000000000000,然后以非原子方式读取 00000000,并且您已读取 DEADBEEF00000000,这是一个从未写入的值。非原子读取足以使访问 64 位长不安全,即使写入是原子的。他们不是。
  • @BlueMonkMN:我明白你的意思;我不明白你为什么要这样做。读写非原子的,故事结束。事实上,在一个我们不知道这一点的世界里,很难推断出哪一个是必然非原子的,这是无趣的;我们并不生活在那个世界。如果我们生活在太阳系中的一颗孤行星上,在一片巨大的尘埃云中,就很难判断太阳是绕着地球转还是地球自转。我们不住在这样的星球上,而且我们知道地球绕着太阳转,所以反事实是无关紧要的。
【解决方案2】:

不,if (_count >= MAXIMUM) return; 不是线程安全的。

编辑:你也必须锁定读取,然后应该在逻辑上与增量分组,所以我会重写

private int _count;

private readonly Object _locker_ = new Object();

public void CheckForWork() {
    lock(_locker_)
    {
        if (_count >= MAXIMUM)
            return;
        _count++;
    }
    Task.Run(() => Work());
}

public void CompletedWorkHandler() {
    lock(_locker_)
    {
        _count--;
    }
    ...
}

【讨论】:

    【解决方案3】:

    这就是SemaphoreSemaphoreSlim 的用途:

    private readonly SemaphoreSlim WorkSem = new SemaphoreSlim(Maximum);
    
    public void CheckForWork() {
        if (!WorkSem.Wait(0)) return;
        Task.Run(() => Work());
    }
    
    public void CompletedWorkHandler() {
        WorkSem.Release();
        ...
    }
    

    【讨论】:

      【解决方案4】:

      不,你所拥有的并不安全。检查_count >= MAXIMUM 是否可以与另一个线程对Interlocked.Increment 的调用竞争。这实际上真的很难使用低锁定技术解决。要使其正常工作,您需要在不使用锁的情况下使一系列操作看起来是原子的。那是困难的部分。这里有问题的一系列操作是:

      • 阅读_count
      • 测试_count >= MAXIMUM
      • 根据上述情况做出决定。
      • 根据做出的决定增加_count

      如果您没有使所有这 4 个步骤看起来都是原子的,那么就会出现竞争条件。在不获取锁的情况下执行复杂操作的标准模式如下。

      public static T InterlockedOperation<T>(ref T location)
      {
        T initial, computed;
        do
        {
          initial = location;
          computed = op(initial); // where op() represents the operation
        } 
        while (Interlocked.CompareExchange(ref location, computed, initial) != initial);
        return computed;
      }
      

      注意正在发生的事情。重复执行该操作,直到 ICX 操作确定初始值在首次读取和尝试更改它的时间之间没有更改。这是标准模式,因为CompareExchange (ICX) 调用而发生了奇迹。但是请注意,这并没有考虑到ABA problem.1

      可以做什么:

      因此,采用上述模式并将其合并到您的代码中会导致这种情况。

      public void CheckForWork() 
      {
          int initial, computed;
          do
          {
            initial = _count;
            computed = initial < MAXIMUM ? initial + 1 : initial;
          }
          while (Interlocked.CompareExchange(ref _count, computed, initial) != initial);
          if (replacement > initial)
          {
            Task.Run(() => Work());
          }
      }
      

      就个人而言,我会完全采用低锁定策略。我上面介绍的有几个问题。

      • 这实际上可能比使用硬锁运行得慢。原因很难解释,超出了我的回答范围。
      • 任何与上述内容的偏差都可能导致代码失败。是的,它真的那么脆弱。
      • 很难理解。我的意思是看看它。太丑了。

      应该做什么:

      使用硬锁定路线,您的代码可能如下所示。

      private object _lock = new object();
      private int _count;
      
      public void CheckForWork() 
      {
        lock (_lock)
        {
          if (_count >= MAXIMUM) return;
          _count++;
        }
        Task.Run(() => Work());
      }
      
      public void CompletedWorkHandler() 
      {
        lock (_lock)
        {
          _count--;
        }
      }
      

      请注意,这要简单得多,而且更不容易出错。您实际上可能会发现这种方法(硬锁)实际上比我上面展示的(低锁)更快。同样,原因很棘手,并且可以使用一些技术来加快速度,但这超出了此答案的范围。


      1在这种情况下,ABA 问题并不是真正的问题,因为逻辑不依赖于 _count 保持不变。重要的是它的值在两个时间点是相同的,无论其间发生了什么。换句话说,可以将问题简化为 看起来 值没有改变的问题,即使实际上它可能已经改变了。

      【讨论】:

      • 好吧,我应该省略Task.Run。我想说的是该方法是排队一些我试图限制的工作。
      【解决方案5】:

      定义线程安全。

      如果您想确保 _count 永远不会大于 MAXIMUM,那么您没有成功。

      你应该做的也是锁定它:

      private int _count;
      private object locker = new object();
      
      public void CheckForWork() 
      {
          lock(locker)
          {
              if (_count >= MAXIMUM) return;
              _count++;
          }
          Task.Run(() => Work());
      }
      
      public void CompletedWorkHandler() 
      {
          lock(locker)
          {
              _count--;
          }
          ...
      }
      

      您可能还想看看SemaphoreSlim 类。

      【讨论】:

      • 感谢 SemaphoreSlim 课程链接,看起来它可以帮助我完成目标
      【解决方案6】:

      如果您不想锁定或移动到信号量,可以执行以下操作:

      if (_count >= MAXIMUM) return; // not necessary but handy as early return
      if(Interlocked.Increment(ref _count)>=MAXIMUM+1)
      {
          Interlocked.Decrement(ref _count);//restore old value
          return;
      }
      Task.Run(() => Work());
      

      增量返回增量值,您可以在该值上仔细检查 _count 是否小于最大值,如果测试失败则恢复旧值

      【讨论】:

      • 您的代码允许发生活锁,其中 40 个线程总是在增加太多和通过减少修复之间。在这种情况下无法完成任何工作,因为您会误报“工作是否太多?”。
      • @Strilanc if 块中的减量只会减少到 MAXIMUM ,因此会提前返回,第一个 MAXIMUM 线程也将能够工作,当一个线程完成时,增量将为允许它运行的某个线程返回 MAXIMUM-1,除了选择原子时,活锁总是危险。
      • Strilanc 是对的。这真的很难想象。让我尝试用不同的方式来解释。想象一下 400 个线程都在竞争增量和减量。现在,假设在任何给定时刻,任何一个线程都处于两个调用之间的概率为 10%。如果分布和概率保持稳定(这可能在重负载下发生),那么我们推断有 400 * 0.1 = 40 个线程始终处于这种半生不熟的状态。如果 40 个线程始终处于此状态,则 _count 必须始终 >= 40,即使当前未完成任何工作。实时锁定!
      • @BrianGideon 我明白了,但你真的不应该有 400 个线程争夺有限的资源,但是 Brian 的解决方案会解决它
      猜你喜欢
      • 1970-01-01
      • 2015-10-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-08-03
      • 1970-01-01
      相关资源
      最近更新 更多