C#线程问题之争用条件

用多个线程编程并不容易。在启动访问相同数据的多个线程时,会间歇性地遇到难以发现的问题。如果使用任务、并行 LINQ 或 Parallel 类,也会遇到这些问题。为了避免这些问题,必须特别注意同步问题和多个线程可能发生的其他问题。下面探讨与线程相关的问题争用条件。.

ThreadingIssues示例的代码使用了如下名称空间: 

  • System.Diagnostics 
  • System.Threading
  • System.Threading.Tasks
  • static System.Console

可以使用命令行参数启动 ThreadingIssues示例应用程序,来模拟争用条件。

如果两个或多个线程访问相同的对象,并且对共享状态的访问没有同步,就会出现争用条件。为了说明争用条件,下面的例子定义一个 StateObject 类,它包含一个 int 字段和一个 ChangeState() 方法。在 ChangeState() 方法的实现代码中,验证状态变量是否包含5。如果它包含,就递增其值。下一条语句是Trace.Assert,它立刻验证 state 现在是包含 6。

在给包含 5 的变量递增了 1 后,可能认为该变量的值就是 6。但事实不一定是这样。例如,如果一个线程刚刚执行完  if(_state==5)语句,它就被其他线程抢占,调度器运行另一个线程。第二个线程现在进入 if 体,因为 state 的值仍是 5,所以将它递增到 6。第一个线程现在再次被调度,在下一条语句中,State 递增到 7。这时就发生了争用条件,并显示断言消息:

public class StateObject
{
  private int _state = 5;
  public void ChangeState(int loop) 
{
    if (_state == 5)
    {
      _state++;
      If (_state != 6)
      {
        Console.WriteLine($"Race conditon occurred after {loop} loops"); 
        Trace.Fail("race condition");
      }
    }
    _state = 5;
  }
}

下面通过给任务定义一个方法来验证这一点。SampleTask 类的 RaceCondition()方法将一个 StateObject 类作参数。在一个无限while循环中,调用ChangeState() 方法。变量 i 仅用于显示断言消息中的循环次数。

public class SampleTask
{
  public void RaceCondition(object o)
  {
    Trace.Assert(o is StateObject, "o must be of type StateObject"); 
    StateObject state = o as StateObject;
    int i = 0; 
    while (true)
    {
      state.ChangeState(i++);
    }
  }
}

在程序的 Main() 方法中,新建了一个 StateObject 对象,它由所有任务共享。通过使用传递给 Task 的 Run 方法的 lambda 表达式调用 RaceCondition 方法来创建 Task 对象。然后,主线程等待用户输入。但是,因为可能出现争用,所以程序很有可能在读取用户输入前就挂起:

public void RaceConditions()
{
  var state = new StateObject(); 
  for (int i = 0; i < 2; i++)
  {
    Task.Run(() => new SampleTask().RaceCondition(state));
  }
}

启动程序,就会出现争用条件。多久以后出现第一个争用条件要取决于系统以及将程序构建为发布版本还是调试版本。如果构建为发布版本,该问题的出现次数就会比较多,因为代码被优化了。如果系统中有多个 CPU 或使用双核/四核 CPU,其中多个线程可以同时运行,则该问题也会比单核 CPU 的出现次数多。在单核CPU中,因为线程调度是抢占式的,也会出现该问题,只是没有那么频繁。

在我的系统上运行程序时,显示在 85232 个循环后出现错误;在另一次运行程序时,显示在 70037 个循环后出现错误。多次启动应用程序,总是会得到不同的结果。

要避免该问题,可以锁定共享的对象。这可以在线程中完成:用下面的 lock 语句锁定在线程中共享的 state 变量。只有一个线程能在锁定块中处理共享的 state 对象。由于这个对象在所有的线程之间共享,因此,如果一个线程锁定了 state,另一个线程就必须等待该锁定的解除。一旦接受锁定,线程就拥有该锁定,直到该锁定块的末尾才解除锁定。如果改变 state 变量引用的对象的每个线程都使用一个锁定,就不会出现争用条件。

public class SampleTask
{
  public void RaceCondition(object o)
  {
    Trace.Assert(o is StateObject, "o must be of type StateObject"); 
    StateObject state = o as StateObject; 
    int t = 0;
    while (true)
    {
      lock (state) // no race condition with this lock
      {
        state.ChangeState(i++);
      }
    }
  }
}

注意:

在下载的示例代码中,需要取消锁定语句的注释,才能解决争用条件的问题。

在使用共享对象时,除了进行锁定之外,还可以将共享对象设置为线程安全的对象。在下面的代码中, ChangeState() 方法包含一条 lock 语句。由于不能锁定 state 变量本身(只有引用类型才能用于锁定),因此定义一个object 类型的变量 sync,将它用于lock 语句。如果每次 state 的值更改时,都使用同一个同步对象来锁定,就不会出现争用条件。

public class StateObject
{
  private int state = 5;
  private _object sync = new object(); 
  public void ChangeState(int loop) 
  {
    lock (_sync)
    {
      if (_state == 5)
      {
        _state++;
       if (_state != 6)
       {
         Console.WriteLine($"Race condition occured after {loop} loops");
         Trace.Fail($"race condition at {loop}");
       }
       }
       _state = 5;
     }
   }
 }