티스토리 뷰

가변 데이터를 공유하지 말자.
그럼에도 여러 스레드가 가변 데이터를 공유해야 한다면
그 데이터를 읽고 쓰는 동작은 반드시 동기화해야 한다.

 

📚 동기화

synchronized 키워드는 해당 메서드나 블록을 한 번에 한 스레드씩 수행하도록 보장한다.

한 객체가 일관된 상태를 가지고 생성되고, 이 객체에 접근하는 메서드는 그 객체에 락(lock)을 건다.

락을 건 메서드는 객체의 상태를 확인하고 필요하면 수정한다.

즉, 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시킨다.

 

또한 동기화에는 중요한 기능이 하나 더 있다.

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

 

자바 언어 명세는 스레드가 필드를 읽을 때 항상 '수정이 완전히 반영된' 값을 얻는다고 보장하지만,

한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다.

즉, 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.

 

따라서 동기화는 배타적 수행과 스레드 간 통신이라는 두 가지 기능을 수행한다.

 

📢 다른 스레드를 멈추는 작업

우선, 자바의 Thread.stop 메서드는 안전하지 않아 이미 오래전에 사용이 자제되었다.

따라서 Thread.stop은 사용하지 말기로 하고, 다음과 같은 방법들을 생각해볼 수 있다.

 

첫 번째, boolean 필드를 사용한다.

첫 번째 스레드는 자신의 boolean 필드를 폴링 하면서 그 값이 true가 되면 멈춘다.

이 필드를 false로 초기화해놓고, 다른 스레드에서 이 스레드를 멈추고자 할 때 true로 변경한다.

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

주석처리 부분처럼 실행한다면 메인 스레드가 1초 후 stopRequested를 true로 설정하여 backgroundThread는 반복문을 빠져나올 것 같지만 동기화되지 않아 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에나 보게 될지 보증할 수 없다.

 

따라서 동기화가 빠지면 가상 머신이 주석이 아닌 코드처럼 최적화를 수행할 수도 있는 것이다.

해당 방법은 OpenJDK 서버 VM이 실제로 적용하는 끌어올리기라는 최적화 기법이다.

이 결과 프로그램은 응답 불가 상태가 되어 더 이상 진전이 없다.

 

따라서 아래 코드처럼 stopRequest 필드를 동기화하면 문제를 해결할 수 있다. (1초 후 종료)

쓰기(requestStop)와 읽기(stopRequested) 모두 동기화해야 한다.

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

 

두 번째 방법으로, volatile 한정자를 사용한다.

위의 코드에서 stopRequested 필드를 volatile으로 선언하면 동기화를 생략해도 된다.

volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.

따라서 반복문에서 매번 동기화하는 비용이 크진 않지만 속도가 더 빠르다.

public class StopThread{
	private static volatile boolean 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);
        stopRequested = true;
    }
}

주의해야 할 점은, 메서드에 synchronized를 붙였다면 필드에서는 volatile을 제거해야 한다.

 

세 번째 방법으로, java.util.concurrent.atomic 패키지의 AtomicLong을 사용한다.

이 패키지에는 락 없이도 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨있다.

volatile은 동기화의 두 효과 중 통신 쪽만 지원하지만, 이 패키지는 원자성까지 지원한다.

private static final AtomicLong nextSerialNum = new AtomicLong();

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

 

💡 정리

앞에서 언급한 문제들을 피하는 가장 좋은 방법은 물론 애초에 가변 데이터를 공유하지 않는 것이다.

가변 데이터는 단일 스레드에서만 쓰는 것이 좋다.

 

또한 한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다.

그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽어갈 수 있다.

 

객체를 안전하게 발행하는 방법은 많다.

클래스 초기화 과정에서 객체를 정적 필드, volatile필드, final필드, 혹은 보통의 락을 통해 접근하는 필드에 저장해도 된다.

그리고 동시성 컬렉션에 저장하는 방법도 있다.

 

따라서 결론은, 여러 스레드가 가변 데이터를 공유한다면 그 데이터를 일고 쓰는 동작은 반드시 동기화해야 한다.

동기화하지 않으면 한 스레드가 수행한 변경을 다른 스레드가 보지 못할 수도 있다. 

공유되는 가변 데이터를 동기화하는 데 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 수 있다.

댓글
공지사항
최근에 올라온 글