竞争条件和关键部分

时间:2020-01-09 10:35:52  来源:igfitidea点击:

竞态条件是可能在关键区域内发生的特殊条件。关键部分是由多个线程执行的代码部分,其中线程的执行顺序对关键部分的并发执行结果产生影响。

当多个线程执行一个关键部分的结果可能根据线程执行的顺序而有所不同时,则说关键部分包含竞争条件。竞赛条件一词的隐喻是线程正在竞争通过临界区,而竞赛的结果会影响执行临界区的结果。

这听起来似乎有点复杂,所以我将在以下各节中详细介绍比赛条件和关键部分。

关键部分

在同一应用程序中运行多个线程本身不会导致问题。当多个线程访问相同的资源时,就会出现问题。例如,相同的内存(变量,数组或者对象),系统(数据库,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到寄存器。
  • 增值注册。
  • 将寄存器写入内存。

观察线程A和B的以下混合执行会发生什么:

this.count = 0;

   A:  Reads this.count into a register (0)
   B:  Reads this.count into a register (0)
   B:  Adds value 2 to register
   B:  Writes register value (2) back to memory. this.count now equals 2
   A:  Adds value 3 to register
   A:  Writes register value (3) back to memory. this.count now equals 3

这两个线程想将值2和3加到计数器上。因此,在两个线程完成执行之后,该值应为5. 但是,由于两个线程的执行是交错的,因此结果最终会有所不同。

在上面列出的执行序列示例中,两个线程都从内存中读取值0。然后,他们将各自的值2和3加到该值上,并将结果写回到内存中。而不是5,this.count中剩余的值将是最后一个线程写入该值所写入的值。在上述情况下,它是线程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;
        }
    }
}

注意add()方法如何将值添加到两个不同的sum成员变量。为了防止竞争情况,求和在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()方法。

当然,这个例子很简单。在现实生活中共享的资源中,关键部分的分解可能要复杂得多,并且需要对执行顺序的可能性进行更多的分析。