이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/race-conditions-and-critical-sections.html



 경합 조건은 임계 영역 안에서 발생할 수 있는 특별한 현상이다. 임계 영역이란, 멀티쓰레드 환경에서 다수의 쓰레드들이 동시에 어떤 코드를 실행할 때, 각 쓰레드들이 서로 다른 결과를 내놓는, 동시 접근 문제가 발생할 수 있는 코드의 한 부분(영역)이다.


다수의 쓰레드가 코드의 임계 영역을 실행하게 되면 그 결과는 쓰레드의 실행 시퀀스에 따라 다르게 나올 수 있다. 임계 영역의 의미는 경합 조건을 포함하는데, 경합 조건이라는 용어는 쓰레드들이 임계 영역에서 서로 경쟁하게 되는 현상에 대한 비유로써 생겨났다. 그리고 이 경쟁의 결과는 임계 영역의 실행 결과에 영향을 미친다.


이 설명은 다소 복잡하게 느껴질 수 있는데, 때문에 이 글에서는 경합 조건과 임계 영역에 대해 보다 상세히 설명하기로 한다.


 

임계 영역


 한 어플리케이션에서 다수의 쓰레드를 실행하는 일이 스스로 문제를 유발하는 것은 아니다. 문제는 다수의 쓰레드가 같은 자원에 접근할 때 발생한다. 여기서 같은 자원이란, 같은 메모리(변수, 배열, 혹은 객체), 시스템(데이터베이스, 웹서비스 기타등등), 또는 파일을 의미한다.


하나 이상의 쓰레드가 이들 자원에 접근하여 쓰기 작업을 수행할 때 문제는 발생한다. 다수의 쓰레드가 같은 자원에 접근하더라도, 자원에 변경(수정)을 시도하지 않는 한 문제는 발생하지 않는다.


아래는 자바 코드의 임계 영역 예제이다. 이 코드는 멀티쓰레드로 동시에 실행될 때 문제가 발생할 수 있다.


  public class Counter {

     protected long count = 0;

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


두 쓰레드 A, B 가 있고, 이 쓰레드들이 Counter 클래스의 add() 메소드를 호출하는 상황을 생각해보자. 명령 시스템이 두 쓰레드 중 어느 쪽을 언제 실행할지 알 수 있는 방법은 없다. 이 코드에서 add() 메소드는 원자적인 명령으로 실행되지 않는다. 정확히 말하자면 여기서 이 코드는 세 개의 작은 명령들로 실행되는데, 다음과 비슷하다.


  1. count를 메모리에서 레지스터로 읽는다.
  2. 레지스터에 값을 더한다.
  3. 레지스터의 값을 메모리에 쓴다(writing).


쓰레드 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을 count에 더하려고 하였고, 쓰레드의 실행이 끝난 뒤의 최종 값은 5가 되어야 했다. 그러나 두 쓰레드의 실행이 끼워넣어지면서 전혀 다른 결과값이 나온다.


위에 나열된 실행 순서에서, 두 쓰레드는 메모리로부터 값 0을 읽어들이고 각자의 값 2와 3을 더한다. 그리고 다시 메모리로 이 값을 쓴다. this.count의 값은 5 대신 마지막에 실행이 완료된 쓰레드가 더한 값으로 쓰여진다. 위의 케이스에서 이 쓰레드는 A였지만, 다음 실행에서는 B가 될 수도 있는 일이다.


 

임계 영역에서의 경합 조건


위 예제 코드의 add() 메소드는 임계 영역을 가진다. 다수의 쓰레드가 이 영역을 실행할 때, 경합 조건이 발생한다.


더 형식적으로 말하자면, 두 쓰레드가 같은 자원으로 접근하기 위해 경쟁하고, 이 자원으로 접근되는 순서가 중요한 의미를 지니는 이러한 상황을 경합 조건이라 한다. 그리고 경합 조건을 불러오는 코드는 임계 영역이라 부른다.


 

경합 조건 예방


 경합 조건의 발생을 예방하기 위해서는 임계 영역 실행의 원자성을 보장해야 한다. 이는 한 쓰레드가 코드를 실행하면 이 쓰레드가 작업을 마치고 임계 영역에서 손을 놓을 때까지 때까지 다른 쓰레드는 이 코드를 실행할 수 없어야 함을 의미한다.


경합 조건은 임계 영역에 대한 적절한 쓰레드 동기화로 예방될 수 있다. 자바에서의 쓰레드 동기화는 기본적으로 synchronized 블록을 사용하여 이루어지고, locks 생성자나 java.util.concurrent.atomic.AtomicInteger 같은 원자성 변수와 같은 동기화 생성자/변수를 사용하여 이루어지기도 한다.



임계 영역 처리량


 코드에 임계 영역들이 작게 분포되어 있는 경우, 코드의 임계 영역 전체를 synchronized 블록으로 감싸는 것이 경합 조건 예방을 위한 방법이 될 수 있다.하지만 임계 영역의 범위가 큰 경우에는 이를 다수의 작은 임계 영역으로 분리하는 편이 좋을 수도 있다. 이렇게 하면 쓰레드들이 나뉘어진 임계 영역을 각각 실행할 수 있고, 이는 공유 자원으로의 경합을 줄일 수 있게 된다. 결과적으로 임계 영역의 처리량을 향상시킬 수 있는 것이다.


이게 무슨 의미인지 간단한 자바 코드로 살펴보자.


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() 메소드가 두 개의 멤버변수에 어떻게 값을 더하는지 보자. 경합 조건을 예방하기 위해 값을 더하는 작업은 synchronized 블록 안에서 이루어진다. 이렇게 하여 한 시점에 오직 하나의 쓰레드만이 이 코드를 실행할 수 있다.


아니면, 두 변수가 서로 독립적인 관계에 있으니 이 둘에 값을 더하는 작업을 분리할 수도 있다.


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


이제 add() 메소드는 한 시점에 두 개의 쓰레드가 실행 가능하게 됐다. 한 쓰레드는 첫 번째 synchronized 블록을 실행하고, 다른 하나는 두 번째 블록을 실행한다. add() 메소드 전체를 synchronized 블록으로 감쌌을 때 쓰레드들은 먼저 블록에 진입한 한 쓰레드가 메소드 전체를 실행할 동안 기다려야 했지만, 이렇게 블록을 나눔으로써 쓰레드의 기다리는 시간을 줄인 것이다.


이 예제는 매우 단순한 것이다. 당연하게도 실제 프로그램에서 임계 영역을 나누는 작업은 훨씬 복잡하고, 실행 순서의 면밀한 분석이 요구된다.



+ Recent posts