Home [Effective Java] Item78. 공유 중인 가변 데이터는 동기화해 사용하라!
Post
Cancel

[Effective Java] Item78. 공유 중인 가변 데이터는 동기화해 사용하라!

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

Intro

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

동기화

배타적 실행

  • 한 객체가 일관된 상태를 가지고 생성되고, 이 객체에 접근하는 메서드는 그 객체에 락(lock)을 건다.
  • 락을 건 메서드는 객체의 상태를 확인하고 필요 시 수정한다. 즉, 다른 일관된 상태로 변화시킨다.
  • 동기화를 제대로 사용해야 어떤 메서드도 이 객체의 상태가 일관되지 않은 상태를 볼 수 없을 것이다.

스레드 간 안정적인 통신

  • 동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다.
  • 동기화는 일관성이 깨진 상태에 접근하지 못하게 하는 것은 물론, 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.

원자적(atomic)

  • 예를 들어 언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적이다.
    • (자세한 것은 조금 더 공부해봐야 하지만) 32-bit 메모리에 값을 할당하는 연산은 원자적이고, long과 double은 32-bit 메모리를 2번 사용한 64-bit 메모리를 가지기에 원자적이지 않다.
  • 여러 스레드가 같은 변수를 동기화 없이 수정하는 중이라도, 항상 어떤 스레드가 정상적으로 저장한 값을 온전하게 읽어옴을 보장한다.
  • 위 설명을 보면 ‘원자적 데이터 사용에 동기화가 필요할까?’하는 의문이 들 수 있지만, 동기화는 필요하다.
    • 그 이유는? 자바 언어 명세는 스레드가 필드를 읽을 때 항상 ‘수정이 완전히 반영된’ 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 ‘보이는가’는 보장하지 않는다.

예제

  • 공유 중인 가변 데이터를 바록 원자적으로 읽고 쓸 수 있을지라도 동기화에 실패하면 처참한 결과로 이어질 수 있다.
  • 절대 사용하지 말아야 할 Thread.stop과 같은 기능을 하는 다른 스레드를 멈추는 동작을 하는 경우를 다루는 예제다.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class StopThread {

    private static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {

        Thread backgroundTread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        });

        backgroundTread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}
  • 안 끝난다.
  • 이 프로그램에 1초 후에 종료되리라 생각할 것이다. 1초 후에 stopRequested를 true로 만들기 때문이다. 하지만 그렇지 않다.
  • 문제의 원인은 동기화다. 동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에나 보게 될지 보증할 수 없다.

synchronized를 사용해 동기화하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StopThread {
  private static volatile boolean stopRequested;

  public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
      int i = 0;
      while (!stopRequested) {
        i++;
      }
    }).start();

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

volatile 사용 시 주의할 점

1
2
3
4
5
private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber(){
  return nextSerialNumber++; 
}
  • 동기화가 필요하다.
  • nextSerialNumber는 원자적으로 접근할 수 있고, 어떤 값이든 허용하는데, 동기화 없이는 문제가 되는 이유가 뭘까?
    • ++ 연산자의 사용이 원인이다. ++ 연산자 사용 시, 기존 값을 읽고, 증가시킨 후 새로운 값을 지정하게 된다. 고로 필드에 두 번 접근하게 되는데, 이 사이를 비집고 들어와 값을 읽어갈 수도 있을 것이다.
    • 이렇듯 프로그램이 잘못된 결과를 계산해내는 오류를 안전 실패(safety failure)라고 한다.
    • volatile 키워드는 하나 이상의 스레드가 write 하는 상황에서 동시성을 보장한다고 볼 수 없다.
  • generateSerialNumber 메서드에 synchronized 한정자를 붙여 해결할 수 있고, 이 경우 nextSerialNumber 선언에서 volatile을 제거한다.

동기화 문제를 피하는 방법

  • 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다. 불변 데이터만 공유하거나 아무것도 공유하지 말자.
    • 다시 말해 가변 데이터는 단일 스레드에서만 쓰도록 하자.
  • 이 정책을 받아들였다면 그 사실을 문서화하자.
  • 사용하려는 프레임워크, 라이브러리를 깊이 이해하여 스레드 수행의 복병으로 작용하는 경우가 있는지 파악하자.

사실상 불변 effectively immutable

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

안전 발행 safe publication 방법

  • 클래스 초기화 과정에서 객체를 정적 필드, volatile 필드, final 필드, 혹은 보통의 락을 통해 접근하는 필드에 저장해도 된다.
  • 그리고 동시성 컬렉션에 저장하는 방법도 있다.

핵심 정리

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

[Effective Java] Item77. 예외를 무시하지 말라!

[Effective Java] Item79. 과도한 동기화는 피하라!

Comments powered by Disqus.