티스토리 뷰

Java

자바 동기화 처리 - volatile 와 synchronized

개발자-김씨 2020. 11. 25. 17:49
반응형

volatile은 멀티 스레드 프로그래밍에 사용되는 키워드입니다.

 

스레드는 실행되고 있는 CPU메모리 영역에 데이터를 캐싱합니다.

따라서 멀티 코어 프로세서에서 다수의 스레드가 변수 a를 공유하더라도 캐싱된(갱신) 시점에 따라 데이터가 다를 수도 있습니다. 그리고 캐싱된 데이터가 언제 갱신되는지 정확히 알수 없습니다. 

 

멀티코어 환경에서 스레드1, 2가 변수a(counter 성격의 가변 데이터)를 공유하고 있다고 가정해 봅시다.

1번 스레드가 a=2를 할당한 이후에 2번 스레드가 a를 읽더라도 가장 최신값 2가 아닌 이전에 캐싱된 값을 읽어올 수 있습니다. 

 

이런 경우 volatile키워드를 사용하여 CPU메모리 영역에 캐싱된 값이 아니라 항상 최신의 값을 가지도록 메인 메모리 영역에서 값을 참조하도록 할 수 있습니다.

즉, 동일 시점에 모든 스레드가 동일한 값을 가지도록 동기화합니다. 이런 동기화를 통신-동기화 또는 메모리-동기화라고 합니다.

 

하지만 volatile를 사용한다고 해서 모든 동기화 문제가 해결되는 건 아닙니다. 단지 캐시없이 최신의 값을 보게 할 뿐입니다.

 

어떠한 문제가 있는지 알아보도록 하겠습니다.

1) 변수 a =1
2) 1번 스레드가 a에 값을 증가시키기 위해 a의 값을 읽습니다. a=1
3) 1번 스레드가 1 + 1을 계산하여 2를 얻습니다. (아직 저장은 안 함)
4) 2번 스레드가 a에 값을 증가시키기 위해 a의 값을 읽습니다. a=1 
5) 1번 스레드가 변수 a에 2를 저장
6) 2번 스레드가 1 + 1을 계산하여 2를 얻습니다. (아직 저장은 안 함)
7) 2번 스레드가가 변수 a에 2를 저장

의도대로라면 1,2번 스레드에서 각각 1을 더하기 때문에 3이 되어야 하겠지만 위 순서대로 처리되면서 a의 최종 값은 2가 됩니다.

 

 

 

volatile은 원자적 연산에서만 동기화 보장

위와 같은 문제로 인해 volatile은 원자적 연산에서만 동기화만 보장됩니다.

a = a + 1 연산은 a에서 값을 읽고/ 1을 더하고/a에 값을 쓰는 3개의 연산으로 구성되어 있습니다.

원자적 연산이라 함은 

  a = 1;        => 쓰기

  flag= false; => 쓰기

  b = a;        => 읽기(a에서 읽어서 b에 저장하지만 변수 a의 관점에서는 단지 읽기 연산)

처럼 변수에 한 번만 접근하는 것을 의미합니다.

1) 변수 a=1
2) 스레드 1이 a의 값을 읽습니다. a=1
3) 스레드 2가 a에 2를 저장
4) 스레드 1이 a의 값을 읽습니다. a=2
5) 스레드 3이 a에 1을 저장
6) 스레드 2가 a의 값을 읽습니다. a=1

위의 예시처럼 변수가 원자적 연산만으로만 공유된다면 volatile만으로도 충분히 동기화가 가능합니다.

 

이펙티브 3판에 있는 코드를 조금 변형해서 예제 코드를 작성해 보도록 하겠습니다.

public class RunNStopTest  {

    private static volatile boolean stop = false;
    
    public static void main(String[] args) throws InterruptedException {
    	
    	for (int i = 0 ; i < 10; i++) {
            Thread backgroundThread = new Thread(() -> {
                int j = 0;
                while (!stop) { //읽기
                    j++;
                }
            });
            backgroundThread.start();
    	}

        TimeUnit.SECONDS.sleep(5);
        stop = true;//쓰기
    }
}

프로그램이 실행되면 메인 스레드가 백그라운드 스레드를 10개 실행하고 5초 후 10개 스레드를 모두 중지시키는 샘플 코드입니다.

모든 스레드를 중지시키기 위해 값을 저장하는 연산(stop = true), 중지 여부를 확인하기 위해 읽는 연산( while(!sotp)) 모두 원자적으로 처리되기 때문에 공유 변수에 volatile키워드를 붙이는 것만으로도 완전한 동기화 처리가 됩니다.

 

 

그런데 가변 데이터를 위와 같이 원자적 연산만으로 공유하는 경우만 있는 것은 아닙니다.

비-원자적으로 사용되는 가장 일반적인 예제 코드를 작성해보도록 하겠습니다.

public class CounterSample  {
    private static volatile boolean stop = false;
    private static volatile int runningThreadCount = 50;
    
    public static void main(String[] args) throws InterruptedException {
    	
        for (int i = 0 ; i < 50; i++) {
            Thread backgroundThread = new Thread(() -> {
                while(stop) {
                   //
                }
                int readCount = runningThreadCount;
                runningThreadCount  = readCount - 1;
                System.out.println("read count = " + readCount);
            });
            backgroundThread.start();
    	}

        TimeUnit.SECONDS.sleep(1);
        stop = true;

        TimeUnit.SECONDS.sleep(1);
        System.out.println("last - running thread count = " + runningThreadCount);
    }
}

메인 스레드가 백그라운드 스레드 50개를 실행시키고 1초 뒤에 모든 백그라운드 스레드를 종료하도록 했습니다.

백그라운드 스레드가 종료될 때마다 runningThreadCount(실행중 스레드 숫자)를 1씩 줄이도록 했습니다.

그리고 다시 1초 후에 최종적으로 runningThreadCount가 0이 되었는지 확인해 보도록 하겠습니다.

1) read count = 50
2) read count = 47
...
6) read count = 50
..
49) read count = 5
50) read count = 4
51) last - running thread count = 1

"read count"로그가 50번 찍혔는데도 불구하고 동시에 runningThreadCount에서 50을 읽은 스레드가 2개 있어서 최종적으로 runningThreadCount은 0이 아니라 1이 되었습니다. 

(매번 실행할 때마다 위 결과를 얻는 건 아닙니다. 대게 정상적으로 0으로 찍힘)

 

 

비-원자적 연산에서의 동기화 처리

만약 값을 읽고 쓰는 비-원자적 연산 작업이 하나의 스레드에서만 일어나고 다른 스레드들에서는 단지 값을 읽는 원자적 연산만 한다면 위와 같은 문제는 발생하지 않기 때문에 volatile키워드만으로 충분히 동기화가 됩니다. 

 

하지만 위 CounterSample 코드처럼 단일 스레드가 아닌 다수의 스레드에서 읽고 쓰는 작업을 해야 하는 경우라면 volatile만으로는 데이터가 꼬이는 문제를 해결할 수 없습니다. 이럴 때는 2가지 방법으로 해결 가능합니다.

 

첫 번째 방법은 synchronized키워드를 사용하는 방법입니다.

값을 변경하기 위해 읽고-저장하는 작업(비-원자적 연산)은 동시에 하나의 스레드만 처리할 수 있도록 일종의 락을 거는 방법입니다. 비-원자적 연산을 원자화시키는 거라고 볼 수 있습니다.

 

 

두 번째 방법은 concurrent패키지의 atomic클래스를 이용하는 방법입니다.

synchronized방법보다 간단하고 성능적으로도 더 나은 방법입니다. atomic이야기를 하면 또 할게 많으므로 이번 글에서는 더 이상 다루지 않도록 하겠습니다.

 

 

그럼 위의 CounterSample코드를 volatile대신 synchronized키워드를 사용하여 동기화해보도록 하겠습니다.

public class CounterSample  {
    private static volatile boolean stop = false;
    private static int runningThreadCount = 50;
    
    static synchronized int decrementRunningCount() {
	    int readCount = runningThreadCount;
	    runningThreadCount = readCount - 1;
	    return readCount;
	}
	    
    static synchronized int getRunningCount() {
        return runningThreadCount;
    }
    
    public static void main(String[] args) throws InterruptedException {
    	
        for (int i = 0 ; i < 50; i++) {
            Thread backgroundThread = new Thread(() -> {
                while(stop) {
                   //
                }
                int countBeforeDecrement = decrementRunningCount();
                System.out.println("count before decrement = " + countBeforeDecrement);
            });
            backgroundThread.start();
    	}

        TimeUnit.SECONDS.sleep(1);
        stop = true;

        TimeUnit.SECONDS.sleep(1);
        System.out.println("last - running thread count = " + getRunningCount());
    }
}

백그라운드 스레드가 종료될 때, decrementRunningCount()메소들 호출하여 카운터를 -1씩 빼도록 했는데, 해당 메소드에 synchronized키워드를 붙여 동시에 하나의 스레드만 진입할 수 있도록 했습니다. 이제 count의 최종 값은 항상 0 나올 것입니다.

이렇게 synchronized를 붙여 동시에 하나의 스레드만 진입할 수 있도록 하는 것을 배타적 실행(동기화)라고 합니다.

자. 그런데 주의할 건 getRunningCount메소드에도 synchronized가 붙었다는 겁니다.

getRunningCount에 synchronized가 붙은 이유는 배타적 실행이 아니라 통신(메모리) 동기화입니다.

 

 

배타적 실행 동기화와 통신(메모리) 동기화

runningThreadCount변수에 volatile키워드를 뺏기 때문에 getRunningCount메소드를 호출하여 count를 가져오면 캐시 된 값을 가져올 수 있습니다. 그래서 synchonized키워드를 붙여 가장 최신의 값을 참조하도록 했습니다. 

synchronized은 단일 스레드만 진입하도록 하는 배타적 실행 동기화뿐만 아니라 가장 최근의 값(메인 메모리에서)을 가져오는 통신 동기화 기능도 같이 수행합니다.

 

이렇게 volatile와 synchonized를 알아봤습니다.

volatile(통신 동기화)만으로 동기화가 되는 상황이라면 synchonized보다는 volatile만으로 동기화 처리를 하는 것이 낫습니다. 왜냐하면 배타적 실행을 위해 락을 획득하고 반환하는 비용이 발생하지 않기 때문입니다.

만약 배타적 실행 제어가 필요하다면 synchonized를 사용해야 합니다.

 

 

 

volitile의 다른 기능 - 재배치 방지

간혹 synchonized키워드와 volatile키워드가 같이 사용되는 코드를 볼 수 있습니다. 

자바 1.5이후부터 volatile키워드가 붙은 변수들은 컴파일 단계에서 재배치-최적화를 하지 않도록 변경되었기 때문에  Double-checked locking구문에서 최적화를 하지 않도록 하기 위해 volatile를 붙이기도 합니다.

자세한 내용은 여기를 참고

 

 

기타

volatile와 double, long

4바이트씩 값을 쓰고 읽기 때문에 4바이트 이상의 값을 가지는 데이터타입에 대해서 멀티 스레드 환경에서 순간적으로 다른 값을 참조할 수 있는데 volatile이 붙으면 이런 원자성마저 보장합니다.

 

 

반응형

'Java' 카테고리의 다른 글

ClientSocketUtil 개선버전  (0) 2023.04.12
Socket Util 만들어 보기  (0) 2023.04.11
synchronized 와 Double-checked locking  (0) 2020.11.20
Optional에 대해....  (0) 2020.10.22
ThreadPoolExecutor  (0) 2017.01.09
댓글