본문

synchronized, volatile : Race Condition을 위한 Java의 Keywords

* Race Condition 설명

은행계좌의 잔고를 변경하는 스레드를 동시에 여러개 실행하다보면, 잔고가 초과되거나 부족해지는 현상이 발생하게 된다. 아래의 경우는 두개의 스레드가 동시에 잔고를 수정하려고 할 때 생기는 문제로서, 다음의 코드를 동시에 실행하는 두 개의 스레드를 생각해 보면 된다. account+=amount;

문제의 원인으로, 이 코드는 원자단위 연산(atomic operations, 참고 : "C++에서의 원자성(Atomicity)설명과 그 예")이 아니라는 점이며, 이는 다음과 같이 처리된다.


1. LOAD : 레지스터(데이터 연산이 처리되는 부분)에 account의 값을 불러온다. -> 

2. ADD : 잔고에 입금된 금액을 추가한다. -> 

3. STORE : account에 연산 결과를 저장한다.




위의 그림은 500원과 900원이 동시에 통장에 입금되는 상황을 묘사한 것이다. 첫 번째 스레드가 load(기존 통장 잔고를 불러온다), add(기존잔고에 500원을 입금한다)를 수행하고 두 번째 스레드가 load, add, store를 수행한다. 이후 첫 번째 스레드가 자신의 작업으로 돌아와 store(기존 잔고와 입금된 잔고를 합한 5500원을 통장 잔고로 하여 저장한다)를 수행하는데, 결과적으로 500원과 900원이 입금되어 출력될 6400원과는 다른 다른 값을 보여준다. 


* 참고 : 가상머신(VM)의 바이트코드(bytecode)를 아래와 같이 분석함으로서 실행과정을 알아볼 수 있다. 코드의 내용보다는, 증가작업이 여러개의 지시어로 이루어 져 있다는 사실이 중요하며, 또한 이들을 실행하는 스레드는 어떠한 위치에서든 인터럽트될 수 있다는 것이다. javap -c -v Bank를 실행시키면 account += amount; 부분은 아래의 바이트코드로 출력된다.

aload_0

getfield #16 <Field Bank.accounts [J>

iload_1

dup2

laload

iload_3

i2l

lsub

lastore


단, 위 경우와 같이 기대와 결과가 다르게 출력되는 현상은 자주 발생하지 않는다. 각각의 스레드가 비활성화(sleep)되기 전에 너무나 적은 일을 수행하기 때문에, 스케줄러가 진행중인 작업을 다른 작업으로 대체(preempt)할 필요가 없기 때문이다. 이보다 더 중요한 문제는, 위와 같은 작업들을 포함하는 메소드가 진행 도중에 인터럽트되는 것이다. 


하지만, 메소드가 끝까지 작업할때까지 스레드의 제어권을 잃지 않는다는 보장을 할 수 있다면, 그러한 걱정은 사라지게 된다. 많은 스레드 라이브러리는 리소스 접근 도중에 발생하는 인터럽트를 방지하기 위하여 세마포어나 임계구역(참고 : 임계구역 문제, 세마포어, 뮤텍스에 대하여)등을 사용하도록 도와준다. 이것은 절차적 프로그래밍에는 충분하지만 객체지향적이라 보기는 어렵다. 자바 프로그래밍 언어는 이보다 더 좋은 메커니즘을 가지고 있다. 이는 Tony Hoare에 의해 만들어진 monitor 개념에 기반하였으며, 이것을 사용하기 위해서는 그저 인터럽트되지 않고자 하는 작업앞에 synchronized(동기화) 구문을 추가해 주면 된다.


* synchronized 구문



public synchronized void transfer(int from, int to, int amount){
    if (accounts[from] < amount) return;
    accounts[from] -= amount;
    accounts[to] += amount;
    ntransacts++;
    if (ntransacts % NTEST == 0) test();
}


어떤 스레드가 동기화된 메소드를 호출하였다면, 그 메소드는 '또다른 스레드가 이와 동일한 객체의 동기화된 메소드를 호출하여 실행되기 전에' 작업을 끝마칠 수 있도록 보장해준다. 예를 들어 어떤 스레드가 위의 transfer을 호출하고 또다른 스레드 역시 transfer를 호출할 경우, 두 번째로 호출되는 스레드의 작업이 중단되어 비활성화되고, 처음 수행된 transfer 메소드가 끝날때까지 기다리도록 한다. 대개 자료를 갱신하거나 불러오는 메소드들에 synchronized를 붙여서 사용하는것이 보편적이다.


물론 동기화 메커니즘은 자원을 소모한다. 따라서 모든 클래스의 모든 메소드를 동기화할 필요는 없다. 만약 객체가 스레드간에 공유되지 않는다면 동기화를 사용할 필요는 없다. 또한 만약 어떤 메소드가 주어진 객체에 대해 동일한 겂을 반환한다면 이 역시 동기화 구문을 추가할 필요가 없다. 


* volatile 구문

동기화에 소요되는 자원낭비를 막기 위하여, 불러오고 저장하는(load & store) 단순한 작업을 하는 상황에서 동기화 설정을 생략하는 경우가 있다. 하지만 이것은 두 가지의 이유 때문에 위험할 수 있다. 첫 번째로, 64비트 값을 불러오거나 저장하는 작업은 원자성을 가진다고 확신할 수 없다. double이나 long형에 값을 대입하는 경우, 전체의 반(32비트)이 우선 대입된 후 다른 스레드에 의해 선점(preempted)될 수 있다. 그렇게 되면 나중에 수행되는 스레드는 예상과는 다른 값을 가지게 될 것이다. 게다가, 멀티프로세서 환경일 경우 각각의 프로세서는 주 메모리상에 있는 데이터와 분리된, 프로세서 자체 캐시(레지스터)를 사용하여 작업을 처리하는데, 이 두 데이터가 서로 다를 수 있다. synchronized 구문은 로컬(프로세서) 캐시가 주 메모리와 동일한 값을 가지도록 보장해준다. 동기화 되지 않은 메소드의 경우, 다른 스레드에 의해 공유자원이 변경되어도 인지하지 못한다.


volatile 구문은 이러한 문제를 해결하기 위하여 만들어졌다. volatile로 지정된 64비트 변수의 불러오기와 저장하기 작업은 원자성이 보장된다. 멀티프로세서의 경우에도 마찬가지로, 이 작업은 프로세서 캐시와의 동기화를 보장한다. 특정한 경우에 따라, 동기화 설정 대신 volatile 변수만을 사용하여 작업을 처리하는 경우도 있을 수 있다. 하지만 이것은 프로그램의 복잡도를 높여줄 수 있다. 뿐만 아니라 volatile 변수가 VM에서 정상적으로 동작하지 않는 경우 또한 보고된 적이 있기 때문에 주의를 요한다.  따라서 volatile변수를 사용하는것 대신 동기화를 사용하는 것을 권장한다.



원문출처 : http://e-university.wisdomjobs.com/core-java/chapter-1272-231/multithreading.html

========================

쓸데없는 부분을 꽤 많이 잘라내고 의역도 많이 했다. 지난번에 작성했던 "C++에서의 원자성(Atomicity)설명과 그 예" 에서 뒷부분에 잠깐 언급했던 궁금증이 앞부분에 나온 load, add, store의 내용이다. 만약 지난 게시물의 내용이 맞다면 account+=amount; 또한 원자성을 가져야 하지만 이 글의 내용상으로는 그렇지 않다. java와 c의 구현상 차이라고 생각해야 할것같다? 


그리고 "[Java] synchronized 에 대해서" 라는 글에서 더욱 자세히 언급되었듯, 특정 클래스내의 synchronized 키워드가 붙은 모든 메소드는, 메소드명이 다르더라도 단 하나만 실행이 가능하다. 인용하자면


쓰레드 1과 쓰레드 2가 있다고 가정하면 쓰레드 1이 메소드 A를 수행하는 동안, 쓰레드 2는 메소드 A와 메소드 B에 접근이 제한이 됩니다


public void A(){
    synchronized (this){
        System.out.println("Method A");
    }
}


이것과 같습니다. this, 즉 현재 클래스의 인스턴스를 이용해서 락(Lock)을 걸기 때문입니다.

그렇게 때문에 한 인스턴스 내에서 synchronized된 메소드들은 여러 개의 쓰레드에서 동시에 진입이 불가능합니다.

그리고 여기서 알 수 있는 또 다른 점은 락은 인스턴스마다 존재한다는 점입니다.


또한, static 메소드의 경우는 자기 클래스를 기준으로 동기화가 이루어지며, 메소드간에 동기화가 필요없는 경우는 새로운 Object를 기준으로 synchronized(obj){}을 설정하는게 성능을 끌어올리기 위한 방법이다.


그리고 위 volatile 구문 부분은 원래 간략한 '알아두기'정도의 부분이어서 만족스럽지 않아 이부분을 다시 설명하고자 한다. 

volatile은 "각기 다른 스레드에 의해 변수의 값이 바뀔 수 있다"라는 것을 나타내기 위해 사용된다. "The volatile keyword in Java" 페이지에 따르면, 

1. 변수의 값은 스레드 내부에서 캐시되지 않는다 => 모든 자료의 입출력은 주 메모리만을 사용하여 처리한다.

2. 변수에 접근하는것은 synchronized 블록내에 있는 변수를 접근(이때 객체에 락/언락이 이루어지고 있대 메모리 벽이 형성된다)하는것과 마찬가지의 효과를 한다. 단, 실제적으로 락을 걸어주는 객체가 존재하는건 아니다

이라고 한다. 그리고 위 링크에서 링크된 또다른 페이지(The volatile keyword in Java 5)에서는 Java 5이후로 volatile 변수에 접근할 때 메모리 벽(memory barrier)가 생성되어 주 메모리와 캐시를 동기화 시켜준다(위의 2번의 구체적인 설명)고 한다. 


참고로 메모리 벽에 대해서는 여기("Memory Visibility(메모리 가시성) 와 Memory Barrier(메모리 장벽)")에서 한글로 잘 설명되어 있다. 위 링크를 요약하자면, 메모리 벽을 만났을 경우, 캐시되어있던 데이터를 주 메모리에 반영(flush to memory)한다는 것이다. 여기서 다시 윗단락을 보면, 1번과 2번이 서로 양립할 수 없을 것 같은데(캐시를 사용하지 않는데(1) 캐시를 사용해(2)??) 결국 마치 변수의 값이 캐시되지 않는것처럼, 변수에 접근할때마다 주 메모리에서 값을 가져온다고 이해해야 할 것 같다.


하지만 본문 중간에 volatile을 사용하면 원자성이 보장된다고 적어놓았는데 과연 volatile=atomic일까? 위 설명을 차근히 읽다보면 용어 사용상의 약간의 차이가 있는것처럼 보인다. 원자성이란 작업이 도중에 중단되는 일이 발생되지 않는 속성을 말하는 것인데 volatile은 차라리 데이터 접근방법에 대한 것이기 떄문이다. 또한 Java SE 5 이후로, java.concurrent.Atomic.* 클래스가 따로 제공되어 원자성을 제공하기 떄문에 더더욱 그렇게 느껴진다. 제목에는 volatile또한 race condition을 해결하기 위한 방법인것처럼 적어놓았는데 막상 따져보면 그렇지만은 않다. 변수의 가시성(visibility, 다른 스레드에 의해 데이터가 변경되었는지를 알 수 있다, volatile 본문에 밑줄 친 부분에 대한 이야기이다.)가 확보가 되는것이지 그것에 대한 제어(lock)는 제공하지 않기 떄문이다.


참고로 Atomic 클래스의 경우 다음과 같이 사용할 수 있다. 일반 변수 사용시와 약 3배정도의 성능 차이가 있다고 하는데 synchronized를 사용하는것보다 훨씬 적은 비용이다. atomic 클래스의 경우 값이 변할때까지 loop를 돌다가 변하게 되면 값을 반환하는 방식-CAS(compare and swap)-으로 데이터의 변경을 확인다고 한다.

AtomicInteger ai = new AtomicInteger( 5 ); int n = ai.incrementAndGet();

댓글

Holic Spirit :: Tistory Edition

design by tokiidesu. powerd by kakao.