78. 공유 중인 가변 데이터는 동기화해 사용하라

신명철·2022년 5월 4일
0

Effective Java

목록 보기
74/80

들어가며

synchronized 키워드는 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다. 많은 프로그래머들은 동기화를 배타적 실행, 즉 한 스레드가 변경하는 중이라서 상태가 일관되지않은 순간의 객체를 다른 스레드가 보지 못하게 막는 용도로만 생각한다.

먼저 이 관점에서 이야기 한다면, 한 객체가 일관된 상태를 가지고 생성되고 이 객체에 접근하는 메서드는 그 객체에 락을 건다. 락을 건 메서드는 객체의 상태를 확인하고 필요하면 수정한다. 즉, 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화싴니다. 동기화를 제대로 사용하면 어떤 메서드도 이 객체의 상태가 일관되지 않은 순간을 볼 수 없을 것이다.

동기화의 기능

동기화에는 중요 기능이 하나가 더 있다. 동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다.

동기화는 일관성이 깨진 상태를 볼 수 없게 하는 것은 물론, 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.

언어 명세상 long, double 외 변수를 읽고 쓰는 동작은 원자적이다. 즉, 스레드들이 같은 변수들 동기화없이 수정하는 중이라도, 항상 어떤 스레드가 저장한 값을 온전히 읽어옴을 보장한다는 뜻이다. 이 말을 듣고 성능을 위해 읽을 때만 동기화를 하고 쓸 때는 동기화하지 말아야겠다라고 생각할 수 있지만 아주 위험한 발상이다.

자바 언어 명세는 스레드가 필드를 읽을 때 항상 수정이 반영된 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 보이는가는 보장하지 않는다. 동기화는 배타적 실행뿐 아니라 스레드 간 안정적인 통신에 꼭 필요하다. 이는 한 스레드가 만든 변화가 다른 스레드에게 언제 어떻게 보이는지를 규정한 자바의 메모리 모델 때문이다.

동기화 실패의 위험성

공유하고 있는 가변 데이터를 원자적으로 쓰고 읽을 수 있을지라도 동기화에 실패하면 처참한 결과로 이어진다. 다음 코드를 보자.

public class StopThread{
	private static boolean stopRequested;
    
    public static void main(String[] args) throws InterruptedException{
    	Thread backgroundThread = new Thread(() -> {
        	int i=0;
            while(!stopThread){
            	i++;
            }
        })
        backgroundThread.start();
        
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

위 코드는 stopRequestedfalse로 초기화해놓고, 다른 스레드가 stopRequested 값을 true로 변경하면 멈추는 식이다. 메인 스레드가 1초 후에 stopRequestedtrue로 바꾸면 반복문을 빠져나올 것 처럼 보이지만 실제로는 그렇지 않다.

원인은 동기화에 있다. 동기화하지 않으면 메인 스레드가 수정한 값을 backgroundThread가 언제쯤 보게 될지 보증할 수 없다. 동기화가 빠지면 가상 머신이 다음과 같이 최적화를 수행할 수도 있는 것이다.

// 원래 코드
while(!stopThread) i++; 

// 최적화한 코드
if(!stopRequested){
	while(1) i++;
}

OpenJDK서버 VM이 실제로 적용하는 끌어올리기(hoisting)라는 최적화 기법이다. 이 결과 프로그램은 응답 불가(liveness failure) 상태가 되어 더 이상 진전이 없다. 이 문제를 해결하기 위해서는 다음과 같이 변경해야 한다.

적절히 동기화해 스레드가 정상 종료된다

public class StopThread{
	private static boolean stopRequested;
    
    // -synchronized 키워드 사용-
    private static synchronized void requestedStop(){
    	stopRequested = true;
    }
    
    private static synchronized boolean stopRequested(){
    	return stopRequested;
    }
    // -------------------------
    
    public static void main(String[] args) throws InterruptedException{
    	Thread backgroundThread = new Thread(() -> {
        	int i=0;
            while(!stopRequested()){ // 메서드 사용
            	i++;
            }
        })
        backgroundThread.start();
        
        TimeUnit.SECONDS.sleep(1);
        requestedStop(); // 메서드 사용
    }
}

쓰기 메서드 requestedStop()과 읽기 메서드 stopRequested() 모두를 동기화 했음에 주목해야 한다.

쓰기 메서드만 동기화해서는 충분하지 않다. 쓰기와 읽기 모두 동기화되지 않으면 동작을 보장하지 않는다. 위 코드는 동기화의 두 가지 기능 (배타적 수행, 스레드 간 통신) 중 통신 목적으로만 사용되었다.

volatile

volatile 한정자는 배타적 수행과는 상관없지만 항상 최근에 기록된 값을 읽는 것을 보장해준다. 따라서 위 코드같은 경우에 활용할 수 있는 한정자다. volatile은 매번 동기화하는 비용이 크지 않고, 속도가 더 빠른 대안이 된다.

volatile 필드를 사용해 스레드가 정상 종료된다

public class StopThread{
	private static volatile boolean stopRequested; // volatile 한정자 사용
    
    public static void main(String[] args) throws InterruptedException{
    	Thread backgroundThread = new Thread(() -> {
        	int i=0;
            while(!stopThread){
            	i++;
            }
        })
        backgroundThread.start();
        
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

volatile 사용은 주의해야 한다. 다음 예를 보자.

잘못된 코드 - 동기화가 필요하다

private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber(){
	return nextSerialNumber++;
}

이 메서드는 매번 고유한 값을 반환할 의도로 만들어졌다. nextSerialNumber라는 값은 원자적으로 접근할 수 있고, 어떤 값이든 허용하기 때문에 동기화없이도 불변식을 보호할 수 있어 보인다. 하지만 동기화 없이 올바로 작동하지 않는다.

증가 연산자(++)는 코드상으로 하나지만 실제로는 nextSerialNumber 필드에 값을 읽을 때 한번, 그 다음 새로운 값을 저장할 때 한번, 총 두 번 접근한다. 만약 다른 스레드가 이 두 접근 사이를 비집고 들어와서 값을 읽어간다면 두 스레드는 동일한 값을 읽어가게 된다. 프로그램이 잘못된 결과를 계산해내는 이런 결과를 안전 실패(safety failure)라고 한다.

이는 generateSerialNumber() 메서드에 synchronized 한정자를 붙임으로써 해결할 수 있다. 동시에 호출해도 서로 간섭하지 않아 이전 호출이 변경한 값을 읽게 된다. 다만 메서드에 synchronized를 붙였다면 volatile한정자는 제거해야 한다.

AtomicLong

java.util.concurrent.atomic 패키지는 락 없이도 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨 있다. volatile 같은 경우는 동기화의 두 기능 중 통신만을 지원하지만 이 패키지는 배타적 실행까지 지원한다. 성능도 더 우수하다.

java.util.concurrent.atomic 을 이용한 락-프리 동기화

private static final AtomicLong nextSerialNum = new AtmoicLong();

public static long genereateSerialNuimber(){
	return nextSerialNum.getAndIncrement();
}

동기화로 인한 문제를 피하는 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다. 가변 데이터는 단일 스레드에서만 쓰도록 하자.

한 스레드가 데이터를 다 수정하고 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다. 그러면 그 객체를 다시 수정할 일이 생기기 전까지는 다른 스레드들은 동기화없이 자유롭게 값을 읽어갈 수 있다. 이런 객체를 사실상 불변(effectively immutable)이라 하고 다른 스레드에 이런 객체를 건네는 행위를 안전 발행(safe publication)이라고 한다.

<정리>
여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화해야 한다. 동기화하지 않으면 한 스레드가 수행한 변경을 다른 스레드가 보지 못할 수도 있다. 공유되는 가변 데이터를 동기화하는 데 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 수 있다. 이는 디버깅 난이도가 가장 높은 문제다.
간헐적이거나, 특정 타이밍에만 발생할 수도 있고, VM에 따라 현상이 달라지기도 한다. 배타적 실행은 필요없고 스레드 간 통신만 필요하다면 volatile한정자만으로 동기화를 할 수도 있다. 다만 올바로 사용하기가 까다롭다.

profile
내 머릿속 지우개

0개의 댓글