07、竞态条件和临界区

作者:Jakob Jenkov,2020-04-6
翻译:GentlemanTsao,2020-4-27

文章目录

    • 临界区
  • 临界区的竞态条件
  • 避免竞态条件
  • 临界区吞吐量

竞态条件是可能发生在临界区内的特殊条件。临界区是由多个线程执行的一段代码,它的并发执行结果会因线程的执行顺序而有差别。

多个线程执行一个临界区,可能因线程执行的顺序不同而带来不同的结果,在这种情况下,该临界区称为含有竞态条件。术语竞态条件源于这样一个隐喻,即线程在争抢临界区,而争抢的结果会影响临界区的执行结果。

这听起来可能有点复杂,所以我将在下面的章节中详细介绍竞态条件和临界区。

临界区

在同一个应用程序中运行多个线程本身不会导致问题。问题出现在当多个线程访问同一资源时。例如,访问相同的内存(变量、数组或对象)、系统(数据库、web服务等)或文件。

事实上,只有当一个或多个线程写入这些资源时,才会出现问题。只要资源不变,允许多个线程读取同样的资源是安全的。

下面是一个临界区Java代码示例,如果由多个线程同时执行,则可能会出错:

  public class Counter {
   
     

     protected long count = 0;

     public void add(long value){
   
     
         this.count = this.count + value;
     }
  }

假设两个线程A和B在Counter类的同一个实例上执行add方法。我们无法知道操作系统何时在两个线程之间切换。Java虚拟机不会将add()方法中的代码作为单个原子指令执行,而是作为一组较小的指令执行的,类似于:
把this.count从内存读入寄存器中。
寄存器加上value。
将寄存器写入内存。

观察以下线程A和B的混合执行情况:

       this.count = 0;

   A:  读 this.count 到寄存器 (0)
   B:  读 this.count 到寄存器 (0)
   B:  寄存器加上value 2
   B:  寄存器 (2) 写回内存. this.count 现在等于 2
   A:  寄存器加上value 3
   A:  寄存器 (3) 写回内存. this.count 现在等于 3

两个线程希望将值2和3添加到计数器中。因此,这两个线程执行完之后,该值应该是5。但是,由于两个线程是交错执行的,最后的结果却不一样了。

在上面列出的执行序列示例中,两个线程都从内存中读取值0。然后,他们加上各自的值2和3,并将结果写回内存。可最终this.count的值不是5,而是最后一个线程写入的值。在上述情况下,它是线程A,但它也可能是线程B。

临界区的竞态条件

在前面示例中,add()方法中的代码包含一个临界区。当多个线程执行此临界区时,就出现了竞态条件。

更正式地说,当两个线程竞争同一资源,且访问该资源的顺序又十分重要时,这种情况称为竞态条件。导致竞态条件的代码段称为临界区。

避免竞态条件

为了防止竞态条件发生,必须确保临界区作为原子指令执行。也就是说一旦一个线程执行它,在第一个线程离开临界区之前,其他线程都不能执行它。

通过在临界区进行适当的线程同步,可以避免竞态条件。线程同步可以使用同步的Java代码块来实现。线程同步也可以使用其他同步结构(例如锁)或原子变量(例如java.util.concurrent.atomic.AtomicInteger)来实现。

临界区吞吐量

对于较小的临界区,把整个临界区作为同步块是可行的。但是,对于较大的临界区,好的做法可能是将临界区分成较小的临界区,以便允许多个线程执行每个较小的临界区。这可以减少对共享资源的争用,从而增加整个临界区的吞吐量。

这里有一个非常简单的Java代码示例来说明:

public class TwoSums {
   
     
    
    private int sum1 = 0;
    private int sum2 = 0;
    
    public void add(int val1, int val2){
   
     
        synchronized(this){
   
     
            this.sum1 += val1;   
            this.sum2 += val2;
        }
    }
}

上例中有两个不同的sum成员变量,注意add()方法是如何增加它们的值的。为了避免竞态条件,求和被放在Java同步块内执行。这样实现的结果是,同一时间只有一个线程可以执行求和。

但是,由于两个求和变量彼此独立,因此可以将它们的求和拆分为两个单独的同步块,如下所示:

public class TwoSums {
   
     
    
    private int sum1 = 0;
    private int sum2 = 0;

    private Integer sum1Lock = new Integer(1);
    private Integer sum2Lock = new Integer(2);

    public void add(int val1, int val2){
   
     
        synchronized(this.sum1Lock){
   
     
            this.sum1 += val1;   
        }
        synchronized(this.sum2Lock){
   
     
            this.sum2 += val2;
        }
    }
}

现在两个线程可以同时执行add()方法了。一个线程在第一个同步块中执行,另一个线程在第二个同步块中执行。两个同步块在不同的对象上同步,因此两个不同的线程可以独立地执行这两个块。这样就减少了等对方线程执行add()方法的时间。

当然,这个例子很简单。在现实中的共享资源中,临界区的分解可能要复杂得多,需要对可能的执行顺序进行更多的分析。

下一篇:
java并发和多线程教程(九):线程安全和共享资源

更多阅读:
系列专栏:java并发和多线程教程