일상/레벨업 독서

[이펙티브 자바] Item 78 공유 중인 가변 데이터는 동기화해 사용하라.

Gamii 2022. 6. 7. 20:42
728x90
핵심 정리

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

 

 

 1. 동기화(synchronized)란?

 

 

해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다. Multi-Thread 상태에서 동일한 자원을 동시에 접근하게 되었을 때 동시 접근을 막는다. 즉, 공유 데이터에 lock을 걸어서 먼저 작업 중이던 스레드가 작업을 완전히 끝날 때까지는 다른 스레드에게 제어권이 넘어가더라도 데이터가 변경되지 않도록 보호함으로써 스레드의 동기화를 가능하게 한다.

 

 

 

 

2. 동기화가 필요한 이유

 

 

자바 언어 명세는 스레드가 필드를 읽을 때 항상 '수정이 완전히 반영된' 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다. 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.

 

 

 

3. 공유 중인 가변 데이터를 동기화에 실패한 경우

// 잘못된 코드 - 무한 루프에 빠진다.
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);
        requestStop();
    }
}

 

 

 

 

1) 다른 스레드를 멈추는 방법

첫 번째 스레드는 자신의 boolean 필드를 폴링하면서 그 값이 true가 되면 멈춘다. 이 필드를 false로 초기화해놓고, 다른 스레드에서 이 스레드를 멈추고자 할 때 true로 변경하는 식이다. (Thread.stop 메서드는 안전하지 않아 사용하지 말자!)

 

 

 

 

2) 무한 루프에 빠지게 되는 원인

메인 스레드가 1초 후 stopRequested를 true로 설정하면 backgroundThread는 반복문을 빠져나올 것처럼 보일 것이다. 하지만 영원히 수행된다. 원인은 동기화에 있다. 동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에나 보게 될지 보증할 수 없다.

 

 

 

3) 끌어올리기(Hoisting)

동기화에 빠지면 JVM은 Hoisting같은 최적화를 수행할 수도 있는 것이다. 이 결과 프로그램은 응답 불가(liveness failure) 상태가 되어 더 이상 진전이 없다.

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


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

 

 

4. 해결책

 

1) synchronized(동기화) 키워드 사용

stopRequested 필드를 동기화해 접근하면 이 문제를 해결할 수 있다. 쓰기 메서드(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 InterruptedException {
        // 백그라운드 스레드
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested())
                i++;
        });
        backgroundThread.start();

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

 

 

2) volatile 한정자 사용

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

 

public class StopThread {
    // vaolatile 한정자를 필드에 선언하면 동기화를 생략해도 된다.
    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;
    }
}

 

 

 - volatile 주의점

volatile은 주의해서 사용해야 한다. 예를 들어 다음은 일련번호를 생성할 의도로 작성한 메서드다.

 

// 잘못된 코드 - 동기화 필요
private static volatile int nextSerialNumber = 0;
    
public static int generateSerialNumber() {
    return nextSerialNumber++;
}

 

이 메서드는 매번 고유한 값을 반환할 의도로 만들어 졌다. 하지만 이 역시 동기화 없이는 올바로 동작하지 않는다. 문제는 증가 연산자(++)다. 코드상으로는 하나지만 실제로는 nextSeriaNumber 필드에 두 번 접근한다. 먼저 값을 읽고, 그런 다음 (1 증가한) 새로운 값을 저장하는 것이다. 따라서 이 두 접근 사이에 스레드가 들어온다면 값이 증가하기 전의 값을 돌려받게 되고, 잘못된 결과를 계산하는 안전 실패(safety failure)오류가 발생한다.

 

 

// 해결 방법
private static int nextSerialNumber = 0;
    
public static synchronized int generateSerialNumber() {
    return nextSerialNumber++;
}

메서드에 synchronized를 붙였다면 nextSerialNumber 필드에서는 volatile을 제거해야 한다. 이 메서드를 더 견고하게 하려면 int 대신 long을 사용하거나 nextSerialNumber가 최댓값에 도달하면 예외를 던지게 하자.

 

 

참고

https://nesoy.github.io/articles/2018-06/Java-volatile

 

 

 

3) AtomicLong을 사용

java.util.concurrent.atomic 패키지에는 락 없이도(lock-free) 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨있다. volatile은 동기화의 두 효과 중 통신 쪽만 지원하지만 이 패키지는 원자성(배타적 실행)까지 지원한다.

 

private static final AtomicLong nextSerialNum = new AtomicLong();

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

 

 

 

 

5. 사실상 불변(effectively immutable) 활용하자.

한 스레드가 데이터를 다 수정한 후 다른 스레드에 데이터를 공유하는 경우 해당 객체에서 공유되는 부분만 동기화해도 된다. 이렇게 하면 공유 부분을 수정하기 전까지는 동기화없이 자유롭게 값을 읽어갈 수 있다.

이러한 객체들을 사실상 불변(effectively immutable)이라 하고 다른 스레드에 이런 객체를 건네는 행위를 안전 발행(safe publication)이라 한다.

객체를 안전 발행하는 방법은 많다. 클래스 초기화 과정에서 객체를 정적 필드, volatile 필드, final 필드, lock을 통해 접근하는 필드에 저장해도 된다. 혹은 동시성 컬렉션에 저장하는 방법도 있다.