본문 바로가기

개발/이펙티브 자바

Effective Java ( 이펙티브 자바 ) - 11장 (동시성) - 아이템 78

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


  synchronized 키워드는 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다. 많은 프로그래머가 동기화를 배타적 실행, 막는 용도로만 생각한다. 동기화를 제대로 사용하면 어떤 메서드도 이 객체의 상태가 일관되지 않은 순간을 볼 수 없을 것이다.

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

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

 

  언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적이다. 이 말을 듣고 '성능을 높이려면 원자적 데이터를 읽고 쓸 때는 동기화하지 말아야겠다"고 생각하기 쉬운데, 아주 위험한 발상이다. 항상 '수정이 완전히 반영된' 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다.

동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다. (자바의 메모리 모델 때문)

  공유 중인 가변 데이터를 비록 원자적으로 읽고 쓸 수 있더라도 동기화에 실패하면 처참한 결과로 이어질 수 있다. 예시로 다른 스레드를 멈추는 작업을 생각할 수 있다. (Thread.stop은 사용하지 말자! 안전하지 않아 이미 오래전에 deprecated되었다)

 

  다른 스레드를 멈추는 올바른 방법은 boolean 필드를 폴링하면서 true가 되면 멈추고. 다른 스레드에서 이 스레드의 해당 변수를 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초 후에 종료되리라 생각하는가? 하지만 저자의 컴퓨터에서는 도통 끝날 줄 모르고 영원히 수행되었다. 원인은 동기화에 있다.  동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에나 보게 될지 보장할 수 없다. 동기화가 빠지면 JVM이 다음과 같은 최적화를 수행할 수도 있는 것이다.

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

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

실제 OpenJDK 서버 VM이 실제로 적용하는 hoisting 최적화 기법이다. 이는 다음과 같이 바꿔야한다.

public class StopThread {
   private static boolean stopRequested;
   
   private static synchronized void requestStop() {
      stopRequested = true;
   }
   
   private static synchroized 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();
   }
}

쓰기 메서드만 동기하해서는 충분하지 않다. 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다. 겉모습에 속아서는 안 된다. 사실 이 두 메서드는 단순해서 동기화 없이도 원자적으로 동작한다. 하지만 동기화의 배타적 수행과 스레드 간 통신이라는 두 가지 기능 중에, 통신 목적으로만 이 코드에서는 사용한 것이다.

 

  반복문에서 매번 동기화하는 비용이 크진 않지만 속도가 더 빠른 대안을 소개한다. stopRequested 필드를 volatile으로 선언하면 동기화를 생략해도 된다. volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다. 

 

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

private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber() {
   return nextSerialNumber++;
}

이 메서드는 매번 고유한 값을 반환할 의도로 만들어졌다. 이는 원자적으로 접근할 수 있다. 하지만 이 역시 동기화 없이는 동작하지 않는다.

문제는 ++ 다.

  이 연산자는 코드상으로는 하나지만 실제로는 nextSerialNumber에 두 번 접근한다. 먼저 값을 읽고, 그런 다음 새로운 값을 저장하는 것이다. 만약 두 번째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫 번째 스레드와 똑같은 값을 돌려받게 된다. 

 

   generateSerialNumber 메서드에 synchronized 한정자를 붙이면 이 문제가 해결된다. 동시에 호출해도 서로 간섭하지 않으며 이전 호출이 변경한 값을 읽게 된다는 뜻이다. 메서드에 synchronized를 붙였다면 volatile은 제거해야 한다. 메서드의 견고함을 위해 int 대신 long을 쓰거나 최댓값에 도달하면 예외를 던지게 하자.

 


아직 끝이 아니다! AtomicLong을 이용해 보자.이 패키지에는 락 없이도 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨 있다. volatile은 동기화의 두 효과 중 통신 쪽만 지원하지만 이 패키지는 원자성까지 지원한다. 더구나 성능도 동기화 버전보다 우수하다.

private static final AtomicLong nextSerialNum = new AtomicLong();

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

 


가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다. 불변 데이터만 공유하거나 아무것도 공유하지 말자. 가변 데이터는 단일 스레드에서만 쓰도록 하자.

한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다.  그러면 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽어갈 수 있다. 

 

여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화 해야 한다. 동기화에 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 수 있다. 이는 디버깅 난이도가 가장 높은 문제에 속한다. VM에 따라 현상이 달라지기도 한다.