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

wisdom·2023년 3월 12일
0

Effetctive Java

목록 보기
78/80
post-thumbnail

synchronized

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

그러나 동기화에는 중요한 기능이 하나 더 있다. 자바 언어 명세는 스레드가 필드를 읽을 때 항상 '수정이 완전히 반영된' 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다. 동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다. 동기화는 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.

동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.
이는 한 스레드가 만든 변화가 다른 스레드에게 언제 어떻게 보이는지를 규정한 자바의 메모리 모델 때문이다.

동기화 예제

공유 중인 가변 데이터를 원자적으로 읽고 쓸 수 있을지라도(언어 명세상 longdouble 외의 변수를 읽고 쓰는 동작은 원자적(atomic)이다) 동기화에 실패하면 처참한 결과로 이어질 수 있다. 다른 스레드를 멈추는 작업을 예로 들어보자.

Thread.stop

Thread.stop 메서드는 안전하지 않아 오래전부터 deprecated API로 지정되었으며, Thread.stop(Throwable obj) 메서드는 자바 11에서 제거되었다.
그러니 Thread.stop은 사용하지 말자.

Thread.stop 메서드를 사용하지 않고 다른 스레드를 멈추는 올바른 방법은, boolean 필드를 폴링하면서 그 값이 true 가 되면 멈추도록 하면 된다. 이 필드를 false 로 초기화해놓고, 다른 스레드에서 이 스레드를 멈추고자 할 때 true 로 변경하는 방식이다. boolean 필드를 읽고 쓰는 작업은 원자적이다.

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++;
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

이 프로그램은 1초 후에 종료될까? 이 책의 저자의 컴퓨터에서는 종료되지 않았다. 원인은 동기화에 있다.
동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에나 보게 될지 보증할 수 없다.
동기화가 빠지면 가상 머신이 끌어올리기(hosting)라는 기법을 사용해 다음과 같이 최적화를 수행할 수도 있다.

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

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

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

동기화 사용

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 InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested())
                i++;
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}  

쓰기 메서드(requestStop)와 읽기 메서드(stopRequested) 모두를 동기화했음에 주목하자. 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다.

volatile 필드

반복문에서 매번 동기화하는 비용이 크진 않지만 속도가 더 빠른 대안으로는, stopRequested 필드를 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 (!stopRequested)
                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 한정자를 붙이고 nextSerialNumber 필드에 volatile 을 제거하면 해결된다.

락-프리(lock-free) 동기화

java.util.concurrent.atomic 패키지의 AtomicLong 을 사용하는 방법도 있다. 이 패키지에는 락 없이도(lock-free; 락-프리) 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨 있다.

volatile 은 동기화의 두 효과 중 통신 쪽만 지원하지만 이 패키지는 원자성(배타적 실행)까지 지원한다. 성능도 동기화 버전보다 우수하다.

private static final AtomicLong nextSerialNumber = new AtomicLong();

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

가변 데이터는 단일 스레드에서만 쓰자

지금까지 언급한 문제들을 피하는 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다. 불변 데이터만 공유하거나 아무것도 공유하지 말자.

가변 데이터는 단일 스레드에서만 쓰도록 하자.

이 정책을 받아들였다면 그 사실을 문서에 남겨 유지보수 과정에서도 정책이 계속 지켜지도록 하는 것이 중요하다. 또한 프레임워크와 라이브러리를 깊이 이해하는 것도 중요하다. 외부 코드가 인지하지 못한 스레드를 수행하는 복병으로 작용하는 경우도 있기 때문이다.

안전 발행

한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다. 이런 객체를 사실상 불변(effectively immutable) 이라 하고, 다른 스레드에 이런 객체를 건네는 행위를 안전 발행(safe publication) 이라 한다.

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

  • 정적 필드에 저장
  • volatile 필드에 저장
  • final 필드에 저장
  • 보통의 락을 통해 접근
  • 동시성 컬렉션에 저장

📌 핵심 정리

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

profile
백엔드 개발자

0개의 댓글